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="mb-3 col-sm-6">
185 <label className="col-form-label me-2" htmlFor={`${filterType}-filter`}>
186 {capitalizeFirstLetter(I18NextService.i18n.t(filterType))}
189 id={`${filterType}-filter`}
192 label: I18NextService.i18n.t("all"),
205 const communityListing = ({
207 counts: { subscribers },
210 <CommunityLink community={community} />,
212 "number_of_subscribers"
215 const personListing = ({ person, counts: { comment_count } }: PersonView) =>
217 <PersonListing person={person} showApubName />,
223 listing: JSX.ElementClass,
225 translationKey: "number_of_comments" | "number_of_subscribers"
229 <span>{listing}</span>
230 <span>{` - ${I18NextService.i18n.t(translationKey, {
231 count: Number(count),
232 formattedCount: numToSI(count),
238 export class Search extends Component<any, SearchState> {
239 private isoData = setIsoData<SearchData>(this.context);
241 state: SearchState = {
242 resolveObjectRes: { state: "empty" },
243 creatorDetailsRes: { state: "empty" },
244 communitiesRes: { state: "empty" },
245 communityRes: { state: "empty" },
246 siteRes: this.isoData.site_res,
247 creatorSearchOptions: [],
248 communitySearchOptions: [],
249 searchRes: { state: "empty" },
250 searchCreatorLoading: false,
251 searchCommunitiesLoading: false,
255 constructor(props: any, context: any) {
256 super(props, context);
258 this.handleSortChange = this.handleSortChange.bind(this);
259 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
260 this.handlePageChange = this.handlePageChange.bind(this);
261 this.handleCommunityFilterChange =
262 this.handleCommunityFilterChange.bind(this);
263 this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
265 const { q } = getSearchQueryParams();
272 // Only fetch the data if coming from another route
273 if (FirstLoadService.isFirstLoad) {
275 communityResponse: communityRes,
276 creatorDetailsResponse: creatorDetailsRes,
277 listCommunitiesResponse: communitiesRes,
278 resolveObjectResponse: resolveObjectRes,
279 searchResponse: searchRes,
280 } = this.isoData.routeData;
287 if (creatorDetailsRes?.state === "success") {
290 creatorSearchOptions:
291 creatorDetailsRes?.state === "success"
292 ? [personToChoice(creatorDetailsRes.data.person_view)]
298 if (communitiesRes?.state === "success") {
305 if (communityRes?.state === "success") {
317 if (searchRes?.state === "success") {
324 if (resolveObjectRes?.state === "success") {
334 async componentDidMount() {
335 if (!this.state.isIsomorphic) {
336 const promises = [this.fetchCommunities()];
337 if (this.state.searchText) {
338 promises.push(this.search());
341 await Promise.all(promises);
345 async fetchCommunities() {
346 this.setState({ communitiesRes: { state: "loading" } });
348 communitiesRes: await HttpService.client.listCommunities({
349 type_: defaultListingType,
350 sort: defaultSortType,
357 componentWillUnmount() {
358 saveScrollPosition(this.context);
361 static async fetchInitialData({
364 query: { communityId, creatorId, q, type, sort, listingType, page },
365 }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<SearchData> {
366 const community_id = getIdFromString(communityId);
367 let communityResponse: RequestState<GetCommunityResponse> = {
370 let listCommunitiesResponse: RequestState<ListCommunitiesResponse> = {
374 const getCommunityForm: GetCommunity = {
379 communityResponse = await client.getCommunity(getCommunityForm);
381 const listCommunitiesForm: ListCommunities = {
382 type_: defaultListingType,
383 sort: defaultSortType,
388 listCommunitiesResponse = await client.listCommunities(
393 const creator_id = getIdFromString(creatorId);
394 let creatorDetailsResponse: RequestState<GetPersonDetailsResponse> = {
398 const getCreatorForm: GetPersonDetails = {
399 person_id: creator_id,
403 creatorDetailsResponse = await client.getPersonDetails(getCreatorForm);
406 const query = getSearchQueryFromQuery(q);
408 let searchResponse: RequestState<SearchResponse> = { state: "empty" };
409 let resolveObjectResponse: RequestState<ResolveObjectResponse> = {
414 const form: SearchForm = {
418 type_: getSearchTypeFromQuery(type),
419 sort: getSortTypeFromQuery(sort),
420 listing_type: getListingTypeFromQuery(listingType),
421 page: getPageFromString(page),
427 searchResponse = await client.search(form);
429 const resolveObjectForm: ResolveObject = {
433 resolveObjectResponse = await HttpService.silent_client.resolveObject(
437 // If we return this object with a state of failed, the catch-all-handler will redirect
438 // to an error page, so we ignore it by covering up the error with the empty state.
439 if (resolveObjectResponse.state === "failed") {
440 resolveObjectResponse = { state: "empty" };
448 creatorDetailsResponse,
449 listCommunitiesResponse,
450 resolveObjectResponse,
455 get documentTitle(): string {
456 const { q } = getSearchQueryParams();
457 const name = this.state.siteRes.site_view.site.name;
458 return `${I18NextService.i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
462 const { type, page } = getSearchQueryParams();
465 <div className="search container-lg">
467 title={this.documentTitle}
468 path={this.context.router.route.match.url}
470 <h5>{I18NextService.i18n.t("search")}</h5>
473 {this.displayResults(type)}
474 {this.resultsCount === 0 &&
475 this.state.searchRes.state === "success" && (
476 <span>{I18NextService.i18n.t("no_results")}</span>
478 <Paginator page={page} onChange={this.handlePageChange} />
483 displayResults(type: SearchType) {
488 return this.comments;
493 return this.communities;
503 <form className="row" onSubmit={linkEvent(this, this.handleSearchSubmit)}>
504 <div className="col-auto">
507 className="form-control me-2 mb-2 col-sm-8"
508 value={this.state.searchText}
509 placeholder={`${I18NextService.i18n.t("search")}...`}
510 aria-label={I18NextService.i18n.t("search")}
511 onInput={linkEvent(this, this.handleQChange)}
516 <div className="col-auto">
517 <button type="submit" className="btn btn-secondary mb-2">
518 {this.state.searchRes.state === "loading" ? (
521 <span>{I18NextService.i18n.t("search")}</span>
530 const { type, listingType, sort, communityId, creatorId } =
531 getSearchQueryParams();
533 communitySearchOptions,
534 creatorSearchOptions,
535 searchCommunitiesLoading,
536 searchCreatorLoading,
540 const hasCommunities =
541 communitiesRes.state == "success" &&
542 communitiesRes.data.communities.length > 0;
545 <div className="mb-2">
548 onChange={linkEvent(this, this.handleTypeChange)}
549 className="form-select d-inline-block w-auto mb-2"
550 aria-label={I18NextService.i18n.t("type")}
552 <option disabled aria-hidden="true">
553 {I18NextService.i18n.t("type")}
555 {searchTypes.map(option => (
556 <option value={option} key={option}>
557 {I18NextService.i18n.t(
558 option.toString().toLowerCase() as NoOptionI18nKeys
563 <span className="ms-2">
566 showLocal={showLocal(this.isoData)}
568 onChange={this.handleListingTypeChange}
571 <span className="ms-2">
574 onChange={this.handleSortChange}
579 <div className="row">
582 filterType="community"
583 onChange={this.handleCommunityFilterChange}
584 onSearch={this.handleCommunitySearch}
585 options={communitySearchOptions}
587 loading={searchCommunitiesLoading}
592 onChange={this.handleCreatorFilterChange}
593 onSearch={this.handleCreatorSearch}
594 options={creatorSearchOptions}
596 loading={searchCreatorLoading}
603 buildCombined(): Combined[] {
604 const combined: Combined[] = [];
606 resolveObjectRes: resolveObjectResponse,
607 searchRes: searchResponse,
610 // Push the possible resolve / federated objects first
611 if (resolveObjectResponse.state == "success") {
612 const { comment, post, community, person } = resolveObjectResponse.data;
615 combined.push(commentViewToCombined(comment));
618 combined.push(postViewToCombined(post));
621 combined.push(communityViewToCombined(community));
624 combined.push(personViewSafeToCombined(person));
628 // Push the search results
629 if (searchResponse.state === "success") {
630 const { comments, posts, communities, users } = searchResponse.data;
634 ...(comments?.map(commentViewToCombined) ?? []),
635 ...(posts?.map(postViewToCombined) ?? []),
636 ...(communities?.map(communityViewToCombined) ?? []),
637 ...(users?.map(personViewSafeToCombined) ?? []),
642 const { sort } = getSearchQueryParams();
645 if (sort === "New") {
646 combined.sort((a, b) => b.published.localeCompare(a.published));
648 combined.sort((a, b) =>
650 ((b.data as CommentView | PostView).counts.score |
651 (b.data as CommunityView).counts.subscribers |
652 (b.data as PersonView).counts.comment_score) -
653 ((a.data as CommentView | PostView).counts.score |
654 (a.data as CommunityView).counts.subscribers |
655 (a.data as PersonView).counts.comment_score)
664 const combined = this.buildCombined();
669 <div key={i.published} className="row">
670 <div className="col-12">
671 {i.type_ === "posts" && (
673 key={(i.data as PostView).post.id}
674 post_view={i.data as PostView}
676 enableDownvotes={enableDownvotes(this.state.siteRes)}
677 enableNsfw={enableNsfw(this.state.siteRes)}
678 allLanguages={this.state.siteRes.all_languages}
679 siteLanguages={this.state.siteRes.discussion_languages}
681 // All of these are unused, since its view only
682 onPostEdit={() => {}}
683 onPostVote={() => {}}
684 onPostReport={() => {}}
685 onBlockPerson={() => {}}
686 onLockPost={() => {}}
687 onDeletePost={() => {}}
688 onRemovePost={() => {}}
689 onSavePost={() => {}}
690 onFeaturePost={() => {}}
691 onPurgePerson={() => {}}
692 onPurgePost={() => {}}
693 onBanPersonFromCommunity={() => {}}
694 onBanPerson={() => {}}
695 onAddModToCommunity={() => {}}
696 onAddAdmin={() => {}}
697 onTransferCommunity={() => {}}
700 {i.type_ === "comments" && (
702 key={(i.data as CommentView).comment.id}
705 comment_view: i.data as CommentView,
710 viewType={CommentViewType.Flat}
714 enableDownvotes={enableDownvotes(this.state.siteRes)}
715 allLanguages={this.state.siteRes.all_languages}
716 siteLanguages={this.state.siteRes.discussion_languages}
717 // All of these are unused, since its viewonly
719 onSaveComment={() => {}}
720 onBlockPerson={() => {}}
721 onDeleteComment={() => {}}
722 onRemoveComment={() => {}}
723 onCommentVote={() => {}}
724 onCommentReport={() => {}}
725 onDistinguishComment={() => {}}
726 onAddModToCommunity={() => {}}
727 onAddAdmin={() => {}}
728 onTransferCommunity={() => {}}
729 onPurgeComment={() => {}}
730 onPurgePerson={() => {}}
731 onCommentReplyRead={() => {}}
732 onPersonMentionRead={() => {}}
733 onBanPersonFromCommunity={() => {}}
734 onBanPerson={() => {}}
735 onCreateComment={() => Promise.resolve({ state: "empty" })}
736 onEditComment={() => Promise.resolve({ state: "empty" })}
739 {i.type_ === "communities" && (
740 <div>{communityListing(i.data as CommunityView)}</div>
742 {i.type_ === "users" && (
743 <div>{personListing(i.data as PersonView)}</div>
754 searchRes: searchResponse,
755 resolveObjectRes: resolveObjectResponse,
759 searchResponse.state === "success" ? searchResponse.data.comments : [];
762 resolveObjectResponse.state === "success" &&
763 resolveObjectResponse.data.comment
765 comments.unshift(resolveObjectResponse.data.comment);
770 nodes={commentsToFlatNodes(comments)}
771 viewType={CommentViewType.Flat}
775 enableDownvotes={enableDownvotes(siteRes)}
776 allLanguages={siteRes.all_languages}
777 siteLanguages={siteRes.discussion_languages}
778 // All of these are unused, since its viewonly
780 onSaveComment={() => {}}
781 onBlockPerson={() => {}}
782 onDeleteComment={() => {}}
783 onRemoveComment={() => {}}
784 onCommentVote={() => {}}
785 onCommentReport={() => {}}
786 onDistinguishComment={() => {}}
787 onAddModToCommunity={() => {}}
788 onAddAdmin={() => {}}
789 onTransferCommunity={() => {}}
790 onPurgeComment={() => {}}
791 onPurgePerson={() => {}}
792 onCommentReplyRead={() => {}}
793 onPersonMentionRead={() => {}}
794 onBanPersonFromCommunity={() => {}}
795 onBanPerson={() => {}}
796 onCreateComment={() => Promise.resolve({ state: "empty" })}
797 onEditComment={() => Promise.resolve({ state: "empty" })}
804 searchRes: searchResponse,
805 resolveObjectRes: resolveObjectResponse,
809 searchResponse.state === "success" ? searchResponse.data.posts : [];
812 resolveObjectResponse.state === "success" &&
813 resolveObjectResponse.data.post
815 posts.unshift(resolveObjectResponse.data.post);
821 <div key={pv.post.id} className="row">
822 <div className="col-12">
826 enableDownvotes={enableDownvotes(siteRes)}
827 enableNsfw={enableNsfw(siteRes)}
828 allLanguages={siteRes.all_languages}
829 siteLanguages={siteRes.discussion_languages}
831 // All of these are unused, since its view only
832 onPostEdit={() => {}}
833 onPostVote={() => {}}
834 onPostReport={() => {}}
835 onBlockPerson={() => {}}
836 onLockPost={() => {}}
837 onDeletePost={() => {}}
838 onRemovePost={() => {}}
839 onSavePost={() => {}}
840 onFeaturePost={() => {}}
841 onPurgePerson={() => {}}
842 onPurgePost={() => {}}
843 onBanPersonFromCommunity={() => {}}
844 onBanPerson={() => {}}
845 onAddModToCommunity={() => {}}
846 onAddAdmin={() => {}}
847 onTransferCommunity={() => {}}
858 searchRes: searchResponse,
859 resolveObjectRes: resolveObjectResponse,
862 searchResponse.state === "success" ? searchResponse.data.communities : [];
865 resolveObjectResponse.state === "success" &&
866 resolveObjectResponse.data.community
868 communities.unshift(resolveObjectResponse.data.community);
873 {communities.map(cv => (
874 <div key={cv.community.id} className="row">
875 <div className="col-12">{communityListing(cv)}</div>
884 searchRes: searchResponse,
885 resolveObjectRes: resolveObjectResponse,
888 searchResponse.state === "success" ? searchResponse.data.users : [];
891 resolveObjectResponse.state === "success" &&
892 resolveObjectResponse.data.person
894 users.unshift(resolveObjectResponse.data.person);
900 <div key={pvs.person.id} className="row">
901 <div className="col-12">{personListing(pvs)}</div>
908 get resultsCount(): number {
909 const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
912 r.state === "success"
913 ? r.data.posts.length +
914 r.data.comments.length +
915 r.data.communities.length +
920 resolveRes.state === "success"
921 ? resolveRes.data.post ||
922 resolveRes.data.person ||
923 resolveRes.data.community ||
924 resolveRes.data.comment
929 return resObjCount + searchCount;
933 const auth = myAuth();
934 const { searchText: q } = this.state;
935 const { communityId, creatorId, type, sort, listingType, page } =
936 getSearchQueryParams();
939 this.setState({ searchRes: { state: "loading" } });
941 searchRes: await HttpService.client.search({
943 community_id: communityId ?? undefined,
944 creator_id: creatorId ?? undefined,
947 listing_type: listingType,
953 window.scrollTo(0, 0);
954 restoreScrollPosition(this.context);
957 this.setState({ resolveObjectRes: { state: "loading" } });
959 resolveObjectRes: await HttpService.silent_client.resolveObject({
968 handleCreatorSearch = debounce(async (text: string) => {
969 const { creatorId } = getSearchQueryParams();
970 const { creatorSearchOptions } = this.state;
971 const newOptions: Choice[] = [];
973 this.setState({ searchCreatorLoading: true });
975 const selectedChoice = creatorSearchOptions.find(
976 choice => getIdFromString(choice.value) === creatorId
979 if (selectedChoice) {
980 newOptions.push(selectedChoice);
983 if (text.length > 0) {
984 newOptions.push(...(await fetchUsers(text)).map(personToChoice));
988 searchCreatorLoading: false,
989 creatorSearchOptions: newOptions,
993 handleCommunitySearch = debounce(async (text: string) => {
994 const { communityId } = getSearchQueryParams();
995 const { communitySearchOptions } = this.state;
997 searchCommunitiesLoading: true,
1000 const newOptions: Choice[] = [];
1002 const selectedChoice = communitySearchOptions.find(
1003 choice => getIdFromString(choice.value) === communityId
1006 if (selectedChoice) {
1007 newOptions.push(selectedChoice);
1010 if (text.length > 0) {
1011 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
1015 searchCommunitiesLoading: false,
1016 communitySearchOptions: newOptions,
1020 handleSortChange(sort: SortType) {
1021 this.updateUrl({ sort, page: 1 });
1024 handleTypeChange(i: Search, event: any) {
1025 const type = event.target.value as SearchType;
1033 handlePageChange(page: number) {
1034 this.updateUrl({ page });
1037 handleListingTypeChange(listingType: ListingType) {
1044 handleCommunityFilterChange({ value }: Choice) {
1046 communityId: getIdFromString(value) ?? null,
1051 handleCreatorFilterChange({ value }: Choice) {
1053 creatorId: getIdFromString(value) ?? null,
1058 handleSearchSubmit(i: Search, event: any) {
1059 event.preventDefault();
1062 q: i.state.searchText,
1067 handleQChange(i: Search, event: any) {
1068 i.setState({ searchText: event.target.value });
1079 }: Partial<SearchProps>) {
1083 listingType: urlListingType,
1084 communityId: urlCommunityId,
1086 creatorId: urlCreatorId,
1088 } = getSearchQueryParams();
1090 let query = q ?? this.state.searchText ?? urlQ;
1092 if (query && query.length > 0) {
1093 query = encodeURIComponent(query);
1096 const queryParams: QueryParams<SearchProps> = {
1098 type: type ?? urlType,
1099 listingType: listingType ?? urlListingType,
1100 communityId: getUpdatedSearchId(communityId, urlCommunityId),
1101 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
1102 page: (page ?? urlPage).toString(),
1103 sort: sort ?? urlSort,
1106 this.props.history.push(`/search${getQueryString(queryParams)}`);