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";
30 capitalizeFirstLetter,
44 restoreScrollPosition,
49 import { debounce } from "../utils/helpers/debounce";
50 import { getQueryParams } from "../utils/helpers/get-query-params";
51 import { getQueryString } from "../utils/helpers/get-query-string";
52 import type { QueryParams } from "../utils/types/query-params";
53 import { CommentNodes } from "./comment/comment-nodes";
54 import { HtmlTags } from "./common/html-tags";
55 import { Spinner } from "./common/icon";
56 import { ListingTypeSelect } from "./common/listing-type-select";
57 import { Paginator } from "./common/paginator";
58 import { SearchableSelect } from "./common/searchable-select";
59 import { SortSelect } from "./common/sort-select";
60 import { CommunityLink } from "./community/community-link";
61 import { PersonListing } from "./person/person-listing";
62 import { PostListing } from "./post/post-listing";
64 interface SearchProps {
68 listingType: ListingType;
69 communityId?: number | null;
70 creatorId?: number | null;
74 type SearchData = RouteDataResponse<{
75 communityResponse: GetCommunityResponse;
76 listCommunitiesResponse: ListCommunitiesResponse;
77 creatorDetailsResponse: GetPersonDetailsResponse;
78 searchResponse: SearchResponse;
79 resolveObjectResponse: ResolveObjectResponse;
82 type FilterType = "creator" | "community";
84 interface SearchState {
85 searchRes: RequestState<SearchResponse>;
86 resolveObjectRes: RequestState<ResolveObjectResponse>;
87 creatorDetailsRes: RequestState<GetPersonDetailsResponse>;
88 communitiesRes: RequestState<ListCommunitiesResponse>;
89 communityRes: RequestState<GetCommunityResponse>;
90 siteRes: GetSiteResponse;
92 communitySearchOptions: Choice[];
93 creatorSearchOptions: Choice[];
94 searchCreatorLoading: boolean;
95 searchCommunitiesLoading: boolean;
96 isIsomorphic: boolean;
101 data: CommentView | PostView | CommunityView | PersonView;
105 const defaultSearchType = "All";
106 const defaultSortType = "TopAll";
107 const defaultListingType = "All";
109 const searchTypes = ["All", "Comments", "Posts", "Communities", "Users", "Url"];
111 const getSearchQueryParams = () =>
112 getQueryParams<SearchProps>({
113 q: getSearchQueryFromQuery,
114 type: getSearchTypeFromQuery,
115 sort: getSortTypeFromQuery,
116 listingType: getListingTypeFromQuery,
117 communityId: getIdFromString,
118 creatorId: getIdFromString,
119 page: getPageFromString,
122 const getSearchQueryFromQuery = (q?: string): string | undefined =>
123 q ? decodeURIComponent(q) : undefined;
125 function getSearchTypeFromQuery(type_?: string): SearchType {
126 return type_ ? (type_ as SearchType) : defaultSearchType;
129 function getSortTypeFromQuery(sort?: string): SortType {
130 return sort ? (sort as SortType) : defaultSortType;
133 function getListingTypeFromQuery(listingType?: string): ListingType {
134 return listingType ? (listingType as ListingType) : defaultListingType;
137 function postViewToCombined(data: PostView): Combined {
141 published: data.post.published,
145 function commentViewToCombined(data: CommentView): Combined {
149 published: data.comment.published,
153 function communityViewToCombined(data: CommunityView): Combined {
155 type_: "communities",
157 published: data.community.published,
161 function personViewSafeToCombined(data: PersonView): Combined {
165 published: data.person.published,
177 filterType: FilterType;
179 onSearch: (text: string) => void;
180 onChange: (choice: Choice) => void;
181 value?: number | null;
185 <div className="form-group col-sm-6">
186 <label className="col-form-label" htmlFor={`${filterType}-filter`}>
187 {capitalizeFirstLetter(i18n.t(filterType))}
190 id={`${filterType}-filter`}
193 label: i18n.t("all"),
206 const communityListing = ({
208 counts: { subscribers },
211 <CommunityLink community={community} />,
213 "number_of_subscribers"
216 const personListing = ({ person, counts: { comment_count } }: PersonView) =>
218 <PersonListing person={person} showApubName />,
224 listing: JSX.ElementClass,
226 translationKey: "number_of_comments" | "number_of_subscribers"
230 <span>{listing}</span>
231 <span>{` - ${i18n.t(translationKey, {
232 count: Number(count),
233 formattedCount: numToSI(count),
239 export class Search extends Component<any, SearchState> {
240 private isoData = setIsoData<SearchData>(this.context);
242 state: SearchState = {
243 resolveObjectRes: { state: "empty" },
244 creatorDetailsRes: { state: "empty" },
245 communitiesRes: { state: "empty" },
246 communityRes: { state: "empty" },
247 siteRes: this.isoData.site_res,
248 creatorSearchOptions: [],
249 communitySearchOptions: [],
250 searchRes: { state: "empty" },
251 searchCreatorLoading: false,
252 searchCommunitiesLoading: false,
256 constructor(props: any, context: any) {
257 super(props, context);
259 this.handleSortChange = this.handleSortChange.bind(this);
260 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
261 this.handlePageChange = this.handlePageChange.bind(this);
262 this.handleCommunityFilterChange =
263 this.handleCommunityFilterChange.bind(this);
264 this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
266 const { q } = getSearchQueryParams();
273 // Only fetch the data if coming from another route
274 if (FirstLoadService.isFirstLoad) {
276 communityResponse: communityRes,
277 creatorDetailsResponse: creatorDetailsRes,
278 listCommunitiesResponse: communitiesRes,
279 resolveObjectResponse: resolveObjectRes,
280 searchResponse: searchRes,
281 } = this.isoData.routeData;
288 if (creatorDetailsRes?.state === "success") {
291 creatorSearchOptions:
292 creatorDetailsRes?.state === "success"
293 ? [personToChoice(creatorDetailsRes.data.person_view)]
299 if (communitiesRes?.state === "success") {
306 if (communityRes?.state === "success") {
318 if (searchRes?.state === "success") {
325 if (resolveObjectRes?.state === "success") {
335 async componentDidMount() {
336 if (!this.state.isIsomorphic) {
337 const promises = [this.fetchCommunities()];
338 if (this.state.searchText) {
339 promises.push(this.search());
342 await Promise.all(promises);
346 async fetchCommunities() {
347 this.setState({ communitiesRes: { state: "loading" } });
349 communitiesRes: await HttpService.client.listCommunities({
350 type_: defaultListingType,
351 sort: defaultSortType,
358 componentWillUnmount() {
359 saveScrollPosition(this.context);
362 static async fetchInitialData({
365 query: { communityId, creatorId, q, type, sort, listingType, page },
366 }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<SearchData> {
367 const community_id = getIdFromString(communityId);
368 let communityResponse: RequestState<GetCommunityResponse> = {
371 let listCommunitiesResponse: RequestState<ListCommunitiesResponse> = {
375 const getCommunityForm: GetCommunity = {
380 communityResponse = await client.getCommunity(getCommunityForm);
382 const listCommunitiesForm: ListCommunities = {
383 type_: defaultListingType,
384 sort: defaultSortType,
389 listCommunitiesResponse = await client.listCommunities(
394 const creator_id = getIdFromString(creatorId);
395 let creatorDetailsResponse: RequestState<GetPersonDetailsResponse> = {
399 const getCreatorForm: GetPersonDetails = {
400 person_id: creator_id,
404 creatorDetailsResponse = await client.getPersonDetails(getCreatorForm);
407 const query = getSearchQueryFromQuery(q);
409 let searchResponse: RequestState<SearchResponse> = { state: "empty" };
410 let resolveObjectResponse: RequestState<ResolveObjectResponse> = {
415 const form: SearchForm = {
419 type_: getSearchTypeFromQuery(type),
420 sort: getSortTypeFromQuery(sort),
421 listing_type: getListingTypeFromQuery(listingType),
422 page: getPageFromString(page),
428 searchResponse = await client.search(form);
430 const resolveObjectForm: ResolveObject = {
434 resolveObjectResponse = await client.resolveObject(resolveObjectForm);
441 creatorDetailsResponse,
442 listCommunitiesResponse,
443 resolveObjectResponse,
448 get documentTitle(): string {
449 const { q } = getSearchQueryParams();
450 const name = this.state.siteRes.site_view.site.name;
451 return `${i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
455 const { type, page } = getSearchQueryParams();
458 <div className="container-lg">
460 title={this.documentTitle}
461 path={this.context.router.route.match.url}
463 <h5>{i18n.t("search")}</h5>
466 {this.displayResults(type)}
467 {this.resultsCount === 0 &&
468 this.state.searchRes.state === "success" && (
469 <span>{i18n.t("no_results")}</span>
471 <Paginator page={page} onChange={this.handlePageChange} />
476 displayResults(type: SearchType) {
481 return this.comments;
486 return this.communities;
497 className="form-inline"
498 onSubmit={linkEvent(this, this.handleSearchSubmit)}
502 className="form-control mr-2 mb-2"
503 value={this.state.searchText}
504 placeholder={`${i18n.t("search")}...`}
505 aria-label={i18n.t("search")}
506 onInput={linkEvent(this, this.handleQChange)}
510 <button type="submit" className="btn btn-secondary mr-2 mb-2">
511 {this.state.searchRes.state === "loading" ? (
514 <span>{i18n.t("search")}</span>
522 const { type, listingType, sort, communityId, creatorId } =
523 getSearchQueryParams();
525 communitySearchOptions,
526 creatorSearchOptions,
527 searchCommunitiesLoading,
528 searchCreatorLoading,
532 const hasCommunities =
533 communitiesRes.state == "success" &&
534 communitiesRes.data.communities.length > 0;
537 <div className="mb-2">
540 onChange={linkEvent(this, this.handleTypeChange)}
541 className="custom-select w-auto mb-2"
542 aria-label={i18n.t("type")}
544 <option disabled aria-hidden="true">
547 {searchTypes.map(option => (
548 <option value={option} key={option}>
549 {i18n.t(option.toString().toLowerCase() as NoOptionI18nKeys)}
553 <span className="ml-2">
556 showLocal={showLocal(this.isoData)}
558 onChange={this.handleListingTypeChange}
561 <span className="ml-2">
564 onChange={this.handleSortChange}
569 <div className="form-row">
572 filterType="community"
573 onChange={this.handleCommunityFilterChange}
574 onSearch={this.handleCommunitySearch}
575 options={communitySearchOptions}
577 loading={searchCommunitiesLoading}
582 onChange={this.handleCreatorFilterChange}
583 onSearch={this.handleCreatorSearch}
584 options={creatorSearchOptions}
586 loading={searchCreatorLoading}
593 buildCombined(): Combined[] {
594 const combined: Combined[] = [];
596 resolveObjectRes: resolveObjectResponse,
597 searchRes: searchResponse,
600 // Push the possible resolve / federated objects first
601 if (resolveObjectResponse.state == "success") {
602 const { comment, post, community, person } = resolveObjectResponse.data;
605 combined.push(commentViewToCombined(comment));
608 combined.push(postViewToCombined(post));
611 combined.push(communityViewToCombined(community));
614 combined.push(personViewSafeToCombined(person));
618 // Push the search results
619 if (searchResponse.state === "success") {
620 const { comments, posts, communities, users } = searchResponse.data;
624 ...(comments?.map(commentViewToCombined) ?? []),
625 ...(posts?.map(postViewToCombined) ?? []),
626 ...(communities?.map(communityViewToCombined) ?? []),
627 ...(users?.map(personViewSafeToCombined) ?? []),
632 const { sort } = getSearchQueryParams();
635 if (sort === "New") {
636 combined.sort((a, b) => b.published.localeCompare(a.published));
638 combined.sort((a, b) =>
640 ((b.data as CommentView | PostView).counts.score |
641 (b.data as CommunityView).counts.subscribers |
642 (b.data as PersonView).counts.comment_score) -
643 ((a.data as CommentView | PostView).counts.score |
644 (a.data as CommunityView).counts.subscribers |
645 (a.data as PersonView).counts.comment_score)
654 const combined = this.buildCombined();
659 <div key={i.published} className="row">
660 <div className="col-12">
661 {i.type_ === "posts" && (
663 key={(i.data as PostView).post.id}
664 post_view={i.data as PostView}
666 enableDownvotes={enableDownvotes(this.state.siteRes)}
667 enableNsfw={enableNsfw(this.state.siteRes)}
668 allLanguages={this.state.siteRes.all_languages}
669 siteLanguages={this.state.siteRes.discussion_languages}
671 // All of these are unused, since its view only
672 onPostEdit={() => {}}
673 onPostVote={() => {}}
674 onPostReport={() => {}}
675 onBlockPerson={() => {}}
676 onLockPost={() => {}}
677 onDeletePost={() => {}}
678 onRemovePost={() => {}}
679 onSavePost={() => {}}
680 onFeaturePost={() => {}}
681 onPurgePerson={() => {}}
682 onPurgePost={() => {}}
683 onBanPersonFromCommunity={() => {}}
684 onBanPerson={() => {}}
685 onAddModToCommunity={() => {}}
686 onAddAdmin={() => {}}
687 onTransferCommunity={() => {}}
690 {i.type_ === "comments" && (
692 key={(i.data as CommentView).comment.id}
695 comment_view: i.data as CommentView,
700 viewType={CommentViewType.Flat}
704 enableDownvotes={enableDownvotes(this.state.siteRes)}
705 allLanguages={this.state.siteRes.all_languages}
706 siteLanguages={this.state.siteRes.discussion_languages}
707 // All of these are unused, since its viewonly
709 onSaveComment={() => {}}
710 onBlockPerson={() => {}}
711 onDeleteComment={() => {}}
712 onRemoveComment={() => {}}
713 onCommentVote={() => {}}
714 onCommentReport={() => {}}
715 onDistinguishComment={() => {}}
716 onAddModToCommunity={() => {}}
717 onAddAdmin={() => {}}
718 onTransferCommunity={() => {}}
719 onPurgeComment={() => {}}
720 onPurgePerson={() => {}}
721 onCommentReplyRead={() => {}}
722 onPersonMentionRead={() => {}}
723 onBanPersonFromCommunity={() => {}}
724 onBanPerson={() => {}}
725 onCreateComment={() => Promise.resolve({ state: "empty" })}
726 onEditComment={() => Promise.resolve({ state: "empty" })}
729 {i.type_ === "communities" && (
730 <div>{communityListing(i.data as CommunityView)}</div>
732 {i.type_ === "users" && (
733 <div>{personListing(i.data as PersonView)}</div>
744 searchRes: searchResponse,
745 resolveObjectRes: resolveObjectResponse,
749 searchResponse.state === "success" ? searchResponse.data.comments : [];
752 resolveObjectResponse.state === "success" &&
753 resolveObjectResponse.data.comment
755 comments.unshift(resolveObjectResponse.data.comment);
760 nodes={commentsToFlatNodes(comments)}
761 viewType={CommentViewType.Flat}
765 enableDownvotes={enableDownvotes(siteRes)}
766 allLanguages={siteRes.all_languages}
767 siteLanguages={siteRes.discussion_languages}
768 // All of these are unused, since its viewonly
770 onSaveComment={() => {}}
771 onBlockPerson={() => {}}
772 onDeleteComment={() => {}}
773 onRemoveComment={() => {}}
774 onCommentVote={() => {}}
775 onCommentReport={() => {}}
776 onDistinguishComment={() => {}}
777 onAddModToCommunity={() => {}}
778 onAddAdmin={() => {}}
779 onTransferCommunity={() => {}}
780 onPurgeComment={() => {}}
781 onPurgePerson={() => {}}
782 onCommentReplyRead={() => {}}
783 onPersonMentionRead={() => {}}
784 onBanPersonFromCommunity={() => {}}
785 onBanPerson={() => {}}
786 onCreateComment={() => Promise.resolve({ state: "empty" })}
787 onEditComment={() => Promise.resolve({ state: "empty" })}
794 searchRes: searchResponse,
795 resolveObjectRes: resolveObjectResponse,
799 searchResponse.state === "success" ? searchResponse.data.posts : [];
802 resolveObjectResponse.state === "success" &&
803 resolveObjectResponse.data.post
805 posts.unshift(resolveObjectResponse.data.post);
811 <div key={pv.post.id} className="row">
812 <div className="col-12">
816 enableDownvotes={enableDownvotes(siteRes)}
817 enableNsfw={enableNsfw(siteRes)}
818 allLanguages={siteRes.all_languages}
819 siteLanguages={siteRes.discussion_languages}
821 // All of these are unused, since its view only
822 onPostEdit={() => {}}
823 onPostVote={() => {}}
824 onPostReport={() => {}}
825 onBlockPerson={() => {}}
826 onLockPost={() => {}}
827 onDeletePost={() => {}}
828 onRemovePost={() => {}}
829 onSavePost={() => {}}
830 onFeaturePost={() => {}}
831 onPurgePerson={() => {}}
832 onPurgePost={() => {}}
833 onBanPersonFromCommunity={() => {}}
834 onBanPerson={() => {}}
835 onAddModToCommunity={() => {}}
836 onAddAdmin={() => {}}
837 onTransferCommunity={() => {}}
848 searchRes: searchResponse,
849 resolveObjectRes: resolveObjectResponse,
852 searchResponse.state === "success" ? searchResponse.data.communities : [];
855 resolveObjectResponse.state === "success" &&
856 resolveObjectResponse.data.community
858 communities.unshift(resolveObjectResponse.data.community);
863 {communities.map(cv => (
864 <div key={cv.community.id} className="row">
865 <div className="col-12">{communityListing(cv)}</div>
874 searchRes: searchResponse,
875 resolveObjectRes: resolveObjectResponse,
878 searchResponse.state === "success" ? searchResponse.data.users : [];
881 resolveObjectResponse.state === "success" &&
882 resolveObjectResponse.data.person
884 users.unshift(resolveObjectResponse.data.person);
890 <div key={pvs.person.id} className="row">
891 <div className="col-12">{personListing(pvs)}</div>
898 get resultsCount(): number {
899 const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
902 r.state === "success"
903 ? r.data.posts.length +
904 r.data.comments.length +
905 r.data.communities.length +
910 resolveRes.state === "success"
911 ? resolveRes.data.post ||
912 resolveRes.data.person ||
913 resolveRes.data.community ||
914 resolveRes.data.comment
919 return resObjCount + searchCount;
923 const auth = myAuth();
924 const { searchText: q } = this.state;
925 const { communityId, creatorId, type, sort, listingType, page } =
926 getSearchQueryParams();
929 this.setState({ searchRes: { state: "loading" } });
931 searchRes: await HttpService.client.search({
933 community_id: communityId ?? undefined,
934 creator_id: creatorId ?? undefined,
937 listing_type: listingType,
943 window.scrollTo(0, 0);
944 restoreScrollPosition(this.context);
947 this.setState({ resolveObjectRes: { state: "loading" } });
949 resolveObjectRes: await HttpService.client.resolveObject({
958 handleCreatorSearch = debounce(async (text: string) => {
959 const { creatorId } = getSearchQueryParams();
960 const { creatorSearchOptions } = this.state;
961 const newOptions: Choice[] = [];
963 this.setState({ searchCreatorLoading: true });
965 const selectedChoice = creatorSearchOptions.find(
966 choice => getIdFromString(choice.value) === creatorId
969 if (selectedChoice) {
970 newOptions.push(selectedChoice);
973 if (text.length > 0) {
974 newOptions.push(...(await fetchUsers(text)).map(personToChoice));
978 searchCreatorLoading: false,
979 creatorSearchOptions: newOptions,
983 handleCommunitySearch = debounce(async (text: string) => {
984 const { communityId } = getSearchQueryParams();
985 const { communitySearchOptions } = this.state;
987 searchCommunitiesLoading: true,
990 const newOptions: Choice[] = [];
992 const selectedChoice = communitySearchOptions.find(
993 choice => getIdFromString(choice.value) === communityId
996 if (selectedChoice) {
997 newOptions.push(selectedChoice);
1000 if (text.length > 0) {
1001 newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
1005 searchCommunitiesLoading: false,
1006 communitySearchOptions: newOptions,
1010 handleSortChange(sort: SortType) {
1011 this.updateUrl({ sort, page: 1 });
1014 handleTypeChange(i: Search, event: any) {
1015 const type = event.target.value as SearchType;
1023 handlePageChange(page: number) {
1024 this.updateUrl({ page });
1027 handleListingTypeChange(listingType: ListingType) {
1034 handleCommunityFilterChange({ value }: Choice) {
1036 communityId: getIdFromString(value) ?? null,
1041 handleCreatorFilterChange({ value }: Choice) {
1043 creatorId: getIdFromString(value) ?? null,
1048 handleSearchSubmit(i: Search, event: any) {
1049 event.preventDefault();
1052 q: i.state.searchText,
1057 handleQChange(i: Search, event: any) {
1058 i.setState({ searchText: event.target.value });
1069 }: Partial<SearchProps>) {
1073 listingType: urlListingType,
1074 communityId: urlCommunityId,
1076 creatorId: urlCreatorId,
1078 } = getSearchQueryParams();
1080 let query = q ?? this.state.searchText ?? urlQ;
1082 if (query && query.length > 0) {
1083 query = encodeURIComponent(query);
1086 const queryParams: QueryParams<SearchProps> = {
1088 type: type ?? urlType,
1089 listingType: listingType ?? urlListingType,
1090 communityId: getUpdatedSearchId(communityId, urlCommunityId),
1091 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
1092 page: (page ?? urlPage).toString(),
1093 sort: sort ?? urlSort,
1096 this.props.history.push(`/search${getQueryString(queryParams)}`);
1098 await this.search();