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="form-group col-sm-6">
184 <label className="col-form-label" 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;
495 className="form-inline"
496 onSubmit={linkEvent(this, this.handleSearchSubmit)}
500 className="form-control mr-2 mb-2"
501 value={this.state.searchText}
502 placeholder={`${i18n.t("search")}...`}
503 aria-label={i18n.t("search")}
504 onInput={linkEvent(this, this.handleQChange)}
508 <button type="submit" className="btn btn-secondary mr-2 mb-2">
509 {this.state.searchRes.state === "loading" ? (
512 <span>{i18n.t("search")}</span>
520 const { type, listingType, sort, communityId, creatorId } =
521 getSearchQueryParams();
523 communitySearchOptions,
524 creatorSearchOptions,
525 searchCommunitiesLoading,
526 searchCreatorLoading,
530 const hasCommunities =
531 communitiesRes.state == "success" &&
532 communitiesRes.data.communities.length > 0;
535 <div className="mb-2">
538 onChange={linkEvent(this, this.handleTypeChange)}
539 className="custom-select w-auto mb-2"
540 aria-label={i18n.t("type")}
542 <option disabled aria-hidden="true">
545 {searchTypes.map(option => (
546 <option value={option} key={option}>
547 {i18n.t(option.toString().toLowerCase() as NoOptionI18nKeys)}
551 <span className="ml-2">
554 showLocal={showLocal(this.isoData)}
556 onChange={this.handleListingTypeChange}
559 <span className="ml-2">
562 onChange={this.handleSortChange}
567 <div className="form-row">
570 filterType="community"
571 onChange={this.handleCommunityFilterChange}
572 onSearch={this.handleCommunitySearch}
573 options={communitySearchOptions}
575 loading={searchCommunitiesLoading}
580 onChange={this.handleCreatorFilterChange}
581 onSearch={this.handleCreatorSearch}
582 options={creatorSearchOptions}
584 loading={searchCreatorLoading}
591 buildCombined(): Combined[] {
592 const combined: Combined[] = [];
594 resolveObjectRes: resolveObjectResponse,
595 searchRes: searchResponse,
598 // Push the possible resolve / federated objects first
599 if (resolveObjectResponse.state == "success") {
600 const { comment, post, community, person } = resolveObjectResponse.data;
603 combined.push(commentViewToCombined(comment));
606 combined.push(postViewToCombined(post));
609 combined.push(communityViewToCombined(community));
612 combined.push(personViewSafeToCombined(person));
616 // Push the search results
617 if (searchResponse.state === "success") {
618 const { comments, posts, communities, users } = searchResponse.data;
622 ...(comments?.map(commentViewToCombined) ?? []),
623 ...(posts?.map(postViewToCombined) ?? []),
624 ...(communities?.map(communityViewToCombined) ?? []),
625 ...(users?.map(personViewSafeToCombined) ?? []),
630 const { sort } = getSearchQueryParams();
633 if (sort === "New") {
634 combined.sort((a, b) => b.published.localeCompare(a.published));
636 combined.sort((a, b) =>
638 ((b.data as CommentView | PostView).counts.score |
639 (b.data as CommunityView).counts.subscribers |
640 (b.data as PersonView).counts.comment_score) -
641 ((a.data as CommentView | PostView).counts.score |
642 (a.data as CommunityView).counts.subscribers |
643 (a.data as PersonView).counts.comment_score)
652 const combined = this.buildCombined();
657 <div key={i.published} className="row">
658 <div className="col-12">
659 {i.type_ === "posts" && (
661 key={(i.data as PostView).post.id}
662 post_view={i.data as PostView}
664 enableDownvotes={enableDownvotes(this.state.siteRes)}
665 enableNsfw={enableNsfw(this.state.siteRes)}
666 allLanguages={this.state.siteRes.all_languages}
667 siteLanguages={this.state.siteRes.discussion_languages}
669 // All of these are unused, since its view only
670 onPostEdit={() => {}}
671 onPostVote={() => {}}
672 onPostReport={() => {}}
673 onBlockPerson={() => {}}
674 onLockPost={() => {}}
675 onDeletePost={() => {}}
676 onRemovePost={() => {}}
677 onSavePost={() => {}}
678 onFeaturePost={() => {}}
679 onPurgePerson={() => {}}
680 onPurgePost={() => {}}
681 onBanPersonFromCommunity={() => {}}
682 onBanPerson={() => {}}
683 onAddModToCommunity={() => {}}
684 onAddAdmin={() => {}}
685 onTransferCommunity={() => {}}
688 {i.type_ === "comments" && (
690 key={(i.data as CommentView).comment.id}
693 comment_view: i.data as CommentView,
698 viewType={CommentViewType.Flat}
702 enableDownvotes={enableDownvotes(this.state.siteRes)}
703 allLanguages={this.state.siteRes.all_languages}
704 siteLanguages={this.state.siteRes.discussion_languages}
705 // All of these are unused, since its viewonly
707 onSaveComment={() => {}}
708 onBlockPerson={() => {}}
709 onDeleteComment={() => {}}
710 onRemoveComment={() => {}}
711 onCommentVote={() => {}}
712 onCommentReport={() => {}}
713 onDistinguishComment={() => {}}
714 onAddModToCommunity={() => {}}
715 onAddAdmin={() => {}}
716 onTransferCommunity={() => {}}
717 onPurgeComment={() => {}}
718 onPurgePerson={() => {}}
719 onCommentReplyRead={() => {}}
720 onPersonMentionRead={() => {}}
721 onBanPersonFromCommunity={() => {}}
722 onBanPerson={() => {}}
723 onCreateComment={() => Promise.resolve({ state: "empty" })}
724 onEditComment={() => Promise.resolve({ state: "empty" })}
727 {i.type_ === "communities" && (
728 <div>{communityListing(i.data as CommunityView)}</div>
730 {i.type_ === "users" && (
731 <div>{personListing(i.data as PersonView)}</div>
742 searchRes: searchResponse,
743 resolveObjectRes: resolveObjectResponse,
747 searchResponse.state === "success" ? searchResponse.data.comments : [];
750 resolveObjectResponse.state === "success" &&
751 resolveObjectResponse.data.comment
753 comments.unshift(resolveObjectResponse.data.comment);
758 nodes={commentsToFlatNodes(comments)}
759 viewType={CommentViewType.Flat}
763 enableDownvotes={enableDownvotes(siteRes)}
764 allLanguages={siteRes.all_languages}
765 siteLanguages={siteRes.discussion_languages}
766 // All of these are unused, since its viewonly
768 onSaveComment={() => {}}
769 onBlockPerson={() => {}}
770 onDeleteComment={() => {}}
771 onRemoveComment={() => {}}
772 onCommentVote={() => {}}
773 onCommentReport={() => {}}
774 onDistinguishComment={() => {}}
775 onAddModToCommunity={() => {}}
776 onAddAdmin={() => {}}
777 onTransferCommunity={() => {}}
778 onPurgeComment={() => {}}
779 onPurgePerson={() => {}}
780 onCommentReplyRead={() => {}}
781 onPersonMentionRead={() => {}}
782 onBanPersonFromCommunity={() => {}}
783 onBanPerson={() => {}}
784 onCreateComment={() => Promise.resolve({ state: "empty" })}
785 onEditComment={() => Promise.resolve({ state: "empty" })}
792 searchRes: searchResponse,
793 resolveObjectRes: resolveObjectResponse,
797 searchResponse.state === "success" ? searchResponse.data.posts : [];
800 resolveObjectResponse.state === "success" &&
801 resolveObjectResponse.data.post
803 posts.unshift(resolveObjectResponse.data.post);
809 <div key={pv.post.id} className="row">
810 <div className="col-12">
814 enableDownvotes={enableDownvotes(siteRes)}
815 enableNsfw={enableNsfw(siteRes)}
816 allLanguages={siteRes.all_languages}
817 siteLanguages={siteRes.discussion_languages}
819 // All of these are unused, since its view only
820 onPostEdit={() => {}}
821 onPostVote={() => {}}
822 onPostReport={() => {}}
823 onBlockPerson={() => {}}
824 onLockPost={() => {}}
825 onDeletePost={() => {}}
826 onRemovePost={() => {}}
827 onSavePost={() => {}}
828 onFeaturePost={() => {}}
829 onPurgePerson={() => {}}
830 onPurgePost={() => {}}
831 onBanPersonFromCommunity={() => {}}
832 onBanPerson={() => {}}
833 onAddModToCommunity={() => {}}
834 onAddAdmin={() => {}}
835 onTransferCommunity={() => {}}
846 searchRes: searchResponse,
847 resolveObjectRes: resolveObjectResponse,
850 searchResponse.state === "success" ? searchResponse.data.communities : [];
853 resolveObjectResponse.state === "success" &&
854 resolveObjectResponse.data.community
856 communities.unshift(resolveObjectResponse.data.community);
861 {communities.map(cv => (
862 <div key={cv.community.id} className="row">
863 <div className="col-12">{communityListing(cv)}</div>
872 searchRes: searchResponse,
873 resolveObjectRes: resolveObjectResponse,
876 searchResponse.state === "success" ? searchResponse.data.users : [];
879 resolveObjectResponse.state === "success" &&
880 resolveObjectResponse.data.person
882 users.unshift(resolveObjectResponse.data.person);
888 <div key={pvs.person.id} className="row">
889 <div className="col-12">{personListing(pvs)}</div>
896 get resultsCount(): number {
897 const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
900 r.state === "success"
901 ? r.data.posts.length +
902 r.data.comments.length +
903 r.data.communities.length +
908 resolveRes.state === "success"
909 ? resolveRes.data.post ||
910 resolveRes.data.person ||
911 resolveRes.data.community ||
912 resolveRes.data.comment
917 return resObjCount + searchCount;
921 const auth = myAuth();
922 const { searchText: q } = this.state;
923 const { communityId, creatorId, type, sort, listingType, page } =
924 getSearchQueryParams();
927 this.setState({ searchRes: { state: "loading" } });
929 searchRes: await HttpService.client.search({
931 community_id: communityId ?? undefined,
932 creator_id: creatorId ?? undefined,
935 listing_type: listingType,
941 window.scrollTo(0, 0);
942 restoreScrollPosition(this.context);
945 this.setState({ resolveObjectRes: { state: "loading" } });
947 resolveObjectRes: await HttpService.client.resolveObject({
956 handleCreatorSearch = debounce(async (text: string) => {
957 const { creatorId } = getSearchQueryParams();
958 const { creatorSearchOptions } = this.state;
959 const newOptions: Choice[] = [];
961 this.setState({ searchCreatorLoading: true });
963 const selectedChoice = creatorSearchOptions.find(
964 choice => getIdFromString(choice.value) === creatorId
967 if (selectedChoice) {
968 newOptions.push(selectedChoice);
971 if (text.length > 0) {
972 newOptions.push(...(await fetchUsers(text)).map(personToChoice));
976 searchCreatorLoading: false,
977 creatorSearchOptions: newOptions,
981 handleCommunitySearch = debounce(async (text: string) => {
982 const { communityId } = getSearchQueryParams();
983 const { communitySearchOptions } = this.state;
985 searchCommunitiesLoading: true,
988 const newOptions: Choice[] = [];
990 const selectedChoice = communitySearchOptions.find(
991 choice => getIdFromString(choice.value) === communityId
994 if (selectedChoice) {
995 newOptions.push(selectedChoice);
998 if (text.length > 0) {
999 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
1003 searchCommunitiesLoading: false,
1004 communitySearchOptions: newOptions,
1008 handleSortChange(sort: SortType) {
1009 this.updateUrl({ sort, page: 1 });
1012 handleTypeChange(i: Search, event: any) {
1013 const type = event.target.value as SearchType;
1021 handlePageChange(page: number) {
1022 this.updateUrl({ page });
1025 handleListingTypeChange(listingType: ListingType) {
1032 handleCommunityFilterChange({ value }: Choice) {
1034 communityId: getIdFromString(value) ?? null,
1039 handleCreatorFilterChange({ value }: Choice) {
1041 creatorId: getIdFromString(value) ?? null,
1046 handleSearchSubmit(i: Search, event: any) {
1047 event.preventDefault();
1050 q: i.state.searchText,
1055 handleQChange(i: Search, event: any) {
1056 i.setState({ searchText: event.target.value });
1067 }: Partial<SearchProps>) {
1071 listingType: urlListingType,
1072 communityId: urlCommunityId,
1074 creatorId: urlCreatorId,
1076 } = getSearchQueryParams();
1078 let query = q ?? this.state.searchText ?? urlQ;
1080 if (query && query.length > 0) {
1081 query = encodeURIComponent(query);
1084 const queryParams: QueryParams<SearchProps> = {
1086 type: type ?? urlType,
1087 listingType: listingType ?? urlListingType,
1088 communityId: getUpdatedSearchId(communityId, urlCommunityId),
1089 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
1090 page: (page ?? urlPage).toString(),
1091 sort: sort ?? urlSort,
1094 this.props.history.push(`/search${getQueryString(queryParams)}`);
1096 await this.search();