1 import { debounce, getQueryParams, getQueryString } from "@utils/helpers";
2 import type { QueryParams } from "@utils/types";
3 import type { NoOptionI18nKeys } from "i18next";
4 import { Component, linkEvent } from "inferno";
11 GetPersonDetailsResponse,
14 ListCommunitiesResponse,
19 ResolveObjectResponse,
24 } from "lemmy-js-client";
25 import { i18n } from "../i18next";
26 import { CommentViewType, InitialFetchRequest } from "../interfaces";
27 import { FirstLoadService } from "../services/FirstLoadService";
28 import { HttpService, RequestState } from "../services/HttpService";
32 capitalizeFirstLetter,
46 restoreScrollPosition,
51 import { CommentNodes } from "./comment/comment-nodes";
52 import { HtmlTags } from "./common/html-tags";
53 import { Spinner } from "./common/icon";
54 import { ListingTypeSelect } from "./common/listing-type-select";
55 import { Paginator } from "./common/paginator";
56 import { SearchableSelect } from "./common/searchable-select";
57 import { SortSelect } from "./common/sort-select";
58 import { CommunityLink } from "./community/community-link";
59 import { PersonListing } from "./person/person-listing";
60 import { PostListing } from "./post/post-listing";
62 interface SearchProps {
66 listingType: ListingType;
67 communityId?: number | null;
68 creatorId?: number | null;
72 type SearchData = RouteDataResponse<{
73 communityResponse: GetCommunityResponse;
74 listCommunitiesResponse: ListCommunitiesResponse;
75 creatorDetailsResponse: GetPersonDetailsResponse;
76 searchResponse: SearchResponse;
77 resolveObjectResponse: ResolveObjectResponse;
80 type FilterType = "creator" | "community";
82 interface SearchState {
83 searchRes: RequestState<SearchResponse>;
84 resolveObjectRes: RequestState<ResolveObjectResponse>;
85 creatorDetailsRes: RequestState<GetPersonDetailsResponse>;
86 communitiesRes: RequestState<ListCommunitiesResponse>;
87 communityRes: RequestState<GetCommunityResponse>;
88 siteRes: GetSiteResponse;
90 communitySearchOptions: Choice[];
91 creatorSearchOptions: Choice[];
92 searchCreatorLoading: boolean;
93 searchCommunitiesLoading: boolean;
94 isIsomorphic: boolean;
99 data: CommentView | PostView | CommunityView | PersonView;
103 const defaultSearchType = "All";
104 const defaultSortType = "TopAll";
105 const defaultListingType = "All";
107 const searchTypes = ["All", "Comments", "Posts", "Communities", "Users", "Url"];
109 const getSearchQueryParams = () =>
110 getQueryParams<SearchProps>({
111 q: getSearchQueryFromQuery,
112 type: getSearchTypeFromQuery,
113 sort: getSortTypeFromQuery,
114 listingType: getListingTypeFromQuery,
115 communityId: getIdFromString,
116 creatorId: getIdFromString,
117 page: getPageFromString,
120 const getSearchQueryFromQuery = (q?: string): string | undefined =>
121 q ? decodeURIComponent(q) : undefined;
123 function getSearchTypeFromQuery(type_?: string): SearchType {
124 return type_ ? (type_ as SearchType) : defaultSearchType;
127 function getSortTypeFromQuery(sort?: string): SortType {
128 return sort ? (sort as SortType) : defaultSortType;
131 function getListingTypeFromQuery(listingType?: string): ListingType {
132 return listingType ? (listingType as ListingType) : defaultListingType;
135 function postViewToCombined(data: PostView): Combined {
139 published: data.post.published,
143 function commentViewToCombined(data: CommentView): Combined {
147 published: data.comment.published,
151 function communityViewToCombined(data: CommunityView): Combined {
153 type_: "communities",
155 published: data.community.published,
159 function personViewSafeToCombined(data: PersonView): Combined {
163 published: data.person.published,
175 filterType: FilterType;
177 onSearch: (text: string) => void;
178 onChange: (choice: Choice) => void;
179 value?: number | null;
183 <div className="mb-3 col-sm-6">
184 <label className="col-form-label me-2" htmlFor={`${filterType}-filter`}>
185 {capitalizeFirstLetter(i18n.t(filterType))}
188 id={`${filterType}-filter`}
191 label: i18n.t("all"),
204 const communityListing = ({
206 counts: { subscribers },
209 <CommunityLink community={community} />,
211 "number_of_subscribers"
214 const personListing = ({ person, counts: { comment_count } }: PersonView) =>
216 <PersonListing person={person} showApubName />,
222 listing: JSX.ElementClass,
224 translationKey: "number_of_comments" | "number_of_subscribers"
228 <span>{listing}</span>
229 <span>{` - ${i18n.t(translationKey, {
230 count: Number(count),
231 formattedCount: numToSI(count),
237 export class Search extends Component<any, SearchState> {
238 private isoData = setIsoData<SearchData>(this.context);
240 state: SearchState = {
241 resolveObjectRes: { state: "empty" },
242 creatorDetailsRes: { state: "empty" },
243 communitiesRes: { state: "empty" },
244 communityRes: { state: "empty" },
245 siteRes: this.isoData.site_res,
246 creatorSearchOptions: [],
247 communitySearchOptions: [],
248 searchRes: { state: "empty" },
249 searchCreatorLoading: false,
250 searchCommunitiesLoading: false,
254 constructor(props: any, context: any) {
255 super(props, context);
257 this.handleSortChange = this.handleSortChange.bind(this);
258 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
259 this.handlePageChange = this.handlePageChange.bind(this);
260 this.handleCommunityFilterChange =
261 this.handleCommunityFilterChange.bind(this);
262 this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
264 const { q } = getSearchQueryParams();
271 // Only fetch the data if coming from another route
272 if (FirstLoadService.isFirstLoad) {
274 communityResponse: communityRes,
275 creatorDetailsResponse: creatorDetailsRes,
276 listCommunitiesResponse: communitiesRes,
277 resolveObjectResponse: resolveObjectRes,
278 searchResponse: searchRes,
279 } = this.isoData.routeData;
286 if (creatorDetailsRes?.state === "success") {
289 creatorSearchOptions:
290 creatorDetailsRes?.state === "success"
291 ? [personToChoice(creatorDetailsRes.data.person_view)]
297 if (communitiesRes?.state === "success") {
304 if (communityRes?.state === "success") {
316 if (searchRes?.state === "success") {
323 if (resolveObjectRes?.state === "success") {
333 async componentDidMount() {
334 if (!this.state.isIsomorphic) {
335 const promises = [this.fetchCommunities()];
336 if (this.state.searchText) {
337 promises.push(this.search());
340 await Promise.all(promises);
344 async fetchCommunities() {
345 this.setState({ communitiesRes: { state: "loading" } });
347 communitiesRes: await HttpService.client.listCommunities({
348 type_: defaultListingType,
349 sort: defaultSortType,
356 componentWillUnmount() {
357 saveScrollPosition(this.context);
360 static async fetchInitialData({
363 query: { communityId, creatorId, q, type, sort, listingType, page },
364 }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<SearchData> {
365 const community_id = getIdFromString(communityId);
366 let communityResponse: RequestState<GetCommunityResponse> = {
369 let listCommunitiesResponse: RequestState<ListCommunitiesResponse> = {
373 const getCommunityForm: GetCommunity = {
378 communityResponse = await client.getCommunity(getCommunityForm);
380 const listCommunitiesForm: ListCommunities = {
381 type_: defaultListingType,
382 sort: defaultSortType,
387 listCommunitiesResponse = await client.listCommunities(
392 const creator_id = getIdFromString(creatorId);
393 let creatorDetailsResponse: RequestState<GetPersonDetailsResponse> = {
397 const getCreatorForm: GetPersonDetails = {
398 person_id: creator_id,
402 creatorDetailsResponse = await client.getPersonDetails(getCreatorForm);
405 const query = getSearchQueryFromQuery(q);
407 let searchResponse: RequestState<SearchResponse> = { state: "empty" };
408 let resolveObjectResponse: RequestState<ResolveObjectResponse> = {
413 const form: SearchForm = {
417 type_: getSearchTypeFromQuery(type),
418 sort: getSortTypeFromQuery(sort),
419 listing_type: getListingTypeFromQuery(listingType),
420 page: getPageFromString(page),
426 searchResponse = await client.search(form);
428 const resolveObjectForm: ResolveObject = {
432 resolveObjectResponse = await client.resolveObject(resolveObjectForm);
439 creatorDetailsResponse,
440 listCommunitiesResponse,
441 resolveObjectResponse,
446 get documentTitle(): string {
447 const { q } = getSearchQueryParams();
448 const name = this.state.siteRes.site_view.site.name;
449 return `${i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
453 const { type, page } = getSearchQueryParams();
456 <div className="container-lg">
458 title={this.documentTitle}
459 path={this.context.router.route.match.url}
461 <h5>{i18n.t("search")}</h5>
464 {this.displayResults(type)}
465 {this.resultsCount === 0 &&
466 this.state.searchRes.state === "success" && (
467 <span>{i18n.t("no_results")}</span>
469 <Paginator page={page} onChange={this.handlePageChange} />
474 displayResults(type: SearchType) {
479 return this.comments;
484 return this.communities;
494 <form className="row" onSubmit={linkEvent(this, this.handleSearchSubmit)}>
495 <div className="col-auto">
498 className="form-control me-2 mb-2 col-sm-8"
499 value={this.state.searchText}
500 placeholder={`${i18n.t("search")}...`}
501 aria-label={i18n.t("search")}
502 onInput={linkEvent(this, this.handleQChange)}
507 <div className="col-auto">
508 <button type="submit" className="btn btn-secondary mb-2">
509 {this.state.searchRes.state === "loading" ? (
512 <span>{i18n.t("search")}</span>
521 const { type, listingType, sort, communityId, creatorId } =
522 getSearchQueryParams();
524 communitySearchOptions,
525 creatorSearchOptions,
526 searchCommunitiesLoading,
527 searchCreatorLoading,
531 const hasCommunities =
532 communitiesRes.state == "success" &&
533 communitiesRes.data.communities.length > 0;
536 <div className="mb-2">
539 onChange={linkEvent(this, this.handleTypeChange)}
540 className="form-select d-inline-block w-auto mb-2"
541 aria-label={i18n.t("type")}
543 <option disabled aria-hidden="true">
546 {searchTypes.map(option => (
547 <option value={option} key={option}>
548 {i18n.t(option.toString().toLowerCase() as NoOptionI18nKeys)}
552 <span className="ms-2">
555 showLocal={showLocal(this.isoData)}
557 onChange={this.handleListingTypeChange}
560 <span className="ms-2">
563 onChange={this.handleSortChange}
568 <div className="row">
571 filterType="community"
572 onChange={this.handleCommunityFilterChange}
573 onSearch={this.handleCommunitySearch}
574 options={communitySearchOptions}
576 loading={searchCommunitiesLoading}
581 onChange={this.handleCreatorFilterChange}
582 onSearch={this.handleCreatorSearch}
583 options={creatorSearchOptions}
585 loading={searchCreatorLoading}
592 buildCombined(): Combined[] {
593 const combined: Combined[] = [];
595 resolveObjectRes: resolveObjectResponse,
596 searchRes: searchResponse,
599 // Push the possible resolve / federated objects first
600 if (resolveObjectResponse.state == "success") {
601 const { comment, post, community, person } = resolveObjectResponse.data;
604 combined.push(commentViewToCombined(comment));
607 combined.push(postViewToCombined(post));
610 combined.push(communityViewToCombined(community));
613 combined.push(personViewSafeToCombined(person));
617 // Push the search results
618 if (searchResponse.state === "success") {
619 const { comments, posts, communities, users } = searchResponse.data;
623 ...(comments?.map(commentViewToCombined) ?? []),
624 ...(posts?.map(postViewToCombined) ?? []),
625 ...(communities?.map(communityViewToCombined) ?? []),
626 ...(users?.map(personViewSafeToCombined) ?? []),
631 const { sort } = getSearchQueryParams();
634 if (sort === "New") {
635 combined.sort((a, b) => b.published.localeCompare(a.published));
637 combined.sort((a, b) =>
639 ((b.data as CommentView | PostView).counts.score |
640 (b.data as CommunityView).counts.subscribers |
641 (b.data as PersonView).counts.comment_score) -
642 ((a.data as CommentView | PostView).counts.score |
643 (a.data as CommunityView).counts.subscribers |
644 (a.data as PersonView).counts.comment_score)
653 const combined = this.buildCombined();
658 <div key={i.published} className="row">
659 <div className="col-12">
660 {i.type_ === "posts" && (
662 key={(i.data as PostView).post.id}
663 post_view={i.data as PostView}
665 enableDownvotes={enableDownvotes(this.state.siteRes)}
666 enableNsfw={enableNsfw(this.state.siteRes)}
667 allLanguages={this.state.siteRes.all_languages}
668 siteLanguages={this.state.siteRes.discussion_languages}
670 // All of these are unused, since its view only
671 onPostEdit={() => {}}
672 onPostVote={() => {}}
673 onPostReport={() => {}}
674 onBlockPerson={() => {}}
675 onLockPost={() => {}}
676 onDeletePost={() => {}}
677 onRemovePost={() => {}}
678 onSavePost={() => {}}
679 onFeaturePost={() => {}}
680 onPurgePerson={() => {}}
681 onPurgePost={() => {}}
682 onBanPersonFromCommunity={() => {}}
683 onBanPerson={() => {}}
684 onAddModToCommunity={() => {}}
685 onAddAdmin={() => {}}
686 onTransferCommunity={() => {}}
689 {i.type_ === "comments" && (
691 key={(i.data as CommentView).comment.id}
694 comment_view: i.data as CommentView,
699 viewType={CommentViewType.Flat}
703 enableDownvotes={enableDownvotes(this.state.siteRes)}
704 allLanguages={this.state.siteRes.all_languages}
705 siteLanguages={this.state.siteRes.discussion_languages}
706 // All of these are unused, since its viewonly
708 onSaveComment={() => {}}
709 onBlockPerson={() => {}}
710 onDeleteComment={() => {}}
711 onRemoveComment={() => {}}
712 onCommentVote={() => {}}
713 onCommentReport={() => {}}
714 onDistinguishComment={() => {}}
715 onAddModToCommunity={() => {}}
716 onAddAdmin={() => {}}
717 onTransferCommunity={() => {}}
718 onPurgeComment={() => {}}
719 onPurgePerson={() => {}}
720 onCommentReplyRead={() => {}}
721 onPersonMentionRead={() => {}}
722 onBanPersonFromCommunity={() => {}}
723 onBanPerson={() => {}}
724 onCreateComment={() => Promise.resolve({ state: "empty" })}
725 onEditComment={() => Promise.resolve({ state: "empty" })}
728 {i.type_ === "communities" && (
729 <div>{communityListing(i.data as CommunityView)}</div>
731 {i.type_ === "users" && (
732 <div>{personListing(i.data as PersonView)}</div>
743 searchRes: searchResponse,
744 resolveObjectRes: resolveObjectResponse,
748 searchResponse.state === "success" ? searchResponse.data.comments : [];
751 resolveObjectResponse.state === "success" &&
752 resolveObjectResponse.data.comment
754 comments.unshift(resolveObjectResponse.data.comment);
759 nodes={commentsToFlatNodes(comments)}
760 viewType={CommentViewType.Flat}
764 enableDownvotes={enableDownvotes(siteRes)}
765 allLanguages={siteRes.all_languages}
766 siteLanguages={siteRes.discussion_languages}
767 // All of these are unused, since its viewonly
769 onSaveComment={() => {}}
770 onBlockPerson={() => {}}
771 onDeleteComment={() => {}}
772 onRemoveComment={() => {}}
773 onCommentVote={() => {}}
774 onCommentReport={() => {}}
775 onDistinguishComment={() => {}}
776 onAddModToCommunity={() => {}}
777 onAddAdmin={() => {}}
778 onTransferCommunity={() => {}}
779 onPurgeComment={() => {}}
780 onPurgePerson={() => {}}
781 onCommentReplyRead={() => {}}
782 onPersonMentionRead={() => {}}
783 onBanPersonFromCommunity={() => {}}
784 onBanPerson={() => {}}
785 onCreateComment={() => Promise.resolve({ state: "empty" })}
786 onEditComment={() => Promise.resolve({ state: "empty" })}
793 searchRes: searchResponse,
794 resolveObjectRes: resolveObjectResponse,
798 searchResponse.state === "success" ? searchResponse.data.posts : [];
801 resolveObjectResponse.state === "success" &&
802 resolveObjectResponse.data.post
804 posts.unshift(resolveObjectResponse.data.post);
810 <div key={pv.post.id} className="row">
811 <div className="col-12">
815 enableDownvotes={enableDownvotes(siteRes)}
816 enableNsfw={enableNsfw(siteRes)}
817 allLanguages={siteRes.all_languages}
818 siteLanguages={siteRes.discussion_languages}
820 // All of these are unused, since its view only
821 onPostEdit={() => {}}
822 onPostVote={() => {}}
823 onPostReport={() => {}}
824 onBlockPerson={() => {}}
825 onLockPost={() => {}}
826 onDeletePost={() => {}}
827 onRemovePost={() => {}}
828 onSavePost={() => {}}
829 onFeaturePost={() => {}}
830 onPurgePerson={() => {}}
831 onPurgePost={() => {}}
832 onBanPersonFromCommunity={() => {}}
833 onBanPerson={() => {}}
834 onAddModToCommunity={() => {}}
835 onAddAdmin={() => {}}
836 onTransferCommunity={() => {}}
847 searchRes: searchResponse,
848 resolveObjectRes: resolveObjectResponse,
851 searchResponse.state === "success" ? searchResponse.data.communities : [];
854 resolveObjectResponse.state === "success" &&
855 resolveObjectResponse.data.community
857 communities.unshift(resolveObjectResponse.data.community);
862 {communities.map(cv => (
863 <div key={cv.community.id} className="row">
864 <div className="col-12">{communityListing(cv)}</div>
873 searchRes: searchResponse,
874 resolveObjectRes: resolveObjectResponse,
877 searchResponse.state === "success" ? searchResponse.data.users : [];
880 resolveObjectResponse.state === "success" &&
881 resolveObjectResponse.data.person
883 users.unshift(resolveObjectResponse.data.person);
889 <div key={pvs.person.id} className="row">
890 <div className="col-12">{personListing(pvs)}</div>
897 get resultsCount(): number {
898 const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
901 r.state === "success"
902 ? r.data.posts.length +
903 r.data.comments.length +
904 r.data.communities.length +
909 resolveRes.state === "success"
910 ? resolveRes.data.post ||
911 resolveRes.data.person ||
912 resolveRes.data.community ||
913 resolveRes.data.comment
918 return resObjCount + searchCount;
922 const auth = myAuth();
923 const { searchText: q } = this.state;
924 const { communityId, creatorId, type, sort, listingType, page } =
925 getSearchQueryParams();
928 this.setState({ searchRes: { state: "loading" } });
930 searchRes: await HttpService.client.search({
932 community_id: communityId ?? undefined,
933 creator_id: creatorId ?? undefined,
936 listing_type: listingType,
942 window.scrollTo(0, 0);
943 restoreScrollPosition(this.context);
946 this.setState({ resolveObjectRes: { state: "loading" } });
948 resolveObjectRes: await HttpService.client.resolveObject({
957 handleCreatorSearch = debounce(async (text: string) => {
958 const { creatorId } = getSearchQueryParams();
959 const { creatorSearchOptions } = this.state;
960 const newOptions: Choice[] = [];
962 this.setState({ searchCreatorLoading: true });
964 const selectedChoice = creatorSearchOptions.find(
965 choice => getIdFromString(choice.value) === creatorId
968 if (selectedChoice) {
969 newOptions.push(selectedChoice);
972 if (text.length > 0) {
973 newOptions.push(...(await fetchUsers(text)).map(personToChoice));
977 searchCreatorLoading: false,
978 creatorSearchOptions: newOptions,
982 handleCommunitySearch = debounce(async (text: string) => {
983 const { communityId } = getSearchQueryParams();
984 const { communitySearchOptions } = this.state;
986 searchCommunitiesLoading: true,
989 const newOptions: Choice[] = [];
991 const selectedChoice = communitySearchOptions.find(
992 choice => getIdFromString(choice.value) === communityId
995 if (selectedChoice) {
996 newOptions.push(selectedChoice);
999 if (text.length > 0) {
1000 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
1004 searchCommunitiesLoading: false,
1005 communitySearchOptions: newOptions,
1009 handleSortChange(sort: SortType) {
1010 this.updateUrl({ sort, page: 1 });
1013 handleTypeChange(i: Search, event: any) {
1014 const type = event.target.value as SearchType;
1022 handlePageChange(page: number) {
1023 this.updateUrl({ page });
1026 handleListingTypeChange(listingType: ListingType) {
1033 handleCommunityFilterChange({ value }: Choice) {
1035 communityId: getIdFromString(value) ?? null,
1040 handleCreatorFilterChange({ value }: Choice) {
1042 creatorId: getIdFromString(value) ?? null,
1047 handleSearchSubmit(i: Search, event: any) {
1048 event.preventDefault();
1051 q: i.state.searchText,
1056 handleQChange(i: Search, event: any) {
1057 i.setState({ searchText: event.target.value });
1068 }: Partial<SearchProps>) {
1072 listingType: urlListingType,
1073 communityId: urlCommunityId,
1075 creatorId: urlCreatorId,
1077 } = getSearchQueryParams();
1079 let query = q ?? this.state.searchText ?? urlQ;
1081 if (query && query.length > 0) {
1082 query = encodeURIComponent(query);
1085 const queryParams: QueryParams<SearchProps> = {
1087 type: type ?? urlType,
1088 listingType: listingType ?? urlListingType,
1089 communityId: getUpdatedSearchId(communityId, urlCommunityId),
1090 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
1091 page: (page ?? urlPage).toString(),
1092 sort: sort ?? urlSort,
1095 this.props.history.push(`/search${getQueryString(queryParams)}`);
1097 await this.search();