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