1 import { Component, linkEvent } from "inferno";
9 GetPersonDetailsResponse,
12 ListCommunitiesResponse,
18 ResolveObjectResponse,
26 } from "lemmy-js-client";
27 import { Subscription } from "rxjs";
28 import { i18n } from "../i18next";
29 import { CommentViewType, InitialFetchRequest } from "../interfaces";
30 import { WebSocketService } from "../services";
32 capitalizeFirstLetter,
38 createPostLikeFindRes,
51 restoreScrollPosition,
52 routeListingTypeToEnum,
53 routeSearchTypeToEnum,
62 import { CommentNodes } from "./comment/comment-nodes";
63 import { HtmlTags } from "./common/html-tags";
64 import { Spinner } from "./common/icon";
65 import { ListingTypeSelect } from "./common/listing-type-select";
66 import { Paginator } from "./common/paginator";
67 import { SortSelect } from "./common/sort-select";
68 import { CommunityLink } from "./community/community-link";
69 import { PersonListing } from "./person/person-listing";
70 import { PostListing } from "./post/post-listing";
74 Choices = require("choices.js");
77 interface SearchProps {
81 listingType: ListingType;
87 interface SearchState {
91 listingType: ListingType;
95 searchResponse?: SearchResponse;
96 communities: CommunityView[];
97 creatorDetails?: GetPersonDetailsResponse;
99 siteRes: GetSiteResponse;
101 resolveObjectResponse?: ResolveObjectResponse;
104 interface UrlParams {
108 listingType?: ListingType;
109 communityId?: number;
116 data: CommentView | PostView | CommunityView | PersonViewSafe;
120 export class Search extends Component<any, SearchState> {
121 private isoData = setIsoData(this.context);
122 private communityChoices: any;
123 private creatorChoices: any;
124 private subscription?: Subscription;
125 state: SearchState = {
126 q: Search.getSearchQueryFromProps(this.props.match.params.q),
127 type_: Search.getSearchTypeFromProps(this.props.match.params.type),
128 sort: Search.getSortTypeFromProps(this.props.match.params.sort),
129 listingType: Search.getListingTypeFromProps(
130 this.props.match.params.listing_type
132 page: Search.getPageFromProps(this.props.match.params.page),
133 searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
134 communityId: Search.getCommunityIdFromProps(
135 this.props.match.params.community_id
137 creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
139 siteRes: this.isoData.site_res,
143 static getSearchQueryFromProps(q?: string): string | undefined {
144 return q ? decodeURIComponent(q) : undefined;
147 static getSearchTypeFromProps(type_: string): SearchType {
148 return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
151 static getSortTypeFromProps(sort: string): SortType {
152 return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
155 static getListingTypeFromProps(listingType: string): ListingType {
156 return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
159 static getCommunityIdFromProps(id: string): number {
160 return id ? Number(id) : 0;
163 static getCreatorIdFromProps(id: string): number {
164 return id ? Number(id) : 0;
167 static getPageFromProps(page: string): number {
168 return page ? Number(page) : 1;
171 constructor(props: any, context: any) {
172 super(props, context);
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 communityRes = this.isoData.routeData[0] as
184 | GetCommunityResponse
186 let communitiesRes = this.isoData.routeData[1] as
187 | ListCommunitiesResponse
189 // This can be single or multiple communities given
190 if (communitiesRes) {
193 communities: communitiesRes.communities,
200 communities: [communityRes.community_view],
206 creatorDetails: this.isoData.routeData[2] as GetPersonDetailsResponse,
209 if (this.state.q != "") {
212 searchResponse: this.isoData.routeData[3] as SearchResponse,
213 resolveObjectResponse: this.isoData
214 .routeData[4] as ResolveObjectResponse,
221 this.fetchCommunities();
229 componentWillUnmount() {
230 this.subscription?.unsubscribe();
231 saveScrollPosition(this.context);
234 componentDidMount() {
235 this.setupCommunityFilter();
236 this.setupCreatorFilter();
239 static getDerivedStateFromProps(
241 prevState: SearchState
244 q: Search.getSearchQueryFromProps(props.match.params.q),
247 Search.getSearchTypeFromProps(props.match.params.type),
249 prevState.sort ?? Search.getSortTypeFromProps(props.match.params.sort),
251 prevState.listingType ??
252 Search.getListingTypeFromProps(props.match.params.listing_type),
253 communityId: Search.getCommunityIdFromProps(
254 props.match.params.community_id
256 creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
257 page: Search.getPageFromProps(props.match.params.page),
262 let listCommunitiesForm: ListCommunities = {
263 type_: ListingType.All,
264 sort: SortType.TopAll,
268 WebSocketService.Instance.send(
269 wsClient.listCommunities(listCommunitiesForm)
273 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
274 let pathSplit = req.path.split("/");
275 let promises: Promise<any>[] = [];
278 let communityId = this.getCommunityIdFromProps(pathSplit[11]);
279 let community_id = communityId == 0 ? undefined : communityId;
281 let getCommunityForm: GetCommunity = {
285 promises.push(req.client.getCommunity(getCommunityForm));
286 promises.push(Promise.resolve());
288 let listCommunitiesForm: ListCommunities = {
289 type_: ListingType.All,
290 sort: SortType.TopAll,
294 promises.push(Promise.resolve());
295 promises.push(req.client.listCommunities(listCommunitiesForm));
298 let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
299 let creator_id = creatorId == 0 ? undefined : creatorId;
301 let getCreatorForm: GetPersonDetails = {
302 person_id: creator_id,
305 promises.push(req.client.getPersonDetails(getCreatorForm));
307 promises.push(Promise.resolve());
310 let q = this.getSearchQueryFromProps(pathSplit[3]);
313 let form: SearchForm = {
317 type_: this.getSearchTypeFromProps(pathSplit[5]),
318 sort: this.getSortTypeFromProps(pathSplit[7]),
319 listing_type: this.getListingTypeFromProps(pathSplit[9]),
320 page: this.getPageFromProps(pathSplit[15]),
325 let resolveObjectForm: ResolveObject = {
331 promises.push(req.client.search(form));
332 promises.push(req.client.resolveObject(resolveObjectForm));
334 promises.push(Promise.resolve());
335 promises.push(Promise.resolve());
342 componentDidUpdate(_: any, lastState: SearchState) {
344 lastState.q !== this.state.q ||
345 lastState.type_ !== this.state.type_ ||
346 lastState.sort !== this.state.sort ||
347 lastState.listingType !== this.state.listingType ||
348 lastState.communityId !== this.state.communityId ||
349 lastState.creatorId !== this.state.creatorId ||
350 lastState.page !== this.state.page
355 searchText: this.state.q,
362 get documentTitle(): string {
363 let siteName = this.state.siteRes.site_view.site.name;
365 ? `${i18n.t("search")} - ${this.state.q} - ${siteName}`
366 : `${i18n.t("search")} - ${siteName}`;
371 <div className="container-lg">
373 title={this.documentTitle}
374 path={this.context.router.route.match.url}
376 <h5>{i18n.t("search")}</h5>
379 {this.state.type_ == SearchType.All && this.all()}
380 {this.state.type_ == SearchType.Comments && this.comments()}
381 {this.state.type_ == SearchType.Posts && this.posts()}
382 {this.state.type_ == SearchType.Communities && this.communities()}
383 {this.state.type_ == SearchType.Users && this.users()}
384 {this.state.type_ == SearchType.Url && this.posts()}
385 {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
386 <Paginator page={this.state.page} onChange={this.handlePageChange} />
394 className="form-inline"
395 onSubmit={linkEvent(this, this.handleSearchSubmit)}
399 className="form-control mr-2 mb-2"
400 value={this.state.searchText}
401 placeholder={`${i18n.t("search")}...`}
402 aria-label={i18n.t("search")}
403 onInput={linkEvent(this, this.handleQChange)}
407 <button type="submit" className="btn btn-secondary mr-2 mb-2">
408 {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
416 <div className="mb-2">
418 value={this.state.type_}
419 onChange={linkEvent(this, this.handleTypeChange)}
420 className="custom-select w-auto mb-2"
421 aria-label={i18n.t("type")}
423 <option disabled aria-hidden="true">
426 <option value={SearchType.All}>{i18n.t("all")}</option>
427 <option value={SearchType.Comments}>{i18n.t("comments")}</option>
428 <option value={SearchType.Posts}>{i18n.t("posts")}</option>
429 <option value={SearchType.Communities}>
430 {i18n.t("communities")}
432 <option value={SearchType.Users}>{i18n.t("users")}</option>
433 <option value={SearchType.Url}>{i18n.t("url")}</option>
435 <span className="ml-2">
437 type_={this.state.listingType}
438 showLocal={showLocal(this.isoData)}
440 onChange={this.handleListingTypeChange}
443 <span className="ml-2">
445 sort={this.state.sort}
446 onChange={this.handleSortChange}
451 <div className="form-row">
452 {this.state.communities.length > 0 && this.communityFilter()}
453 {this.creatorFilter()}
459 postViewToCombined(postView: PostView): Combined {
463 published: postView.post.published,
467 commentViewToCombined(commentView: CommentView): Combined {
471 published: commentView.comment.published,
475 communityViewToCombined(communityView: CommunityView): Combined {
477 type_: "communities",
479 published: communityView.community.published,
483 personViewSafeToCombined(personViewSafe: PersonViewSafe): Combined {
486 data: personViewSafe,
487 published: personViewSafe.person.published,
491 buildCombined(): Combined[] {
492 let combined: Combined[] = [];
494 let resolveRes = this.state.resolveObjectResponse;
495 // Push the possible resolve / federated objects first
497 let resolveComment = resolveRes.comment;
498 if (resolveComment) {
499 combined.push(this.commentViewToCombined(resolveComment));
501 let resolvePost = resolveRes.post;
503 combined.push(this.postViewToCombined(resolvePost));
505 let resolveCommunity = resolveRes.community;
506 if (resolveCommunity) {
507 combined.push(this.communityViewToCombined(resolveCommunity));
509 let resolveUser = resolveRes.person;
511 combined.push(this.personViewSafeToCombined(resolveUser));
515 // Push the search results
516 let searchRes = this.state.searchResponse;
520 searchRes.comments?.map(e => this.commentViewToCombined(e))
524 searchRes.posts?.map(e => this.postViewToCombined(e))
528 searchRes.communities?.map(e => this.communityViewToCombined(e))
532 searchRes.users?.map(e => this.personViewSafeToCombined(e))
537 if (this.state.sort == SortType.New) {
538 combined.sort((a, b) => b.published.localeCompare(a.published));
542 ((b.data as CommentView | PostView).counts.score |
543 (b.data as CommunityView).counts.subscribers |
544 (b.data as PersonViewSafe).counts.comment_score) -
545 ((a.data as CommentView | PostView).counts.score |
546 (a.data as CommunityView).counts.subscribers |
547 (a.data as PersonViewSafe).counts.comment_score)
554 let combined = this.buildCombined();
558 <div key={i.published} className="row">
559 <div className="col-12">
560 {i.type_ == "posts" && (
562 key={(i.data as PostView).post.id}
563 post_view={i.data as PostView}
565 enableDownvotes={enableDownvotes(this.state.siteRes)}
566 enableNsfw={enableNsfw(this.state.siteRes)}
567 allLanguages={this.state.siteRes.all_languages}
568 siteLanguages={this.state.siteRes.discussion_languages}
572 {i.type_ == "comments" && (
574 key={(i.data as CommentView).comment.id}
577 comment_view: i.data as CommentView,
582 viewType={CommentViewType.Flat}
586 enableDownvotes={enableDownvotes(this.state.siteRes)}
587 allLanguages={this.state.siteRes.all_languages}
588 siteLanguages={this.state.siteRes.discussion_languages}
591 {i.type_ == "communities" && (
592 <div>{this.communityListing(i.data as CommunityView)}</div>
594 {i.type_ == "users" && (
595 <div>{this.personListing(i.data as PersonViewSafe)}</div>
605 let comments: CommentView[] = [];
606 pushNotNull(comments, this.state.resolveObjectResponse?.comment);
607 pushNotNull(comments, this.state.searchResponse?.comments);
611 nodes={commentsToFlatNodes(comments)}
612 viewType={CommentViewType.Flat}
616 enableDownvotes={enableDownvotes(this.state.siteRes)}
617 allLanguages={this.state.siteRes.all_languages}
618 siteLanguages={this.state.siteRes.discussion_languages}
624 let posts: PostView[] = [];
626 pushNotNull(posts, this.state.resolveObjectResponse?.post);
627 pushNotNull(posts, this.state.searchResponse?.posts);
632 <div key={pv.post.id} className="row">
633 <div className="col-12">
637 enableDownvotes={enableDownvotes(this.state.siteRes)}
638 enableNsfw={enableNsfw(this.state.siteRes)}
639 allLanguages={this.state.siteRes.all_languages}
640 siteLanguages={this.state.siteRes.discussion_languages}
651 let communities: CommunityView[] = [];
653 pushNotNull(communities, this.state.resolveObjectResponse?.community);
654 pushNotNull(communities, this.state.searchResponse?.communities);
658 {communities.map(cv => (
659 <div key={cv.community.id} className="row">
660 <div className="col-12">{this.communityListing(cv)}</div>
668 let users: PersonViewSafe[] = [];
670 pushNotNull(users, this.state.resolveObjectResponse?.person);
671 pushNotNull(users, this.state.searchResponse?.users);
676 <div key={pvs.person.id} className="row">
677 <div className="col-12">{this.personListing(pvs)}</div>
684 communityListing(community_view: CommunityView) {
688 <CommunityLink community={community_view.community} />
691 ${i18n.t("number_of_subscribers", {
692 count: community_view.counts.subscribers,
693 formattedCount: numToSI(community_view.counts.subscribers),
700 personListing(person_view: PersonViewSafe) {
704 <PersonListing person={person_view.person} showApubName />
706 <span>{` - ${i18n.t("number_of_comments", {
707 count: person_view.counts.comment_count,
708 formattedCount: numToSI(person_view.counts.comment_count),
716 <div className="form-group col-sm-6">
717 <label className="col-form-label" htmlFor="community-filter">
718 {i18n.t("community")}
722 className="form-control"
723 id="community-filter"
724 value={this.state.communityId}
726 <option value="0">{i18n.t("all")}</option>
727 {this.state.communities.map(cv => (
728 <option key={cv.community.id} value={cv.community.id}>
729 {communitySelectName(cv)}
739 let creatorPv = this.state.creatorDetails?.person_view;
741 <div className="form-group col-sm-6">
742 <label className="col-form-label" htmlFor="creator-filter">
743 {capitalizeFirstLetter(i18n.t("creator"))}
747 className="form-control"
749 value={this.state.creatorId}
751 <option value="0">{i18n.t("all")}</option>
753 <option value={creatorPv.person.id}>
754 {personSelectName(creatorPv)}
763 resultsCount(): number {
764 let r = this.state.searchResponse;
769 r.communities?.length +
773 let resolveRes = this.state.resolveObjectResponse;
774 let resObjCount = resolveRes
777 resolveRes.community ||
783 return resObjCount + searchCount;
786 handlePageChange(page: number) {
787 this.updateUrl({ page });
792 this.state.communityId == 0 ? undefined : this.state.communityId;
794 this.state.creatorId == 0 ? undefined : this.state.creatorId;
796 let auth = myAuth(false);
797 if (this.state.q && this.state.q != "") {
798 let form: SearchForm = {
802 type_: this.state.type_,
803 sort: this.state.sort,
804 listing_type: this.state.listingType,
805 page: this.state.page,
810 let resolveObjectForm: ResolveObject = {
816 searchResponse: undefined,
817 resolveObjectResponse: undefined,
820 WebSocketService.Instance.send(wsClient.search(form));
821 WebSocketService.Instance.send(wsClient.resolveObject(resolveObjectForm));
825 setupCommunityFilter() {
827 let selectId: any = document.getElementById("community-filter");
829 this.communityChoices = new Choices(selectId, choicesConfig);
830 this.communityChoices.passedElement.element.addEventListener(
833 this.handleCommunityFilterChange(Number(e.detail.choice.value));
837 this.communityChoices.passedElement.element.addEventListener(
839 debounce(async (e: any) => {
841 let communities = (await fetchCommunities(e.detail.value))
843 let choices = communities.map(cv => communityToChoice(cv));
844 choices.unshift({ value: "0", label: i18n.t("all") });
845 this.communityChoices.setChoices(choices, "value", "label", true);
856 setupCreatorFilter() {
858 let selectId: any = document.getElementById("creator-filter");
860 this.creatorChoices = new Choices(selectId, choicesConfig);
861 this.creatorChoices.passedElement.element.addEventListener(
864 this.handleCreatorFilterChange(Number(e.detail.choice.value));
868 this.creatorChoices.passedElement.element.addEventListener(
870 debounce(async (e: any) => {
872 let creators = (await fetchUsers(e.detail.value)).users;
873 let choices = creators.map(pvs => personToChoice(pvs));
874 choices.unshift({ value: "0", label: i18n.t("all") });
875 this.creatorChoices.setChoices(choices, "value", "label", true);
886 handleSortChange(val: SortType) {
887 const updateObj = { sort: val, page: 1 };
888 this.setState(updateObj);
889 this.updateUrl(updateObj);
892 handleTypeChange(i: Search, event: any) {
894 type_: SearchType[event.target.value],
897 i.setState(updateObj);
898 i.updateUrl(updateObj);
901 handleListingTypeChange(val: ListingType) {
906 this.setState(updateObj);
907 this.updateUrl(updateObj);
910 handleCommunityFilterChange(communityId: number) {
917 handleCreatorFilterChange(creatorId: number) {
924 handleSearchSubmit(i: Search, event: any) {
925 event.preventDefault();
927 q: i.state.searchText,
928 type_: i.state.type_,
929 listingType: i.state.listingType,
930 communityId: i.state.communityId,
931 creatorId: i.state.creatorId,
937 handleQChange(i: Search, event: any) {
938 i.setState({ searchText: event.target.value });
941 updateUrl(paramUpdates: UrlParams) {
942 const qStr = paramUpdates.q || this.state.q;
943 const qStrEncoded = encodeURIComponent(qStr || "");
944 const typeStr = paramUpdates.type_ || this.state.type_;
945 const listingTypeStr = paramUpdates.listingType || this.state.listingType;
946 const sortStr = paramUpdates.sort || this.state.sort;
948 paramUpdates.communityId == 0
950 : paramUpdates.communityId || this.state.communityId;
952 paramUpdates.creatorId == 0
954 : paramUpdates.creatorId || this.state.creatorId;
955 const page = paramUpdates.page || this.state.page;
956 this.props.history.push(
957 `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
961 parseMessage(msg: any) {
963 let op = wsUserOp(msg);
965 if (msg.error == "couldnt_find_object") {
967 resolveObjectResponse: {},
969 this.checkFinishedLoading();
971 toast(i18n.t(msg.error), "danger");
974 } else if (op == UserOperation.Search) {
975 let data = wsJsonToRes<SearchResponse>(msg);
976 this.setState({ searchResponse: data });
977 window.scrollTo(0, 0);
978 this.checkFinishedLoading();
979 restoreScrollPosition(this.context);
980 } else if (op == UserOperation.CreateCommentLike) {
981 let data = wsJsonToRes<CommentResponse>(msg);
982 createCommentLikeRes(
984 this.state.searchResponse?.comments
986 this.setState(this.state);
987 } else if (op == UserOperation.CreatePostLike) {
988 let data = wsJsonToRes<PostResponse>(msg);
989 createPostLikeFindRes(data.post_view, this.state.searchResponse?.posts);
990 this.setState(this.state);
991 } else if (op == UserOperation.ListCommunities) {
992 let data = wsJsonToRes<ListCommunitiesResponse>(msg);
993 this.setState({ communities: data.communities });
994 this.setupCommunityFilter();
995 } else if (op == UserOperation.ResolveObject) {
996 let data = wsJsonToRes<ResolveObjectResponse>(msg);
997 this.setState({ resolveObjectResponse: data });
998 this.checkFinishedLoading();
1002 checkFinishedLoading() {
1003 if (this.state.searchResponse && this.state.resolveObjectResponse) {
1004 this.setState({ loading: false });