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,
62 import { CommentNodes } from "./comment/comment-nodes";
63 import { HtmlTags } from "./common/html-tags";
64 import { Spinner } from "./common/icon";
65 import { ListingTypeSelect } from "./common/listing-type-select";
66 import { Paginator } from "./common/paginator";
67 import { SearchableSelect } from "./common/searchable-select";
68 import { SortSelect } from "./common/sort-select";
69 import { CommunityLink } from "./community/community-link";
70 import { PersonListing } from "./person/person-listing";
71 import { PostListing } from "./post/post-listing";
73 interface SearchProps {
77 listingType: ListingType;
78 communityId?: number | null;
79 creatorId?: number | null;
83 type FilterType = "creator" | "community";
85 interface SearchState {
86 searchResponse?: SearchResponse;
87 communities: CommunityView[];
88 creatorDetails?: GetPersonDetailsResponse;
89 searchLoading: boolean;
90 searchCommunitiesLoading: boolean;
91 searchCreatorLoading: boolean;
92 siteRes: GetSiteResponse;
94 resolveObjectResponse?: ResolveObjectResponse;
95 communitySearchOptions: Choice[];
96 creatorSearchOptions: Choice[];
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(this.context);
241 private subscription?: Subscription;
242 state: SearchState = {
243 searchLoading: false,
244 siteRes: this.isoData.site_res,
246 searchCommunitiesLoading: false,
247 searchCreatorLoading: false,
248 creatorSearchOptions: [],
249 communitySearchOptions: [],
252 constructor(props: any, context: any) {
253 super(props, context);
255 this.handleSortChange = this.handleSortChange.bind(this);
256 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
257 this.handlePageChange = this.handlePageChange.bind(this);
258 this.handleCommunityFilterChange =
259 this.handleCommunityFilterChange.bind(this);
260 this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
262 this.parseMessage = this.parseMessage.bind(this);
263 this.subscription = wsSubscribe(this.parseMessage);
265 const { q } = getSearchQueryParams();
272 // Only fetch the data if coming from another route
273 if (this.isoData.path === this.context.router.route.match.url) {
274 const communityRes = this.isoData.routeData[0] as
275 | GetCommunityResponse
277 const communitiesRes = this.isoData.routeData[1] as
278 | ListCommunitiesResponse
280 // This can be single or multiple communities given
281 if (communitiesRes) {
284 communities: communitiesRes.communities,
290 communities: [communityRes.community_view],
291 communitySearchOptions: [
292 communityToChoice(communityRes.community_view),
297 const creatorRes = this.isoData.routeData[2] as GetPersonDetailsResponse;
301 creatorDetails: creatorRes,
302 creatorSearchOptions: creatorRes
303 ? [personToChoice(creatorRes.person_view)]
310 searchResponse: this.isoData.routeData[3] as SearchResponse,
311 resolveObjectResponse: this.isoData
312 .routeData[4] as ResolveObjectResponse,
313 searchLoading: false,
319 const listCommunitiesForm: ListCommunities = {
320 type_: defaultListingType,
321 sort: defaultSortType,
326 WebSocketService.Instance.send(
327 wsClient.listCommunities(listCommunitiesForm)
336 componentWillUnmount() {
337 this.subscription?.unsubscribe();
338 saveScrollPosition(this.context);
341 static fetchInitialData({
344 query: { communityId, creatorId, q, type, sort, listingType, page },
345 }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<any>[] {
346 const promises: Promise<any>[] = [];
348 const community_id = getIdFromString(communityId);
350 const getCommunityForm: GetCommunity = {
354 promises.push(client.getCommunity(getCommunityForm));
355 promises.push(Promise.resolve());
357 const listCommunitiesForm: ListCommunities = {
358 type_: defaultListingType,
359 sort: defaultSortType,
363 promises.push(Promise.resolve());
364 promises.push(client.listCommunities(listCommunitiesForm));
367 const creator_id = getIdFromString(creatorId);
369 const getCreatorForm: GetPersonDetails = {
370 person_id: creator_id,
373 promises.push(client.getPersonDetails(getCreatorForm));
375 promises.push(Promise.resolve());
378 const query = getSearchQueryFromQuery(q);
381 const form: SearchForm = {
385 type_: getSearchTypeFromQuery(type),
386 sort: getSortTypeFromQuery(sort),
387 listing_type: getListingTypeFromQuery(listingType),
388 page: getPageFromString(page),
394 promises.push(client.search(form));
396 const resolveObjectForm: ResolveObject = {
400 promises.push(client.resolveObject(resolveObjectForm));
403 promises.push(Promise.resolve());
404 promises.push(Promise.resolve());
411 get documentTitle(): string {
412 const { q } = getSearchQueryParams();
413 const name = this.state.siteRes.site_view.site.name;
414 return `${i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
418 const { type, page } = getSearchQueryParams();
421 <div className="container-lg">
423 title={this.documentTitle}
424 path={this.context.router.route.match.url}
426 <h5>{i18n.t("search")}</h5>
429 {this.displayResults(type)}
430 {this.resultsCount === 0 && !this.state.searchLoading && (
431 <span>{i18n.t("no_results")}</span>
433 <Paginator page={page} onChange={this.handlePageChange} />
438 displayResults(type: SearchType) {
443 return this.comments;
448 return this.communities;
459 className="form-inline"
460 onSubmit={linkEvent(this, this.handleSearchSubmit)}
464 className="form-control mr-2 mb-2"
465 value={this.state.searchText}
466 placeholder={`${i18n.t("search")}...`}
467 aria-label={i18n.t("search")}
468 onInput={linkEvent(this, this.handleQChange)}
472 <button type="submit" className="btn btn-secondary mr-2 mb-2">
473 {this.state.searchLoading ? (
476 <span>{i18n.t("search")}</span>
484 const { type, listingType, sort, communityId, creatorId } =
485 getSearchQueryParams();
487 communitySearchOptions,
488 creatorSearchOptions,
489 searchCommunitiesLoading,
490 searchCreatorLoading,
494 <div className="mb-2">
497 onChange={linkEvent(this, this.handleTypeChange)}
498 className="custom-select w-auto mb-2"
499 aria-label={i18n.t("type")}
501 <option disabled aria-hidden="true">
504 {searchTypes.map(option => (
505 <option value={option} key={option}>
506 {i18n.t(option.toString().toLowerCase() as NoOptionI18nKeys)}
510 <span className="ml-2">
513 showLocal={showLocal(this.isoData)}
515 onChange={this.handleListingTypeChange}
518 <span className="ml-2">
521 onChange={this.handleSortChange}
526 <div className="form-row">
527 {this.state.communities.length > 0 && (
529 filterType="community"
530 onChange={this.handleCommunityFilterChange}
531 onSearch={this.handleCommunitySearch}
532 options={communitySearchOptions}
533 loading={searchCommunitiesLoading}
539 onChange={this.handleCreatorFilterChange}
540 onSearch={this.handleCreatorSearch}
541 options={creatorSearchOptions}
542 loading={searchCreatorLoading}
550 buildCombined(): Combined[] {
551 const combined: Combined[] = [];
552 const { resolveObjectResponse, searchResponse } = this.state;
554 // Push the possible resolve / federated objects first
555 if (resolveObjectResponse) {
556 const { comment, post, community, person } = resolveObjectResponse;
559 combined.push(commentViewToCombined(comment));
562 combined.push(postViewToCombined(post));
565 combined.push(communityViewToCombined(community));
568 combined.push(personViewSafeToCombined(person));
572 // Push the search results
573 if (searchResponse) {
574 const { comments, posts, communities, users } = searchResponse;
578 ...(comments?.map(commentViewToCombined) ?? []),
579 ...(posts?.map(postViewToCombined) ?? []),
580 ...(communities?.map(communityViewToCombined) ?? []),
581 ...(users?.map(personViewSafeToCombined) ?? []),
586 const { sort } = getSearchQueryParams();
589 if (sort === "New") {
590 combined.sort((a, b) => b.published.localeCompare(a.published));
592 combined.sort((a, b) =>
594 ((b.data as CommentView | PostView).counts.score |
595 (b.data as CommunityView).counts.subscribers |
596 (b.data as PersonView).counts.comment_score) -
597 ((a.data as CommentView | PostView).counts.score |
598 (a.data as CommunityView).counts.subscribers |
599 (a.data as PersonView).counts.comment_score)
608 const combined = this.buildCombined();
613 <div key={i.published} className="row">
614 <div className="col-12">
615 {i.type_ === "posts" && (
617 key={(i.data as PostView).post.id}
618 post_view={i.data as PostView}
620 enableDownvotes={enableDownvotes(this.state.siteRes)}
621 enableNsfw={enableNsfw(this.state.siteRes)}
622 allLanguages={this.state.siteRes.all_languages}
623 siteLanguages={this.state.siteRes.discussion_languages}
627 {i.type_ === "comments" && (
629 key={(i.data as CommentView).comment.id}
632 comment_view: i.data as CommentView,
637 viewType={CommentViewType.Flat}
641 enableDownvotes={enableDownvotes(this.state.siteRes)}
642 allLanguages={this.state.siteRes.all_languages}
643 siteLanguages={this.state.siteRes.discussion_languages}
646 {i.type_ === "communities" && (
647 <div>{communityListing(i.data as CommunityView)}</div>
649 {i.type_ === "users" && (
650 <div>{personListing(i.data as PersonView)}</div>
660 const { searchResponse, resolveObjectResponse, siteRes } = this.state;
661 const comments = searchResponse?.comments ?? [];
663 if (resolveObjectResponse?.comment) {
664 comments.unshift(resolveObjectResponse?.comment);
669 nodes={commentsToFlatNodes(comments)}
670 viewType={CommentViewType.Flat}
674 enableDownvotes={enableDownvotes(siteRes)}
675 allLanguages={siteRes.all_languages}
676 siteLanguages={siteRes.discussion_languages}
682 const { searchResponse, resolveObjectResponse, siteRes } = this.state;
683 const posts = searchResponse?.posts ?? [];
685 if (resolveObjectResponse?.post) {
686 posts.unshift(resolveObjectResponse.post);
692 <div key={pv.post.id} className="row">
693 <div className="col-12">
697 enableDownvotes={enableDownvotes(siteRes)}
698 enableNsfw={enableNsfw(siteRes)}
699 allLanguages={siteRes.all_languages}
700 siteLanguages={siteRes.discussion_languages}
711 const { searchResponse, resolveObjectResponse } = this.state;
712 const communities = searchResponse?.communities ?? [];
714 if (resolveObjectResponse?.community) {
715 communities.unshift(resolveObjectResponse.community);
720 {communities.map(cv => (
721 <div key={cv.community.id} className="row">
722 <div className="col-12">{communityListing(cv)}</div>
730 const { searchResponse, resolveObjectResponse } = this.state;
731 const users = searchResponse?.users ?? [];
733 if (resolveObjectResponse?.person) {
734 users.unshift(resolveObjectResponse.person);
740 <div key={pvs.person.id} className="row">
741 <div className="col-12">{personListing(pvs)}</div>
748 get resultsCount(): number {
749 const { searchResponse: r, resolveObjectResponse: resolveRes } = this.state;
751 const searchCount = r
754 r.communities.length +
758 const resObjCount = resolveRes
761 resolveRes.community ||
767 return resObjCount + searchCount;
771 const auth = myAuth(false);
772 const { searchText: q } = this.state;
773 const { communityId, creatorId, type, sort, listingType, page } =
774 getSearchQueryParams();
777 const form: SearchForm = {
779 community_id: communityId ?? undefined,
780 creator_id: creatorId ?? undefined,
783 listing_type: listingType,
790 const resolveObjectForm: ResolveObject = {
794 WebSocketService.Instance.send(
795 wsClient.resolveObject(resolveObjectForm)
800 searchResponse: undefined,
801 resolveObjectResponse: undefined,
805 WebSocketService.Instance.send(wsClient.search(form));
809 handleCreatorSearch = debounce(async (text: string) => {
810 const { creatorId } = getSearchQueryParams();
811 const { creatorSearchOptions } = this.state;
813 searchCreatorLoading: true,
816 const newOptions: Choice[] = [];
818 const selectedChoice = creatorSearchOptions.find(
819 choice => getIdFromString(choice.value) === creatorId
822 if (selectedChoice) {
823 newOptions.push(selectedChoice);
826 if (text.length > 0) {
827 newOptions.push(...(await fetchUsers(text)).users.map(personToChoice));
831 searchCreatorLoading: false,
832 creatorSearchOptions: newOptions,
836 handleCommunitySearch = debounce(async (text: string) => {
837 const { communityId } = getSearchQueryParams();
838 const { communitySearchOptions } = this.state;
840 searchCommunitiesLoading: true,
843 const newOptions: Choice[] = [];
845 const selectedChoice = communitySearchOptions.find(
846 choice => getIdFromString(choice.value) === communityId
849 if (selectedChoice) {
850 newOptions.push(selectedChoice);
853 if (text.length > 0) {
855 ...(await fetchCommunities(text)).communities.map(communityToChoice)
860 searchCommunitiesLoading: false,
861 communitySearchOptions: newOptions,
865 handleSortChange(sort: SortType) {
866 this.updateUrl({ sort, page: 1 });
869 handleTypeChange(i: Search, event: any) {
870 const type = event.target.value as SearchType;
878 handlePageChange(page: number) {
879 this.updateUrl({ page });
882 handleListingTypeChange(listingType: ListingType) {
889 handleCommunityFilterChange({ value }: Choice) {
891 communityId: getIdFromString(value) ?? null,
896 handleCreatorFilterChange({ value }: Choice) {
898 creatorId: getIdFromString(value) ?? null,
903 handleSearchSubmit(i: Search, event: any) {
904 event.preventDefault();
907 q: i.state.searchText,
912 handleQChange(i: Search, event: any) {
913 i.setState({ searchText: event.target.value });
924 }: Partial<SearchProps>) {
928 listingType: urlListingType,
929 communityId: urlCommunityId,
931 creatorId: urlCreatorId,
933 } = getSearchQueryParams();
935 let query = q ?? this.state.searchText ?? urlQ;
937 if (query && query.length > 0) {
938 query = encodeURIComponent(query);
941 const queryParams: QueryParams<SearchProps> = {
943 type: type ?? urlType,
944 listingType: listingType ?? urlListingType,
945 communityId: getUpdatedSearchId(communityId, urlCommunityId),
946 creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
947 page: (page ?? urlPage).toString(),
948 sort: sort ?? urlSort,
951 this.props.history.push(`/search${getQueryString(queryParams)}`);
956 parseMessage(msg: any) {
958 const op = wsUserOp(msg);
960 if (msg.error === "couldnt_find_object") {
962 resolveObjectResponse: {},
964 this.checkFinishedLoading();
966 toast(i18n.t(msg.error), "danger");
970 case UserOperation.Search: {
971 const searchResponse = wsJsonToRes<SearchResponse>(msg);
972 this.setState({ searchResponse });
973 window.scrollTo(0, 0);
974 this.checkFinishedLoading();
975 restoreScrollPosition(this.context);
980 case UserOperation.CreateCommentLike: {
981 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
982 createCommentLikeRes(
984 this.state.searchResponse?.comments
990 case UserOperation.CreatePostLike: {
991 const { post_view } = wsJsonToRes<PostResponse>(msg);
992 createPostLikeFindRes(post_view, this.state.searchResponse?.posts);
997 case UserOperation.ListCommunities: {
998 const { communities } = wsJsonToRes<ListCommunitiesResponse>(msg);
999 this.setState({ communities });
1004 case UserOperation.ResolveObject: {
1005 const resolveObjectResponse = wsJsonToRes<ResolveObjectResponse>(msg);
1006 this.setState({ resolveObjectResponse });
1007 this.checkFinishedLoading();
1015 checkFinishedLoading() {
1016 if (this.state.searchResponse || this.state.resolveObjectResponse) {
1017 this.setState({ searchLoading: false });