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";
36 capitalizeFirstLetter,
40 createPostLikeFindRes,
55 restoreScrollPosition,
63 import { CommentNodes } from "./comment/comment-nodes";
64 import { HtmlTags } from "./common/html-tags";
65 import { Spinner } from "./common/icon";
66 import { ListingTypeSelect } from "./common/listing-type-select";
67 import { Paginator } from "./common/paginator";
68 import { SearchableSelect } from "./common/searchable-select";
69 import { SortSelect } from "./common/sort-select";
70 import { CommunityLink } from "./community/community-link";
71 import { PersonListing } from "./person/person-listing";
72 import { PostListing } from "./post/post-listing";
74 interface SearchProps {
78 listingType: ListingType;
79 communityId?: number | null;
80 creatorId?: number | null;
84 interface SearchData {
85 communityResponse?: GetCommunityResponse;
86 listCommunitiesResponse?: ListCommunitiesResponse;
87 creatorDetailsResponse?: GetPersonDetailsResponse;
88 searchResponse?: SearchResponse;
89 resolveObjectResponse?: ResolveObjectResponse;
92 type FilterType = "creator" | "community";
94 interface SearchState {
95 searchResponse?: SearchResponse;
96 communities: CommunityView[];
97 creatorDetails?: GetPersonDetailsResponse;
98 searchLoading: boolean;
99 searchCommunitiesLoading: boolean;
100 searchCreatorLoading: boolean;
101 siteRes: GetSiteResponse;
103 resolveObjectResponse?: ResolveObjectResponse;
104 communitySearchOptions: Choice[];
105 creatorSearchOptions: Choice[];
110 data: CommentView | PostView | CommunityView | PersonView;
114 const defaultSearchType = "All";
115 const defaultSortType = "TopAll";
116 const defaultListingType = "All";
118 const searchTypes = ["All", "Comments", "Posts", "Communities", "Users", "Url"];
120 const getSearchQueryParams = () =>
121 getQueryParams<SearchProps>({
122 q: getSearchQueryFromQuery,
123 type: getSearchTypeFromQuery,
124 sort: getSortTypeFromQuery,
125 listingType: getListingTypeFromQuery,
126 communityId: getIdFromString,
127 creatorId: getIdFromString,
128 page: getPageFromString,
131 const getSearchQueryFromQuery = (q?: string): string | undefined =>
132 q ? decodeURIComponent(q) : undefined;
134 function getSearchTypeFromQuery(type_?: string): SearchType {
135 return type_ ? (type_ as SearchType) : defaultSearchType;
138 function getSortTypeFromQuery(sort?: string): SortType {
139 return sort ? (sort as SortType) : defaultSortType;
142 function getListingTypeFromQuery(listingType?: string): ListingType {
143 return listingType ? (listingType as ListingType) : defaultListingType;
146 function postViewToCombined(data: PostView): Combined {
150 published: data.post.published,
154 function commentViewToCombined(data: CommentView): Combined {
158 published: data.comment.published,
162 function communityViewToCombined(data: CommunityView): Combined {
164 type_: "communities",
166 published: data.community.published,
170 function personViewSafeToCombined(data: PersonView): Combined {
174 published: data.person.published,
186 filterType: FilterType;
188 onSearch: (text: string) => void;
189 onChange: (choice: Choice) => void;
190 value?: number | null;
194 <div className="form-group col-sm-6">
195 <label className="col-form-label" htmlFor={`${filterType}-filter`}>
196 {capitalizeFirstLetter(i18n.t(filterType))}
199 id={`${filterType}-filter`}
202 label: i18n.t("all"),
215 const communityListing = ({
217 counts: { subscribers },
220 <CommunityLink community={community} />,
222 "number_of_subscribers"
225 const personListing = ({ person, counts: { comment_count } }: PersonView) =>
227 <PersonListing person={person} showApubName />,
233 listing: JSX.ElementClass,
235 translationKey: "number_of_comments" | "number_of_subscribers"
239 <span>{listing}</span>
240 <span>{` - ${i18n.t(translationKey, {
241 count: Number(count),
242 formattedCount: numToSI(count),
248 export class Search extends Component<any, SearchState> {
249 private isoData = setIsoData<SearchData>(this.context);
250 private subscription?: Subscription;
251 state: SearchState = {
252 searchLoading: false,
253 siteRes: this.isoData.site_res,
255 searchCommunitiesLoading: false,
256 searchCreatorLoading: false,
257 creatorSearchOptions: [],
258 communitySearchOptions: [],
261 constructor(props: any, context: any) {
262 super(props, context);
264 this.handleSortChange = this.handleSortChange.bind(this);
265 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
266 this.handlePageChange = this.handlePageChange.bind(this);
267 this.handleCommunityFilterChange =
268 this.handleCommunityFilterChange.bind(this);
269 this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
271 this.parseMessage = this.parseMessage.bind(this);
272 this.subscription = wsSubscribe(this.parseMessage);
274 const { q } = getSearchQueryParams();
281 // Only fetch the data if coming from another route
282 if (this.isoData.path === this.context.router.route.match.url) {
285 creatorDetailsResponse,
286 listCommunitiesResponse,
287 resolveObjectResponse,
289 } = this.isoData.routeData;
291 // This can be single or multiple communities given
292 if (listCommunitiesResponse) {
295 communities: listCommunitiesResponse.communities,
298 if (communityResponse) {
301 communities: [communityResponse.community_view],
302 communitySearchOptions: [
303 communityToChoice(communityResponse.community_view),
310 creatorDetails: creatorDetailsResponse,
311 creatorSearchOptions: creatorDetailsResponse
312 ? [personToChoice(creatorDetailsResponse.person_view)]
320 resolveObjectResponse,
321 searchLoading: false,
327 const listCommunitiesForm: ListCommunities = {
328 type_: defaultListingType,
329 sort: defaultSortType,
334 WebSocketService.Instance.send(
335 wsClient.listCommunities(listCommunitiesForm)
344 componentWillUnmount() {
345 this.subscription?.unsubscribe();
346 saveScrollPosition(this.context);
349 static fetchInitialData({
352 query: { communityId, creatorId, q, type, sort, listingType, page },
353 }: InitialFetchRequest<
354 QueryParams<SearchProps>
355 >): WithPromiseKeys<SearchData> {
356 const community_id = getIdFromString(communityId);
357 let communityResponse: Promise<GetCommunityResponse> | undefined =
359 let listCommunitiesResponse: Promise<ListCommunitiesResponse> | undefined =
362 const getCommunityForm: GetCommunity = {
367 communityResponse = client.getCommunity(getCommunityForm);
369 const listCommunitiesForm: ListCommunities = {
370 type_: defaultListingType,
371 sort: defaultSortType,
376 listCommunitiesResponse = client.listCommunities(listCommunitiesForm);
379 const creator_id = getIdFromString(creatorId);
380 let creatorDetailsResponse: Promise<GetPersonDetailsResponse> | undefined =
383 const getCreatorForm: GetPersonDetails = {
384 person_id: creator_id,
388 creatorDetailsResponse = client.getPersonDetails(getCreatorForm);
391 const query = getSearchQueryFromQuery(q);
393 let searchResponse: Promise<SearchResponse> | undefined = undefined;
394 let resolveObjectResponse:
395 | Promise<ResolveObjectResponse | undefined>
396 | undefined = undefined;
399 const form: SearchForm = {
403 type_: getSearchTypeFromQuery(type),
404 sort: getSortTypeFromQuery(sort),
405 listing_type: getListingTypeFromQuery(listingType),
406 page: getPageFromString(page),
412 searchResponse = client.search(form);
414 const resolveObjectForm: ResolveObject = {
418 resolveObjectResponse = client
419 .resolveObject(resolveObjectForm)
420 .catch(() => undefined);
427 creatorDetailsResponse,
428 listCommunitiesResponse,
429 resolveObjectResponse,
434 get documentTitle(): string {
435 const { q } = getSearchQueryParams();
436 const name = this.state.siteRes.site_view.site.name;
437 return `${i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
441 const { type, page } = getSearchQueryParams();
444 <div className="container-lg">
446 title={this.documentTitle}
447 path={this.context.router.route.match.url}
449 <h5>{i18n.t("search")}</h5>
452 {this.displayResults(type)}
453 {this.resultsCount === 0 && !this.state.searchLoading && (
454 <span>{i18n.t("no_results")}</span>
456 <Paginator page={page} onChange={this.handlePageChange} />
461 displayResults(type: SearchType) {
466 return this.comments;
471 return this.communities;
482 className="form-inline"
483 onSubmit={linkEvent(this, this.handleSearchSubmit)}
487 className="form-control mr-2 mb-2"
488 value={this.state.searchText}
489 placeholder={`${i18n.t("search")}...`}
490 aria-label={i18n.t("search")}
491 onInput={linkEvent(this, this.handleQChange)}
495 <button type="submit" className="btn btn-secondary mr-2 mb-2">
496 {this.state.searchLoading ? (
499 <span>{i18n.t("search")}</span>
507 const { type, listingType, sort, communityId, creatorId } =
508 getSearchQueryParams();
510 communitySearchOptions,
511 creatorSearchOptions,
512 searchCommunitiesLoading,
513 searchCreatorLoading,
517 <div className="mb-2">
520 onChange={linkEvent(this, this.handleTypeChange)}
521 className="custom-select w-auto mb-2"
522 aria-label={i18n.t("type")}
524 <option disabled aria-hidden="true">
527 {searchTypes.map(option => (
528 <option value={option} key={option}>
529 {i18n.t(option.toString().toLowerCase() as NoOptionI18nKeys)}
533 <span className="ml-2">
536 showLocal={showLocal(this.isoData)}
538 onChange={this.handleListingTypeChange}
541 <span className="ml-2">
544 onChange={this.handleSortChange}
549 <div className="form-row">
550 {this.state.communities.length > 0 && (
552 filterType="community"
553 onChange={this.handleCommunityFilterChange}
554 onSearch={this.handleCommunitySearch}
555 options={communitySearchOptions}
556 loading={searchCommunitiesLoading}
562 onChange={this.handleCreatorFilterChange}
563 onSearch={this.handleCreatorSearch}
564 options={creatorSearchOptions}
565 loading={searchCreatorLoading}
573 buildCombined(): Combined[] {
574 const combined: Combined[] = [];
575 const { resolveObjectResponse, searchResponse } = this.state;
577 // Push the possible resolve / federated objects first
578 if (resolveObjectResponse) {
579 const { comment, post, community, person } = resolveObjectResponse;
582 combined.push(commentViewToCombined(comment));
585 combined.push(postViewToCombined(post));
588 combined.push(communityViewToCombined(community));
591 combined.push(personViewSafeToCombined(person));
595 // Push the search results
596 if (searchResponse) {
597 const { comments, posts, communities, users } = searchResponse;
601 ...(comments?.map(commentViewToCombined) ?? []),
602 ...(posts?.map(postViewToCombined) ?? []),
603 ...(communities?.map(communityViewToCombined) ?? []),
604 ...(users?.map(personViewSafeToCombined) ?? []),
609 const { sort } = getSearchQueryParams();
612 if (sort === "New") {
613 combined.sort((a, b) => b.published.localeCompare(a.published));
615 combined.sort((a, b) =>
617 ((b.data as CommentView | PostView).counts.score |
618 (b.data as CommunityView).counts.subscribers |
619 (b.data as PersonView).counts.comment_score) -
620 ((a.data as CommentView | PostView).counts.score |
621 (a.data as CommunityView).counts.subscribers |
622 (a.data as PersonView).counts.comment_score)
631 const combined = this.buildCombined();
636 <div key={i.published} className="row">
637 <div className="col-12">
638 {i.type_ === "posts" && (
640 key={(i.data as PostView).post.id}
641 post_view={i.data as PostView}
643 enableDownvotes={enableDownvotes(this.state.siteRes)}
644 enableNsfw={enableNsfw(this.state.siteRes)}
645 allLanguages={this.state.siteRes.all_languages}
646 siteLanguages={this.state.siteRes.discussion_languages}
650 {i.type_ === "comments" && (
652 key={(i.data as CommentView).comment.id}
655 comment_view: i.data as CommentView,
660 viewType={CommentViewType.Flat}
664 enableDownvotes={enableDownvotes(this.state.siteRes)}
665 allLanguages={this.state.siteRes.all_languages}
666 siteLanguages={this.state.siteRes.discussion_languages}
669 {i.type_ === "communities" && (
670 <div>{communityListing(i.data as CommunityView)}</div>
672 {i.type_ === "users" && (
673 <div>{personListing(i.data as PersonView)}</div>
683 const { searchResponse, resolveObjectResponse, siteRes } = this.state;
684 const comments = searchResponse?.comments ?? [];
686 if (resolveObjectResponse?.comment) {
687 comments.unshift(resolveObjectResponse?.comment);
692 nodes={commentsToFlatNodes(comments)}
693 viewType={CommentViewType.Flat}
697 enableDownvotes={enableDownvotes(siteRes)}
698 allLanguages={siteRes.all_languages}
699 siteLanguages={siteRes.discussion_languages}
705 const { searchResponse, resolveObjectResponse, siteRes } = this.state;
706 const posts = searchResponse?.posts ?? [];
708 if (resolveObjectResponse?.post) {
709 posts.unshift(resolveObjectResponse.post);
715 <div key={pv.post.id} className="row">
716 <div className="col-12">
720 enableDownvotes={enableDownvotes(siteRes)}
721 enableNsfw={enableNsfw(siteRes)}
722 allLanguages={siteRes.all_languages}
723 siteLanguages={siteRes.discussion_languages}
734 const { searchResponse, resolveObjectResponse } = this.state;
735 const communities = searchResponse?.communities ?? [];
737 if (resolveObjectResponse?.community) {
738 communities.unshift(resolveObjectResponse.community);
743 {communities.map(cv => (
744 <div key={cv.community.id} className="row">
745 <div className="col-12">{communityListing(cv)}</div>
753 const { searchResponse, resolveObjectResponse } = this.state;
754 const users = searchResponse?.users ?? [];
756 if (resolveObjectResponse?.person) {
757 users.unshift(resolveObjectResponse.person);
763 <div key={pvs.person.id} className="row">
764 <div className="col-12">{personListing(pvs)}</div>
771 get resultsCount(): number {
772 const { searchResponse: r, resolveObjectResponse: resolveRes } = this.state;
774 const searchCount = r
777 r.communities.length +
781 const resObjCount = resolveRes
784 resolveRes.community ||
790 return resObjCount + searchCount;
794 const auth = myAuth(false);
795 const { searchText: q } = this.state;
796 const { communityId, creatorId, type, sort, listingType, page } =
797 getSearchQueryParams();
800 const form: SearchForm = {
802 community_id: communityId ?? undefined,
803 creator_id: creatorId ?? undefined,
806 listing_type: listingType,
813 const resolveObjectForm: ResolveObject = {
817 WebSocketService.Instance.send(
818 wsClient.resolveObject(resolveObjectForm)
823 searchResponse: undefined,
824 resolveObjectResponse: undefined,
828 WebSocketService.Instance.send(wsClient.search(form));
832 handleCreatorSearch = debounce(async (text: string) => {
833 const { creatorId } = getSearchQueryParams();
834 const { creatorSearchOptions } = this.state;
836 searchCreatorLoading: true,
839 const newOptions: Choice[] = [];
841 const selectedChoice = creatorSearchOptions.find(
842 choice => getIdFromString(choice.value) === creatorId
845 if (selectedChoice) {
846 newOptions.push(selectedChoice);
849 if (text.length > 0) {
850 newOptions.push(...(await fetchUsers(text)).users.map(personToChoice));
854 searchCreatorLoading: false,
855 creatorSearchOptions: newOptions,
859 handleCommunitySearch = debounce(async (text: string) => {
860 const { communityId } = getSearchQueryParams();
861 const { communitySearchOptions } = this.state;
863 searchCommunitiesLoading: true,
866 const newOptions: Choice[] = [];
868 const selectedChoice = communitySearchOptions.find(
869 choice => getIdFromString(choice.value) === communityId
872 if (selectedChoice) {
873 newOptions.push(selectedChoice);
876 if (text.length > 0) {
878 ...(await fetchCommunities(text)).communities.map(communityToChoice)
883 searchCommunitiesLoading: false,
884 communitySearchOptions: newOptions,
888 handleSortChange(sort: SortType) {
889 this.updateUrl({ sort, page: 1 });
892 handleTypeChange(i: Search, event: any) {
893 const type = event.target.value as SearchType;
901 handlePageChange(page: number) {
902 this.updateUrl({ page });
905 handleListingTypeChange(listingType: ListingType) {
912 handleCommunityFilterChange({ value }: Choice) {
914 communityId: getIdFromString(value) ?? null,
919 handleCreatorFilterChange({ value }: Choice) {
921 creatorId: getIdFromString(value) ?? null,
926 handleSearchSubmit(i: Search, event: any) {
927 event.preventDefault();
930 q: i.state.searchText,
935 handleQChange(i: Search, event: any) {
936 i.setState({ searchText: event.target.value });
947 }: Partial<SearchProps>) {
951 listingType: urlListingType,
952 communityId: urlCommunityId,
954 creatorId: urlCreatorId,
956 } = getSearchQueryParams();
958 let query = q ?? this.state.searchText ?? urlQ;
960 if (query && query.length > 0) {
961 query = encodeURIComponent(query);
964 const queryParams: QueryParams<SearchProps> = {
966 type: type ?? urlType,
967 listingType: listingType ?? urlListingType,
968 communityId: getUpdatedSearchId(communityId, urlCommunityId),
969 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
970 page: (page ?? urlPage).toString(),
971 sort: sort ?? urlSort,
974 this.props.history.push(`/search${getQueryString(queryParams)}`);
979 parseMessage(msg: any) {
981 const op = wsUserOp(msg);
983 if (msg.error === "couldnt_find_object") {
985 resolveObjectResponse: {},
987 this.checkFinishedLoading();
989 toast(i18n.t(msg.error), "danger");
993 case UserOperation.Search: {
994 const searchResponse = wsJsonToRes<SearchResponse>(msg);
995 this.setState({ searchResponse });
996 window.scrollTo(0, 0);
997 this.checkFinishedLoading();
998 restoreScrollPosition(this.context);
1003 case UserOperation.CreateCommentLike: {
1004 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
1005 createCommentLikeRes(
1007 this.state.searchResponse?.comments
1013 case UserOperation.CreatePostLike: {
1014 const { post_view } = wsJsonToRes<PostResponse>(msg);
1015 createPostLikeFindRes(post_view, this.state.searchResponse?.posts);
1020 case UserOperation.ListCommunities: {
1021 const { communities } = wsJsonToRes<ListCommunitiesResponse>(msg);
1022 this.setState({ communities });
1027 case UserOperation.ResolveObject: {
1028 const resolveObjectResponse = wsJsonToRes<ResolveObjectResponse>(msg);
1029 this.setState({ resolveObjectResponse });
1030 this.checkFinishedLoading();
1038 checkFinishedLoading() {
1039 if (this.state.searchResponse || this.state.resolveObjectResponse) {
1040 this.setState({ searchLoading: false });