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,
45 restoreScrollPosition,
46 routeListingTypeToEnum,
47 routeSearchTypeToEnum,
59 import { CommentNodes } from "./comment/comment-nodes";
60 import { HtmlTags } from "./common/html-tags";
61 import { Spinner } from "./common/icon";
62 import { ListingTypeSelect } from "./common/listing-type-select";
63 import { Paginator } from "./common/paginator";
64 import { SortSelect } from "./common/sort-select";
65 import { CommunityLink } from "./community/community-link";
66 import { PersonListing } from "./person/person-listing";
67 import { PostListing } from "./post/post-listing";
71 Choices = require("choices.js");
74 interface SearchProps {
78 listingType: ListingType;
84 interface SearchState {
88 listingType: ListingType;
92 searchResponse?: SearchResponse;
93 communities: CommunityView[];
94 creator?: PersonViewSafe;
98 resolveObjectResponse?: ResolveObjectResponse;
101 interface UrlParams {
105 listingType?: ListingType;
106 communityId?: number;
113 data: CommentView | PostView | CommunityView | PersonViewSafe;
117 export class Search extends Component<any, SearchState> {
118 private isoData = setIsoData(this.context);
119 private communityChoices: any;
120 private creatorChoices: any;
121 private subscription: Subscription;
122 private emptyState: SearchState = {
123 q: Search.getSearchQueryFromProps(this.props.match.params.q),
124 type_: Search.getSearchTypeFromProps(this.props.match.params.type),
125 sort: Search.getSortTypeFromProps(this.props.match.params.sort),
126 listingType: Search.getListingTypeFromProps(
127 this.props.match.params.listing_type
129 page: Search.getPageFromProps(this.props.match.params.page),
130 searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
131 communityId: Search.getCommunityIdFromProps(
132 this.props.match.params.community_id
134 creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
135 searchResponse: null,
136 resolveObjectResponse: null,
138 site: this.isoData.site_res.site_view.site,
142 static getSearchQueryFromProps(q: string): string {
143 return decodeURIComponent(q) || "";
146 static getSearchTypeFromProps(type_: string): SearchType {
147 return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
150 static getSortTypeFromProps(sort: string): SortType {
151 return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
154 static getListingTypeFromProps(listingType: string): ListingType {
155 return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
158 static getCommunityIdFromProps(id: string): number {
159 return id ? Number(id) : 0;
162 static getCreatorIdFromProps(id: string): number {
163 return id ? Number(id) : 0;
166 static getPageFromProps(page: string): number {
167 return page ? Number(page) : 1;
170 constructor(props: any, context: any) {
171 super(props, context);
173 this.state = this.emptyState;
174 this.handleSortChange = this.handleSortChange.bind(this);
175 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
176 this.handlePageChange = this.handlePageChange.bind(this);
178 this.parseMessage = this.parseMessage.bind(this);
179 this.subscription = wsSubscribe(this.parseMessage);
181 // Only fetch the data if coming from another route
182 if (this.isoData.path == this.context.router.route.match.url) {
183 let singleOrMultipleCommunities = this.isoData.routeData[0];
184 if (singleOrMultipleCommunities.communities) {
185 this.state.communities = this.isoData.routeData[0].communities;
187 this.state.communities = [this.isoData.routeData[0].community_view];
190 let creator = this.isoData.routeData[1];
191 if (creator?.person_view) {
192 this.state.creator = this.isoData.routeData[1].person_view;
194 if (this.state.q != "") {
195 this.state.searchResponse = this.isoData.routeData[2];
196 this.state.resolveObjectResponse = this.isoData.routeData[3];
197 this.state.loading = false;
202 this.fetchCommunities();
207 componentWillUnmount() {
208 this.subscription.unsubscribe();
209 saveScrollPosition(this.context);
212 componentDidMount() {
213 this.setupCommunityFilter();
214 this.setupCreatorFilter();
217 static getDerivedStateFromProps(props: any): SearchProps {
219 q: Search.getSearchQueryFromProps(props.match.params.q),
220 type_: Search.getSearchTypeFromProps(props.match.params.type),
221 sort: Search.getSortTypeFromProps(props.match.params.sort),
222 listingType: Search.getListingTypeFromProps(
223 props.match.params.listing_type
225 communityId: Search.getCommunityIdFromProps(
226 props.match.params.community_id
228 creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
229 page: Search.getPageFromProps(props.match.params.page),
234 let listCommunitiesForm: ListCommunities = {
235 type_: ListingType.All,
236 sort: SortType.TopAll,
238 auth: authField(false),
240 WebSocketService.Instance.send(
241 wsClient.listCommunities(listCommunitiesForm)
245 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
246 let pathSplit = req.path.split("/");
247 let promises: Promise<any>[] = [];
249 let communityId = this.getCommunityIdFromProps(pathSplit[11]);
250 if (communityId !== 0) {
251 let getCommunityForm: GetCommunity = {
254 setOptionalAuth(getCommunityForm, req.auth);
255 promises.push(req.client.getCommunity(getCommunityForm));
257 let listCommunitiesForm: ListCommunities = {
258 type_: ListingType.All,
259 sort: SortType.TopAll,
262 setOptionalAuth(listCommunitiesForm, req.auth);
263 promises.push(req.client.listCommunities(listCommunitiesForm));
266 let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
267 if (creatorId !== 0) {
268 let getCreatorForm: GetPersonDetails = {
269 person_id: creatorId,
271 setOptionalAuth(getCreatorForm, req.auth);
272 promises.push(req.client.getPersonDetails(getCreatorForm));
274 promises.push(Promise.resolve());
277 let form: SearchForm = {
278 q: this.getSearchQueryFromProps(pathSplit[3]),
279 type_: this.getSearchTypeFromProps(pathSplit[5]),
280 sort: this.getSortTypeFromProps(pathSplit[7]),
281 listing_type: this.getListingTypeFromProps(pathSplit[9]),
282 page: this.getPageFromProps(pathSplit[15]),
285 if (communityId !== 0) {
286 form.community_id = communityId;
288 if (creatorId !== 0) {
289 form.creator_id = creatorId;
291 setOptionalAuth(form, req.auth);
293 let resolveObjectForm: ResolveObject = {
294 q: this.getSearchQueryFromProps(pathSplit[3]),
296 setOptionalAuth(resolveObjectForm, req.auth);
299 //this.state.loading = false;
300 //this.setState(this.state);
301 promises.push(req.client.search(form));
302 promises.push(req.client.resolveObject(resolveObjectForm));
308 componentDidUpdate(_: any, lastState: SearchState) {
310 lastState.q !== this.state.q ||
311 lastState.type_ !== this.state.type_ ||
312 lastState.sort !== this.state.sort ||
313 lastState.listingType !== this.state.listingType ||
314 lastState.communityId !== this.state.communityId ||
315 lastState.creatorId !== this.state.creatorId ||
316 lastState.page !== this.state.page
320 searchText: this.state.q,
321 searchResponse: null,
322 resolveObjectResponse: null,
328 get documentTitle(): string {
330 return `${i18n.t("search")} - ${this.state.q} - ${this.state.site.name}`;
332 return `${i18n.t("search")} - ${this.state.site.name}`;
338 <div class="container">
340 title={this.documentTitle}
341 path={this.context.router.route.match.url}
343 <h5>{i18n.t("search")}</h5>
346 {this.state.type_ == SearchType.All && this.all()}
347 {this.state.type_ == SearchType.Comments && this.comments()}
348 {this.state.type_ == SearchType.Posts && this.posts()}
349 {this.state.type_ == SearchType.Communities && this.communities()}
350 {this.state.type_ == SearchType.Users && this.users()}
351 {this.state.type_ == SearchType.Url && this.posts()}
352 {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
353 <Paginator page={this.state.page} onChange={this.handlePageChange} />
362 onSubmit={linkEvent(this, this.handleSearchSubmit)}
366 class="form-control mr-2 mb-2"
367 value={this.state.searchText}
368 placeholder={`${i18n.t("search")}...`}
369 aria-label={i18n.t("search")}
370 onInput={linkEvent(this, this.handleQChange)}
374 <button type="submit" class="btn btn-secondary mr-2 mb-2">
375 {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
383 <div className="mb-2">
385 value={this.state.type_}
386 onChange={linkEvent(this, this.handleTypeChange)}
387 class="custom-select w-auto mb-2"
388 aria-label={i18n.t("type")}
390 <option disabled aria-hidden="true">
393 <option value={SearchType.All}>{i18n.t("all")}</option>
394 <option value={SearchType.Comments}>{i18n.t("comments")}</option>
395 <option value={SearchType.Posts}>{i18n.t("posts")}</option>
396 <option value={SearchType.Communities}>
397 {i18n.t("communities")}
399 <option value={SearchType.Users}>{i18n.t("users")}</option>
400 <option value={SearchType.Url}>{i18n.t("url")}</option>
404 type_={this.state.listingType}
405 showLocal={showLocal(this.isoData)}
407 onChange={this.handleListingTypeChange}
412 sort={this.state.sort}
413 onChange={this.handleSortChange}
418 <div class="form-row">
419 {this.state.communities.length > 0 && this.communityFilter()}
420 {this.creatorFilter()}
426 postViewToCombined(postView: PostView): Combined {
430 published: postView.post.published,
434 commentViewToCombined(commentView: CommentView): Combined {
438 published: commentView.comment.published,
442 communityViewToCombined(communityView: CommunityView): Combined {
444 type_: "communities",
446 published: communityView.community.published,
450 personViewSafeToCombined(personViewSafe: PersonViewSafe): Combined {
453 data: personViewSafe,
454 published: personViewSafe.person.published,
458 buildCombined(): Combined[] {
459 let combined: Combined[] = [];
461 // Push the possible resolve / federated objects first
462 let resolveComment = this.state.resolveObjectResponse?.comment;
463 if (resolveComment) {
464 combined.push(this.commentViewToCombined(resolveComment));
466 let resolvePost = this.state.resolveObjectResponse?.post;
468 combined.push(this.postViewToCombined(resolvePost));
470 let resolveCommunity = this.state.resolveObjectResponse?.community;
471 if (resolveCommunity) {
472 combined.push(this.communityViewToCombined(resolveCommunity));
474 let resolveUser = this.state.resolveObjectResponse?.person;
476 combined.push(this.personViewSafeToCombined(resolveUser));
479 // Push the search results
482 this.state.searchResponse?.comments?.map(e =>
483 this.commentViewToCombined(e)
488 this.state.searchResponse?.posts?.map(e => this.postViewToCombined(e))
492 this.state.searchResponse?.communities?.map(e =>
493 this.communityViewToCombined(e)
498 this.state.searchResponse?.users?.map(e =>
499 this.personViewSafeToCombined(e)
504 if (this.state.sort == SortType.New) {
505 combined.sort((a, b) => b.published.localeCompare(a.published));
509 ((b.data as CommentView | PostView).counts.score |
510 (b.data as CommunityView).counts.subscribers |
511 (b.data as PersonViewSafe).counts.comment_score) -
512 ((a.data as CommentView | PostView).counts.score |
513 (a.data as CommunityView).counts.subscribers |
514 (a.data as PersonViewSafe).counts.comment_score)
521 let combined = this.buildCombined();
527 {i.type_ == "posts" && (
529 key={(i.data as PostView).post.id}
530 post_view={i.data as PostView}
532 enableDownvotes={this.state.site.enable_downvotes}
533 enableNsfw={this.state.site.enable_nsfw}
536 {i.type_ == "comments" && (
538 key={(i.data as CommentView).comment.id}
539 nodes={[{ comment_view: i.data as CommentView }]}
542 enableDownvotes={this.state.site.enable_downvotes}
545 {i.type_ == "communities" && (
546 <div>{this.communityListing(i.data as CommunityView)}</div>
548 {i.type_ == "users" && (
549 <div>{this.userListing(i.data as PersonViewSafe)}</div>
559 let comments: CommentView[] = [];
561 pushNotNull(comments, this.state.resolveObjectResponse?.comment);
562 pushNotNull(comments, this.state.searchResponse?.comments);
566 nodes={commentsToFlatNodes(comments)}
569 enableDownvotes={this.state.site.enable_downvotes}
575 let posts: PostView[] = [];
577 pushNotNull(posts, this.state.resolveObjectResponse?.post);
578 pushNotNull(posts, this.state.searchResponse?.posts);
588 enableDownvotes={this.state.site.enable_downvotes}
589 enableNsfw={this.state.site.enable_nsfw}
599 let communities: CommunityView[] = [];
601 pushNotNull(communities, this.state.resolveObjectResponse?.community);
602 pushNotNull(communities, this.state.searchResponse?.communities);
606 {communities.map(community => (
608 <div class="col-12">{this.communityListing(community)}</div>
616 let users: PersonViewSafe[] = [];
618 pushNotNull(users, this.state.resolveObjectResponse?.person);
619 pushNotNull(users, this.state.searchResponse?.users);
625 <div class="col-12">{this.userListing(user)}</div>
632 communityListing(community_view: CommunityView) {
636 <CommunityLink community={community_view.community} />
639 ${i18n.t("number_of_subscribers", {
640 count: community_view.counts.subscribers,
641 formattedCount: numToSI(community_view.counts.subscribers),
648 userListing(person_view: PersonViewSafe) {
651 <PersonListing person={person_view.person} showApubName />
653 <span>{` - ${i18n.t("number_of_comments", {
654 count: person_view.counts.comment_count,
655 formattedCount: numToSI(person_view.counts.comment_count),
662 <div class="form-group col-sm-6">
663 <label class="col-form-label" htmlFor="community-filter">
664 {i18n.t("community")}
669 id="community-filter"
670 value={this.state.communityId}
672 <option value="0">{i18n.t("all")}</option>
673 {this.state.communities.map(cv => (
674 <option value={cv.community.id}>{communitySelectName(cv)}</option>
684 <div class="form-group col-sm-6">
685 <label class="col-form-label" htmlFor="creator-filter">
686 {capitalizeFirstLetter(i18n.t("creator"))}
692 value={this.state.creatorId}
694 <option value="0">{i18n.t("all")}</option>
695 {this.state.creator && (
696 <option value={this.state.creator.person.id}>
697 {personSelectName(this.state.creator)}
706 resultsCount(): number {
707 let res = this.state.searchResponse;
708 let resObj = this.state.resolveObjectResponse;
710 resObj?.post || resObj?.person || resObj?.community || resObj?.comment
715 res?.comments?.length +
716 res?.communities?.length +
722 handlePageChange(page: number) {
723 this.updateUrl({ page });
727 let form: SearchForm = {
729 type_: this.state.type_,
730 sort: this.state.sort,
731 listing_type: this.state.listingType,
732 page: this.state.page,
734 auth: authField(false),
736 if (this.state.communityId !== 0) {
737 form.community_id = this.state.communityId;
739 if (this.state.creatorId !== 0) {
740 form.creator_id = this.state.creatorId;
743 let resolveObjectForm: ResolveObject = {
745 auth: authField(false),
748 if (this.state.q != "") {
749 this.state.searchResponse = null;
750 this.state.resolveObjectResponse = null;
751 this.state.loading = true;
752 this.setState(this.state);
753 WebSocketService.Instance.send(wsClient.search(form));
754 WebSocketService.Instance.send(wsClient.resolveObject(resolveObjectForm));
758 setupCommunityFilter() {
760 let selectId: any = document.getElementById("community-filter");
762 this.communityChoices = new Choices(selectId, choicesConfig);
763 this.communityChoices.passedElement.element.addEventListener(
766 this.handleCommunityFilterChange(Number(e.detail.choice.value));
770 this.communityChoices.passedElement.element.addEventListener(
772 debounce(async (e: any) => {
774 let communities = (await fetchCommunities(e.detail.value))
776 let choices = communities.map(cv => communityToChoice(cv));
777 choices.unshift({ value: "0", label: i18n.t("all") });
778 this.communityChoices.setChoices(choices, "value", "label", true);
789 setupCreatorFilter() {
791 let selectId: any = document.getElementById("creator-filter");
793 this.creatorChoices = new Choices(selectId, choicesConfig);
794 this.creatorChoices.passedElement.element.addEventListener(
797 this.handleCreatorFilterChange(Number(e.detail.choice.value));
801 this.creatorChoices.passedElement.element.addEventListener(
803 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);
819 handleSortChange(val: SortType) {
820 this.updateUrl({ sort: val, page: 1 });
823 handleTypeChange(i: Search, event: any) {
825 type_: SearchType[event.target.value],
830 handleListingTypeChange(val: ListingType) {
837 handleCommunityFilterChange(communityId: number) {
844 handleCreatorFilterChange(creatorId: number) {
851 handleSearchSubmit(i: Search, event: any) {
852 event.preventDefault();
854 q: i.state.searchText,
855 type_: i.state.type_,
856 listingType: i.state.listingType,
857 communityId: i.state.communityId,
858 creatorId: i.state.creatorId,
864 handleQChange(i: Search, event: any) {
865 i.setState({ searchText: event.target.value });
868 updateUrl(paramUpdates: UrlParams) {
869 const qStr = paramUpdates.q || this.state.q;
870 const qStrEncoded = encodeURIComponent(qStr);
871 const typeStr = paramUpdates.type_ || this.state.type_;
872 const listingTypeStr = paramUpdates.listingType || this.state.listingType;
873 const sortStr = paramUpdates.sort || this.state.sort;
875 paramUpdates.communityId == 0
877 : paramUpdates.communityId || this.state.communityId;
879 paramUpdates.creatorId == 0
881 : paramUpdates.creatorId || this.state.creatorId;
882 const page = paramUpdates.page || this.state.page;
883 this.props.history.push(
884 `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
888 parseMessage(msg: any) {
890 let op = wsUserOp(msg);
892 if (msg.error == "couldnt_find_object") {
893 this.state.resolveObjectResponse = {
899 this.checkFinishedLoading();
901 toast(i18n.t(msg.error), "danger");
904 } else if (op == UserOperation.Search) {
905 let data = wsJsonToRes<SearchResponse>(msg).data;
906 this.state.searchResponse = data;
907 window.scrollTo(0, 0);
908 this.checkFinishedLoading();
909 restoreScrollPosition(this.context);
910 } else if (op == UserOperation.CreateCommentLike) {
911 let data = wsJsonToRes<CommentResponse>(msg).data;
912 createCommentLikeRes(
914 this.state.searchResponse?.comments
916 this.setState(this.state);
917 } else if (op == UserOperation.CreatePostLike) {
918 let data = wsJsonToRes<PostResponse>(msg).data;
919 createPostLikeFindRes(data.post_view, this.state.searchResponse?.posts);
920 this.setState(this.state);
921 } else if (op == UserOperation.ListCommunities) {
922 let data = wsJsonToRes<ListCommunitiesResponse>(msg).data;
923 this.state.communities = data.communities;
924 this.setState(this.state);
925 this.setupCommunityFilter();
926 } else if (op == UserOperation.ResolveObject) {
927 let data = wsJsonToRes<ResolveObjectResponse>(msg).data;
928 this.state.resolveObjectResponse = data;
929 this.checkFinishedLoading();
933 checkFinishedLoading() {
935 this.state.searchResponse != null &&
936 this.state.resolveObjectResponse != null
938 this.state.loading = false;
939 this.setState(this.state);