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