1 import type { NoOptionI18nKeys } from "i18next";
2 import { Component, linkEvent } from "inferno";
9 GetPersonDetailsResponse,
12 ListCommunitiesResponse,
17 ResolveObjectResponse,
22 } from "lemmy-js-client";
23 import { i18n } from "../i18next";
24 import { CommentViewType, InitialFetchRequest } from "../interfaces";
25 import { FirstLoadService } from "../services/FirstLoadService";
26 import { HttpService, RequestState } from "../services/HttpService";
29 capitalizeFirstLetter,
43 restoreScrollPosition,
48 import { debounce } from "../utils/helpers/debounce";
49 import { getQueryParams } from "../utils/helpers/get-query-params";
50 import { getQueryString } from "../utils/helpers/get-query-string";
51 import type { QueryParams } from "../utils/types/query-params";
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 FilterType = "creator" | "community";
75 interface SearchState {
76 searchRes: RequestState<SearchResponse>;
77 resolveObjectRes: RequestState<ResolveObjectResponse>;
78 creatorDetailsRes: RequestState<GetPersonDetailsResponse>;
79 communitiesRes: RequestState<ListCommunitiesResponse>;
80 communityRes: RequestState<GetCommunityResponse>;
81 siteRes: GetSiteResponse;
83 communitySearchOptions: Choice[];
84 creatorSearchOptions: Choice[];
85 searchCreatorLoading: boolean;
86 searchCommunitiesLoading: boolean;
87 isIsomorphic: boolean;
92 data: CommentView | PostView | CommunityView | PersonView;
96 const defaultSearchType = "All";
97 const defaultSortType = "TopAll";
98 const defaultListingType = "All";
100 const searchTypes = ["All", "Comments", "Posts", "Communities", "Users", "Url"];
102 const getSearchQueryParams = () =>
103 getQueryParams<SearchProps>({
104 q: getSearchQueryFromQuery,
105 type: getSearchTypeFromQuery,
106 sort: getSortTypeFromQuery,
107 listingType: getListingTypeFromQuery,
108 communityId: getIdFromString,
109 creatorId: getIdFromString,
110 page: getPageFromString,
113 const getSearchQueryFromQuery = (q?: string): string | undefined =>
114 q ? decodeURIComponent(q) : undefined;
116 function getSearchTypeFromQuery(type_?: string): SearchType {
117 return type_ ? (type_ as SearchType) : defaultSearchType;
120 function getSortTypeFromQuery(sort?: string): SortType {
121 return sort ? (sort as SortType) : defaultSortType;
124 function getListingTypeFromQuery(listingType?: string): ListingType {
125 return listingType ? (listingType as ListingType) : defaultListingType;
128 function postViewToCombined(data: PostView): Combined {
132 published: data.post.published,
136 function commentViewToCombined(data: CommentView): Combined {
140 published: data.comment.published,
144 function communityViewToCombined(data: CommunityView): Combined {
146 type_: "communities",
148 published: data.community.published,
152 function personViewSafeToCombined(data: PersonView): Combined {
156 published: data.person.published,
168 filterType: FilterType;
170 onSearch: (text: string) => void;
171 onChange: (choice: Choice) => void;
172 value?: number | null;
176 <div className="form-group col-sm-6">
177 <label className="col-form-label" htmlFor={`${filterType}-filter`}>
178 {capitalizeFirstLetter(i18n.t(filterType))}
181 id={`${filterType}-filter`}
184 label: i18n.t("all"),
197 const communityListing = ({
199 counts: { subscribers },
202 <CommunityLink community={community} />,
204 "number_of_subscribers"
207 const personListing = ({ person, counts: { comment_count } }: PersonView) =>
209 <PersonListing person={person} showApubName />,
215 listing: JSX.ElementClass,
217 translationKey: "number_of_comments" | "number_of_subscribers"
221 <span>{listing}</span>
222 <span>{` - ${i18n.t(translationKey, {
223 count: Number(count),
224 formattedCount: numToSI(count),
230 export class Search extends Component<any, SearchState> {
231 private isoData = setIsoData(this.context);
232 state: SearchState = {
233 resolveObjectRes: { state: "empty" },
234 creatorDetailsRes: { state: "empty" },
235 communitiesRes: { state: "empty" },
236 communityRes: { state: "empty" },
237 siteRes: this.isoData.site_res,
238 creatorSearchOptions: [],
239 communitySearchOptions: [],
240 searchRes: { state: "empty" },
241 searchCreatorLoading: false,
242 searchCommunitiesLoading: false,
246 constructor(props: any, context: any) {
247 super(props, context);
249 this.handleSortChange = this.handleSortChange.bind(this);
250 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
251 this.handlePageChange = this.handlePageChange.bind(this);
252 this.handleCommunityFilterChange =
253 this.handleCommunityFilterChange.bind(this);
254 this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
256 const { q } = getSearchQueryParams();
263 // Only fetch the data if coming from another route
264 if (FirstLoadService.isFirstLoad) {
271 ] = this.isoData.routeData;
278 creatorSearchOptions:
279 creatorDetailsRes.state == "success"
280 ? [personToChoice(creatorDetailsRes.data.person_view)]
285 if (communityRes.state === "success") {
288 communitySearchOptions: [
289 communityToChoice(communityRes.data.community_view),
304 async componentDidMount() {
305 if (!this.state.isIsomorphic) {
306 const promises = [this.fetchCommunities()];
307 if (this.state.searchText) {
308 promises.push(this.search());
311 await Promise.all(promises);
315 async fetchCommunities() {
316 this.setState({ communitiesRes: { state: "loading" } });
318 communitiesRes: await HttpService.client.listCommunities({
319 type_: defaultListingType,
320 sort: defaultSortType,
327 componentWillUnmount() {
328 saveScrollPosition(this.context);
331 static fetchInitialData({
334 query: { communityId, creatorId, q, type, sort, listingType, page },
335 }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<
338 const promises: Promise<RequestState<any>>[] = [];
340 const community_id = getIdFromString(communityId);
342 const getCommunityForm: GetCommunity = {
346 promises.push(client.getCommunity(getCommunityForm));
347 promises.push(Promise.resolve({ state: "empty" }));
349 const listCommunitiesForm: ListCommunities = {
350 type_: defaultListingType,
351 sort: defaultSortType,
355 promises.push(Promise.resolve({ state: "empty" }));
356 promises.push(client.listCommunities(listCommunitiesForm));
359 const creator_id = getIdFromString(creatorId);
361 const getCreatorForm: GetPersonDetails = {
362 person_id: creator_id,
365 promises.push(client.getPersonDetails(getCreatorForm));
367 promises.push(Promise.resolve({ state: "empty" }));
370 const query = getSearchQueryFromQuery(q);
373 const form: SearchForm = {
377 type_: getSearchTypeFromQuery(type),
378 sort: getSortTypeFromQuery(sort),
379 listing_type: getListingTypeFromQuery(listingType),
380 page: getPageFromString(page),
386 promises.push(client.search(form));
388 const resolveObjectForm: ResolveObject = {
392 promises.push(client.resolveObject(resolveObjectForm));
395 promises.push(Promise.resolve({ state: "empty" }));
396 promises.push(Promise.resolve({ state: "empty" }));
403 get documentTitle(): string {
404 const { q } = getSearchQueryParams();
405 const name = this.state.siteRes.site_view.site.name;
406 return `${i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
410 const { type, page } = getSearchQueryParams();
413 <div className="container-lg">
415 title={this.documentTitle}
416 path={this.context.router.route.match.url}
418 <h5>{i18n.t("search")}</h5>
421 {this.displayResults(type)}
422 {this.resultsCount === 0 &&
423 this.state.searchRes.state === "success" && (
424 <span>{i18n.t("no_results")}</span>
426 <Paginator page={page} onChange={this.handlePageChange} />
431 displayResults(type: SearchType) {
436 return this.comments;
441 return this.communities;
452 className="form-inline"
453 onSubmit={linkEvent(this, this.handleSearchSubmit)}
457 className="form-control mr-2 mb-2"
458 value={this.state.searchText}
459 placeholder={`${i18n.t("search")}...`}
460 aria-label={i18n.t("search")}
461 onInput={linkEvent(this, this.handleQChange)}
465 <button type="submit" className="btn btn-secondary mr-2 mb-2">
466 {this.state.searchRes.state == "loading" ? (
469 <span>{i18n.t("search")}</span>
477 const { type, listingType, sort, communityId, creatorId } =
478 getSearchQueryParams();
480 communitySearchOptions,
481 creatorSearchOptions,
482 searchCommunitiesLoading,
483 searchCreatorLoading,
487 const hasCommunities =
488 communitiesRes.state == "success" &&
489 communitiesRes.data.communities.length > 0;
492 <div className="mb-2">
495 onChange={linkEvent(this, this.handleTypeChange)}
496 className="custom-select w-auto mb-2"
497 aria-label={i18n.t("type")}
499 <option disabled aria-hidden="true">
502 {searchTypes.map(option => (
503 <option value={option} key={option}>
504 {i18n.t(option.toString().toLowerCase() as NoOptionI18nKeys)}
508 <span className="ml-2">
511 showLocal={showLocal(this.isoData)}
513 onChange={this.handleListingTypeChange}
516 <span className="ml-2">
519 onChange={this.handleSortChange}
524 <div className="form-row">
527 filterType="community"
528 onChange={this.handleCommunityFilterChange}
529 onSearch={this.handleCommunitySearch}
530 options={communitySearchOptions}
532 loading={searchCommunitiesLoading}
537 onChange={this.handleCreatorFilterChange}
538 onSearch={this.handleCreatorSearch}
539 options={creatorSearchOptions}
541 loading={searchCreatorLoading}
548 buildCombined(): Combined[] {
549 const combined: Combined[] = [];
551 resolveObjectRes: resolveObjectResponse,
552 searchRes: searchResponse,
555 // Push the possible resolve / federated objects first
556 if (resolveObjectResponse.state == "success") {
557 const { comment, post, community, person } = resolveObjectResponse.data;
560 combined.push(commentViewToCombined(comment));
563 combined.push(postViewToCombined(post));
566 combined.push(communityViewToCombined(community));
569 combined.push(personViewSafeToCombined(person));
573 // Push the search results
574 if (searchResponse.state === "success") {
575 const { comments, posts, communities, users } = searchResponse.data;
579 ...(comments?.map(commentViewToCombined) ?? []),
580 ...(posts?.map(postViewToCombined) ?? []),
581 ...(communities?.map(communityViewToCombined) ?? []),
582 ...(users?.map(personViewSafeToCombined) ?? []),
587 const { sort } = getSearchQueryParams();
590 if (sort === "New") {
591 combined.sort((a, b) => b.published.localeCompare(a.published));
593 combined.sort((a, b) =>
595 ((b.data as CommentView | PostView).counts.score |
596 (b.data as CommunityView).counts.subscribers |
597 (b.data as PersonView).counts.comment_score) -
598 ((a.data as CommentView | PostView).counts.score |
599 (a.data as CommunityView).counts.subscribers |
600 (a.data as PersonView).counts.comment_score)
609 const combined = this.buildCombined();
614 <div key={i.published} className="row">
615 <div className="col-12">
616 {i.type_ === "posts" && (
618 key={(i.data as PostView).post.id}
619 post_view={i.data as PostView}
621 enableDownvotes={enableDownvotes(this.state.siteRes)}
622 enableNsfw={enableNsfw(this.state.siteRes)}
623 allLanguages={this.state.siteRes.all_languages}
624 siteLanguages={this.state.siteRes.discussion_languages}
626 // All of these are unused, since its view only
627 onPostEdit={() => {}}
628 onPostVote={() => {}}
629 onPostReport={() => {}}
630 onBlockPerson={() => {}}
631 onLockPost={() => {}}
632 onDeletePost={() => {}}
633 onRemovePost={() => {}}
634 onSavePost={() => {}}
635 onFeaturePost={() => {}}
636 onPurgePerson={() => {}}
637 onPurgePost={() => {}}
638 onBanPersonFromCommunity={() => {}}
639 onBanPerson={() => {}}
640 onAddModToCommunity={() => {}}
641 onAddAdmin={() => {}}
642 onTransferCommunity={() => {}}
645 {i.type_ === "comments" && (
647 key={(i.data as CommentView).comment.id}
650 comment_view: i.data as CommentView,
655 viewType={CommentViewType.Flat}
659 enableDownvotes={enableDownvotes(this.state.siteRes)}
660 allLanguages={this.state.siteRes.all_languages}
661 siteLanguages={this.state.siteRes.discussion_languages}
662 // All of these are unused, since its viewonly
664 onSaveComment={() => {}}
665 onBlockPerson={() => {}}
666 onDeleteComment={() => {}}
667 onRemoveComment={() => {}}
668 onCommentVote={() => {}}
669 onCommentReport={() => {}}
670 onDistinguishComment={() => {}}
671 onAddModToCommunity={() => {}}
672 onAddAdmin={() => {}}
673 onTransferCommunity={() => {}}
674 onPurgeComment={() => {}}
675 onPurgePerson={() => {}}
676 onCommentReplyRead={() => {}}
677 onPersonMentionRead={() => {}}
678 onBanPersonFromCommunity={() => {}}
679 onBanPerson={() => {}}
680 onCreateComment={() => Promise.resolve({ state: "empty" })}
681 onEditComment={() => Promise.resolve({ state: "empty" })}
684 {i.type_ === "communities" && (
685 <div>{communityListing(i.data as CommunityView)}</div>
687 {i.type_ === "users" && (
688 <div>{personListing(i.data as PersonView)}</div>
699 searchRes: searchResponse,
700 resolveObjectRes: resolveObjectResponse,
704 searchResponse.state === "success" ? searchResponse.data.comments : [];
707 resolveObjectResponse.state === "success" &&
708 resolveObjectResponse.data.comment
710 comments.unshift(resolveObjectResponse.data.comment);
715 nodes={commentsToFlatNodes(comments)}
716 viewType={CommentViewType.Flat}
720 enableDownvotes={enableDownvotes(siteRes)}
721 allLanguages={siteRes.all_languages}
722 siteLanguages={siteRes.discussion_languages}
723 // All of these are unused, since its viewonly
725 onSaveComment={() => {}}
726 onBlockPerson={() => {}}
727 onDeleteComment={() => {}}
728 onRemoveComment={() => {}}
729 onCommentVote={() => {}}
730 onCommentReport={() => {}}
731 onDistinguishComment={() => {}}
732 onAddModToCommunity={() => {}}
733 onAddAdmin={() => {}}
734 onTransferCommunity={() => {}}
735 onPurgeComment={() => {}}
736 onPurgePerson={() => {}}
737 onCommentReplyRead={() => {}}
738 onPersonMentionRead={() => {}}
739 onBanPersonFromCommunity={() => {}}
740 onBanPerson={() => {}}
741 onCreateComment={() => Promise.resolve({ state: "empty" })}
742 onEditComment={() => Promise.resolve({ state: "empty" })}
749 searchRes: searchResponse,
750 resolveObjectRes: resolveObjectResponse,
754 searchResponse.state === "success" ? searchResponse.data.posts : [];
757 resolveObjectResponse.state === "success" &&
758 resolveObjectResponse.data.post
760 posts.unshift(resolveObjectResponse.data.post);
766 <div key={pv.post.id} className="row">
767 <div className="col-12">
771 enableDownvotes={enableDownvotes(siteRes)}
772 enableNsfw={enableNsfw(siteRes)}
773 allLanguages={siteRes.all_languages}
774 siteLanguages={siteRes.discussion_languages}
776 // All of these are unused, since its view only
777 onPostEdit={() => {}}
778 onPostVote={() => {}}
779 onPostReport={() => {}}
780 onBlockPerson={() => {}}
781 onLockPost={() => {}}
782 onDeletePost={() => {}}
783 onRemovePost={() => {}}
784 onSavePost={() => {}}
785 onFeaturePost={() => {}}
786 onPurgePerson={() => {}}
787 onPurgePost={() => {}}
788 onBanPersonFromCommunity={() => {}}
789 onBanPerson={() => {}}
790 onAddModToCommunity={() => {}}
791 onAddAdmin={() => {}}
792 onTransferCommunity={() => {}}
803 searchRes: searchResponse,
804 resolveObjectRes: resolveObjectResponse,
807 searchResponse.state === "success" ? searchResponse.data.communities : [];
810 resolveObjectResponse.state === "success" &&
811 resolveObjectResponse.data.community
813 communities.unshift(resolveObjectResponse.data.community);
818 {communities.map(cv => (
819 <div key={cv.community.id} className="row">
820 <div className="col-12">{communityListing(cv)}</div>
829 searchRes: searchResponse,
830 resolveObjectRes: resolveObjectResponse,
833 searchResponse.state === "success" ? searchResponse.data.users : [];
836 resolveObjectResponse.state === "success" &&
837 resolveObjectResponse.data.person
839 users.unshift(resolveObjectResponse.data.person);
845 <div key={pvs.person.id} className="row">
846 <div className="col-12">{personListing(pvs)}</div>
853 get resultsCount(): number {
854 const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
857 r.state === "success"
858 ? r.data.posts.length +
859 r.data.comments.length +
860 r.data.communities.length +
865 resolveRes.state === "success"
866 ? resolveRes.data.post ||
867 resolveRes.data.person ||
868 resolveRes.data.community ||
869 resolveRes.data.comment
874 return resObjCount + searchCount;
878 const auth = myAuth();
879 const { searchText: q } = this.state;
880 const { communityId, creatorId, type, sort, listingType, page } =
881 getSearchQueryParams();
884 this.setState({ searchRes: { state: "loading" } });
886 searchRes: await HttpService.client.search({
888 community_id: communityId ?? undefined,
889 creator_id: creatorId ?? undefined,
892 listing_type: listingType,
898 window.scrollTo(0, 0);
899 restoreScrollPosition(this.context);
902 this.setState({ resolveObjectRes: { state: "loading" } });
904 resolveObjectRes: await HttpService.client.resolveObject({
913 handleCreatorSearch = debounce(async (text: string) => {
914 const { creatorId } = getSearchQueryParams();
915 const { creatorSearchOptions } = this.state;
916 const newOptions: Choice[] = [];
918 this.setState({ searchCreatorLoading: true });
920 const selectedChoice = creatorSearchOptions.find(
921 choice => getIdFromString(choice.value) === creatorId
924 if (selectedChoice) {
925 newOptions.push(selectedChoice);
928 if (text.length > 0) {
929 newOptions.push(...(await fetchUsers(text)).map(personToChoice));
933 searchCreatorLoading: false,
934 creatorSearchOptions: newOptions,
938 handleCommunitySearch = debounce(async (text: string) => {
939 const { communityId } = getSearchQueryParams();
940 const { communitySearchOptions } = this.state;
942 searchCommunitiesLoading: true,
945 const newOptions: Choice[] = [];
947 const selectedChoice = communitySearchOptions.find(
948 choice => getIdFromString(choice.value) === communityId
951 if (selectedChoice) {
952 newOptions.push(selectedChoice);
955 if (text.length > 0) {
956 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
960 searchCommunitiesLoading: false,
961 communitySearchOptions: newOptions,
965 handleSortChange(sort: SortType) {
966 this.updateUrl({ sort, page: 1 });
969 handleTypeChange(i: Search, event: any) {
970 const type = event.target.value as SearchType;
978 handlePageChange(page: number) {
979 this.updateUrl({ page });
982 handleListingTypeChange(listingType: ListingType) {
989 handleCommunityFilterChange({ value }: Choice) {
991 communityId: getIdFromString(value) ?? null,
996 handleCreatorFilterChange({ value }: Choice) {
998 creatorId: getIdFromString(value) ?? null,
1003 handleSearchSubmit(i: Search, event: any) {
1004 event.preventDefault();
1007 q: i.state.searchText,
1012 handleQChange(i: Search, event: any) {
1013 i.setState({ searchText: event.target.value });
1024 }: Partial<SearchProps>) {
1028 listingType: urlListingType,
1029 communityId: urlCommunityId,
1031 creatorId: urlCreatorId,
1033 } = getSearchQueryParams();
1035 let query = q ?? this.state.searchText ?? urlQ;
1037 if (query && query.length > 0) {
1038 query = encodeURIComponent(query);
1041 const queryParams: QueryParams<SearchProps> = {
1043 type: type ?? urlType,
1044 listingType: listingType ?? urlListingType,
1045 communityId: getUpdatedSearchId(communityId, urlCommunityId),
1046 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
1047 page: (page ?? urlPage).toString(),
1048 sort: sort ?? urlSort,
1051 this.props.history.push(`/search${getQueryString(queryParams)}`);
1053 await this.search();