]> Untitled Git - lemmy.git/blob - ui/src/components/search.tsx
First pass at fixing UI to work with new websocketresponses.
[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   wsJsonToRes,
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       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(retryWhen(errors => errors.pipe(delay(3000), take(10))))
85       .subscribe(
86         msg => this.parseMessage(msg),
87         err => console.error(err),
88         () => console.log('complete')
89       );
90
91     if (this.state.q) {
92       this.search();
93     }
94   }
95
96   componentWillUnmount() {
97     this.subscription.unsubscribe();
98   }
99
100   // Necessary for back button for some reason
101   componentWillReceiveProps(nextProps: any) {
102     if (
103       nextProps.history.action == 'POP' ||
104       nextProps.history.action == 'PUSH'
105     ) {
106       this.state = this.emptyState;
107       this.state.q = this.getSearchQueryFromProps(nextProps);
108       this.state.type_ = this.getSearchTypeFromProps(nextProps);
109       this.state.sort = this.getSortTypeFromProps(nextProps);
110       this.state.page = this.getPageFromProps(nextProps);
111       this.setState(this.state);
112       this.search();
113     }
114   }
115
116   componentDidMount() {
117     document.title = `${i18n.t('search')} - ${
118       WebSocketService.Instance.site.name
119     }`;
120   }
121
122   render() {
123     return (
124       <div class="container">
125         <div class="row">
126           <div class="col-12">
127             <h5>
128               <T i18nKey="search">#</T>
129             </h5>
130             {this.selects()}
131             {this.searchForm()}
132             {this.state.type_ == SearchType.All && this.all()}
133             {this.state.type_ == SearchType.Comments && this.comments()}
134             {this.state.type_ == SearchType.Posts && this.posts()}
135             {this.state.type_ == SearchType.Communities && this.communities()}
136             {this.state.type_ == SearchType.Users && this.users()}
137             {this.noResults()}
138             {this.paginator()}
139           </div>
140         </div>
141       </div>
142     );
143   }
144
145   searchForm() {
146     return (
147       <form
148         class="form-inline"
149         onSubmit={linkEvent(this, this.handleSearchSubmit)}
150       >
151         <input
152           type="text"
153           class="form-control mr-2"
154           value={this.state.q}
155           placeholder={`${i18n.t('search')}...`}
156           onInput={linkEvent(this, this.handleQChange)}
157           required
158           minLength={3}
159         />
160         <button type="submit" class="btn btn-secondary mr-2">
161           {this.state.loading ? (
162             <svg class="icon icon-spinner spin">
163               <use xlinkHref="#icon-spinner"></use>
164             </svg>
165           ) : (
166             <span>
167               <T i18nKey="search">#</T>
168             </span>
169           )}
170         </button>
171       </form>
172     );
173   }
174
175   selects() {
176     return (
177       <div className="mb-2">
178         <select
179           value={this.state.type_}
180           onChange={linkEvent(this, this.handleTypeChange)}
181           class="custom-select custom-select-sm w-auto"
182         >
183           <option disabled>
184             <T i18nKey="type">#</T>
185           </option>
186           <option value={SearchType.All}>
187             <T i18nKey="all">#</T>
188           </option>
189           <option value={SearchType.Comments}>
190             <T i18nKey="comments">#</T>
191           </option>
192           <option value={SearchType.Posts}>
193             <T i18nKey="posts">#</T>
194           </option>
195           <option value={SearchType.Communities}>
196             <T i18nKey="communities">#</T>
197           </option>
198           <option value={SearchType.Users}>
199             <T i18nKey="users">#</T>
200           </option>
201         </select>
202         <span class="ml-2">
203           <SortSelect
204             sort={this.state.sort}
205             onChange={this.handleSortChange}
206             hideHot
207           />
208         </span>
209       </div>
210     );
211   }
212
213   all() {
214     let combined: Array<{
215       type_: string;
216       data: Comment | Post | Community | UserView;
217     }> = [];
218     let comments = this.state.searchResponse.comments.map(e => {
219       return { type_: 'comments', data: e };
220     });
221     let posts = this.state.searchResponse.posts.map(e => {
222       return { type_: 'posts', data: e };
223     });
224     let communities = this.state.searchResponse.communities.map(e => {
225       return { type_: 'communities', data: e };
226     });
227     let users = this.state.searchResponse.users.map(e => {
228       return { type_: 'users', data: e };
229     });
230
231     combined.push(...comments);
232     combined.push(...posts);
233     combined.push(...communities);
234     combined.push(...users);
235
236     // Sort it
237     if (this.state.sort == SortType.New) {
238       combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
239     } else {
240       combined.sort(
241         (a, b) =>
242           ((b.data as Comment | Post).score |
243             (b.data as Community).number_of_subscribers |
244             (b.data as UserView).comment_score) -
245           ((a.data as Comment | Post).score |
246             (a.data as Community).number_of_subscribers |
247             (a.data as UserView).comment_score)
248       );
249     }
250
251     return (
252       <div>
253         {combined.map(i => (
254           <div>
255             {i.type_ == 'posts' && (
256               <PostListing post={i.data as Post} showCommunity viewOnly />
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         ))}
304       </div>
305     );
306   }
307
308   comments() {
309     return (
310       <div>
311         {this.state.searchResponse.comments.map(comment => (
312           <CommentNodes nodes={[{ comment: comment }]} locked noIndent />
313         ))}
314       </div>
315     );
316   }
317
318   posts() {
319     return (
320       <div>
321         {this.state.searchResponse.posts.map(post => (
322           <PostListing post={post} showCommunity viewOnly />
323         ))}
324       </div>
325     );
326   }
327
328   // Todo possibly create UserListing and CommunityListing
329   communities() {
330     return (
331       <div>
332         {this.state.searchResponse.communities.map(community => (
333           <div>
334             <span>
335               <Link to={`/c/${community.name}`}>{`/c/${community.name}`}</Link>
336             </span>
337             <span>{` - ${community.title} - ${community.number_of_subscribers} subscribers`}</span>
338           </div>
339         ))}
340       </div>
341     );
342   }
343
344   users() {
345     return (
346       <div>
347         {this.state.searchResponse.users.map(user => (
348           <div>
349             <span>
350               <Link
351                 className="text-info"
352                 to={`/u/${user.name}`}
353               >{`/u/${user.name}`}</Link>
354             </span>
355             <span>{` - ${user.comment_score} comment karma`}</span>
356           </div>
357         ))}
358       </div>
359     );
360   }
361
362   paginator() {
363     return (
364       <div class="mt-2">
365         {this.state.page > 1 && (
366           <button
367             class="btn btn-sm btn-secondary mr-1"
368             onClick={linkEvent(this, this.prevPage)}
369           >
370             <T i18nKey="prev">#</T>
371           </button>
372         )}
373         <button
374           class="btn btn-sm btn-secondary"
375           onClick={linkEvent(this, this.nextPage)}
376         >
377           <T i18nKey="next">#</T>
378         </button>
379       </div>
380     );
381   }
382
383   noResults() {
384     let res = this.state.searchResponse;
385     return (
386       <div>
387         {res &&
388           res.posts.length == 0 &&
389           res.comments.length == 0 &&
390           res.communities.length == 0 &&
391           res.users.length == 0 && (
392             <span>
393               <T i18nKey="no_results">#</T>
394             </span>
395           )}
396       </div>
397     );
398   }
399
400   nextPage(i: Search) {
401     i.state.page++;
402     i.setState(i.state);
403     i.updateUrl();
404     i.search();
405   }
406
407   prevPage(i: Search) {
408     i.state.page--;
409     i.setState(i.state);
410     i.updateUrl();
411     i.search();
412   }
413
414   search() {
415     let form: SearchForm = {
416       q: this.state.q,
417       type_: SearchType[this.state.type_],
418       sort: SortType[this.state.sort],
419       page: this.state.page,
420       limit: fetchLimit,
421     };
422
423     if (this.state.q != '') {
424       WebSocketService.Instance.search(form);
425     }
426   }
427
428   handleSortChange(val: SortType) {
429     this.state.sort = val;
430     this.state.page = 1;
431     this.setState(this.state);
432     this.updateUrl();
433   }
434
435   handleTypeChange(i: Search, event: any) {
436     i.state.type_ = Number(event.target.value);
437     i.state.page = 1;
438     i.setState(i.state);
439     i.updateUrl();
440   }
441
442   handleSearchSubmit(i: Search, event: any) {
443     event.preventDefault();
444     i.state.loading = true;
445     i.search();
446     i.setState(i.state);
447     i.updateUrl();
448   }
449
450   handleQChange(i: Search, event: any) {
451     i.state.q = event.target.value;
452     i.setState(i.state);
453   }
454
455   updateUrl() {
456     let typeStr = SearchType[this.state.type_].toLowerCase();
457     let sortStr = SortType[this.state.sort].toLowerCase();
458     this.props.history.push(
459       `/search/q/${this.state.q}/type/${typeStr}/sort/${sortStr}/page/${this.state.page}`
460     );
461   }
462
463   parseMessage(msg: any) {
464     console.log(msg);
465     let res = wsJsonToRes(msg);
466     if (res.error) {
467       alert(i18n.t(res.error));
468       return;
469     } else if (res.op == UserOperation.Search) {
470       let data = res.data as SearchResponse;
471       this.state.searchResponse = data;
472       this.state.loading = false;
473       document.title = `${i18n.t('search')} - ${this.state.q} - ${
474         WebSocketService.Instance.site.name
475       }`;
476       window.scrollTo(0, 0);
477       this.setState(this.state);
478     }
479   }
480 }