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,
44 restoreScrollPosition,
45 routeListingTypeToEnum,
46 routeSearchTypeToEnum,
58 import { CommentNodes } from "./comment/comment-nodes";
59 import { HtmlTags } from "./common/html-tags";
60 import { Spinner } from "./common/icon";
61 import { ListingTypeSelect } from "./common/listing-type-select";
62 import { Paginator } from "./common/paginator";
63 import { SortSelect } from "./common/sort-select";
64 import { CommunityLink } from "./community/community-link";
65 import { PersonListing } from "./person/person-listing";
66 import { PostListing } from "./post/post-listing";
70 Choices = require("choices.js");
73 interface SearchProps {
77 listingType: ListingType;
83 interface SearchState {
87 listingType: ListingType;
91 searchResponse: SearchResponse;
92 communities: CommunityView[];
93 creator?: PersonViewSafe;
97 resolveObjectResponse: ResolveObjectResponse;
100 interface UrlParams {
104 listingType?: ListingType;
105 communityId?: number;
112 data: CommentView | PostView | CommunityView | PersonViewSafe;
116 export class Search extends Component<any, SearchState> {
117 private isoData = setIsoData(this.context);
118 private communityChoices: any;
119 private creatorChoices: any;
120 private subscription: Subscription;
121 private emptyState: SearchState = {
122 q: Search.getSearchQueryFromProps(this.props.match.params.q),
123 type_: Search.getSearchTypeFromProps(this.props.match.params.type),
124 sort: Search.getSortTypeFromProps(this.props.match.params.sort),
125 listingType: Search.getListingTypeFromProps(
126 this.props.match.params.listing_type
128 page: Search.getPageFromProps(this.props.match.params.page),
129 searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
130 communityId: Search.getCommunityIdFromProps(
131 this.props.match.params.community_id
133 creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
141 resolveObjectResponse: {
148 site: this.isoData.site_res.site_view.site,
152 static getSearchQueryFromProps(q: string): string {
153 return decodeURIComponent(q) || "";
156 static getSearchTypeFromProps(type_: string): SearchType {
157 return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
160 static getSortTypeFromProps(sort: string): SortType {
161 return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
164 static getListingTypeFromProps(listingType: string): ListingType {
165 return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
168 static getCommunityIdFromProps(id: string): number {
169 return id ? Number(id) : 0;
172 static getCreatorIdFromProps(id: string): number {
173 return id ? Number(id) : 0;
176 static getPageFromProps(page: string): number {
177 return page ? Number(page) : 1;
180 constructor(props: any, context: any) {
181 super(props, context);
183 this.state = this.emptyState;
184 this.handleSortChange = this.handleSortChange.bind(this);
185 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
186 this.handlePageChange = this.handlePageChange.bind(this);
188 this.parseMessage = this.parseMessage.bind(this);
189 this.subscription = wsSubscribe(this.parseMessage);
191 // Only fetch the data if coming from another route
192 if (this.isoData.path == this.context.router.route.match.url) {
193 let singleOrMultipleCommunities = this.isoData.routeData[0];
194 if (singleOrMultipleCommunities.communities) {
195 this.state.communities = this.isoData.routeData[0].communities;
197 this.state.communities = [this.isoData.routeData[0].community_view];
200 let creator = this.isoData.routeData[1];
201 if (creator?.person_view) {
202 this.state.creator = this.isoData.routeData[1].person_view;
204 if (this.state.q != "") {
205 this.state.searchResponse = this.isoData.routeData[2];
206 this.state.resolveObjectResponse = this.isoData.routeData[3];
207 this.state.loading = false;
212 this.fetchCommunities();
217 componentWillUnmount() {
218 this.subscription.unsubscribe();
219 saveScrollPosition(this.context);
222 componentDidMount() {
223 this.setupCommunityFilter();
224 this.setupCreatorFilter();
227 static getDerivedStateFromProps(props: any): SearchProps {
229 q: Search.getSearchQueryFromProps(props.match.params.q),
230 type_: Search.getSearchTypeFromProps(props.match.params.type),
231 sort: Search.getSortTypeFromProps(props.match.params.sort),
232 listingType: Search.getListingTypeFromProps(
233 props.match.params.listing_type
235 communityId: Search.getCommunityIdFromProps(
236 props.match.params.community_id
238 creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
239 page: Search.getPageFromProps(props.match.params.page),
244 let listCommunitiesForm: ListCommunities = {
245 type_: ListingType.All,
246 sort: SortType.TopAll,
248 auth: authField(false),
250 WebSocketService.Instance.send(
251 wsClient.listCommunities(listCommunitiesForm)
255 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
256 let pathSplit = req.path.split("/");
257 let promises: Promise<any>[] = [];
259 let communityId = this.getCommunityIdFromProps(pathSplit[11]);
260 if (communityId !== 0) {
261 let getCommunityForm: GetCommunity = {
264 setOptionalAuth(getCommunityForm, req.auth);
265 promises.push(req.client.getCommunity(getCommunityForm));
267 let listCommunitiesForm: ListCommunities = {
268 type_: ListingType.All,
269 sort: SortType.TopAll,
272 setOptionalAuth(listCommunitiesForm, req.auth);
273 promises.push(req.client.listCommunities(listCommunitiesForm));
276 let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
277 if (creatorId !== 0) {
278 let getCreatorForm: GetPersonDetails = {
279 person_id: creatorId,
281 setOptionalAuth(getCreatorForm, req.auth);
282 promises.push(req.client.getPersonDetails(getCreatorForm));
284 promises.push(Promise.resolve());
287 let form: SearchForm = {
288 q: this.getSearchQueryFromProps(pathSplit[3]),
289 type_: this.getSearchTypeFromProps(pathSplit[5]),
290 sort: this.getSortTypeFromProps(pathSplit[7]),
291 listing_type: this.getListingTypeFromProps(pathSplit[9]),
292 page: this.getPageFromProps(pathSplit[15]),
295 if (communityId !== 0) {
296 form.community_id = communityId;
298 if (creatorId !== 0) {
299 form.creator_id = creatorId;
301 setOptionalAuth(form, req.auth);
303 let resolveObjectForm: ResolveObject = {
304 q: this.getSearchQueryFromProps(pathSplit[3]),
306 setOptionalAuth(resolveObjectForm, req.auth);
309 promises.push(req.client.search(form));
310 promises.push(req.client.resolveObject(resolveObjectForm));
316 componentDidUpdate(_: any, lastState: SearchState) {
318 lastState.q !== this.state.q ||
319 lastState.type_ !== this.state.type_ ||
320 lastState.sort !== this.state.sort ||
321 lastState.listingType !== this.state.listingType ||
322 lastState.communityId !== this.state.communityId ||
323 lastState.creatorId !== this.state.creatorId ||
324 lastState.page !== this.state.page
326 this.setState({ loading: true, searchText: this.state.q });
331 get documentTitle(): string {
333 return `${i18n.t("search")} - ${this.state.q} - ${this.state.site.name}`;
335 return `${i18n.t("search")} - ${this.state.site.name}`;
341 <div class="container">
343 title={this.documentTitle}
344 path={this.context.router.route.match.url}
346 <h5>{i18n.t("search")}</h5>
349 {this.state.type_ == SearchType.All && this.all()}
350 {this.state.type_ == SearchType.Comments && this.comments()}
351 {this.state.type_ == SearchType.Posts && this.posts()}
352 {this.state.type_ == SearchType.Communities && this.communities()}
353 {this.state.type_ == SearchType.Users && this.users()}
354 {this.state.type_ == SearchType.Url && this.posts()}
355 {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
356 <Paginator page={this.state.page} onChange={this.handlePageChange} />
365 onSubmit={linkEvent(this, this.handleSearchSubmit)}
369 class="form-control mr-2 mb-2"
370 value={this.state.searchText}
371 placeholder={`${i18n.t("search")}...`}
372 aria-label={i18n.t("search")}
373 onInput={linkEvent(this, this.handleQChange)}
377 <button type="submit" class="btn btn-secondary mr-2 mb-2">
378 {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
386 <div className="mb-2">
388 value={this.state.type_}
389 onChange={linkEvent(this, this.handleTypeChange)}
390 class="custom-select w-auto mb-2"
391 aria-label={i18n.t("type")}
393 <option disabled aria-hidden="true">
396 <option value={SearchType.All}>{i18n.t("all")}</option>
397 <option value={SearchType.Comments}>{i18n.t("comments")}</option>
398 <option value={SearchType.Posts}>{i18n.t("posts")}</option>
399 <option value={SearchType.Communities}>
400 {i18n.t("communities")}
402 <option value={SearchType.Users}>{i18n.t("users")}</option>
403 <option value={SearchType.Url}>{i18n.t("url")}</option>
407 type_={this.state.listingType}
408 showLocal={showLocal(this.isoData)}
409 onChange={this.handleListingTypeChange}
414 sort={this.state.sort}
415 onChange={this.handleSortChange}
420 <div class="form-row">
421 {this.state.communities.length > 0 && this.communityFilter()}
422 {this.creatorFilter()}
428 postViewToCombined(postView: PostView): Combined {
432 published: postView.post.published,
436 commentViewToCombined(commentView: CommentView): Combined {
440 published: commentView.comment.published,
444 communityViewToCombined(communityView: CommunityView): Combined {
446 type_: "communities",
448 published: communityView.community.published,
452 personViewSafeToCombined(personViewSafe: PersonViewSafe): Combined {
455 data: personViewSafe,
456 published: personViewSafe.person.published,
460 buildCombined(): Combined[] {
461 let combined: Combined[] = [];
463 // Push the possible resolve / federated objects first
464 let resolveComment = this.state.resolveObjectResponse.comment;
465 if (resolveComment) {
466 combined.push(this.commentViewToCombined(resolveComment));
468 let resolvePost = this.state.resolveObjectResponse.post;
470 combined.push(this.postViewToCombined(resolvePost));
472 let resolveCommunity = this.state.resolveObjectResponse.community;
473 if (resolveCommunity) {
474 combined.push(this.communityViewToCombined(resolveCommunity));
476 let resolveUser = this.state.resolveObjectResponse.person;
478 combined.push(this.personViewSafeToCombined(resolveUser));
481 // Push the search results
483 ...this.state.searchResponse.comments.map(e =>
484 this.commentViewToCombined(e)
488 ...this.state.searchResponse.posts.map(e => this.postViewToCombined(e))
491 ...this.state.searchResponse.communities.map(e =>
492 this.communityViewToCombined(e)
496 ...this.state.searchResponse.users.map(e =>
497 this.personViewSafeToCombined(e)
502 if (this.state.sort == SortType.New) {
503 combined.sort((a, b) => b.published.localeCompare(a.published));
507 ((b.data as CommentView | PostView).counts.score |
508 (b.data as CommunityView).counts.subscribers |
509 (b.data as PersonViewSafe).counts.comment_score) -
510 ((a.data as CommentView | PostView).counts.score |
511 (a.data as CommunityView).counts.subscribers |
512 (a.data as PersonViewSafe).counts.comment_score)
519 let combined = this.buildCombined();
525 {i.type_ == "posts" && (
527 key={(i.data as PostView).post.id}
528 post_view={i.data as PostView}
530 enableDownvotes={this.state.site.enable_downvotes}
531 enableNsfw={this.state.site.enable_nsfw}
534 {i.type_ == "comments" && (
536 key={(i.data as CommentView).comment.id}
537 nodes={[{ comment_view: i.data as CommentView }]}
540 enableDownvotes={this.state.site.enable_downvotes}
543 {i.type_ == "communities" && (
544 <div>{this.communityListing(i.data as CommunityView)}</div>
546 {i.type_ == "users" && (
547 <div>{this.userListing(i.data as PersonViewSafe)}</div>
557 let comments: CommentView[] = [];
559 let resolveComment = this.state.resolveObjectResponse.comment;
560 if (resolveComment) {
561 comments.push(resolveComment);
564 comments.push(...this.state.searchResponse.comments);
568 nodes={commentsToFlatNodes(comments)}
571 enableDownvotes={this.state.site.enable_downvotes}
577 let posts: PostView[] = [];
579 let resolvePost = this.state.resolveObjectResponse.post;
581 posts.push(resolvePost);
584 posts.push(...this.state.searchResponse.posts);
594 enableDownvotes={this.state.site.enable_downvotes}
595 enableNsfw={this.state.site.enable_nsfw}
605 let communities: CommunityView[] = [];
607 let resolveCommunity = this.state.resolveObjectResponse.community;
608 if (resolveCommunity) {
609 communities.push(resolveCommunity);
612 communities.push(...this.state.searchResponse.communities);
615 {communities.map(community => (
617 <div class="col-12">{this.communityListing(community)}</div>
625 let users: PersonViewSafe[] = [];
627 let resolveUser = this.state.resolveObjectResponse.person;
629 users.push(resolveUser);
632 users.push(...this.state.searchResponse.users);
637 <div class="col-12">{this.userListing(user)}</div>
644 communityListing(community_view: CommunityView) {
648 <CommunityLink community={community_view.community} />
651 ${i18n.t("number_of_subscribers", {
652 count: community_view.counts.subscribers,
653 formattedCount: numToSI(community_view.counts.subscribers),
660 userListing(person_view: PersonViewSafe) {
663 <PersonListing person={person_view.person} showApubName />
665 <span>{` - ${i18n.t("number_of_comments", {
666 count: person_view.counts.comment_count,
667 formattedCount: numToSI(person_view.counts.comment_count),
674 <div class="form-group col-sm-6">
675 <label class="col-form-label" htmlFor="community-filter">
676 {i18n.t("community")}
681 id="community-filter"
682 value={this.state.communityId}
684 <option value="0">{i18n.t("all")}</option>
685 {this.state.communities.map(cv => (
686 <option value={cv.community.id}>{communitySelectName(cv)}</option>
696 <div class="form-group col-sm-6">
697 <label class="col-form-label" htmlFor="creator-filter">
698 {capitalizeFirstLetter(i18n.t("creator"))}
704 value={this.state.creatorId}
706 <option value="0">{i18n.t("all")}</option>
707 {this.state.creator && (
708 <option value={this.state.creator.person.id}>
709 {personSelectName(this.state.creator)}
718 resultsCount(): number {
719 let res = this.state.searchResponse;
720 let resObj = this.state.resolveObjectResponse;
722 resObj.post || resObj.person || resObj.community || resObj.comment
727 res.comments.length +
728 res.communities.length +
734 handlePageChange(page: number) {
735 this.updateUrl({ page });
739 let form: SearchForm = {
741 type_: this.state.type_,
742 sort: this.state.sort,
743 listing_type: this.state.listingType,
744 page: this.state.page,
746 auth: authField(false),
748 if (this.state.communityId !== 0) {
749 form.community_id = this.state.communityId;
751 if (this.state.creatorId !== 0) {
752 form.creator_id = this.state.creatorId;
755 let resolveObjectForm: ResolveObject = {
757 auth: authField(false),
760 if (this.state.q != "") {
761 WebSocketService.Instance.send(wsClient.search(form));
762 WebSocketService.Instance.send(wsClient.resolveObject(resolveObjectForm));
766 setupCommunityFilter() {
768 let selectId: any = document.getElementById("community-filter");
770 this.communityChoices = new Choices(selectId, choicesConfig);
771 this.communityChoices.passedElement.element.addEventListener(
774 this.handleCommunityFilterChange(Number(e.detail.choice.value));
778 this.communityChoices.passedElement.element.addEventListener(
780 debounce(async (e: any) => {
782 let communities = (await fetchCommunities(e.detail.value))
784 let choices = communities.map(cv => communityToChoice(cv));
785 choices.unshift({ value: "0", label: i18n.t("all") });
786 this.communityChoices.setChoices(choices, "value", "label", true);
797 setupCreatorFilter() {
799 let selectId: any = document.getElementById("creator-filter");
801 this.creatorChoices = new Choices(selectId, choicesConfig);
802 this.creatorChoices.passedElement.element.addEventListener(
805 this.handleCreatorFilterChange(Number(e.detail.choice.value));
809 this.creatorChoices.passedElement.element.addEventListener(
811 debounce(async (e: any) => {
812 let creators = (await fetchUsers(e.detail.value)).users;
813 let choices = creators.map(pvs => personToChoice(pvs));
814 choices.unshift({ value: "0", label: i18n.t("all") });
815 this.creatorChoices.setChoices(choices, "value", "label", true);
823 handleSortChange(val: SortType) {
824 this.updateUrl({ sort: val, page: 1 });
827 handleTypeChange(i: Search, event: any) {
829 type_: SearchType[event.target.value],
834 handleListingTypeChange(val: ListingType) {
841 handleCommunityFilterChange(communityId: number) {
848 handleCreatorFilterChange(creatorId: number) {
855 handleSearchSubmit(i: Search, event: any) {
856 event.preventDefault();
858 q: i.state.searchText,
859 type_: i.state.type_,
860 listingType: i.state.listingType,
861 communityId: i.state.communityId,
862 creatorId: i.state.creatorId,
868 handleQChange(i: Search, event: any) {
869 i.setState({ searchText: event.target.value });
872 updateUrl(paramUpdates: UrlParams) {
873 const qStr = paramUpdates.q || this.state.q;
874 const qStrEncoded = encodeURIComponent(qStr);
875 const typeStr = paramUpdates.type_ || this.state.type_;
876 const listingTypeStr = paramUpdates.listingType || this.state.listingType;
877 const sortStr = paramUpdates.sort || this.state.sort;
879 paramUpdates.communityId == 0
881 : paramUpdates.communityId || this.state.communityId;
883 paramUpdates.creatorId == 0
885 : paramUpdates.creatorId || this.state.creatorId;
886 const page = paramUpdates.page || this.state.page;
887 this.props.history.push(
888 `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
892 parseMessage(msg: any) {
894 let op = wsUserOp(msg);
896 if (msg.error != "couldnt_find_object") {
897 toast(i18n.t(msg.error), "danger");
901 resolveObjectResponse: {
909 } else if (op == UserOperation.Search) {
910 let data = wsJsonToRes<SearchResponse>(msg).data;
911 this.state.searchResponse = data;
912 this.state.loading = false;
913 window.scrollTo(0, 0);
914 this.setState(this.state);
915 restoreScrollPosition(this.context);
916 } else if (op == UserOperation.CreateCommentLike) {
917 let data = wsJsonToRes<CommentResponse>(msg).data;
918 createCommentLikeRes(
920 this.state.searchResponse.comments
922 this.setState(this.state);
923 } else if (op == UserOperation.CreatePostLike) {
924 let data = wsJsonToRes<PostResponse>(msg).data;
925 createPostLikeFindRes(data.post_view, this.state.searchResponse.posts);
926 this.setState(this.state);
927 } else if (op == UserOperation.ListCommunities) {
928 let data = wsJsonToRes<ListCommunitiesResponse>(msg).data;
929 this.state.communities = data.communities;
930 this.setState(this.state);
931 this.setupCommunityFilter();
932 } else if (op == UserOperation.ResolveObject) {
933 let data = wsJsonToRes<ResolveObjectResponse>(msg).data;
934 this.state.resolveObjectResponse = data;
935 this.setState(this.state);