]> Untitled Git - lemmy-ui.git/blob - src/shared/components/search.tsx
First pass at v2_api
[lemmy-ui.git] / src / shared / components / search.tsx
1 import { Component, linkEvent } from 'inferno';
2 import { Subscription } from 'rxjs';
3 import {
4   UserOperation,
5   PostView,
6   CommentView,
7   CommunityView,
8   UserViewSafe,
9   SortType,
10   Search as SearchForm,
11   SearchResponse,
12   SearchType,
13   PostResponse,
14   CommentResponse,
15   Site,
16 } from 'lemmy-js-client';
17 import { UserService, WebSocketService } from '../services';
18 import {
19   wsJsonToRes,
20   fetchLimit,
21   routeSearchTypeToEnum,
22   routeSortTypeToEnum,
23   toast,
24   createCommentLikeRes,
25   createPostLikeFindRes,
26   commentsToFlatNodes,
27   setIsoData,
28   wsSubscribe,
29   wsUserOp,
30 } from '../utils';
31 import { PostListing } from './post-listing';
32 import { HtmlTags } from './html-tags';
33 import { UserListing } from './user-listing';
34 import { CommunityLink } from './community-link';
35 import { SortSelect } from './sort-select';
36 import { CommentNodes } from './comment-nodes';
37 import { i18n } from '../i18next';
38 import { InitialFetchRequest } from 'shared/interfaces';
39
40 interface SearchProps {
41   q: string;
42   type_: SearchType;
43   sort: SortType;
44   page: number;
45 }
46
47 interface SearchState {
48   q: string;
49   type_: SearchType;
50   sort: SortType;
51   page: number;
52   searchResponse: SearchResponse;
53   loading: boolean;
54   site: Site;
55   searchText: string;
56 }
57
58 interface UrlParams {
59   q?: string;
60   type_?: SearchType;
61   sort?: SortType;
62   page?: number;
63 }
64
65 export class Search extends Component<any, SearchState> {
66   private isoData = setIsoData(this.context);
67   private subscription: Subscription;
68   private emptyState: SearchState = {
69     q: Search.getSearchQueryFromProps(this.props.match.params.q),
70     type_: Search.getSearchTypeFromProps(this.props.match.params.type),
71     sort: Search.getSortTypeFromProps(this.props.match.params.sort),
72     page: Search.getPageFromProps(this.props.match.params.page),
73     searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
74     searchResponse: {
75       type_: null,
76       posts: [],
77       comments: [],
78       communities: [],
79       users: [],
80     },
81     loading: false,
82     site: this.isoData.site_res.site_view.site,
83   };
84
85   static getSearchQueryFromProps(q: string): string {
86     return decodeURIComponent(q) || '';
87   }
88
89   static getSearchTypeFromProps(type_: string): SearchType {
90     return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
91   }
92
93   static getSortTypeFromProps(sort: string): SortType {
94     return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
95   }
96
97   static getPageFromProps(page: string): number {
98     return page ? Number(page) : 1;
99   }
100
101   constructor(props: any, context: any) {
102     super(props, context);
103
104     this.state = this.emptyState;
105     this.handleSortChange = this.handleSortChange.bind(this);
106
107     this.parseMessage = this.parseMessage.bind(this);
108     this.subscription = wsSubscribe(this.parseMessage);
109
110     // Only fetch the data if coming from another route
111     if (this.state.q != '') {
112       if (this.isoData.path == this.context.router.route.match.url) {
113         this.state.searchResponse = this.isoData.routeData[0];
114         this.state.loading = false;
115       } else {
116         this.search();
117       }
118     }
119   }
120
121   componentWillUnmount() {
122     this.subscription.unsubscribe();
123   }
124
125   static getDerivedStateFromProps(props: any): SearchProps {
126     return {
127       q: Search.getSearchQueryFromProps(props.match.params.q),
128       type_: Search.getSearchTypeFromProps(props.match.params.type),
129       sort: Search.getSortTypeFromProps(props.match.params.sort),
130       page: Search.getPageFromProps(props.match.params.page),
131     };
132   }
133
134   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
135     let pathSplit = req.path.split('/');
136     let promises: Promise<any>[] = [];
137
138     let form: SearchForm = {
139       q: this.getSearchQueryFromProps(pathSplit[3]),
140       type_: this.getSearchTypeFromProps(pathSplit[5]),
141       sort: this.getSortTypeFromProps(pathSplit[7]),
142       page: this.getPageFromProps(pathSplit[9]),
143       limit: fetchLimit,
144       auth: req.auth,
145     };
146
147     if (form.q != '') {
148       promises.push(req.client.search(form));
149     }
150
151     return promises;
152   }
153
154   componentDidUpdate(_: any, lastState: SearchState) {
155     if (
156       lastState.q !== this.state.q ||
157       lastState.type_ !== this.state.type_ ||
158       lastState.sort !== this.state.sort ||
159       lastState.page !== this.state.page
160     ) {
161       this.setState({ loading: true, searchText: this.state.q });
162       this.search();
163     }
164   }
165
166   get documentTitle(): string {
167     if (this.state.q) {
168       return `${i18n.t('search')} - ${this.state.q} - ${this.state.site.name}`;
169     } else {
170       return `${i18n.t('search')} - ${this.state.site.name}`;
171     }
172   }
173
174   render() {
175     return (
176       <div class="container">
177         <HtmlTags
178           title={this.documentTitle}
179           path={this.context.router.route.match.url}
180         />
181         <h5>{i18n.t('search')}</h5>
182         {this.selects()}
183         {this.searchForm()}
184         {this.state.type_ == SearchType.All && this.all()}
185         {this.state.type_ == SearchType.Comments && this.comments()}
186         {this.state.type_ == SearchType.Posts && this.posts()}
187         {this.state.type_ == SearchType.Communities && this.communities()}
188         {this.state.type_ == SearchType.Users && this.users()}
189         {this.resultsCount() == 0 && <span>{i18n.t('no_results')}</span>}
190         {this.paginator()}
191       </div>
192     );
193   }
194
195   searchForm() {
196     return (
197       <form
198         class="form-inline"
199         onSubmit={linkEvent(this, this.handleSearchSubmit)}
200       >
201         <input
202           type="text"
203           class="form-control mr-2 mb-2"
204           value={this.state.searchText}
205           placeholder={`${i18n.t('search')}...`}
206           onInput={linkEvent(this, this.handleQChange)}
207           required
208           minLength={3}
209         />
210         <button type="submit" class="btn btn-secondary mr-2 mb-2">
211           {this.state.loading ? (
212             <svg class="icon icon-spinner spin">
213               <use xlinkHref="#icon-spinner"></use>
214             </svg>
215           ) : (
216             <span>{i18n.t('search')}</span>
217           )}
218         </button>
219       </form>
220     );
221   }
222
223   selects() {
224     return (
225       <div className="mb-2">
226         <select
227           value={this.state.type_}
228           onChange={linkEvent(this, this.handleTypeChange)}
229           class="custom-select w-auto mb-2"
230         >
231           <option disabled>{i18n.t('type')}</option>
232           <option value={SearchType.All}>{i18n.t('all')}</option>
233           <option value={SearchType.Comments}>{i18n.t('comments')}</option>
234           <option value={SearchType.Posts}>{i18n.t('posts')}</option>
235           <option value={SearchType.Communities}>
236             {i18n.t('communities')}
237           </option>
238           <option value={SearchType.Users}>{i18n.t('users')}</option>
239         </select>
240         <span class="ml-2">
241           <SortSelect
242             sort={this.state.sort}
243             onChange={this.handleSortChange}
244             hideHot
245           />
246         </span>
247       </div>
248     );
249   }
250
251   all() {
252     let combined: {
253       type_: string;
254       data: CommentView | PostView | CommunityView | UserViewSafe;
255       published: string;
256     }[] = [];
257     let comments = this.state.searchResponse.comments.map(e => {
258       return { type_: 'comments', data: e, published: e.comment.published };
259     });
260     let posts = this.state.searchResponse.posts.map(e => {
261       return { type_: 'posts', data: e, published: e.post.published };
262     });
263     let communities = this.state.searchResponse.communities.map(e => {
264       return {
265         type_: 'communities',
266         data: e,
267         published: e.community.published,
268       };
269     });
270     let users = this.state.searchResponse.users.map(e => {
271       return { type_: 'users', data: e, published: e.user.published };
272     });
273
274     combined.push(...comments);
275     combined.push(...posts);
276     combined.push(...communities);
277     combined.push(...users);
278
279     // Sort it
280     if (this.state.sort == SortType.New) {
281       combined.sort((a, b) => b.published.localeCompare(a.published));
282     } else {
283       combined.sort(
284         (a, b) =>
285           ((b.data as CommentView | PostView).counts.score |
286             (b.data as CommunityView).counts.subscribers |
287             (b.data as UserViewSafe).counts.comment_score) -
288           ((a.data as CommentView | PostView).counts.score |
289             (a.data as CommunityView).counts.subscribers |
290             (a.data as UserViewSafe).counts.comment_score)
291       );
292     }
293
294     return (
295       <div>
296         {combined.map(i => (
297           <div class="row">
298             <div class="col-12">
299               {i.type_ == 'posts' && (
300                 <PostListing
301                   key={(i.data as PostView).post.id}
302                   post_view={i.data as PostView}
303                   showCommunity
304                   enableDownvotes={this.state.site.enable_downvotes}
305                   enableNsfw={this.state.site.enable_nsfw}
306                 />
307               )}
308               {i.type_ == 'comments' && (
309                 <CommentNodes
310                   key={(i.data as CommentView).comment.id}
311                   nodes={[{ comment_view: i.data as CommentView }]}
312                   locked
313                   noIndent
314                   enableDownvotes={this.state.site.enable_downvotes}
315                 />
316               )}
317               {i.type_ == 'communities' && (
318                 <div>{this.communityListing(i.data as CommunityView)}</div>
319               )}
320               {i.type_ == 'users' && (
321                 <div>{this.userListing(i.data as UserViewSafe)}</div>
322               )}
323             </div>
324           </div>
325         ))}
326       </div>
327     );
328   }
329
330   comments() {
331     return (
332       <CommentNodes
333         nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
334         locked
335         noIndent
336         enableDownvotes={this.state.site.enable_downvotes}
337       />
338     );
339   }
340
341   posts() {
342     return (
343       <>
344         {this.state.searchResponse.posts.map(post => (
345           <div class="row">
346             <div class="col-12">
347               <PostListing
348                 post_view={post}
349                 showCommunity
350                 enableDownvotes={this.state.site.enable_downvotes}
351                 enableNsfw={this.state.site.enable_nsfw}
352               />
353             </div>
354           </div>
355         ))}
356       </>
357     );
358   }
359
360   communities() {
361     return (
362       <>
363         {this.state.searchResponse.communities.map(community => (
364           <div class="row">
365             <div class="col-12">{this.communityListing(community)}</div>
366           </div>
367         ))}
368       </>
369     );
370   }
371
372   communityListing(community_view: CommunityView) {
373     return (
374       <>
375         <span>
376           <CommunityLink community={community_view.community} />
377         </span>
378         <span>{` - ${community_view.community.title} - 
379         ${i18n.t('number_of_subscribers', {
380           count: community_view.counts.subscribers,
381         })}
382       `}</span>
383       </>
384     );
385   }
386
387   userListing(user_view: UserViewSafe) {
388     return [
389       <span>
390         <UserListing user={user_view.user} showApubName />
391       </span>,
392       <span>{` - ${i18n.t('number_of_comments', {
393         count: user_view.counts.comment_count,
394       })}`}</span>,
395     ];
396   }
397
398   users() {
399     return (
400       <>
401         {this.state.searchResponse.users.map(user => (
402           <div class="row">
403             <div class="col-12">{this.userListing(user)}</div>
404           </div>
405         ))}
406       </>
407     );
408   }
409
410   paginator() {
411     return (
412       <div class="mt-2">
413         {this.state.page > 1 && (
414           <button
415             class="btn btn-secondary mr-1"
416             onClick={linkEvent(this, this.prevPage)}
417           >
418             {i18n.t('prev')}
419           </button>
420         )}
421
422         {this.resultsCount() > 0 && (
423           <button
424             class="btn btn-secondary"
425             onClick={linkEvent(this, this.nextPage)}
426           >
427             {i18n.t('next')}
428           </button>
429         )}
430       </div>
431     );
432   }
433
434   resultsCount(): number {
435     let res = this.state.searchResponse;
436     return (
437       res.posts.length +
438       res.comments.length +
439       res.communities.length +
440       res.users.length
441     );
442   }
443
444   nextPage(i: Search) {
445     i.updateUrl({ page: i.state.page + 1 });
446   }
447
448   prevPage(i: Search) {
449     i.updateUrl({ page: i.state.page - 1 });
450   }
451
452   search() {
453     let form: SearchForm = {
454       q: this.state.q,
455       type_: this.state.type_,
456       sort: this.state.sort,
457       page: this.state.page,
458       limit: fetchLimit,
459       auth: UserService.Instance.authField(false),
460     };
461
462     if (this.state.q != '') {
463       WebSocketService.Instance.client.search(form);
464     }
465   }
466
467   handleSortChange(val: SortType) {
468     this.updateUrl({ sort: val, page: 1 });
469   }
470
471   handleTypeChange(i: Search, event: any) {
472     i.updateUrl({
473       type_: SearchType[event.target.value],
474       page: 1,
475     });
476   }
477
478   handleSearchSubmit(i: Search, event: any) {
479     event.preventDefault();
480     i.updateUrl({
481       q: i.state.searchText,
482       type_: i.state.type_,
483       sort: i.state.sort,
484       page: i.state.page,
485     });
486   }
487
488   handleQChange(i: Search, event: any) {
489     i.setState({ searchText: event.target.value });
490   }
491
492   updateUrl(paramUpdates: UrlParams) {
493     const qStr = paramUpdates.q || this.state.q;
494     const qStrEncoded = encodeURIComponent(qStr);
495     const typeStr = paramUpdates.type_ || this.state.type_;
496     const sortStr = paramUpdates.sort || this.state.sort;
497     const page = paramUpdates.page || this.state.page;
498     this.props.history.push(
499       `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/page/${page}`
500     );
501   }
502
503   parseMessage(msg: any) {
504     console.log(msg);
505     let op = wsUserOp(msg);
506     if (msg.error) {
507       toast(i18n.t(msg.error), 'danger');
508       return;
509     } else if (op == UserOperation.Search) {
510       let data = wsJsonToRes<SearchResponse>(msg).data;
511       this.state.searchResponse = data;
512       this.state.loading = false;
513       window.scrollTo(0, 0);
514       this.setState(this.state);
515     } else if (op == UserOperation.CreateCommentLike) {
516       let data = wsJsonToRes<CommentResponse>(msg).data;
517       createCommentLikeRes(
518         data.comment_view,
519         this.state.searchResponse.comments
520       );
521       this.setState(this.state);
522     } else if (op == UserOperation.CreatePostLike) {
523       let data = wsJsonToRes<PostResponse>(msg).data;
524       createPostLikeFindRes(data.post_view, this.state.searchResponse.posts);
525       this.setState(this.state);
526     }
527   }
528 }