1 import type { NoOptionI18nKeys } from "i18next";
2 import { Component, linkEvent } from "inferno";
10 GetPersonDetailsResponse,
13 ListCommunitiesResponse,
19 ResolveObjectResponse,
27 } from "lemmy-js-client";
28 import { Subscription } from "rxjs";
29 import { i18n } from "../i18next";
30 import { CommentViewType, InitialFetchRequest } from "../interfaces";
31 import { WebSocketService } from "../services";
35 capitalizeFirstLetter,
39 createPostLikeFindRes,
54 restoreScrollPosition,
55 routeListingTypeToEnum,
56 routeSearchTypeToEnum,
65 import { CommentNodes } from "./comment/comment-nodes";
66 import { HtmlTags } from "./common/html-tags";
67 import { Spinner } from "./common/icon";
68 import { ListingTypeSelect } from "./common/listing-type-select";
69 import { Paginator } from "./common/paginator";
70 import { SearchableSelect } from "./common/searchable-select";
71 import { SortSelect } from "./common/sort-select";
72 import { CommunityLink } from "./community/community-link";
73 import { PersonListing } from "./person/person-listing";
74 import { PostListing } from "./post/post-listing";
76 interface SearchProps {
80 listingType: ListingType;
81 communityId?: number | null;
82 creatorId?: number | null;
86 type FilterType = "creator" | "community";
88 interface SearchState {
89 searchResponse?: SearchResponse;
90 communities: CommunityView[];
91 creatorDetails?: GetPersonDetailsResponse;
92 searchLoading: boolean;
93 searchCommunitiesLoading: boolean;
94 searchCreatorLoading: boolean;
95 siteRes: GetSiteResponse;
97 resolveObjectResponse?: ResolveObjectResponse;
98 communitySearchOptions: Choice[];
99 creatorSearchOptions: Choice[];
104 data: CommentView | PostView | CommunityView | PersonViewSafe;
108 const defaultSearchType = SearchType.All;
109 const defaultSortType = SortType.TopAll;
110 const defaultListingType = ListingType.All;
112 const searchTypes = [
116 SearchType.Communities,
121 const getSearchQueryParams = () =>
122 getQueryParams<SearchProps>({
123 q: getSearchQueryFromQuery,
124 type: getSearchTypeFromQuery,
125 sort: getSortTypeFromQuery,
126 listingType: getListingTypeFromQuery,
127 communityId: getIdFromString,
128 creatorId: getIdFromString,
129 page: getPageFromString,
132 const getSearchQueryFromQuery = (q?: string): string | undefined =>
133 q ? decodeURIComponent(q) : undefined;
135 const getSearchTypeFromQuery = (type_?: string): SearchType =>
136 routeSearchTypeToEnum(type_ ?? "", defaultSearchType);
138 const getSortTypeFromQuery = (sort?: string): SortType =>
139 routeSortTypeToEnum(sort ?? "", defaultSortType);
141 const getListingTypeFromQuery = (listingType?: string): ListingType =>
142 routeListingTypeToEnum(listingType ?? "", defaultListingType);
144 const postViewToCombined = (data: PostView): Combined => ({
147 published: data.post.published,
150 const commentViewToCombined = (data: CommentView): Combined => ({
153 published: data.comment.published,
156 const communityViewToCombined = (data: CommunityView): Combined => ({
157 type_: "communities",
159 published: data.community.published,
162 const personViewSafeToCombined = (data: PersonViewSafe): Combined => ({
165 published: data.person.published,
176 filterType: FilterType;
178 onSearch: (text: string) => void;
179 onChange: (choice: Choice) => void;
180 value?: number | null;
184 <div className="form-group col-sm-6">
185 <label className="col-form-label" htmlFor={`${filterType}-filter`}>
186 {capitalizeFirstLetter(i18n.t(filterType))}
189 id={`${filterType}-filter`}
192 label: i18n.t("all"),
205 const communityListing = ({
207 counts: { subscribers },
210 <CommunityLink community={community} />,
212 "number_of_subscribers"
215 const personListing = ({ person, counts: { comment_count } }: PersonViewSafe) =>
217 <PersonListing person={person} showApubName />,
223 listing: JSX.ElementClass,
225 translationKey: "number_of_comments" | "number_of_subscribers"
228 <span>{listing}</span>
229 <span>{` - ${i18n.t(translationKey, {
231 formattedCount: numToSI(count),
236 export class Search extends Component<any, SearchState> {
237 private isoData = setIsoData(this.context);
238 private subscription?: Subscription;
239 state: SearchState = {
240 searchLoading: false,
241 siteRes: this.isoData.site_res,
243 searchCommunitiesLoading: false,
244 searchCreatorLoading: false,
245 creatorSearchOptions: [],
246 communitySearchOptions: [],
249 constructor(props: any, context: any) {
250 super(props, context);
252 this.handleSortChange = this.handleSortChange.bind(this);
253 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
254 this.handlePageChange = this.handlePageChange.bind(this);
255 this.handleCommunityFilterChange =
256 this.handleCommunityFilterChange.bind(this);
257 this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
259 this.parseMessage = this.parseMessage.bind(this);
260 this.subscription = wsSubscribe(this.parseMessage);
262 const { q } = getSearchQueryParams();
269 // Only fetch the data if coming from another route
270 if (this.isoData.path === this.context.router.route.match.url) {
271 const communityRes = this.isoData.routeData[0] as
272 | GetCommunityResponse
274 const communitiesRes = this.isoData.routeData[1] as
275 | ListCommunitiesResponse
277 // This can be single or multiple communities given
278 if (communitiesRes) {
281 communities: communitiesRes.communities,
287 communities: [communityRes.community_view],
288 communitySearchOptions: [
289 communityToChoice(communityRes.community_view),
294 const creatorRes = this.isoData.routeData[2] as GetPersonDetailsResponse;
298 creatorDetails: creatorRes,
299 creatorSearchOptions: creatorRes
300 ? [personToChoice(creatorRes.person_view)]
307 searchResponse: this.isoData.routeData[3] as SearchResponse,
308 resolveObjectResponse: this.isoData
309 .routeData[4] as ResolveObjectResponse,
310 searchLoading: false,
316 const listCommunitiesForm: ListCommunities = {
317 type_: defaultListingType,
318 sort: defaultSortType,
323 WebSocketService.Instance.send(
324 wsClient.listCommunities(listCommunitiesForm)
333 componentWillUnmount() {
334 this.subscription?.unsubscribe();
335 saveScrollPosition(this.context);
338 static fetchInitialData({
341 query: { communityId, creatorId, q, type, sort, listingType, page },
342 }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<any>[] {
343 const promises: Promise<any>[] = [];
345 const community_id = getIdFromString(communityId);
347 const getCommunityForm: GetCommunity = {
351 promises.push(client.getCommunity(getCommunityForm));
352 promises.push(Promise.resolve());
354 const listCommunitiesForm: ListCommunities = {
355 type_: defaultListingType,
356 sort: defaultSortType,
360 promises.push(Promise.resolve());
361 promises.push(client.listCommunities(listCommunitiesForm));
364 const creator_id = getIdFromString(creatorId);
366 const getCreatorForm: GetPersonDetails = {
367 person_id: creator_id,
370 promises.push(client.getPersonDetails(getCreatorForm));
372 promises.push(Promise.resolve());
375 const query = getSearchQueryFromQuery(q);
378 const form: SearchForm = {
382 type_: getSearchTypeFromQuery(type),
383 sort: getSortTypeFromQuery(sort),
384 listing_type: getListingTypeFromQuery(listingType),
385 page: getIdFromString(page),
390 const resolveObjectForm: ResolveObject = {
396 promises.push(client.search(form));
397 promises.push(client.resolveObject(resolveObjectForm));
399 promises.push(Promise.resolve());
400 promises.push(Promise.resolve());
407 get documentTitle(): string {
408 const { q } = getSearchQueryParams();
409 const name = this.state.siteRes.site_view.site.name;
410 return `${i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
414 const { type, page } = getSearchQueryParams();
417 <div className="container-lg">
419 title={this.documentTitle}
420 path={this.context.router.route.match.url}
422 <h5>{i18n.t("search")}</h5>
425 {this.displayResults(type)}
426 {this.resultsCount === 0 && !this.state.searchLoading && (
427 <span>{i18n.t("no_results")}</span>
429 <Paginator page={page} onChange={this.handlePageChange} />
434 displayResults(type: SearchType) {
438 case SearchType.Comments:
439 return this.comments;
440 case SearchType.Posts:
443 case SearchType.Communities:
444 return this.communities;
445 case SearchType.Users:
455 className="form-inline"
456 onSubmit={linkEvent(this, this.handleSearchSubmit)}
460 className="form-control mr-2 mb-2"
461 value={this.state.searchText}
462 placeholder={`${i18n.t("search")}...`}
463 aria-label={i18n.t("search")}
464 onInput={linkEvent(this, this.handleQChange)}
468 <button type="submit" className="btn btn-secondary mr-2 mb-2">
469 {this.state.searchLoading ? (
472 <span>{i18n.t("search")}</span>
480 const { type, listingType, sort, communityId, creatorId } =
481 getSearchQueryParams();
483 communitySearchOptions,
484 creatorSearchOptions,
485 searchCommunitiesLoading,
486 searchCreatorLoading,
490 <div className="mb-2">
493 onChange={linkEvent(this, this.handleTypeChange)}
494 className="custom-select w-auto mb-2"
495 aria-label={i18n.t("type")}
497 <option disabled aria-hidden="true">
500 {searchTypes.map(option => (
501 <option value={option} key={option}>
502 {i18n.t(option.toString().toLowerCase() as NoOptionI18nKeys)}
506 <span className="ml-2">
509 showLocal={showLocal(this.isoData)}
511 onChange={this.handleListingTypeChange}
514 <span className="ml-2">
517 onChange={this.handleSortChange}
522 <div className="form-row">
523 {this.state.communities.length > 0 && (
525 filterType="community"
526 onChange={this.handleCommunityFilterChange}
527 onSearch={this.handleCommunitySearch}
528 options={communitySearchOptions}
529 loading={searchCommunitiesLoading}
535 onChange={this.handleCreatorFilterChange}
536 onSearch={this.handleCreatorSearch}
537 options={creatorSearchOptions}
538 loading={searchCreatorLoading}
546 buildCombined(): Combined[] {
547 const combined: Combined[] = [];
548 const { resolveObjectResponse, searchResponse } = this.state;
550 // Push the possible resolve / federated objects first
551 if (resolveObjectResponse) {
552 const { comment, post, community, person } = resolveObjectResponse;
555 combined.push(commentViewToCombined(comment));
558 combined.push(postViewToCombined(post));
561 combined.push(communityViewToCombined(community));
564 combined.push(personViewSafeToCombined(person));
568 // Push the search results
569 if (searchResponse) {
570 const { comments, posts, communities, users } = searchResponse;
574 ...(comments?.map(commentViewToCombined) ?? []),
575 ...(posts?.map(postViewToCombined) ?? []),
576 ...(communities?.map(communityViewToCombined) ?? []),
577 ...(users?.map(personViewSafeToCombined) ?? []),
582 const { sort } = getSearchQueryParams();
585 if (sort === SortType.New) {
586 combined.sort((a, b) => b.published.localeCompare(a.published));
590 ((b.data as CommentView | PostView).counts.score |
591 (b.data as CommunityView).counts.subscribers |
592 (b.data as PersonViewSafe).counts.comment_score) -
593 ((a.data as CommentView | PostView).counts.score |
594 (a.data as CommunityView).counts.subscribers |
595 (a.data as PersonViewSafe).counts.comment_score)
603 const combined = this.buildCombined();
608 <div key={i.published} className="row">
609 <div className="col-12">
610 {i.type_ === "posts" && (
612 key={(i.data as PostView).post.id}
613 post_view={i.data as PostView}
615 enableDownvotes={enableDownvotes(this.state.siteRes)}
616 enableNsfw={enableNsfw(this.state.siteRes)}
617 allLanguages={this.state.siteRes.all_languages}
618 siteLanguages={this.state.siteRes.discussion_languages}
622 {i.type_ === "comments" && (
624 key={(i.data as CommentView).comment.id}
627 comment_view: i.data as CommentView,
632 viewType={CommentViewType.Flat}
636 enableDownvotes={enableDownvotes(this.state.siteRes)}
637 allLanguages={this.state.siteRes.all_languages}
638 siteLanguages={this.state.siteRes.discussion_languages}
641 {i.type_ === "communities" && (
642 <div>{communityListing(i.data as CommunityView)}</div>
644 {i.type_ === "users" && (
645 <div>{personListing(i.data as PersonViewSafe)}</div>
655 const { searchResponse, resolveObjectResponse, siteRes } = this.state;
656 const comments = searchResponse?.comments ?? [];
658 if (resolveObjectResponse?.comment) {
659 comments.unshift(resolveObjectResponse?.comment);
664 nodes={commentsToFlatNodes(comments)}
665 viewType={CommentViewType.Flat}
669 enableDownvotes={enableDownvotes(siteRes)}
670 allLanguages={siteRes.all_languages}
671 siteLanguages={siteRes.discussion_languages}
677 const { searchResponse, resolveObjectResponse, siteRes } = this.state;
678 const posts = searchResponse?.posts ?? [];
680 if (resolveObjectResponse?.post) {
681 posts.unshift(resolveObjectResponse.post);
687 <div key={pv.post.id} className="row">
688 <div className="col-12">
692 enableDownvotes={enableDownvotes(siteRes)}
693 enableNsfw={enableNsfw(siteRes)}
694 allLanguages={siteRes.all_languages}
695 siteLanguages={siteRes.discussion_languages}
706 const { searchResponse, resolveObjectResponse } = this.state;
707 const communities = searchResponse?.communities ?? [];
709 if (resolveObjectResponse?.community) {
710 communities.unshift(resolveObjectResponse.community);
715 {communities.map(cv => (
716 <div key={cv.community.id} className="row">
717 <div className="col-12">{communityListing(cv)}</div>
725 const { searchResponse, resolveObjectResponse } = this.state;
726 const users = searchResponse?.users ?? [];
728 if (resolveObjectResponse?.person) {
729 users.unshift(resolveObjectResponse.person);
735 <div key={pvs.person.id} className="row">
736 <div className="col-12">{personListing(pvs)}</div>
743 get resultsCount(): number {
744 const { searchResponse: r, resolveObjectResponse: resolveRes } = this.state;
746 const searchCount = r
749 r.communities.length +
753 const resObjCount = resolveRes
756 resolveRes.community ||
762 return resObjCount + searchCount;
766 const auth = myAuth(false);
767 const { searchText: q } = this.state;
768 const { communityId, creatorId, type, sort, listingType, page } =
769 getSearchQueryParams();
772 const form: SearchForm = {
774 community_id: communityId ?? undefined,
775 creator_id: creatorId ?? undefined,
778 listing_type: listingType,
784 const resolveObjectForm: ResolveObject = {
790 searchResponse: undefined,
791 resolveObjectResponse: undefined,
795 WebSocketService.Instance.send(wsClient.search(form));
796 WebSocketService.Instance.send(wsClient.resolveObject(resolveObjectForm));
800 handleCreatorSearch = debounce(async (text: string) => {
801 const { creatorId } = getSearchQueryParams();
802 const { creatorSearchOptions } = this.state;
804 searchCreatorLoading: true,
807 const newOptions: Choice[] = [];
809 const selectedChoice = creatorSearchOptions.find(
810 choice => getIdFromString(choice.value) === creatorId
813 if (selectedChoice) {
814 newOptions.push(selectedChoice);
817 if (text.length > 0) {
818 newOptions.push(...(await fetchUsers(text)).users.map(personToChoice));
822 searchCreatorLoading: false,
823 creatorSearchOptions: newOptions,
827 handleCommunitySearch = debounce(async (text: string) => {
828 const { communityId } = getSearchQueryParams();
829 const { communitySearchOptions } = this.state;
831 searchCommunitiesLoading: true,
834 const newOptions: Choice[] = [];
836 const selectedChoice = communitySearchOptions.find(
837 choice => getIdFromString(choice.value) === communityId
840 if (selectedChoice) {
841 newOptions.push(selectedChoice);
844 if (text.length > 0) {
846 ...(await fetchCommunities(text)).communities.map(communityToChoice)
851 searchCommunitiesLoading: false,
852 communitySearchOptions: newOptions,
856 handleSortChange(sort: SortType) {
857 this.updateUrl({ sort, page: 1 });
860 handleTypeChange(i: Search, event: any) {
861 const type = SearchType[event.target.value];
869 handlePageChange(page: number) {
870 this.updateUrl({ page });
873 handleListingTypeChange(listingType: ListingType) {
880 handleCommunityFilterChange({ value }: Choice) {
882 communityId: getIdFromString(value) ?? null,
887 handleCreatorFilterChange({ value }: Choice) {
889 creatorId: getIdFromString(value) ?? null,
894 handleSearchSubmit(i: Search, event: any) {
895 event.preventDefault();
898 q: i.state.searchText,
903 handleQChange(i: Search, event: any) {
904 i.setState({ searchText: event.target.value });
915 }: Partial<SearchProps>) {
919 listingType: urlListingType,
920 communityId: urlCommunityId,
922 creatorId: urlCreatorId,
924 } = getSearchQueryParams();
926 let query = q ?? this.state.searchText ?? urlQ;
928 if (query && query.length > 0) {
929 query = encodeURIComponent(query);
932 const queryParams: QueryParams<SearchProps> = {
934 type: type ?? urlType,
935 listingType: listingType ?? urlListingType,
936 communityId: getUpdatedSearchId(communityId, urlCommunityId),
937 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
938 page: (page ?? urlPage).toString(),
939 sort: sort ?? urlSort,
942 this.props.history.push(`/search${getQueryString(queryParams)}`);
947 parseMessage(msg: any) {
949 const op = wsUserOp(msg);
951 if (msg.error === "couldnt_find_object") {
953 resolveObjectResponse: {},
955 this.checkFinishedLoading();
957 toast(i18n.t(msg.error), "danger");
961 case UserOperation.Search: {
962 const searchResponse = wsJsonToRes<SearchResponse>(msg);
963 this.setState({ searchResponse });
964 window.scrollTo(0, 0);
965 this.checkFinishedLoading();
966 restoreScrollPosition(this.context);
971 case UserOperation.CreateCommentLike: {
972 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
973 createCommentLikeRes(
975 this.state.searchResponse?.comments
981 case UserOperation.CreatePostLike: {
982 const { post_view } = wsJsonToRes<PostResponse>(msg);
983 createPostLikeFindRes(post_view, this.state.searchResponse?.posts);
988 case UserOperation.ListCommunities: {
989 const { communities } = wsJsonToRes<ListCommunitiesResponse>(msg);
990 this.setState({ communities });
995 case UserOperation.ResolveObject: {
996 const resolveObjectResponse = wsJsonToRes<ResolveObjectResponse>(msg);
997 this.setState({ resolveObjectResponse });
998 this.checkFinishedLoading();
1006 checkFinishedLoading() {
1007 if (this.state.searchResponse || this.state.resolveObjectResponse) {
1008 this.setState({ searchLoading: false });