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