16 restoreScrollPosition,
18 } from "@utils/browser";
20 capitalizeFirstLetter,
27 } from "@utils/helpers";
28 import type { QueryParams } from "@utils/types";
29 import { Choice, RouteDataResponse } from "@utils/types";
30 import type { NoOptionI18nKeys } from "i18next";
31 import { Component, linkEvent } from "inferno";
38 GetPersonDetailsResponse,
41 ListCommunitiesResponse,
46 ResolveObjectResponse,
51 } from "lemmy-js-client";
52 import { fetchLimit } from "../config";
53 import { CommentViewType, InitialFetchRequest } from "../interfaces";
54 import { FirstLoadService, I18NextService } from "../services";
55 import { HttpService, RequestState } from "../services/HttpService";
56 import { CommentNodes } from "./comment/comment-nodes";
57 import { HtmlTags } from "./common/html-tags";
58 import { Spinner } from "./common/icon";
59 import { ListingTypeSelect } from "./common/listing-type-select";
60 import { Paginator } from "./common/paginator";
61 import { SearchableSelect } from "./common/searchable-select";
62 import { SortSelect } from "./common/sort-select";
63 import { CommunityLink } from "./community/community-link";
64 import { PersonListing } from "./person/person-listing";
65 import { PostListing } from "./post/post-listing";
67 interface SearchProps {
71 listingType: ListingType;
72 communityId?: number | null;
73 creatorId?: number | null;
77 type SearchData = RouteDataResponse<{
78 communityResponse: GetCommunityResponse;
79 listCommunitiesResponse: ListCommunitiesResponse;
80 creatorDetailsResponse: GetPersonDetailsResponse;
81 searchResponse: SearchResponse;
82 resolveObjectResponse: ResolveObjectResponse;
85 type FilterType = "creator" | "community";
87 interface SearchState {
88 searchRes: RequestState<SearchResponse>;
89 resolveObjectRes: RequestState<ResolveObjectResponse>;
90 creatorDetailsRes: RequestState<GetPersonDetailsResponse>;
91 communitiesRes: RequestState<ListCommunitiesResponse>;
92 communityRes: RequestState<GetCommunityResponse>;
93 siteRes: GetSiteResponse;
95 communitySearchOptions: Choice[];
96 creatorSearchOptions: Choice[];
97 searchCreatorLoading: boolean;
98 searchCommunitiesLoading: boolean;
99 isIsomorphic: boolean;
104 data: CommentView | PostView | CommunityView | PersonView;
108 const defaultSearchType = "All";
109 const defaultSortType = "TopAll";
110 const defaultListingType = "All";
112 const searchTypes = ["All", "Comments", "Posts", "Communities", "Users", "Url"];
114 const getSearchQueryParams = () =>
115 getQueryParams<SearchProps>({
116 q: getSearchQueryFromQuery,
117 type: getSearchTypeFromQuery,
118 sort: getSortTypeFromQuery,
119 listingType: getListingTypeFromQuery,
120 communityId: getIdFromString,
121 creatorId: getIdFromString,
122 page: getPageFromString,
125 const getSearchQueryFromQuery = (q?: string): string | undefined =>
126 q ? decodeURIComponent(q) : undefined;
128 function getSearchTypeFromQuery(type_?: string): SearchType {
129 return type_ ? (type_ as SearchType) : defaultSearchType;
132 function getSortTypeFromQuery(sort?: string): SortType {
133 return sort ? (sort as SortType) : defaultSortType;
136 function getListingTypeFromQuery(listingType?: string): ListingType {
137 return listingType ? (listingType as ListingType) : defaultListingType;
140 function postViewToCombined(data: PostView): Combined {
144 published: data.post.published,
148 function commentViewToCombined(data: CommentView): Combined {
152 published: data.comment.published,
156 function communityViewToCombined(data: CommunityView): Combined {
158 type_: "communities",
160 published: data.community.published,
164 function personViewSafeToCombined(data: PersonView): Combined {
168 published: data.person.published,
180 filterType: FilterType;
182 onSearch: (text: string) => void;
183 onChange: (choice: Choice) => void;
184 value?: number | null;
188 <div className="mb-3 col-sm-6">
189 <label className="col-form-label me-2" htmlFor={`${filterType}-filter`}>
190 {capitalizeFirstLetter(I18NextService.i18n.t(filterType))}
193 id={`${filterType}-filter`}
196 label: I18NextService.i18n.t("all"),
209 const communityListing = ({
211 counts: { subscribers },
214 <CommunityLink community={community} />,
216 "number_of_subscribers"
219 const personListing = ({ person, counts: { comment_count } }: PersonView) =>
221 <PersonListing person={person} showApubName />,
227 listing: JSX.ElementClass,
229 translationKey: "number_of_comments" | "number_of_subscribers"
233 <span>{listing}</span>
234 <span>{` - ${I18NextService.i18n.t(translationKey, {
235 count: Number(count),
236 formattedCount: numToSI(count),
242 export class Search extends Component<any, SearchState> {
243 private isoData = setIsoData<SearchData>(this.context);
245 state: SearchState = {
246 resolveObjectRes: { state: "empty" },
247 creatorDetailsRes: { state: "empty" },
248 communitiesRes: { state: "empty" },
249 communityRes: { state: "empty" },
250 siteRes: this.isoData.site_res,
251 creatorSearchOptions: [],
252 communitySearchOptions: [],
253 searchRes: { state: "empty" },
254 searchCreatorLoading: false,
255 searchCommunitiesLoading: false,
259 constructor(props: any, context: any) {
260 super(props, context);
262 this.handleSortChange = this.handleSortChange.bind(this);
263 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
264 this.handlePageChange = this.handlePageChange.bind(this);
265 this.handleCommunityFilterChange =
266 this.handleCommunityFilterChange.bind(this);
267 this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
269 const { q } = getSearchQueryParams();
276 // Only fetch the data if coming from another route
277 if (!isBrowser() || FirstLoadService.isFirstLoad) {
279 communityResponse: communityRes,
280 creatorDetailsResponse: creatorDetailsRes,
281 listCommunitiesResponse: communitiesRes,
282 resolveObjectResponse: resolveObjectRes,
283 searchResponse: searchRes,
284 } = this.isoData.routeData;
291 if (creatorDetailsRes?.state === "success") {
294 creatorSearchOptions:
295 creatorDetailsRes?.state === "success"
296 ? [personToChoice(creatorDetailsRes.data.person_view)]
302 if (communitiesRes?.state === "success") {
309 if (communityRes?.state === "success") {
321 if (searchRes?.state === "success") {
328 if (resolveObjectRes?.state === "success") {
338 async componentDidMount() {
339 if (!this.state.isIsomorphic) {
340 const promises = [this.fetchCommunities()];
341 if (this.state.searchText) {
342 promises.push(this.search());
345 await Promise.all(promises);
349 async fetchCommunities() {
350 this.setState({ communitiesRes: { state: "loading" } });
352 communitiesRes: await HttpService.client.listCommunities({
353 type_: defaultListingType,
354 sort: defaultSortType,
361 componentWillUnmount() {
362 saveScrollPosition(this.context);
365 static async fetchInitialData({
368 query: { communityId, creatorId, q, type, sort, listingType, page },
369 }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<SearchData> {
370 const community_id = getIdFromString(communityId);
371 let communityResponse: RequestState<GetCommunityResponse> = {
374 let listCommunitiesResponse: RequestState<ListCommunitiesResponse> = {
378 const getCommunityForm: GetCommunity = {
383 communityResponse = await client.getCommunity(getCommunityForm);
385 const listCommunitiesForm: ListCommunities = {
386 type_: defaultListingType,
387 sort: defaultSortType,
392 listCommunitiesResponse = await client.listCommunities(
397 const creator_id = getIdFromString(creatorId);
398 let creatorDetailsResponse: RequestState<GetPersonDetailsResponse> = {
402 const getCreatorForm: GetPersonDetails = {
403 person_id: creator_id,
407 creatorDetailsResponse = await client.getPersonDetails(getCreatorForm);
410 const query = getSearchQueryFromQuery(q);
412 let searchResponse: RequestState<SearchResponse> = { state: "empty" };
413 let resolveObjectResponse: RequestState<ResolveObjectResponse> = {
418 const form: SearchForm = {
422 type_: getSearchTypeFromQuery(type),
423 sort: getSortTypeFromQuery(sort),
424 listing_type: getListingTypeFromQuery(listingType),
425 page: getPageFromString(page),
431 searchResponse = await client.search(form);
433 const resolveObjectForm: ResolveObject = {
437 resolveObjectResponse = await client.resolveObject(resolveObjectForm);
444 creatorDetailsResponse,
445 listCommunitiesResponse,
446 resolveObjectResponse,
451 get documentTitle(): string {
452 const { q } = getSearchQueryParams();
453 const name = this.state.siteRes.site_view.site.name;
454 return `${I18NextService.i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
458 const { type, page } = getSearchQueryParams();
461 <div className="search container-lg">
463 title={this.documentTitle}
464 path={this.context.router.route.match.url}
466 <h5>{I18NextService.i18n.t("search")}</h5>
469 {this.displayResults(type)}
470 {this.resultsCount === 0 &&
471 this.state.searchRes.state === "success" && (
472 <span>{I18NextService.i18n.t("no_results")}</span>
474 <Paginator page={page} onChange={this.handlePageChange} />
479 displayResults(type: SearchType) {
484 return this.comments;
489 return this.communities;
499 <form className="row" onSubmit={linkEvent(this, this.handleSearchSubmit)}>
500 <div className="col-auto">
503 className="form-control me-2 mb-2 col-sm-8"
504 value={this.state.searchText}
505 placeholder={`${I18NextService.i18n.t("search")}...`}
506 aria-label={I18NextService.i18n.t("search")}
507 onInput={linkEvent(this, this.handleQChange)}
512 <div className="col-auto">
513 <button type="submit" className="btn btn-secondary mb-2">
514 {this.state.searchRes.state === "loading" ? (
517 <span>{I18NextService.i18n.t("search")}</span>
526 const { type, listingType, sort, communityId, creatorId } =
527 getSearchQueryParams();
529 communitySearchOptions,
530 creatorSearchOptions,
531 searchCommunitiesLoading,
532 searchCreatorLoading,
536 const hasCommunities =
537 communitiesRes.state == "success" &&
538 communitiesRes.data.communities.length > 0;
541 <div className="mb-2">
544 onChange={linkEvent(this, this.handleTypeChange)}
545 className="form-select d-inline-block w-auto mb-2"
546 aria-label={I18NextService.i18n.t("type")}
548 <option disabled aria-hidden="true">
549 {I18NextService.i18n.t("type")}
551 {searchTypes.map(option => (
552 <option value={option} key={option}>
553 {I18NextService.i18n.t(
554 option.toString().toLowerCase() as NoOptionI18nKeys
559 <span className="ms-2">
562 showLocal={showLocal(this.isoData)}
564 onChange={this.handleListingTypeChange}
567 <span className="ms-2">
570 onChange={this.handleSortChange}
575 <div className="row">
578 filterType="community"
579 onChange={this.handleCommunityFilterChange}
580 onSearch={this.handleCommunitySearch}
581 options={communitySearchOptions}
583 loading={searchCommunitiesLoading}
588 onChange={this.handleCreatorFilterChange}
589 onSearch={this.handleCreatorSearch}
590 options={creatorSearchOptions}
592 loading={searchCreatorLoading}
599 buildCombined(): Combined[] {
600 const combined: Combined[] = [];
602 resolveObjectRes: resolveObjectResponse,
603 searchRes: searchResponse,
606 // Push the possible resolve / federated objects first
607 if (resolveObjectResponse.state == "success") {
608 const { comment, post, community, person } = resolveObjectResponse.data;
611 combined.push(commentViewToCombined(comment));
614 combined.push(postViewToCombined(post));
617 combined.push(communityViewToCombined(community));
620 combined.push(personViewSafeToCombined(person));
624 // Push the search results
625 if (searchResponse.state === "success") {
626 const { comments, posts, communities, users } = searchResponse.data;
630 ...(comments?.map(commentViewToCombined) ?? []),
631 ...(posts?.map(postViewToCombined) ?? []),
632 ...(communities?.map(communityViewToCombined) ?? []),
633 ...(users?.map(personViewSafeToCombined) ?? []),
638 const { sort } = getSearchQueryParams();
641 if (sort === "New") {
642 combined.sort((a, b) => b.published.localeCompare(a.published));
644 combined.sort((a, b) =>
646 ((b.data as CommentView | PostView).counts.score |
647 (b.data as CommunityView).counts.subscribers |
648 (b.data as PersonView).counts.comment_score) -
649 ((a.data as CommentView | PostView).counts.score |
650 (a.data as CommunityView).counts.subscribers |
651 (a.data as PersonView).counts.comment_score)
660 const combined = this.buildCombined();
665 <div key={i.published} className="row">
666 <div className="col-12">
667 {i.type_ === "posts" && (
669 key={(i.data as PostView).post.id}
670 post_view={i.data as PostView}
672 enableDownvotes={enableDownvotes(this.state.siteRes)}
673 enableNsfw={enableNsfw(this.state.siteRes)}
674 allLanguages={this.state.siteRes.all_languages}
675 siteLanguages={this.state.siteRes.discussion_languages}
677 // All of these are unused, since its view only
678 onPostEdit={() => {}}
679 onPostVote={() => {}}
680 onPostReport={() => {}}
681 onBlockPerson={() => {}}
682 onLockPost={() => {}}
683 onDeletePost={() => {}}
684 onRemovePost={() => {}}
685 onSavePost={() => {}}
686 onFeaturePost={() => {}}
687 onPurgePerson={() => {}}
688 onPurgePost={() => {}}
689 onBanPersonFromCommunity={() => {}}
690 onBanPerson={() => {}}
691 onAddModToCommunity={() => {}}
692 onAddAdmin={() => {}}
693 onTransferCommunity={() => {}}
696 {i.type_ === "comments" && (
698 key={(i.data as CommentView).comment.id}
701 comment_view: i.data as CommentView,
706 viewType={CommentViewType.Flat}
710 enableDownvotes={enableDownvotes(this.state.siteRes)}
711 allLanguages={this.state.siteRes.all_languages}
712 siteLanguages={this.state.siteRes.discussion_languages}
713 // All of these are unused, since its viewonly
715 onSaveComment={() => {}}
716 onBlockPerson={() => {}}
717 onDeleteComment={() => {}}
718 onRemoveComment={() => {}}
719 onCommentVote={() => {}}
720 onCommentReport={() => {}}
721 onDistinguishComment={() => {}}
722 onAddModToCommunity={() => {}}
723 onAddAdmin={() => {}}
724 onTransferCommunity={() => {}}
725 onPurgeComment={() => {}}
726 onPurgePerson={() => {}}
727 onCommentReplyRead={() => {}}
728 onPersonMentionRead={() => {}}
729 onBanPersonFromCommunity={() => {}}
730 onBanPerson={() => {}}
731 onCreateComment={() => Promise.resolve({ state: "empty" })}
732 onEditComment={() => Promise.resolve({ state: "empty" })}
735 {i.type_ === "communities" && (
736 <div>{communityListing(i.data as CommunityView)}</div>
738 {i.type_ === "users" && (
739 <div>{personListing(i.data as PersonView)}</div>
750 searchRes: searchResponse,
751 resolveObjectRes: resolveObjectResponse,
755 searchResponse.state === "success" ? searchResponse.data.comments : [];
758 resolveObjectResponse.state === "success" &&
759 resolveObjectResponse.data.comment
761 comments.unshift(resolveObjectResponse.data.comment);
766 nodes={commentsToFlatNodes(comments)}
767 viewType={CommentViewType.Flat}
771 enableDownvotes={enableDownvotes(siteRes)}
772 allLanguages={siteRes.all_languages}
773 siteLanguages={siteRes.discussion_languages}
774 // All of these are unused, since its viewonly
776 onSaveComment={() => {}}
777 onBlockPerson={() => {}}
778 onDeleteComment={() => {}}
779 onRemoveComment={() => {}}
780 onCommentVote={() => {}}
781 onCommentReport={() => {}}
782 onDistinguishComment={() => {}}
783 onAddModToCommunity={() => {}}
784 onAddAdmin={() => {}}
785 onTransferCommunity={() => {}}
786 onPurgeComment={() => {}}
787 onPurgePerson={() => {}}
788 onCommentReplyRead={() => {}}
789 onPersonMentionRead={() => {}}
790 onBanPersonFromCommunity={() => {}}
791 onBanPerson={() => {}}
792 onCreateComment={() => Promise.resolve({ state: "empty" })}
793 onEditComment={() => Promise.resolve({ state: "empty" })}
800 searchRes: searchResponse,
801 resolveObjectRes: resolveObjectResponse,
805 searchResponse.state === "success" ? searchResponse.data.posts : [];
808 resolveObjectResponse.state === "success" &&
809 resolveObjectResponse.data.post
811 posts.unshift(resolveObjectResponse.data.post);
817 <div key={pv.post.id} className="row">
818 <div className="col-12">
822 enableDownvotes={enableDownvotes(siteRes)}
823 enableNsfw={enableNsfw(siteRes)}
824 allLanguages={siteRes.all_languages}
825 siteLanguages={siteRes.discussion_languages}
827 // All of these are unused, since its view only
828 onPostEdit={() => {}}
829 onPostVote={() => {}}
830 onPostReport={() => {}}
831 onBlockPerson={() => {}}
832 onLockPost={() => {}}
833 onDeletePost={() => {}}
834 onRemovePost={() => {}}
835 onSavePost={() => {}}
836 onFeaturePost={() => {}}
837 onPurgePerson={() => {}}
838 onPurgePost={() => {}}
839 onBanPersonFromCommunity={() => {}}
840 onBanPerson={() => {}}
841 onAddModToCommunity={() => {}}
842 onAddAdmin={() => {}}
843 onTransferCommunity={() => {}}
854 searchRes: searchResponse,
855 resolveObjectRes: resolveObjectResponse,
858 searchResponse.state === "success" ? searchResponse.data.communities : [];
861 resolveObjectResponse.state === "success" &&
862 resolveObjectResponse.data.community
864 communities.unshift(resolveObjectResponse.data.community);
869 {communities.map(cv => (
870 <div key={cv.community.id} className="row">
871 <div className="col-12">{communityListing(cv)}</div>
880 searchRes: searchResponse,
881 resolveObjectRes: resolveObjectResponse,
884 searchResponse.state === "success" ? searchResponse.data.users : [];
887 resolveObjectResponse.state === "success" &&
888 resolveObjectResponse.data.person
890 users.unshift(resolveObjectResponse.data.person);
896 <div key={pvs.person.id} className="row">
897 <div className="col-12">{personListing(pvs)}</div>
904 get resultsCount(): number {
905 const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
908 r.state === "success"
909 ? r.data.posts.length +
910 r.data.comments.length +
911 r.data.communities.length +
916 resolveRes.state === "success"
917 ? resolveRes.data.post ||
918 resolveRes.data.person ||
919 resolveRes.data.community ||
920 resolveRes.data.comment
925 return resObjCount + searchCount;
929 const auth = myAuth();
930 const { searchText: q } = this.state;
931 const { communityId, creatorId, type, sort, listingType, page } =
932 getSearchQueryParams();
935 this.setState({ searchRes: { state: "loading" } });
937 searchRes: await HttpService.client.search({
939 community_id: communityId ?? undefined,
940 creator_id: creatorId ?? undefined,
943 listing_type: listingType,
949 window.scrollTo(0, 0);
950 restoreScrollPosition(this.context);
953 this.setState({ resolveObjectRes: { state: "loading" } });
955 resolveObjectRes: await HttpService.client.resolveObject({
964 handleCreatorSearch = debounce(async (text: string) => {
965 const { creatorId } = getSearchQueryParams();
966 const { creatorSearchOptions } = this.state;
967 const newOptions: Choice[] = [];
969 this.setState({ searchCreatorLoading: true });
971 const selectedChoice = creatorSearchOptions.find(
972 choice => getIdFromString(choice.value) === creatorId
975 if (selectedChoice) {
976 newOptions.push(selectedChoice);
979 if (text.length > 0) {
980 newOptions.push(...(await fetchUsers(text)).map(personToChoice));
984 searchCreatorLoading: false,
985 creatorSearchOptions: newOptions,
989 handleCommunitySearch = debounce(async (text: string) => {
990 const { communityId } = getSearchQueryParams();
991 const { communitySearchOptions } = this.state;
993 searchCommunitiesLoading: true,
996 const newOptions: Choice[] = [];
998 const selectedChoice = communitySearchOptions.find(
999 choice => getIdFromString(choice.value) === communityId
1002 if (selectedChoice) {
1003 newOptions.push(selectedChoice);
1006 if (text.length > 0) {
1007 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
1011 searchCommunitiesLoading: false,
1012 communitySearchOptions: newOptions,
1016 handleSortChange(sort: SortType) {
1017 this.updateUrl({ sort, page: 1 });
1020 handleTypeChange(i: Search, event: any) {
1021 const type = event.target.value as SearchType;
1029 handlePageChange(page: number) {
1030 this.updateUrl({ page });
1033 handleListingTypeChange(listingType: ListingType) {
1040 handleCommunityFilterChange({ value }: Choice) {
1042 communityId: getIdFromString(value) ?? null,
1047 handleCreatorFilterChange({ value }: Choice) {
1049 creatorId: getIdFromString(value) ?? null,
1054 handleSearchSubmit(i: Search, event: any) {
1055 event.preventDefault();
1058 q: i.state.searchText,
1063 handleQChange(i: Search, event: any) {
1064 i.setState({ searchText: event.target.value });
1075 }: Partial<SearchProps>) {
1079 listingType: urlListingType,
1080 communityId: urlCommunityId,
1082 creatorId: urlCreatorId,
1084 } = getSearchQueryParams();
1086 let query = q ?? this.state.searchText ?? urlQ;
1088 if (query && query.length > 0) {
1089 query = encodeURIComponent(query);
1092 const queryParams: QueryParams<SearchProps> = {
1094 type: type ?? urlType,
1095 listingType: listingType ?? urlListingType,
1096 communityId: getUpdatedSearchId(communityId, urlCommunityId),
1097 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
1098 page: (page ?? urlPage).toString(),
1099 sort: sort ?? urlSort,
1102 this.props.history.push(`/search${getQueryString(queryParams)}`);
1104 await this.search();