]> Untitled Git - lemmy-ui.git/blob - src/shared/components/search.tsx
Merge pull request #287 from LemmyNet/feature/url_search
[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   PersonViewSafe,
9   SortType,
10   Search as SearchForm,
11   SearchResponse,
12   SearchType,
13   PostResponse,
14   CommentResponse,
15   Site,
16   ListingType,
17   ListCommunities,
18   ListCommunitiesResponse,
19   GetCommunity,
20   GetPersonDetails,
21 } from "lemmy-js-client";
22 import { WebSocketService } from "../services";
23 import {
24   wsJsonToRes,
25   fetchLimit,
26   routeSearchTypeToEnum,
27   routeSortTypeToEnum,
28   toast,
29   createCommentLikeRes,
30   createPostLikeFindRes,
31   commentsToFlatNodes,
32   setIsoData,
33   wsSubscribe,
34   wsUserOp,
35   wsClient,
36   authField,
37   setOptionalAuth,
38   saveScrollPosition,
39   restoreScrollPosition,
40   routeListingTypeToEnum,
41   showLocal,
42   isBrowser,
43   choicesConfig,
44   debounce,
45   fetchCommunities,
46   communityToChoice,
47   fetchUsers,
48   personToChoice,
49   capitalizeFirstLetter,
50   communitySelectName,
51   personSelectName,
52 } from "../utils";
53 import { PostListing } from "./post-listing";
54 import { HtmlTags } from "./html-tags";
55 import { Spinner } from "./icon";
56 import { PersonListing } from "./person-listing";
57 import { CommunityLink } from "./community-link";
58 import { SortSelect } from "./sort-select";
59 import { ListingTypeSelect } from "./listing-type-select";
60 import { CommentNodes } from "./comment-nodes";
61 import { i18n } from "../i18next";
62 import { InitialFetchRequest } from "shared/interfaces";
63
64 var Choices;
65 if (isBrowser()) {
66   Choices = require("choices.js");
67 }
68
69 interface SearchProps {
70   q: string;
71   type_: SearchType;
72   sort: SortType;
73   listingType: ListingType;
74   communityId: number;
75   creatorId: number;
76   page: number;
77 }
78
79 interface SearchState {
80   q: string;
81   type_: SearchType;
82   sort: SortType;
83   listingType: ListingType;
84   communityId: number;
85   creatorId: number;
86   page: number;
87   searchResponse: SearchResponse;
88   communities: CommunityView[];
89   creator?: PersonViewSafe;
90   loading: boolean;
91   site: Site;
92   searchText: string;
93 }
94
95 interface UrlParams {
96   q?: string;
97   type_?: SearchType;
98   sort?: SortType;
99   listingType?: ListingType;
100   communityId?: number;
101   creatorId?: number;
102   page?: number;
103 }
104
105 export class Search extends Component<any, SearchState> {
106   private isoData = setIsoData(this.context);
107   private communityChoices: any;
108   private creatorChoices: any;
109   private subscription: Subscription;
110   private emptyState: SearchState = {
111     q: Search.getSearchQueryFromProps(this.props.match.params.q),
112     type_: Search.getSearchTypeFromProps(this.props.match.params.type),
113     sort: Search.getSortTypeFromProps(this.props.match.params.sort),
114     listingType: Search.getListingTypeFromProps(
115       this.props.match.params.listing_type
116     ),
117     page: Search.getPageFromProps(this.props.match.params.page),
118     searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
119     communityId: Search.getCommunityIdFromProps(
120       this.props.match.params.community_id
121     ),
122     creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
123     searchResponse: {
124       type_: null,
125       posts: [],
126       comments: [],
127       communities: [],
128       users: [],
129     },
130     loading: true,
131     site: this.isoData.site_res.site_view.site,
132     communities: [],
133   };
134
135   static getSearchQueryFromProps(q: string): string {
136     return decodeURIComponent(q) || "";
137   }
138
139   static getSearchTypeFromProps(type_: string): SearchType {
140     return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
141   }
142
143   static getSortTypeFromProps(sort: string): SortType {
144     return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
145   }
146
147   static getListingTypeFromProps(listingType: string): ListingType {
148     return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
149   }
150
151   static getCommunityIdFromProps(id: string): number {
152     return id ? Number(id) : 0;
153   }
154
155   static getCreatorIdFromProps(id: string): number {
156     return id ? Number(id) : 0;
157   }
158
159   static getPageFromProps(page: string): number {
160     return page ? Number(page) : 1;
161   }
162
163   constructor(props: any, context: any) {
164     super(props, context);
165
166     this.state = this.emptyState;
167     this.handleSortChange = this.handleSortChange.bind(this);
168     this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
169
170     this.parseMessage = this.parseMessage.bind(this);
171     this.subscription = wsSubscribe(this.parseMessage);
172
173     // Only fetch the data if coming from another route
174     if (this.isoData.path == this.context.router.route.match.url) {
175       let singleOrMultipleCommunities = this.isoData.routeData[0];
176       if (singleOrMultipleCommunities.communities) {
177         this.state.communities = this.isoData.routeData[0].communities;
178       } else {
179         this.state.communities = [this.isoData.routeData[0].community_view];
180       }
181
182       let creator = this.isoData.routeData[1];
183       if (creator?.person_view) {
184         this.state.creator = this.isoData.routeData[1].person_view;
185       }
186       if (this.state.q != "") {
187         this.state.searchResponse = this.isoData.routeData[2];
188         this.state.loading = false;
189       } else {
190         this.search();
191       }
192     } else {
193       this.fetchCommunities();
194       this.search();
195     }
196   }
197
198   componentWillUnmount() {
199     this.subscription.unsubscribe();
200     saveScrollPosition(this.context);
201   }
202
203   componentDidMount() {
204     this.setupCommunityFilter();
205     this.setupCreatorFilter();
206   }
207
208   static getDerivedStateFromProps(props: any): SearchProps {
209     return {
210       q: Search.getSearchQueryFromProps(props.match.params.q),
211       type_: Search.getSearchTypeFromProps(props.match.params.type),
212       sort: Search.getSortTypeFromProps(props.match.params.sort),
213       listingType: Search.getListingTypeFromProps(
214         props.match.params.listing_type
215       ),
216       communityId: Search.getCommunityIdFromProps(
217         props.match.params.community_id
218       ),
219       creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
220       page: Search.getPageFromProps(props.match.params.page),
221     };
222   }
223
224   fetchCommunities() {
225     let listCommunitiesForm: ListCommunities = {
226       type_: ListingType.All,
227       sort: SortType.TopAll,
228       limit: fetchLimit,
229       auth: authField(false),
230     };
231     WebSocketService.Instance.send(
232       wsClient.listCommunities(listCommunitiesForm)
233     );
234   }
235
236   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
237     let pathSplit = req.path.split("/");
238     let promises: Promise<any>[] = [];
239
240     let communityId = this.getCommunityIdFromProps(pathSplit[11]);
241     if (communityId !== 0) {
242       let getCommunityForm: GetCommunity = {
243         id: communityId,
244       };
245       setOptionalAuth(getCommunityForm, req.auth);
246       promises.push(req.client.getCommunity(getCommunityForm));
247     } else {
248       let listCommunitiesForm: ListCommunities = {
249         type_: ListingType.All,
250         sort: SortType.TopAll,
251         limit: fetchLimit,
252       };
253       setOptionalAuth(listCommunitiesForm, req.auth);
254       promises.push(req.client.listCommunities(listCommunitiesForm));
255     }
256
257     let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
258     if (creatorId !== 0) {
259       let getCreatorForm: GetPersonDetails = {
260         person_id: creatorId,
261       };
262       setOptionalAuth(getCreatorForm, req.auth);
263       promises.push(req.client.getPersonDetails(getCreatorForm));
264     } else {
265       promises.push(Promise.resolve());
266     }
267
268     let form: SearchForm = {
269       q: this.getSearchQueryFromProps(pathSplit[3]),
270       type_: this.getSearchTypeFromProps(pathSplit[5]),
271       sort: this.getSortTypeFromProps(pathSplit[7]),
272       listing_type: this.getListingTypeFromProps(pathSplit[9]),
273       page: this.getPageFromProps(pathSplit[15]),
274       limit: fetchLimit,
275     };
276     if (communityId !== 0) {
277       form.community_id = communityId;
278     }
279     if (creatorId !== 0) {
280       form.creator_id = creatorId;
281     }
282     setOptionalAuth(form, req.auth);
283
284     if (form.q != "") {
285       promises.push(req.client.search(form));
286     }
287
288     return promises;
289   }
290
291   componentDidUpdate(_: any, lastState: SearchState) {
292     if (
293       lastState.q !== this.state.q ||
294       lastState.type_ !== this.state.type_ ||
295       lastState.sort !== this.state.sort ||
296       lastState.listingType !== this.state.listingType ||
297       lastState.communityId !== this.state.communityId ||
298       lastState.creatorId !== this.state.creatorId ||
299       lastState.page !== this.state.page
300     ) {
301       this.setState({ loading: true, searchText: this.state.q });
302       this.search();
303     }
304   }
305
306   get documentTitle(): string {
307     if (this.state.q) {
308       return `${i18n.t("search")} - ${this.state.q} - ${this.state.site.name}`;
309     } else {
310       return `${i18n.t("search")} - ${this.state.site.name}`;
311     }
312   }
313
314   render() {
315     return (
316       <div class="container">
317         <HtmlTags
318           title={this.documentTitle}
319           path={this.context.router.route.match.url}
320         />
321         <h5>{i18n.t("search")}</h5>
322         {this.selects()}
323         {this.searchForm()}
324         {this.state.type_ == SearchType.All && this.all()}
325         {this.state.type_ == SearchType.Comments && this.comments()}
326         {this.state.type_ == SearchType.Posts && this.posts()}
327         {this.state.type_ == SearchType.Communities && this.communities()}
328         {this.state.type_ == SearchType.Users && this.users()}
329         {this.state.type_ == SearchType.Url && this.posts()}
330         {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
331         {this.paginator()}
332       </div>
333     );
334   }
335
336   searchForm() {
337     return (
338       <form
339         class="form-inline"
340         onSubmit={linkEvent(this, this.handleSearchSubmit)}
341       >
342         <input
343           type="text"
344           class="form-control mr-2 mb-2"
345           value={this.state.searchText}
346           placeholder={`${i18n.t("search")}...`}
347           aria-label={i18n.t("search")}
348           onInput={linkEvent(this, this.handleQChange)}
349           required
350           minLength={3}
351         />
352         <button type="submit" class="btn btn-secondary mr-2 mb-2">
353           {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
354         </button>
355       </form>
356     );
357   }
358
359   selects() {
360     return (
361       <div className="mb-2">
362         <select
363           value={this.state.type_}
364           onChange={linkEvent(this, this.handleTypeChange)}
365           class="custom-select w-auto mb-2"
366           aria-label={i18n.t("type")}
367         >
368           <option disabled aria-hidden="true">
369             {i18n.t("type")}
370           </option>
371           <option value={SearchType.All}>{i18n.t("all")}</option>
372           <option value={SearchType.Comments}>{i18n.t("comments")}</option>
373           <option value={SearchType.Posts}>{i18n.t("posts")}</option>
374           <option value={SearchType.Communities}>
375             {i18n.t("communities")}
376           </option>
377           <option value={SearchType.Users}>{i18n.t("users")}</option>
378           <option value={SearchType.Url}>{i18n.t("url")}</option>
379         </select>
380         <span class="ml-2">
381           <ListingTypeSelect
382             type_={this.state.listingType}
383             showLocal={showLocal(this.isoData)}
384             onChange={this.handleListingTypeChange}
385           />
386         </span>
387         <span class="ml-2">
388           <SortSelect
389             sort={this.state.sort}
390             onChange={this.handleSortChange}
391             hideHot
392             hideMostComments
393           />
394         </span>
395         <div class="form-row">
396           {this.state.communities.length > 0 && this.communityFilter()}
397           {this.creatorFilter()}
398         </div>
399       </div>
400     );
401   }
402
403   all() {
404     let combined: {
405       type_: string;
406       data: CommentView | PostView | CommunityView | PersonViewSafe;
407       published: string;
408     }[] = [];
409     let comments = this.state.searchResponse.comments.map(e => {
410       return { type_: "comments", data: e, published: e.comment.published };
411     });
412     let posts = this.state.searchResponse.posts.map(e => {
413       return { type_: "posts", data: e, published: e.post.published };
414     });
415     let communities = this.state.searchResponse.communities.map(e => {
416       return {
417         type_: "communities",
418         data: e,
419         published: e.community.published,
420       };
421     });
422     let users = this.state.searchResponse.users.map(e => {
423       return { type_: "users", data: e, published: e.person.published };
424     });
425
426     combined.push(...comments);
427     combined.push(...posts);
428     combined.push(...communities);
429     combined.push(...users);
430
431     // Sort it
432     if (this.state.sort == SortType.New) {
433       combined.sort((a, b) => b.published.localeCompare(a.published));
434     } else {
435       combined.sort(
436         (a, b) =>
437           ((b.data as CommentView | PostView).counts.score |
438             (b.data as CommunityView).counts.subscribers |
439             (b.data as PersonViewSafe).counts.comment_score) -
440           ((a.data as CommentView | PostView).counts.score |
441             (a.data as CommunityView).counts.subscribers |
442             (a.data as PersonViewSafe).counts.comment_score)
443       );
444     }
445
446     return (
447       <div>
448         {combined.map(i => (
449           <div class="row">
450             <div class="col-12">
451               {i.type_ == "posts" && (
452                 <PostListing
453                   key={(i.data as PostView).post.id}
454                   post_view={i.data as PostView}
455                   showCommunity
456                   enableDownvotes={this.state.site.enable_downvotes}
457                   enableNsfw={this.state.site.enable_nsfw}
458                 />
459               )}
460               {i.type_ == "comments" && (
461                 <CommentNodes
462                   key={(i.data as CommentView).comment.id}
463                   nodes={[{ comment_view: i.data as CommentView }]}
464                   locked
465                   noIndent
466                   enableDownvotes={this.state.site.enable_downvotes}
467                 />
468               )}
469               {i.type_ == "communities" && (
470                 <div>{this.communityListing(i.data as CommunityView)}</div>
471               )}
472               {i.type_ == "users" && (
473                 <div>{this.userListing(i.data as PersonViewSafe)}</div>
474               )}
475             </div>
476           </div>
477         ))}
478       </div>
479     );
480   }
481
482   comments() {
483     return (
484       <CommentNodes
485         nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
486         locked
487         noIndent
488         enableDownvotes={this.state.site.enable_downvotes}
489       />
490     );
491   }
492
493   posts() {
494     return (
495       <>
496         {this.state.searchResponse.posts.map(post => (
497           <div class="row">
498             <div class="col-12">
499               <PostListing
500                 post_view={post}
501                 showCommunity
502                 enableDownvotes={this.state.site.enable_downvotes}
503                 enableNsfw={this.state.site.enable_nsfw}
504               />
505             </div>
506           </div>
507         ))}
508       </>
509     );
510   }
511
512   communities() {
513     return (
514       <>
515         {this.state.searchResponse.communities.map(community => (
516           <div class="row">
517             <div class="col-12">{this.communityListing(community)}</div>
518           </div>
519         ))}
520       </>
521     );
522   }
523
524   communityListing(community_view: CommunityView) {
525     return (
526       <>
527         <span>
528           <CommunityLink community={community_view.community} />
529         </span>
530         <span>{` -
531         ${i18n.t("number_of_subscribers", {
532           count: community_view.counts.subscribers,
533         })}
534       `}</span>
535       </>
536     );
537   }
538
539   userListing(person_view: PersonViewSafe) {
540     return [
541       <span>
542         <PersonListing person={person_view.person} showApubName />
543       </span>,
544       <span>{` - ${i18n.t("number_of_comments", {
545         count: person_view.counts.comment_count,
546       })}`}</span>,
547     ];
548   }
549
550   users() {
551     return (
552       <>
553         {this.state.searchResponse.users.map(user => (
554           <div class="row">
555             <div class="col-12">{this.userListing(user)}</div>
556           </div>
557         ))}
558       </>
559     );
560   }
561
562   communityFilter() {
563     return (
564       <div class="form-group col-sm-6">
565         <label class="col-form-label" htmlFor="community-filter">
566           {i18n.t("community")}
567         </label>
568         <div>
569           <select
570             class="form-control"
571             id="community-filter"
572             value={this.state.communityId}
573           >
574             <option value="0">{i18n.t("all")}</option>
575             {this.state.communities.map(cv => (
576               <option value={cv.community.id}>{communitySelectName(cv)}</option>
577             ))}
578           </select>
579         </div>
580       </div>
581     );
582   }
583
584   creatorFilter() {
585     return (
586       <div class="form-group col-sm-6">
587         <label class="col-form-label" htmlFor="creator-filter">
588           {capitalizeFirstLetter(i18n.t("creator"))}
589         </label>
590         <div>
591           <select
592             class="form-control"
593             id="creator-filter"
594             value={this.state.creatorId}
595           >
596             <option value="0">{i18n.t("all")}</option>
597             {this.state.creator && (
598               <option value={this.state.creator.person.id}>
599                 {personSelectName(this.state.creator)}
600               </option>
601             )}
602           </select>
603         </div>
604       </div>
605     );
606   }
607
608   paginator() {
609     return (
610       <div class="mt-2">
611         {this.state.page > 1 && (
612           <button
613             class="btn btn-secondary mr-1"
614             onClick={linkEvent(this, this.prevPage)}
615           >
616             {i18n.t("prev")}
617           </button>
618         )}
619
620         {this.resultsCount() > 0 && (
621           <button
622             class="btn btn-secondary"
623             onClick={linkEvent(this, this.nextPage)}
624           >
625             {i18n.t("next")}
626           </button>
627         )}
628       </div>
629     );
630   }
631
632   resultsCount(): number {
633     let res = this.state.searchResponse;
634     return (
635       res.posts.length +
636       res.comments.length +
637       res.communities.length +
638       res.users.length
639     );
640   }
641
642   nextPage(i: Search) {
643     i.updateUrl({ page: i.state.page + 1 });
644   }
645
646   prevPage(i: Search) {
647     i.updateUrl({ page: i.state.page - 1 });
648   }
649
650   search() {
651     let form: SearchForm = {
652       q: this.state.q,
653       type_: this.state.type_,
654       sort: this.state.sort,
655       listing_type: this.state.listingType,
656       page: this.state.page,
657       limit: fetchLimit,
658       auth: authField(false),
659     };
660     if (this.state.communityId !== 0) {
661       form.community_id = this.state.communityId;
662     }
663     if (this.state.creatorId !== 0) {
664       form.creator_id = this.state.creatorId;
665     }
666
667     if (this.state.q != "") {
668       WebSocketService.Instance.send(wsClient.search(form));
669     }
670   }
671
672   setupCommunityFilter() {
673     if (isBrowser()) {
674       let selectId: any = document.getElementById("community-filter");
675       if (selectId) {
676         this.communityChoices = new Choices(selectId, choicesConfig);
677         this.communityChoices.passedElement.element.addEventListener(
678           "choice",
679           (e: any) => {
680             this.handleCommunityFilterChange(Number(e.detail.choice.value));
681           },
682           false
683         );
684         this.communityChoices.passedElement.element.addEventListener(
685           "search",
686           debounce(async (e: any) => {
687             let communities = (await fetchCommunities(e.detail.value))
688               .communities;
689             let choices = communities.map(cv => communityToChoice(cv));
690             choices.unshift({ value: "0", label: i18n.t("all") });
691             this.communityChoices.setChoices(choices, "value", "label", true);
692           }, 400),
693           false
694         );
695       }
696     }
697   }
698
699   setupCreatorFilter() {
700     if (isBrowser()) {
701       let selectId: any = document.getElementById("creator-filter");
702       if (selectId) {
703         this.creatorChoices = new Choices(selectId, choicesConfig);
704         this.creatorChoices.passedElement.element.addEventListener(
705           "choice",
706           (e: any) => {
707             this.handleCreatorFilterChange(Number(e.detail.choice.value));
708           },
709           false
710         );
711         this.creatorChoices.passedElement.element.addEventListener(
712           "search",
713           debounce(async (e: any) => {
714             let creators = (await fetchUsers(e.detail.value)).users;
715             let choices = creators.map(pvs => personToChoice(pvs));
716             choices.unshift({ value: "0", label: i18n.t("all") });
717             this.creatorChoices.setChoices(choices, "value", "label", true);
718           }, 400),
719           false
720         );
721       }
722     }
723   }
724
725   handleSortChange(val: SortType) {
726     this.updateUrl({ sort: val, page: 1 });
727   }
728
729   handleTypeChange(i: Search, event: any) {
730     i.updateUrl({
731       type_: SearchType[event.target.value],
732       page: 1,
733     });
734   }
735
736   handleListingTypeChange(val: ListingType) {
737     this.updateUrl({
738       listingType: val,
739       page: 1,
740     });
741   }
742
743   handleCommunityFilterChange(communityId: number) {
744     this.updateUrl({
745       communityId,
746       page: 1,
747     });
748   }
749
750   handleCreatorFilterChange(creatorId: number) {
751     this.updateUrl({
752       creatorId,
753       page: 1,
754     });
755   }
756
757   handleSearchSubmit(i: Search, event: any) {
758     event.preventDefault();
759     i.updateUrl({
760       q: i.state.searchText,
761       type_: i.state.type_,
762       listingType: i.state.listingType,
763       communityId: i.state.communityId,
764       creatorId: i.state.creatorId,
765       sort: i.state.sort,
766       page: i.state.page,
767     });
768   }
769
770   handleQChange(i: Search, event: any) {
771     i.setState({ searchText: event.target.value });
772   }
773
774   updateUrl(paramUpdates: UrlParams) {
775     const qStr = paramUpdates.q || this.state.q;
776     const qStrEncoded = encodeURIComponent(qStr);
777     const typeStr = paramUpdates.type_ || this.state.type_;
778     const listingTypeStr = paramUpdates.listingType || this.state.listingType;
779     const sortStr = paramUpdates.sort || this.state.sort;
780     const communityId =
781       paramUpdates.communityId == 0
782         ? 0
783         : paramUpdates.communityId || this.state.communityId;
784     const creatorId =
785       paramUpdates.creatorId == 0
786         ? 0
787         : paramUpdates.creatorId || this.state.creatorId;
788     const page = paramUpdates.page || this.state.page;
789     this.props.history.push(
790       `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
791     );
792   }
793
794   parseMessage(msg: any) {
795     console.log(msg);
796     let op = wsUserOp(msg);
797     if (msg.error) {
798       toast(i18n.t(msg.error), "danger");
799       return;
800     } else if (op == UserOperation.Search) {
801       let data = wsJsonToRes<SearchResponse>(msg).data;
802       this.state.searchResponse = data;
803       this.state.loading = false;
804       window.scrollTo(0, 0);
805       this.setState(this.state);
806       restoreScrollPosition(this.context);
807     } else if (op == UserOperation.CreateCommentLike) {
808       let data = wsJsonToRes<CommentResponse>(msg).data;
809       createCommentLikeRes(
810         data.comment_view,
811         this.state.searchResponse.comments
812       );
813       this.setState(this.state);
814     } else if (op == UserOperation.CreatePostLike) {
815       let data = wsJsonToRes<PostResponse>(msg).data;
816       createPostLikeFindRes(data.post_view, this.state.searchResponse.posts);
817       this.setState(this.state);
818     } else if (op == UserOperation.ListCommunities) {
819       let data = wsJsonToRes<ListCommunitiesResponse>(msg).data;
820       this.state.communities = data.communities;
821       this.setState(this.state);
822       this.setupCommunityFilter();
823     }
824   }
825 }