]> Untitled Git - lemmy-ui.git/blob - src/shared/components/search.tsx
Adding more aria labels based on tota11y recs. #170
[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
218           type="submit"
219           class="btn btn-secondary mr-2 mb-2"
220           aria-label={i18n.t('search')}
221         >
222           {this.state.loading ? (
223             <svg class="icon icon-spinner spin">
224               <use xlinkHref="#icon-spinner"></use>
225             </svg>
226           ) : (
227             <span>{i18n.t('search')}</span>
228           )}
229         </button>
230       </form>
231     );
232   }
233
234   selects() {
235     return (
236       <div className="mb-2">
237         <select
238           value={this.state.type_}
239           onChange={linkEvent(this, this.handleTypeChange)}
240           class="custom-select w-auto mb-2"
241           aria-label={i18n.t('type')}
242         >
243           <option disabled aria-hidden="true">
244             {i18n.t('type')}
245           </option>
246           <option value={SearchType.All}>{i18n.t('all')}</option>
247           <option value={SearchType.Comments}>{i18n.t('comments')}</option>
248           <option value={SearchType.Posts}>{i18n.t('posts')}</option>
249           <option value={SearchType.Communities}>
250             {i18n.t('communities')}
251           </option>
252           <option value={SearchType.Users}>{i18n.t('users')}</option>
253         </select>
254         <span class="ml-2">
255           <SortSelect
256             sort={this.state.sort}
257             onChange={this.handleSortChange}
258             hideHot
259             hideMostComments
260           />
261         </span>
262       </div>
263     );
264   }
265
266   all() {
267     let combined: {
268       type_: string;
269       data: CommentView | PostView | CommunityView | UserViewSafe;
270       published: string;
271     }[] = [];
272     let comments = this.state.searchResponse.comments.map(e => {
273       return { type_: 'comments', data: e, published: e.comment.published };
274     });
275     let posts = this.state.searchResponse.posts.map(e => {
276       return { type_: 'posts', data: e, published: e.post.published };
277     });
278     let communities = this.state.searchResponse.communities.map(e => {
279       return {
280         type_: 'communities',
281         data: e,
282         published: e.community.published,
283       };
284     });
285     let users = this.state.searchResponse.users.map(e => {
286       return { type_: 'users', data: e, published: e.user.published };
287     });
288
289     combined.push(...comments);
290     combined.push(...posts);
291     combined.push(...communities);
292     combined.push(...users);
293
294     // Sort it
295     if (this.state.sort == SortType.New) {
296       combined.sort((a, b) => b.published.localeCompare(a.published));
297     } else {
298       combined.sort(
299         (a, b) =>
300           ((b.data as CommentView | PostView).counts.score |
301             (b.data as CommunityView).counts.subscribers |
302             (b.data as UserViewSafe).counts.comment_score) -
303           ((a.data as CommentView | PostView).counts.score |
304             (a.data as CommunityView).counts.subscribers |
305             (a.data as UserViewSafe).counts.comment_score)
306       );
307     }
308
309     return (
310       <div>
311         {combined.map(i => (
312           <div class="row">
313             <div class="col-12">
314               {i.type_ == 'posts' && (
315                 <PostListing
316                   key={(i.data as PostView).post.id}
317                   post_view={i.data as PostView}
318                   showCommunity
319                   enableDownvotes={this.state.site.enable_downvotes}
320                   enableNsfw={this.state.site.enable_nsfw}
321                 />
322               )}
323               {i.type_ == 'comments' && (
324                 <CommentNodes
325                   key={(i.data as CommentView).comment.id}
326                   nodes={[{ comment_view: i.data as CommentView }]}
327                   locked
328                   noIndent
329                   enableDownvotes={this.state.site.enable_downvotes}
330                 />
331               )}
332               {i.type_ == 'communities' && (
333                 <div>{this.communityListing(i.data as CommunityView)}</div>
334               )}
335               {i.type_ == 'users' && (
336                 <div>{this.userListing(i.data as UserViewSafe)}</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                 post_view={post}
364                 showCommunity
365                 enableDownvotes={this.state.site.enable_downvotes}
366                 enableNsfw={this.state.site.enable_nsfw}
367               />
368             </div>
369           </div>
370         ))}
371       </>
372     );
373   }
374
375   communities() {
376     return (
377       <>
378         {this.state.searchResponse.communities.map(community => (
379           <div class="row">
380             <div class="col-12">{this.communityListing(community)}</div>
381           </div>
382         ))}
383       </>
384     );
385   }
386
387   communityListing(community_view: CommunityView) {
388     return (
389       <>
390         <span>
391           <CommunityLink community={community_view.community} />
392         </span>
393         <span>{` -
394         ${i18n.t('number_of_subscribers', {
395           count: community_view.counts.subscribers,
396         })}
397       `}</span>
398       </>
399     );
400   }
401
402   userListing(user_view: UserViewSafe) {
403     return [
404       <span>
405         <UserListing user={user_view.user} showApubName />
406       </span>,
407       <span>{` - ${i18n.t('number_of_comments', {
408         count: user_view.counts.comment_count,
409       })}`}</span>,
410     ];
411   }
412
413   users() {
414     return (
415       <>
416         {this.state.searchResponse.users.map(user => (
417           <div class="row">
418             <div class="col-12">{this.userListing(user)}</div>
419           </div>
420         ))}
421       </>
422     );
423   }
424
425   paginator() {
426     return (
427       <div class="mt-2">
428         {this.state.page > 1 && (
429           <button
430             class="btn btn-secondary mr-1"
431             onClick={linkEvent(this, this.prevPage)}
432           >
433             {i18n.t('prev')}
434           </button>
435         )}
436
437         {this.resultsCount() > 0 && (
438           <button
439             class="btn btn-secondary"
440             onClick={linkEvent(this, this.nextPage)}
441           >
442             {i18n.t('next')}
443           </button>
444         )}
445       </div>
446     );
447   }
448
449   resultsCount(): number {
450     let res = this.state.searchResponse;
451     return (
452       res.posts.length +
453       res.comments.length +
454       res.communities.length +
455       res.users.length
456     );
457   }
458
459   nextPage(i: Search) {
460     i.updateUrl({ page: i.state.page + 1 });
461   }
462
463   prevPage(i: Search) {
464     i.updateUrl({ page: i.state.page - 1 });
465   }
466
467   search() {
468     let form: SearchForm = {
469       q: this.state.q,
470       type_: this.state.type_,
471       sort: this.state.sort,
472       page: this.state.page,
473       limit: fetchLimit,
474       auth: authField(false),
475     };
476
477     if (this.state.q != '') {
478       WebSocketService.Instance.send(wsClient.search(form));
479     }
480   }
481
482   handleSortChange(val: SortType) {
483     this.updateUrl({ sort: val, page: 1 });
484   }
485
486   handleTypeChange(i: Search, event: any) {
487     i.updateUrl({
488       type_: SearchType[event.target.value],
489       page: 1,
490     });
491   }
492
493   handleSearchSubmit(i: Search, event: any) {
494     event.preventDefault();
495     i.updateUrl({
496       q: i.state.searchText,
497       type_: i.state.type_,
498       sort: i.state.sort,
499       page: i.state.page,
500     });
501   }
502
503   handleQChange(i: Search, event: any) {
504     i.setState({ searchText: event.target.value });
505   }
506
507   updateUrl(paramUpdates: UrlParams) {
508     const qStr = paramUpdates.q || this.state.q;
509     const qStrEncoded = encodeURIComponent(qStr);
510     const typeStr = paramUpdates.type_ || this.state.type_;
511     const sortStr = paramUpdates.sort || this.state.sort;
512     const page = paramUpdates.page || this.state.page;
513     this.props.history.push(
514       `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/page/${page}`
515     );
516   }
517
518   parseMessage(msg: any) {
519     console.log(msg);
520     let op = wsUserOp(msg);
521     if (msg.error) {
522       toast(i18n.t(msg.error), 'danger');
523       return;
524     } else if (op == UserOperation.Search) {
525       let data = wsJsonToRes<SearchResponse>(msg).data;
526       this.state.searchResponse = data;
527       this.state.loading = false;
528       window.scrollTo(0, 0);
529       this.setState(this.state);
530       restoreScrollPosition(this.context);
531     } else if (op == UserOperation.CreateCommentLike) {
532       let data = wsJsonToRes<CommentResponse>(msg).data;
533       createCommentLikeRes(
534         data.comment_view,
535         this.state.searchResponse.comments
536       );
537       this.setState(this.state);
538     } else if (op == UserOperation.CreatePostLike) {
539       let data = wsJsonToRes<PostResponse>(msg).data;
540       createPostLikeFindRes(data.post_view, this.state.searchResponse.posts);
541       this.setState(this.state);
542     }
543   }
544 }