1 import { Component, linkEvent } from "inferno";
9 ListCommunitiesResponse,
15 ResolveObjectResponse,
22 } from "lemmy-js-client";
23 import { Subscription } from "rxjs";
24 import { InitialFetchRequest } from "shared/interfaces";
25 import { i18n } from "../i18next";
26 import { WebSocketService } from "../services";
29 capitalizeFirstLetter,
35 createPostLikeFindRes,
43 restoreScrollPosition,
44 routeListingTypeToEnum,
45 routeSearchTypeToEnum,
57 import { CommentNodes } from "./comment/comment-nodes";
58 import { HtmlTags } from "./common/html-tags";
59 import { Spinner } from "./common/icon";
60 import { ListingTypeSelect } from "./common/listing-type-select";
61 import { Paginator } from "./common/paginator";
62 import { SortSelect } from "./common/sort-select";
63 import { CommunityLink } from "./community/community-link";
64 import { PersonListing } from "./person/person-listing";
65 import { PostListing } from "./post/post-listing";
69 Choices = require("choices.js");
72 interface SearchProps {
76 listingType: ListingType;
82 interface SearchState {
86 listingType: ListingType;
90 searchResponse: SearchResponse;
91 communities: CommunityView[];
92 creator?: PersonViewSafe;
96 resolveObjectResponse: ResolveObjectResponse;
103 listingType?: ListingType;
104 communityId?: number;
111 data: CommentView | PostView | CommunityView | PersonViewSafe;
115 export class Search extends Component<any, SearchState> {
116 private isoData = setIsoData(this.context);
117 private communityChoices: any;
118 private creatorChoices: any;
119 private subscription: Subscription;
120 private emptyState: SearchState = {
121 q: Search.getSearchQueryFromProps(this.props.match.params.q),
122 type_: Search.getSearchTypeFromProps(this.props.match.params.type),
123 sort: Search.getSortTypeFromProps(this.props.match.params.sort),
124 listingType: Search.getListingTypeFromProps(
125 this.props.match.params.listing_type
127 page: Search.getPageFromProps(this.props.match.params.page),
128 searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
129 communityId: Search.getCommunityIdFromProps(
130 this.props.match.params.community_id
132 creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
140 resolveObjectResponse: {
147 site: this.isoData.site_res.site_view.site,
151 static getSearchQueryFromProps(q: string): string {
152 return decodeURIComponent(q) || "";
155 static getSearchTypeFromProps(type_: string): SearchType {
156 return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
159 static getSortTypeFromProps(sort: string): SortType {
160 return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
163 static getListingTypeFromProps(listingType: string): ListingType {
164 return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
167 static getCommunityIdFromProps(id: string): number {
168 return id ? Number(id) : 0;
171 static getCreatorIdFromProps(id: string): number {
172 return id ? Number(id) : 0;
175 static getPageFromProps(page: string): number {
176 return page ? Number(page) : 1;
179 constructor(props: any, context: any) {
180 super(props, context);
182 this.state = this.emptyState;
183 this.handleSortChange = this.handleSortChange.bind(this);
184 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
185 this.handlePageChange = this.handlePageChange.bind(this);
187 this.parseMessage = this.parseMessage.bind(this);
188 this.subscription = wsSubscribe(this.parseMessage);
190 // Only fetch the data if coming from another route
191 if (this.isoData.path == this.context.router.route.match.url) {
192 let singleOrMultipleCommunities = this.isoData.routeData[0];
193 if (singleOrMultipleCommunities.communities) {
194 this.state.communities = this.isoData.routeData[0].communities;
196 this.state.communities = [this.isoData.routeData[0].community_view];
199 let creator = this.isoData.routeData[1];
200 if (creator?.person_view) {
201 this.state.creator = this.isoData.routeData[1].person_view;
203 if (this.state.q != "") {
204 this.state.searchResponse = this.isoData.routeData[2];
205 this.state.resolveObjectResponse = this.isoData.routeData[3];
206 this.state.loading = false;
211 this.fetchCommunities();
216 componentWillUnmount() {
217 this.subscription.unsubscribe();
218 saveScrollPosition(this.context);
221 componentDidMount() {
222 this.setupCommunityFilter();
223 this.setupCreatorFilter();
226 static getDerivedStateFromProps(props: any): SearchProps {
228 q: Search.getSearchQueryFromProps(props.match.params.q),
229 type_: Search.getSearchTypeFromProps(props.match.params.type),
230 sort: Search.getSortTypeFromProps(props.match.params.sort),
231 listingType: Search.getListingTypeFromProps(
232 props.match.params.listing_type
234 communityId: Search.getCommunityIdFromProps(
235 props.match.params.community_id
237 creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
238 page: Search.getPageFromProps(props.match.params.page),
243 let listCommunitiesForm: ListCommunities = {
244 type_: ListingType.All,
245 sort: SortType.TopAll,
247 auth: authField(false),
249 WebSocketService.Instance.send(
250 wsClient.listCommunities(listCommunitiesForm)
254 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
255 let pathSplit = req.path.split("/");
256 let promises: Promise<any>[] = [];
258 let communityId = this.getCommunityIdFromProps(pathSplit[11]);
259 if (communityId !== 0) {
260 let getCommunityForm: GetCommunity = {
263 setOptionalAuth(getCommunityForm, req.auth);
264 promises.push(req.client.getCommunity(getCommunityForm));
266 let listCommunitiesForm: ListCommunities = {
267 type_: ListingType.All,
268 sort: SortType.TopAll,
271 setOptionalAuth(listCommunitiesForm, req.auth);
272 promises.push(req.client.listCommunities(listCommunitiesForm));
275 let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
276 if (creatorId !== 0) {
277 let getCreatorForm: GetPersonDetails = {
278 person_id: creatorId,
280 setOptionalAuth(getCreatorForm, req.auth);
281 promises.push(req.client.getPersonDetails(getCreatorForm));
283 promises.push(Promise.resolve());
286 let form: SearchForm = {
287 q: this.getSearchQueryFromProps(pathSplit[3]),
288 type_: this.getSearchTypeFromProps(pathSplit[5]),
289 sort: this.getSortTypeFromProps(pathSplit[7]),
290 listing_type: this.getListingTypeFromProps(pathSplit[9]),
291 page: this.getPageFromProps(pathSplit[15]),
294 if (communityId !== 0) {
295 form.community_id = communityId;
297 if (creatorId !== 0) {
298 form.creator_id = creatorId;
300 setOptionalAuth(form, req.auth);
302 let resolveObjectForm: ResolveObject = {
303 q: this.getSearchQueryFromProps(pathSplit[3]),
305 setOptionalAuth(resolveObjectForm, req.auth);
308 promises.push(req.client.search(form));
309 promises.push(req.client.resolveObject(resolveObjectForm));
315 componentDidUpdate(_: any, lastState: SearchState) {
317 lastState.q !== this.state.q ||
318 lastState.type_ !== this.state.type_ ||
319 lastState.sort !== this.state.sort ||
320 lastState.listingType !== this.state.listingType ||
321 lastState.communityId !== this.state.communityId ||
322 lastState.creatorId !== this.state.creatorId ||
323 lastState.page !== this.state.page
325 this.setState({ loading: true, searchText: this.state.q });
330 get documentTitle(): string {
332 return `${i18n.t("search")} - ${this.state.q} - ${this.state.site.name}`;
334 return `${i18n.t("search")} - ${this.state.site.name}`;
340 <div class="container">
342 title={this.documentTitle}
343 path={this.context.router.route.match.url}
345 <h5>{i18n.t("search")}</h5>
348 {this.state.type_ == SearchType.All && this.all()}
349 {this.state.type_ == SearchType.Comments && this.comments()}
350 {this.state.type_ == SearchType.Posts && this.posts()}
351 {this.state.type_ == SearchType.Communities && this.communities()}
352 {this.state.type_ == SearchType.Users && this.users()}
353 {this.state.type_ == SearchType.Url && this.posts()}
354 {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
355 <Paginator page={this.state.page} onChange={this.handlePageChange} />
364 onSubmit={linkEvent(this, this.handleSearchSubmit)}
368 class="form-control mr-2 mb-2"
369 value={this.state.searchText}
370 placeholder={`${i18n.t("search")}...`}
371 aria-label={i18n.t("search")}
372 onInput={linkEvent(this, this.handleQChange)}
376 <button type="submit" class="btn btn-secondary mr-2 mb-2">
377 {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
385 <div className="mb-2">
387 value={this.state.type_}
388 onChange={linkEvent(this, this.handleTypeChange)}
389 class="custom-select w-auto mb-2"
390 aria-label={i18n.t("type")}
392 <option disabled aria-hidden="true">
395 <option value={SearchType.All}>{i18n.t("all")}</option>
396 <option value={SearchType.Comments}>{i18n.t("comments")}</option>
397 <option value={SearchType.Posts}>{i18n.t("posts")}</option>
398 <option value={SearchType.Communities}>
399 {i18n.t("communities")}
401 <option value={SearchType.Users}>{i18n.t("users")}</option>
402 <option value={SearchType.Url}>{i18n.t("url")}</option>
406 type_={this.state.listingType}
407 showLocal={showLocal(this.isoData)}
408 onChange={this.handleListingTypeChange}
413 sort={this.state.sort}
414 onChange={this.handleSortChange}
419 <div class="form-row">
420 {this.state.communities.length > 0 && this.communityFilter()}
421 {this.creatorFilter()}
427 postViewToCombined(postView: PostView): Combined {
431 published: postView.post.published,
435 commentViewToCombined(commentView: CommentView): Combined {
439 published: commentView.comment.published,
443 communityViewToCombined(communityView: CommunityView): Combined {
445 type_: "communities",
447 published: communityView.community.published,
451 personViewSafeToCombined(personViewSafe: PersonViewSafe): Combined {
454 data: personViewSafe,
455 published: personViewSafe.person.published,
459 buildCombined(): Combined[] {
460 let combined: Combined[] = [];
462 // Push the possible resolve / federated objects first
463 let resolveComment = this.state.resolveObjectResponse.comment;
464 if (resolveComment) {
465 combined.push(this.commentViewToCombined(resolveComment));
467 let resolvePost = this.state.resolveObjectResponse.post;
469 combined.push(this.postViewToCombined(resolvePost));
471 let resolveCommunity = this.state.resolveObjectResponse.community;
472 if (resolveCommunity) {
473 combined.push(this.communityViewToCombined(resolveCommunity));
475 let resolveUser = this.state.resolveObjectResponse.person;
477 combined.push(this.personViewSafeToCombined(resolveUser));
480 // Push the search results
482 ...this.state.searchResponse.comments.map(e =>
483 this.commentViewToCombined(e)
487 ...this.state.searchResponse.posts.map(e => this.postViewToCombined(e))
490 ...this.state.searchResponse.communities.map(e =>
491 this.communityViewToCombined(e)
495 ...this.state.searchResponse.users.map(e =>
496 this.personViewSafeToCombined(e)
501 if (this.state.sort == SortType.New) {
502 combined.sort((a, b) => b.published.localeCompare(a.published));
506 ((b.data as CommentView | PostView).counts.score |
507 (b.data as CommunityView).counts.subscribers |
508 (b.data as PersonViewSafe).counts.comment_score) -
509 ((a.data as CommentView | PostView).counts.score |
510 (a.data as CommunityView).counts.subscribers |
511 (a.data as PersonViewSafe).counts.comment_score)
518 let combined = this.buildCombined();
524 {i.type_ == "posts" && (
526 key={(i.data as PostView).post.id}
527 post_view={i.data as PostView}
529 enableDownvotes={this.state.site.enable_downvotes}
530 enableNsfw={this.state.site.enable_nsfw}
533 {i.type_ == "comments" && (
535 key={(i.data as CommentView).comment.id}
536 nodes={[{ comment_view: i.data as CommentView }]}
539 enableDownvotes={this.state.site.enable_downvotes}
542 {i.type_ == "communities" && (
543 <div>{this.communityListing(i.data as CommunityView)}</div>
545 {i.type_ == "users" && (
546 <div>{this.userListing(i.data as PersonViewSafe)}</div>
556 let comments: CommentView[] = [];
558 let resolveComment = this.state.resolveObjectResponse.comment;
559 if (resolveComment) {
560 comments.push(resolveComment);
563 comments.push(...this.state.searchResponse.comments);
567 nodes={commentsToFlatNodes(comments)}
570 enableDownvotes={this.state.site.enable_downvotes}
576 let posts: PostView[] = [];
578 let resolvePost = this.state.resolveObjectResponse.post;
580 posts.push(resolvePost);
583 posts.push(...this.state.searchResponse.posts);
593 enableDownvotes={this.state.site.enable_downvotes}
594 enableNsfw={this.state.site.enable_nsfw}
604 let communities: CommunityView[] = [];
606 let resolveCommunity = this.state.resolveObjectResponse.community;
607 if (resolveCommunity) {
608 communities.push(resolveCommunity);
611 communities.push(...this.state.searchResponse.communities);
614 {communities.map(community => (
616 <div class="col-12">{this.communityListing(community)}</div>
624 let users: PersonViewSafe[] = [];
626 let resolveUser = this.state.resolveObjectResponse.person;
628 users.push(resolveUser);
631 users.push(...this.state.searchResponse.users);
636 <div class="col-12">{this.userListing(user)}</div>
643 communityListing(community_view: CommunityView) {
647 <CommunityLink community={community_view.community} />
650 ${i18n.t("number_of_subscribers", {
651 count: community_view.counts.subscribers,
658 userListing(person_view: PersonViewSafe) {
661 <PersonListing person={person_view.person} showApubName />
663 <span>{` - ${i18n.t("number_of_comments", {
664 count: person_view.counts.comment_count,
671 <div class="form-group col-sm-6">
672 <label class="col-form-label" htmlFor="community-filter">
673 {i18n.t("community")}
678 id="community-filter"
679 value={this.state.communityId}
681 <option value="0">{i18n.t("all")}</option>
682 {this.state.communities.map(cv => (
683 <option value={cv.community.id}>{communitySelectName(cv)}</option>
693 <div class="form-group col-sm-6">
694 <label class="col-form-label" htmlFor="creator-filter">
695 {capitalizeFirstLetter(i18n.t("creator"))}
701 value={this.state.creatorId}
703 <option value="0">{i18n.t("all")}</option>
704 {this.state.creator && (
705 <option value={this.state.creator.person.id}>
706 {personSelectName(this.state.creator)}
715 resultsCount(): number {
716 let res = this.state.searchResponse;
717 let resObj = this.state.resolveObjectResponse;
719 resObj.post || resObj.person || resObj.community || resObj.comment
724 res.comments.length +
725 res.communities.length +
731 handlePageChange(page: number) {
732 this.updateUrl({ page });
736 let form: SearchForm = {
738 type_: this.state.type_,
739 sort: this.state.sort,
740 listing_type: this.state.listingType,
741 page: this.state.page,
743 auth: authField(false),
745 if (this.state.communityId !== 0) {
746 form.community_id = this.state.communityId;
748 if (this.state.creatorId !== 0) {
749 form.creator_id = this.state.creatorId;
752 let resolveObjectForm: ResolveObject = {
754 auth: authField(false),
757 if (this.state.q != "") {
758 WebSocketService.Instance.send(wsClient.search(form));
759 WebSocketService.Instance.send(wsClient.resolveObject(resolveObjectForm));
763 setupCommunityFilter() {
765 let selectId: any = document.getElementById("community-filter");
767 this.communityChoices = new Choices(selectId, choicesConfig);
768 this.communityChoices.passedElement.element.addEventListener(
771 this.handleCommunityFilterChange(Number(e.detail.choice.value));
775 this.communityChoices.passedElement.element.addEventListener(
777 debounce(async (e: any) => {
778 let communities = (await fetchCommunities(e.detail.value))
780 let choices = communities.map(cv => communityToChoice(cv));
781 choices.unshift({ value: "0", label: i18n.t("all") });
782 this.communityChoices.setChoices(choices, "value", "label", true);
790 setupCreatorFilter() {
792 let selectId: any = document.getElementById("creator-filter");
794 this.creatorChoices = new Choices(selectId, choicesConfig);
795 this.creatorChoices.passedElement.element.addEventListener(
798 this.handleCreatorFilterChange(Number(e.detail.choice.value));
802 this.creatorChoices.passedElement.element.addEventListener(
804 debounce(async (e: any) => {
805 let creators = (await fetchUsers(e.detail.value)).users;
806 let choices = creators.map(pvs => personToChoice(pvs));
807 choices.unshift({ value: "0", label: i18n.t("all") });
808 this.creatorChoices.setChoices(choices, "value", "label", true);
816 handleSortChange(val: SortType) {
817 this.updateUrl({ sort: val, page: 1 });
820 handleTypeChange(i: Search, event: any) {
822 type_: SearchType[event.target.value],
827 handleListingTypeChange(val: ListingType) {
834 handleCommunityFilterChange(communityId: number) {
841 handleCreatorFilterChange(creatorId: number) {
848 handleSearchSubmit(i: Search, event: any) {
849 event.preventDefault();
851 q: i.state.searchText,
852 type_: i.state.type_,
853 listingType: i.state.listingType,
854 communityId: i.state.communityId,
855 creatorId: i.state.creatorId,
861 handleQChange(i: Search, event: any) {
862 i.setState({ searchText: event.target.value });
865 updateUrl(paramUpdates: UrlParams) {
866 const qStr = paramUpdates.q || this.state.q;
867 const qStrEncoded = encodeURIComponent(qStr);
868 const typeStr = paramUpdates.type_ || this.state.type_;
869 const listingTypeStr = paramUpdates.listingType || this.state.listingType;
870 const sortStr = paramUpdates.sort || this.state.sort;
872 paramUpdates.communityId == 0
874 : paramUpdates.communityId || this.state.communityId;
876 paramUpdates.creatorId == 0
878 : paramUpdates.creatorId || this.state.creatorId;
879 const page = paramUpdates.page || this.state.page;
880 this.props.history.push(
881 `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
885 parseMessage(msg: any) {
887 let op = wsUserOp(msg);
889 if (msg.error != "couldnt_find_object") {
890 toast(i18n.t(msg.error), "danger");
894 resolveObjectResponse: {
902 } else if (op == UserOperation.Search) {
903 let data = wsJsonToRes<SearchResponse>(msg).data;
904 this.state.searchResponse = data;
905 this.state.loading = false;
906 window.scrollTo(0, 0);
907 this.setState(this.state);
908 restoreScrollPosition(this.context);
909 } else if (op == UserOperation.CreateCommentLike) {
910 let data = wsJsonToRes<CommentResponse>(msg).data;
911 createCommentLikeRes(
913 this.state.searchResponse.comments
915 this.setState(this.state);
916 } else if (op == UserOperation.CreatePostLike) {
917 let data = wsJsonToRes<PostResponse>(msg).data;
918 createPostLikeFindRes(data.post_view, this.state.searchResponse.posts);
919 this.setState(this.state);
920 } else if (op == UserOperation.ListCommunities) {
921 let data = wsJsonToRes<ListCommunitiesResponse>(msg).data;
922 this.state.communities = data.communities;
923 this.setState(this.state);
924 this.setupCommunityFilter();
925 } else if (op == UserOperation.ResolveObject) {
926 let data = wsJsonToRes<ResolveObjectResponse>(msg).data;
927 this.state.resolveObjectResponse = data;
928 this.setState(this.state);