]> Untitled Git - lemmy-ui.git/blob - src/shared/components/search.tsx
Fix search page community titles.
[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           />
249         </span>
250       </div>
251     );
252   }
253
254   all() {
255     let combined: {
256       type_: string;
257       data: CommentView | PostView | CommunityView | UserViewSafe;
258       published: string;
259     }[] = [];
260     let comments = this.state.searchResponse.comments.map(e => {
261       return { type_: 'comments', data: e, published: e.comment.published };
262     });
263     let posts = this.state.searchResponse.posts.map(e => {
264       return { type_: 'posts', data: e, published: e.post.published };
265     });
266     let communities = this.state.searchResponse.communities.map(e => {
267       return {
268         type_: 'communities',
269         data: e,
270         published: e.community.published,
271       };
272     });
273     let users = this.state.searchResponse.users.map(e => {
274       return { type_: 'users', data: e, published: e.user.published };
275     });
276
277     combined.push(...comments);
278     combined.push(...posts);
279     combined.push(...communities);
280     combined.push(...users);
281
282     // Sort it
283     if (this.state.sort == SortType.New) {
284       combined.sort((a, b) => b.published.localeCompare(a.published));
285     } else {
286       combined.sort(
287         (a, b) =>
288           ((b.data as CommentView | PostView).counts.score |
289             (b.data as CommunityView).counts.subscribers |
290             (b.data as UserViewSafe).counts.comment_score) -
291           ((a.data as CommentView | PostView).counts.score |
292             (a.data as CommunityView).counts.subscribers |
293             (a.data as UserViewSafe).counts.comment_score)
294       );
295     }
296
297     return (
298       <div>
299         {combined.map(i => (
300           <div class="row">
301             <div class="col-12">
302               {i.type_ == 'posts' && (
303                 <PostListing
304                   key={(i.data as PostView).post.id}
305                   post_view={i.data as PostView}
306                   showCommunity
307                   enableDownvotes={this.state.site.enable_downvotes}
308                   enableNsfw={this.state.site.enable_nsfw}
309                 />
310               )}
311               {i.type_ == 'comments' && (
312                 <CommentNodes
313                   key={(i.data as CommentView).comment.id}
314                   nodes={[{ comment_view: i.data as CommentView }]}
315                   locked
316                   noIndent
317                   enableDownvotes={this.state.site.enable_downvotes}
318                 />
319               )}
320               {i.type_ == 'communities' && (
321                 <div>{this.communityListing(i.data as CommunityView)}</div>
322               )}
323               {i.type_ == 'users' && (
324                 <div>{this.userListing(i.data as UserViewSafe)}</div>
325               )}
326             </div>
327           </div>
328         ))}
329       </div>
330     );
331   }
332
333   comments() {
334     return (
335       <CommentNodes
336         nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
337         locked
338         noIndent
339         enableDownvotes={this.state.site.enable_downvotes}
340       />
341     );
342   }
343
344   posts() {
345     return (
346       <>
347         {this.state.searchResponse.posts.map(post => (
348           <div class="row">
349             <div class="col-12">
350               <PostListing
351                 post_view={post}
352                 showCommunity
353                 enableDownvotes={this.state.site.enable_downvotes}
354                 enableNsfw={this.state.site.enable_nsfw}
355               />
356             </div>
357           </div>
358         ))}
359       </>
360     );
361   }
362
363   communities() {
364     return (
365       <>
366         {this.state.searchResponse.communities.map(community => (
367           <div class="row">
368             <div class="col-12">{this.communityListing(community)}</div>
369           </div>
370         ))}
371       </>
372     );
373   }
374
375   communityListing(community_view: CommunityView) {
376     return (
377       <>
378         <span>
379           <CommunityLink community={community_view.community} />
380         </span>
381         <span>{` - 
382         ${i18n.t('number_of_subscribers', {
383           count: community_view.counts.subscribers,
384         })}
385       `}</span>
386       </>
387     );
388   }
389
390   userListing(user_view: UserViewSafe) {
391     return [
392       <span>
393         <UserListing user={user_view.user} showApubName />
394       </span>,
395       <span>{` - ${i18n.t('number_of_comments', {
396         count: user_view.counts.comment_count,
397       })}`}</span>,
398     ];
399   }
400
401   users() {
402     return (
403       <>
404         {this.state.searchResponse.users.map(user => (
405           <div class="row">
406             <div class="col-12">{this.userListing(user)}</div>
407           </div>
408         ))}
409       </>
410     );
411   }
412
413   paginator() {
414     return (
415       <div class="mt-2">
416         {this.state.page > 1 && (
417           <button
418             class="btn btn-secondary mr-1"
419             onClick={linkEvent(this, this.prevPage)}
420           >
421             {i18n.t('prev')}
422           </button>
423         )}
424
425         {this.resultsCount() > 0 && (
426           <button
427             class="btn btn-secondary"
428             onClick={linkEvent(this, this.nextPage)}
429           >
430             {i18n.t('next')}
431           </button>
432         )}
433       </div>
434     );
435   }
436
437   resultsCount(): number {
438     let res = this.state.searchResponse;
439     return (
440       res.posts.length +
441       res.comments.length +
442       res.communities.length +
443       res.users.length
444     );
445   }
446
447   nextPage(i: Search) {
448     i.updateUrl({ page: i.state.page + 1 });
449   }
450
451   prevPage(i: Search) {
452     i.updateUrl({ page: i.state.page - 1 });
453   }
454
455   search() {
456     let form: SearchForm = {
457       q: this.state.q,
458       type_: this.state.type_,
459       sort: this.state.sort,
460       page: this.state.page,
461       limit: fetchLimit,
462       auth: authField(false),
463     };
464
465     if (this.state.q != '') {
466       WebSocketService.Instance.send(wsClient.search(form));
467     }
468   }
469
470   handleSortChange(val: SortType) {
471     this.updateUrl({ sort: val, page: 1 });
472   }
473
474   handleTypeChange(i: Search, event: any) {
475     i.updateUrl({
476       type_: SearchType[event.target.value],
477       page: 1,
478     });
479   }
480
481   handleSearchSubmit(i: Search, event: any) {
482     event.preventDefault();
483     i.updateUrl({
484       q: i.state.searchText,
485       type_: i.state.type_,
486       sort: i.state.sort,
487       page: i.state.page,
488     });
489   }
490
491   handleQChange(i: Search, event: any) {
492     i.setState({ searchText: event.target.value });
493   }
494
495   updateUrl(paramUpdates: UrlParams) {
496     const qStr = paramUpdates.q || this.state.q;
497     const qStrEncoded = encodeURIComponent(qStr);
498     const typeStr = paramUpdates.type_ || this.state.type_;
499     const sortStr = paramUpdates.sort || this.state.sort;
500     const page = paramUpdates.page || this.state.page;
501     this.props.history.push(
502       `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/page/${page}`
503     );
504   }
505
506   parseMessage(msg: any) {
507     console.log(msg);
508     let op = wsUserOp(msg);
509     if (msg.error) {
510       toast(i18n.t(msg.error), 'danger');
511       return;
512     } else if (op == UserOperation.Search) {
513       let data = wsJsonToRes<SearchResponse>(msg).data;
514       this.state.searchResponse = data;
515       this.state.loading = false;
516       window.scrollTo(0, 0);
517       this.setState(this.state);
518     } else if (op == UserOperation.CreateCommentLike) {
519       let data = wsJsonToRes<CommentResponse>(msg).data;
520       createCommentLikeRes(
521         data.comment_view,
522         this.state.searchResponse.comments
523       );
524       this.setState(this.state);
525     } else if (op == UserOperation.CreatePostLike) {
526       let data = wsJsonToRes<PostResponse>(msg).data;
527       createPostLikeFindRes(data.post_view, this.state.searchResponse.posts);
528       this.setState(this.state);
529     }
530   }
531 }