14 import { restoreScrollPosition, saveScrollPosition } from "@utils/browser";
16 capitalizeFirstLetter,
23 } from "@utils/helpers";
24 import type { QueryParams } from "@utils/types";
25 import { Choice, RouteDataResponse } from "@utils/types";
26 import type { NoOptionI18nKeys } from "i18next";
27 import { Component, linkEvent } from "inferno";
34 GetPersonDetailsResponse,
37 ListCommunitiesResponse,
42 ResolveObjectResponse,
47 } from "lemmy-js-client";
48 import { fetchLimit } from "../config";
49 import { CommentViewType, InitialFetchRequest } from "../interfaces";
50 import { FirstLoadService, I18NextService } from "../services";
51 import { HttpService, RequestState } from "../services/HttpService";
52 import { CommentNodes } from "./comment/comment-nodes";
53 import { HtmlTags } from "./common/html-tags";
54 import { Spinner } from "./common/icon";
55 import { ListingTypeSelect } from "./common/listing-type-select";
56 import { Paginator } from "./common/paginator";
57 import { SearchableSelect } from "./common/searchable-select";
58 import { SortSelect } from "./common/sort-select";
59 import { CommunityLink } from "./community/community-link";
60 import { PersonListing } from "./person/person-listing";
61 import { PostListing } from "./post/post-listing";
63 interface SearchProps {
67 listingType: ListingType;
68 communityId?: number | null;
69 creatorId?: number | null;
73 type SearchData = RouteDataResponse<{
74 communityResponse: GetCommunityResponse;
75 listCommunitiesResponse: ListCommunitiesResponse;
76 creatorDetailsResponse: GetPersonDetailsResponse;
77 searchResponse: SearchResponse;
78 resolveObjectResponse: ResolveObjectResponse;
81 type FilterType = "creator" | "community";
83 interface SearchState {
84 searchRes: RequestState<SearchResponse>;
85 resolveObjectRes: RequestState<ResolveObjectResponse>;
86 creatorDetailsRes: RequestState<GetPersonDetailsResponse>;
87 communitiesRes: RequestState<ListCommunitiesResponse>;
88 communityRes: RequestState<GetCommunityResponse>;
89 siteRes: GetSiteResponse;
91 communitySearchOptions: Choice[];
92 creatorSearchOptions: Choice[];
93 searchCreatorLoading: boolean;
94 searchCommunitiesLoading: boolean;
95 isIsomorphic: boolean;
100 data: CommentView | PostView | CommunityView | PersonView;
104 const defaultSearchType = "All";
105 const defaultSortType = "TopAll";
106 const defaultListingType = "All";
108 const searchTypes = ["All", "Comments", "Posts", "Communities", "Users", "Url"];
110 const getSearchQueryParams = () =>
111 getQueryParams<SearchProps>({
112 q: getSearchQueryFromQuery,
113 type: getSearchTypeFromQuery,
114 sort: getSortTypeFromQuery,
115 listingType: getListingTypeFromQuery,
116 communityId: getIdFromString,
117 creatorId: getIdFromString,
118 page: getPageFromString,
121 const getSearchQueryFromQuery = (q?: string): string | undefined =>
122 q ? decodeURIComponent(q) : undefined;
124 function getSearchTypeFromQuery(type_?: string): SearchType {
125 return type_ ? (type_ as SearchType) : defaultSearchType;
128 function getSortTypeFromQuery(sort?: string): SortType {
129 return sort ? (sort as SortType) : defaultSortType;
132 function getListingTypeFromQuery(listingType?: string): ListingType {
133 return listingType ? (listingType as ListingType) : defaultListingType;
136 function postViewToCombined(data: PostView): Combined {
140 published: data.post.published,
144 function commentViewToCombined(data: CommentView): Combined {
148 published: data.comment.published,
152 function communityViewToCombined(data: CommunityView): Combined {
154 type_: "communities",
156 published: data.community.published,
160 function personViewSafeToCombined(data: PersonView): Combined {
164 published: data.person.published,
176 filterType: FilterType;
178 onSearch: (text: string) => void;
179 onChange: (choice: Choice) => void;
180 value?: number | null;
184 <div className="col-sm-6">
185 <div className="row gx-2">
187 className="col-12 col-sm-auto col-form-label flex-grow-0"
188 htmlFor={`${filterType}-filter`}
190 {capitalizeFirstLetter(I18NextService.i18n.t(filterType))}
193 id={`${filterType}-filter`}
196 label: I18NextService.i18n.t("all"),
210 const communityListing = ({
212 counts: { subscribers },
215 <CommunityLink community={community} />,
217 "number_of_subscribers"
220 const personListing = ({ person, counts: { comment_count } }: PersonView) =>
222 <PersonListing person={person} showApubName />,
228 listing: JSX.ElementClass,
230 translationKey: "number_of_comments" | "number_of_subscribers"
234 <span>{listing}</span>
235 <span>{` - ${I18NextService.i18n.t(translationKey, {
236 count: Number(count),
237 formattedCount: numToSI(count),
243 export class Search extends Component<any, SearchState> {
244 private isoData = setIsoData<SearchData>(this.context);
246 state: SearchState = {
247 resolveObjectRes: { state: "empty" },
248 creatorDetailsRes: { state: "empty" },
249 communitiesRes: { state: "empty" },
250 communityRes: { state: "empty" },
251 siteRes: this.isoData.site_res,
252 creatorSearchOptions: [],
253 communitySearchOptions: [],
254 searchRes: { state: "empty" },
255 searchCreatorLoading: false,
256 searchCommunitiesLoading: false,
260 constructor(props: any, context: any) {
261 super(props, context);
263 this.handleSortChange = this.handleSortChange.bind(this);
264 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
265 this.handlePageChange = this.handlePageChange.bind(this);
266 this.handleCommunityFilterChange =
267 this.handleCommunityFilterChange.bind(this);
268 this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
270 const { q } = getSearchQueryParams();
277 // Only fetch the data if coming from another route
278 if (FirstLoadService.isFirstLoad) {
280 communityResponse: communityRes,
281 creatorDetailsResponse: creatorDetailsRes,
282 listCommunitiesResponse: communitiesRes,
283 resolveObjectResponse: resolveObjectRes,
284 searchResponse: searchRes,
285 } = this.isoData.routeData;
292 if (creatorDetailsRes?.state === "success") {
295 creatorSearchOptions:
296 creatorDetailsRes?.state === "success"
297 ? [personToChoice(creatorDetailsRes.data.person_view)]
303 if (communitiesRes?.state === "success") {
310 if (communityRes?.state === "success") {
322 if (searchRes?.state === "success") {
329 if (resolveObjectRes?.state === "success") {
339 async componentDidMount() {
340 if (!this.state.isIsomorphic) {
341 const promises = [this.fetchCommunities()];
342 if (this.state.searchText) {
343 promises.push(this.search());
346 await Promise.all(promises);
350 async fetchCommunities() {
351 this.setState({ communitiesRes: { state: "loading" } });
353 communitiesRes: await HttpService.client.listCommunities({
354 type_: defaultListingType,
355 sort: defaultSortType,
362 componentWillUnmount() {
363 saveScrollPosition(this.context);
366 static async fetchInitialData({
369 query: { communityId, creatorId, q, type, sort, listingType, page },
370 }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<SearchData> {
371 const community_id = getIdFromString(communityId);
372 let communityResponse: RequestState<GetCommunityResponse> = {
375 let listCommunitiesResponse: RequestState<ListCommunitiesResponse> = {
379 const getCommunityForm: GetCommunity = {
384 communityResponse = await client.getCommunity(getCommunityForm);
386 const listCommunitiesForm: ListCommunities = {
387 type_: defaultListingType,
388 sort: defaultSortType,
393 listCommunitiesResponse = await client.listCommunities(
398 const creator_id = getIdFromString(creatorId);
399 let creatorDetailsResponse: RequestState<GetPersonDetailsResponse> = {
403 const getCreatorForm: GetPersonDetails = {
404 person_id: creator_id,
408 creatorDetailsResponse = await client.getPersonDetails(getCreatorForm);
411 const query = getSearchQueryFromQuery(q);
413 let searchResponse: RequestState<SearchResponse> = { state: "empty" };
414 let resolveObjectResponse: RequestState<ResolveObjectResponse> = {
419 const form: SearchForm = {
423 type_: getSearchTypeFromQuery(type),
424 sort: getSortTypeFromQuery(sort),
425 listing_type: getListingTypeFromQuery(listingType),
426 page: getPageFromString(page),
432 searchResponse = await client.search(form);
434 const resolveObjectForm: ResolveObject = {
438 resolveObjectResponse = await HttpService.silent_client.resolveObject(
442 // If we return this object with a state of failed, the catch-all-handler will redirect
443 // to an error page, so we ignore it by covering up the error with the empty state.
444 if (resolveObjectResponse.state === "failed") {
445 resolveObjectResponse = { state: "empty" };
453 creatorDetailsResponse,
454 listCommunitiesResponse,
455 resolveObjectResponse,
460 get documentTitle(): string {
461 const { q } = getSearchQueryParams();
462 const name = this.state.siteRes.site_view.site.name;
463 return `${I18NextService.i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
467 const { type, page } = getSearchQueryParams();
470 <div className="search container-lg">
472 title={this.documentTitle}
473 path={this.context.router.route.match.url}
475 <h1 className="h4 mb-4">{I18NextService.i18n.t("search")}</h1>
478 {this.displayResults(type)}
479 {this.resultsCount === 0 &&
480 this.state.searchRes.state === "success" && (
481 <span>{I18NextService.i18n.t("no_results")}</span>
483 <Paginator page={page} onChange={this.handlePageChange} />
488 displayResults(type: SearchType) {
493 return this.comments;
498 return this.communities;
509 className="row gx-2 gy-3"
510 onSubmit={linkEvent(this, this.handleSearchSubmit)}
512 <div className="col-auto flex-grow-1 flex-sm-grow-0">
515 className="form-control me-2 mb-2 col-sm-8"
516 value={this.state.searchText}
517 placeholder={`${I18NextService.i18n.t("search")}...`}
518 aria-label={I18NextService.i18n.t("search")}
519 onInput={linkEvent(this, this.handleQChange)}
524 <div className="col-auto">
525 <button type="submit" className="btn btn-secondary mb-2">
526 {this.state.searchRes.state === "loading" ? (
529 <span>{I18NextService.i18n.t("search")}</span>
538 const { type, listingType, sort, communityId, creatorId } =
539 getSearchQueryParams();
541 communitySearchOptions,
542 creatorSearchOptions,
543 searchCommunitiesLoading,
544 searchCreatorLoading,
548 const hasCommunities =
549 communitiesRes.state == "success" &&
550 communitiesRes.data.communities.length > 0;
554 <div className="row gx-2 gy-3 mb-2 mb-sm-3">
555 <div className="col-auto">
558 onChange={linkEvent(this, this.handleTypeChange)}
559 className="form-select d-inline-block w-auto"
560 aria-label={I18NextService.i18n.t("type")}
562 <option disabled aria-hidden="true">
563 {I18NextService.i18n.t("type")}
565 {searchTypes.map(option => (
566 <option value={option} key={option}>
567 {I18NextService.i18n.t(
568 option.toString().toLowerCase() as NoOptionI18nKeys
574 <div className="col-auto">
577 showLocal={showLocal(this.isoData)}
579 onChange={this.handleListingTypeChange}
582 <div className="col-auto">
585 onChange={this.handleSortChange}
591 <div className="row gx-5 gy-2 mb-3">
594 filterType="community"
595 onChange={this.handleCommunityFilterChange}
596 onSearch={this.handleCommunitySearch}
597 options={communitySearchOptions}
599 loading={searchCommunitiesLoading}
604 onChange={this.handleCreatorFilterChange}
605 onSearch={this.handleCreatorSearch}
606 options={creatorSearchOptions}
608 loading={searchCreatorLoading}
615 buildCombined(): Combined[] {
616 const combined: Combined[] = [];
618 resolveObjectRes: resolveObjectResponse,
619 searchRes: searchResponse,
622 // Push the possible resolve / federated objects first
623 if (resolveObjectResponse.state == "success") {
624 const { comment, post, community, person } = resolveObjectResponse.data;
627 combined.push(commentViewToCombined(comment));
630 combined.push(postViewToCombined(post));
633 combined.push(communityViewToCombined(community));
636 combined.push(personViewSafeToCombined(person));
640 // Push the search results
641 if (searchResponse.state === "success") {
642 const { comments, posts, communities, users } = searchResponse.data;
646 ...(comments?.map(commentViewToCombined) ?? []),
647 ...(posts?.map(postViewToCombined) ?? []),
648 ...(communities?.map(communityViewToCombined) ?? []),
649 ...(users?.map(personViewSafeToCombined) ?? []),
654 const { sort } = getSearchQueryParams();
657 if (sort === "New") {
658 combined.sort((a, b) => b.published.localeCompare(a.published));
660 combined.sort((a, b) =>
662 ((b.data as CommentView | PostView).counts.score |
663 (b.data as CommunityView).counts.subscribers |
664 (b.data as PersonView).counts.comment_score) -
665 ((a.data as CommentView | PostView).counts.score |
666 (a.data as CommunityView).counts.subscribers |
667 (a.data as PersonView).counts.comment_score)
676 const combined = this.buildCombined();
681 <div key={i.published} className="row">
682 <div className="col-12">
683 {i.type_ === "posts" && (
685 key={(i.data as PostView).post.id}
686 post_view={i.data as PostView}
688 enableDownvotes={enableDownvotes(this.state.siteRes)}
689 enableNsfw={enableNsfw(this.state.siteRes)}
690 allLanguages={this.state.siteRes.all_languages}
691 siteLanguages={this.state.siteRes.discussion_languages}
693 // All of these are unused, since its view only
694 onPostEdit={() => {}}
695 onPostVote={() => {}}
696 onPostReport={() => {}}
697 onBlockPerson={() => {}}
698 onLockPost={() => {}}
699 onDeletePost={() => {}}
700 onRemovePost={() => {}}
701 onSavePost={() => {}}
702 onFeaturePost={() => {}}
703 onPurgePerson={() => {}}
704 onPurgePost={() => {}}
705 onBanPersonFromCommunity={() => {}}
706 onBanPerson={() => {}}
707 onAddModToCommunity={() => {}}
708 onAddAdmin={() => {}}
709 onTransferCommunity={() => {}}
712 {i.type_ === "comments" && (
714 key={(i.data as CommentView).comment.id}
717 comment_view: i.data as CommentView,
722 viewType={CommentViewType.Flat}
726 enableDownvotes={enableDownvotes(this.state.siteRes)}
727 allLanguages={this.state.siteRes.all_languages}
728 siteLanguages={this.state.siteRes.discussion_languages}
729 // All of these are unused, since its viewonly
731 onSaveComment={() => {}}
732 onBlockPerson={() => {}}
733 onDeleteComment={() => {}}
734 onRemoveComment={() => {}}
735 onCommentVote={() => {}}
736 onCommentReport={() => {}}
737 onDistinguishComment={() => {}}
738 onAddModToCommunity={() => {}}
739 onAddAdmin={() => {}}
740 onTransferCommunity={() => {}}
741 onPurgeComment={() => {}}
742 onPurgePerson={() => {}}
743 onCommentReplyRead={() => {}}
744 onPersonMentionRead={() => {}}
745 onBanPersonFromCommunity={() => {}}
746 onBanPerson={() => {}}
747 onCreateComment={() => Promise.resolve({ state: "empty" })}
748 onEditComment={() => Promise.resolve({ state: "empty" })}
751 {i.type_ === "communities" && (
752 <div>{communityListing(i.data as CommunityView)}</div>
754 {i.type_ === "users" && (
755 <div>{personListing(i.data as PersonView)}</div>
766 searchRes: searchResponse,
767 resolveObjectRes: resolveObjectResponse,
771 searchResponse.state === "success" ? searchResponse.data.comments : [];
774 resolveObjectResponse.state === "success" &&
775 resolveObjectResponse.data.comment
777 comments.unshift(resolveObjectResponse.data.comment);
782 nodes={commentsToFlatNodes(comments)}
783 viewType={CommentViewType.Flat}
787 enableDownvotes={enableDownvotes(siteRes)}
788 allLanguages={siteRes.all_languages}
789 siteLanguages={siteRes.discussion_languages}
790 // All of these are unused, since its viewonly
792 onSaveComment={() => {}}
793 onBlockPerson={() => {}}
794 onDeleteComment={() => {}}
795 onRemoveComment={() => {}}
796 onCommentVote={() => {}}
797 onCommentReport={() => {}}
798 onDistinguishComment={() => {}}
799 onAddModToCommunity={() => {}}
800 onAddAdmin={() => {}}
801 onTransferCommunity={() => {}}
802 onPurgeComment={() => {}}
803 onPurgePerson={() => {}}
804 onCommentReplyRead={() => {}}
805 onPersonMentionRead={() => {}}
806 onBanPersonFromCommunity={() => {}}
807 onBanPerson={() => {}}
808 onCreateComment={() => Promise.resolve({ state: "empty" })}
809 onEditComment={() => Promise.resolve({ state: "empty" })}
816 searchRes: searchResponse,
817 resolveObjectRes: resolveObjectResponse,
821 searchResponse.state === "success" ? searchResponse.data.posts : [];
824 resolveObjectResponse.state === "success" &&
825 resolveObjectResponse.data.post
827 posts.unshift(resolveObjectResponse.data.post);
833 <div key={pv.post.id} className="row">
834 <div className="col-12">
838 enableDownvotes={enableDownvotes(siteRes)}
839 enableNsfw={enableNsfw(siteRes)}
840 allLanguages={siteRes.all_languages}
841 siteLanguages={siteRes.discussion_languages}
843 // All of these are unused, since its view only
844 onPostEdit={() => {}}
845 onPostVote={() => {}}
846 onPostReport={() => {}}
847 onBlockPerson={() => {}}
848 onLockPost={() => {}}
849 onDeletePost={() => {}}
850 onRemovePost={() => {}}
851 onSavePost={() => {}}
852 onFeaturePost={() => {}}
853 onPurgePerson={() => {}}
854 onPurgePost={() => {}}
855 onBanPersonFromCommunity={() => {}}
856 onBanPerson={() => {}}
857 onAddModToCommunity={() => {}}
858 onAddAdmin={() => {}}
859 onTransferCommunity={() => {}}
870 searchRes: searchResponse,
871 resolveObjectRes: resolveObjectResponse,
874 searchResponse.state === "success" ? searchResponse.data.communities : [];
877 resolveObjectResponse.state === "success" &&
878 resolveObjectResponse.data.community
880 communities.unshift(resolveObjectResponse.data.community);
885 {communities.map(cv => (
886 <div key={cv.community.id} className="row">
887 <div className="col-12">{communityListing(cv)}</div>
896 searchRes: searchResponse,
897 resolveObjectRes: resolveObjectResponse,
900 searchResponse.state === "success" ? searchResponse.data.users : [];
903 resolveObjectResponse.state === "success" &&
904 resolveObjectResponse.data.person
906 users.unshift(resolveObjectResponse.data.person);
912 <div key={pvs.person.id} className="row">
913 <div className="col-12">{personListing(pvs)}</div>
920 get resultsCount(): number {
921 const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
924 r.state === "success"
925 ? r.data.posts.length +
926 r.data.comments.length +
927 r.data.communities.length +
932 resolveRes.state === "success"
933 ? resolveRes.data.post ||
934 resolveRes.data.person ||
935 resolveRes.data.community ||
936 resolveRes.data.comment
941 return resObjCount + searchCount;
945 const auth = myAuth();
946 const { searchText: q } = this.state;
947 const { communityId, creatorId, type, sort, listingType, page } =
948 getSearchQueryParams();
951 this.setState({ searchRes: { state: "loading" } });
953 searchRes: await HttpService.client.search({
955 community_id: communityId ?? undefined,
956 creator_id: creatorId ?? undefined,
959 listing_type: listingType,
965 window.scrollTo(0, 0);
966 restoreScrollPosition(this.context);
969 this.setState({ resolveObjectRes: { state: "loading" } });
971 resolveObjectRes: await HttpService.silent_client.resolveObject({
980 handleCreatorSearch = debounce(async (text: string) => {
981 const { creatorId } = getSearchQueryParams();
982 const { creatorSearchOptions } = this.state;
983 const newOptions: Choice[] = [];
985 this.setState({ searchCreatorLoading: true });
987 const selectedChoice = creatorSearchOptions.find(
988 choice => getIdFromString(choice.value) === creatorId
991 if (selectedChoice) {
992 newOptions.push(selectedChoice);
995 if (text.length > 0) {
996 newOptions.push(...(await fetchUsers(text)).map(personToChoice));
1000 searchCreatorLoading: false,
1001 creatorSearchOptions: newOptions,
1005 handleCommunitySearch = debounce(async (text: string) => {
1006 const { communityId } = getSearchQueryParams();
1007 const { communitySearchOptions } = this.state;
1009 searchCommunitiesLoading: true,
1012 const newOptions: Choice[] = [];
1014 const selectedChoice = communitySearchOptions.find(
1015 choice => getIdFromString(choice.value) === communityId
1018 if (selectedChoice) {
1019 newOptions.push(selectedChoice);
1022 if (text.length > 0) {
1023 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
1027 searchCommunitiesLoading: false,
1028 communitySearchOptions: newOptions,
1032 handleSortChange(sort: SortType) {
1033 this.updateUrl({ sort, page: 1 });
1036 handleTypeChange(i: Search, event: any) {
1037 const type = event.target.value as SearchType;
1045 handlePageChange(page: number) {
1046 this.updateUrl({ page });
1049 handleListingTypeChange(listingType: ListingType) {
1056 handleCommunityFilterChange({ value }: Choice) {
1058 communityId: getIdFromString(value) ?? null,
1063 handleCreatorFilterChange({ value }: Choice) {
1065 creatorId: getIdFromString(value) ?? null,
1070 handleSearchSubmit(i: Search, event: any) {
1071 event.preventDefault();
1074 q: i.state.searchText,
1079 handleQChange(i: Search, event: any) {
1080 i.setState({ searchText: event.target.value });
1091 }: Partial<SearchProps>) {
1095 listingType: urlListingType,
1096 communityId: urlCommunityId,
1098 creatorId: urlCreatorId,
1100 } = getSearchQueryParams();
1102 let query = q ?? this.state.searchText ?? urlQ;
1104 if (query && query.length > 0) {
1105 query = encodeURIComponent(query);
1108 const queryParams: QueryParams<SearchProps> = {
1110 type: type ?? urlType,
1111 listingType: listingType ?? urlListingType,
1112 communityId: getUpdatedSearchId(communityId, urlCommunityId),
1113 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
1114 page: (page ?? urlPage).toString(),
1115 sort: sort ?? urlSort,
1118 this.props.history.push(`/search${getQueryString(queryParams)}`);