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";
33 capitalizeFirstLetter,
38 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.Element,
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 && <span>{i18n.t("no_results")}</span>}
427 <Paginator page={page} onChange={this.handlePageChange} />
432 displayResults(type: SearchType) {
436 case SearchType.Comments:
437 return this.comments;
438 case SearchType.Posts:
441 case SearchType.Communities:
442 return this.communities;
443 case SearchType.Users:
453 className="form-inline"
454 onSubmit={linkEvent(this, this.handleSearchSubmit)}
458 className="form-control mr-2 mb-2"
459 value={this.state.searchText}
460 placeholder={`${i18n.t("search")}...`}
461 aria-label={i18n.t("search")}
462 onInput={linkEvent(this, this.handleQChange)}
466 <button type="submit" className="btn btn-secondary mr-2 mb-2">
467 {this.state.searchLoading ? (
470 <span>{i18n.t("search")}</span>
478 const { type, listingType, sort, communityId, creatorId } =
479 getSearchQueryParams();
481 communitySearchOptions,
482 creatorSearchOptions,
483 searchCommunitiesLoading,
484 searchCreatorLoading,
488 <div className="mb-2">
491 onChange={linkEvent(this, this.handleTypeChange)}
492 className="custom-select w-auto mb-2"
493 aria-label={i18n.t("type")}
495 <option disabled aria-hidden="true">
498 {searchTypes.map(option => (
499 <option value={option} key={option}>
500 {i18n.t(option.toString().toLowerCase() as NoOptionI18nKeys)}
504 <span className="ml-2">
507 showLocal={showLocal(this.isoData)}
509 onChange={this.handleListingTypeChange}
512 <span className="ml-2">
515 onChange={this.handleSortChange}
520 <div className="form-row">
521 {this.state.communities.length > 0 && (
523 filterType="community"
524 onChange={this.handleCommunityFilterChange}
525 onSearch={this.handleCommunitySearch}
526 options={communitySearchOptions}
527 loading={searchCommunitiesLoading}
533 onChange={this.handleCreatorFilterChange}
534 onSearch={this.handleCreatorSearch}
535 options={creatorSearchOptions}
536 loading={searchCreatorLoading}
544 buildCombined(): Combined[] {
545 const combined: Combined[] = [];
546 const { resolveObjectResponse, searchResponse } = this.state;
548 // Push the possible resolve / federated objects first
549 if (resolveObjectResponse) {
550 const { comment, post, community, person } = resolveObjectResponse;
553 combined.push(commentViewToCombined(comment));
556 combined.push(postViewToCombined(post));
559 combined.push(communityViewToCombined(community));
562 combined.push(personViewSafeToCombined(person));
566 // Push the search results
567 if (searchResponse) {
568 const { comments, posts, communities, users } = searchResponse;
572 ...(comments?.map(commentViewToCombined) ?? []),
573 ...(posts?.map(postViewToCombined) ?? []),
574 ...(communities?.map(communityViewToCombined) ?? []),
575 ...(users?.map(personViewSafeToCombined) ?? []),
580 const { sort } = getSearchQueryParams();
583 if (sort === SortType.New) {
584 combined.sort((a, b) => b.published.localeCompare(a.published));
588 ((b.data as CommentView | PostView).counts.score |
589 (b.data as CommunityView).counts.subscribers |
590 (b.data as PersonViewSafe).counts.comment_score) -
591 ((a.data as CommentView | PostView).counts.score |
592 (a.data as CommunityView).counts.subscribers |
593 (a.data as PersonViewSafe).counts.comment_score)
601 const combined = this.buildCombined();
606 <div key={i.published} className="row">
607 <div className="col-12">
608 {i.type_ === "posts" && (
610 key={(i.data as PostView).post.id}
611 post_view={i.data as PostView}
613 enableDownvotes={enableDownvotes(this.state.siteRes)}
614 enableNsfw={enableNsfw(this.state.siteRes)}
615 allLanguages={this.state.siteRes.all_languages}
616 siteLanguages={this.state.siteRes.discussion_languages}
620 {i.type_ === "comments" && (
622 key={(i.data as CommentView).comment.id}
625 comment_view: i.data as CommentView,
630 viewType={CommentViewType.Flat}
634 enableDownvotes={enableDownvotes(this.state.siteRes)}
635 allLanguages={this.state.siteRes.all_languages}
636 siteLanguages={this.state.siteRes.discussion_languages}
639 {i.type_ === "communities" && (
640 <div>{communityListing(i.data as CommunityView)}</div>
642 {i.type_ === "users" && (
643 <div>{personListing(i.data as PersonViewSafe)}</div>
653 const { searchResponse, resolveObjectResponse, siteRes } = this.state;
654 const comments = searchResponse?.comments ?? [];
656 if (resolveObjectResponse?.comment) {
657 comments.unshift(resolveObjectResponse?.comment);
662 nodes={commentsToFlatNodes(comments)}
663 viewType={CommentViewType.Flat}
667 enableDownvotes={enableDownvotes(siteRes)}
668 allLanguages={siteRes.all_languages}
669 siteLanguages={siteRes.discussion_languages}
675 const { searchResponse, resolveObjectResponse, siteRes } = this.state;
676 const posts = searchResponse?.posts ?? [];
678 if (resolveObjectResponse?.post) {
679 posts.unshift(resolveObjectResponse.post);
685 <div key={pv.post.id} className="row">
686 <div className="col-12">
690 enableDownvotes={enableDownvotes(siteRes)}
691 enableNsfw={enableNsfw(siteRes)}
692 allLanguages={siteRes.all_languages}
693 siteLanguages={siteRes.discussion_languages}
704 const { searchResponse, resolveObjectResponse } = this.state;
705 const communities = searchResponse?.communities ?? [];
707 if (resolveObjectResponse?.community) {
708 communities.unshift(resolveObjectResponse.community);
713 {communities.map(cv => (
714 <div key={cv.community.id} className="row">
715 <div className="col-12">{communityListing(cv)}</div>
723 const { searchResponse, resolveObjectResponse } = this.state;
724 const users = searchResponse?.users ?? [];
726 if (resolveObjectResponse?.person) {
727 users.unshift(resolveObjectResponse.person);
733 <div key={pvs.person.id} className="row">
734 <div className="col-12">{personListing(pvs)}</div>
741 get resultsCount(): number {
742 const { searchResponse: r, resolveObjectResponse: resolveRes } = this.state;
744 const searchCount = r
747 r.communities.length +
751 const resObjCount = resolveRes
754 resolveRes.community ||
760 return resObjCount + searchCount;
764 const auth = myAuth(false);
765 const { searchText: q } = this.state;
766 const { communityId, creatorId, type, sort, listingType, page } =
767 getSearchQueryParams();
770 const form: SearchForm = {
772 community_id: communityId ?? undefined,
773 creator_id: creatorId ?? undefined,
776 listing_type: listingType,
782 const resolveObjectForm: ResolveObject = {
788 searchResponse: undefined,
789 resolveObjectResponse: undefined,
793 WebSocketService.Instance.send(wsClient.search(form));
794 WebSocketService.Instance.send(wsClient.resolveObject(resolveObjectForm));
798 handleCreatorSearch = debounce(async (text: string) => {
799 const { creatorId } = getSearchQueryParams();
800 const { creatorSearchOptions } = this.state;
802 searchCreatorLoading: true,
805 const newOptions: Choice[] = [];
807 const selectedChoice = creatorSearchOptions.find(
808 choice => getIdFromString(choice.value) === creatorId
811 if (selectedChoice) {
812 newOptions.push(selectedChoice);
815 if (text.length > 0) {
816 newOptions.push(...(await fetchUsers(text)).users.map(personToChoice));
820 searchCreatorLoading: false,
821 creatorSearchOptions: newOptions,
825 handleCommunitySearch = debounce(async (text: string) => {
826 const { communityId } = getSearchQueryParams();
827 const { communitySearchOptions } = this.state;
829 searchCommunitiesLoading: true,
832 const newOptions: Choice[] = [];
834 const selectedChoice = communitySearchOptions.find(
835 choice => getIdFromString(choice.value) === communityId
838 if (selectedChoice) {
839 newOptions.push(selectedChoice);
842 if (text.length > 0) {
844 ...(await fetchCommunities(text)).communities.map(communityToChoice)
849 searchCommunitiesLoading: false,
850 communitySearchOptions: newOptions,
854 handleSortChange(sort: SortType) {
855 this.updateUrl({ sort, page: 1 });
858 handleTypeChange(i: Search, event: any) {
859 const type = SearchType[event.target.value];
867 handlePageChange(page: number) {
868 this.updateUrl({ page });
871 handleListingTypeChange(listingType: ListingType) {
878 handleCommunityFilterChange({ value }: Choice) {
880 communityId: getIdFromString(value) ?? null,
885 handleCreatorFilterChange({ value }: Choice) {
887 creatorId: getIdFromString(value) ?? null,
892 handleSearchSubmit(i: Search, event: any) {
893 event.preventDefault();
896 q: i.state.searchText,
901 handleQChange(i: Search, event: any) {
902 i.setState({ searchText: event.target.value });
913 }: Partial<SearchProps>) {
917 listingType: urlListingType,
918 communityId: urlCommunityId,
920 creatorId: urlCreatorId,
922 } = getSearchQueryParams();
924 let query = q ?? this.state.searchText ?? urlQ;
926 if (query && query.length > 0) {
927 query = encodeURIComponent(query);
930 const queryParams: QueryParams<SearchProps> = {
932 type: type ?? urlType,
933 listingType: listingType ?? urlListingType,
934 communityId: getUpdatedSearchId(communityId, urlCommunityId),
935 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
936 page: (page ?? urlPage).toString(),
937 sort: sort ?? urlSort,
940 this.props.history.push(`/search${getQueryString(queryParams)}`);
945 parseMessage(msg: any) {
947 const op = wsUserOp(msg);
949 if (msg.error === "couldnt_find_object") {
951 resolveObjectResponse: {},
953 this.checkFinishedLoading();
955 toast(i18n.t(msg.error), "danger");
959 case UserOperation.Search: {
960 const searchResponse = wsJsonToRes<SearchResponse>(msg);
961 this.setState({ searchResponse });
962 window.scrollTo(0, 0);
963 this.checkFinishedLoading();
964 restoreScrollPosition(this.context);
969 case UserOperation.CreateCommentLike: {
970 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
971 createCommentLikeRes(
973 this.state.searchResponse?.comments
979 case UserOperation.CreatePostLike: {
980 const { post_view } = wsJsonToRes<PostResponse>(msg);
981 createPostLikeFindRes(post_view, this.state.searchResponse?.posts);
986 case UserOperation.ListCommunities: {
987 const { communities } = wsJsonToRes<ListCommunitiesResponse>(msg);
988 this.setState({ communities });
993 case UserOperation.ResolveObject: {
994 const resolveObjectResponse = wsJsonToRes<ResolveObjectResponse>(msg);
995 this.setState({ resolveObjectResponse });
996 this.checkFinishedLoading();
1004 checkFinishedLoading() {
1005 if (this.state.searchResponse && this.state.resolveObjectResponse) {
1006 this.setState({ searchLoading: false });