1 import { Component, linkEvent } from "inferno";
9 GetPersonDetailsResponse,
12 ListCommunitiesResponse,
18 ResolveObjectResponse,
26 } from "lemmy-js-client";
27 import { Subscription } from "rxjs";
28 import { i18n } from "../i18next";
29 import { CommentViewType, InitialFetchRequest } from "../interfaces";
30 import { WebSocketService } from "../services";
32 capitalizeFirstLetter,
38 createPostLikeFindRes,
51 restoreScrollPosition,
52 routeListingTypeToEnum,
53 routeSearchTypeToEnum,
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 { SortSelect } from "./common/sort-select";
68 import { CommunityLink } from "./community/community-link";
69 import { PersonListing } from "./person/person-listing";
70 import { PostListing } from "./post/post-listing";
74 Choices = require("choices.js");
77 interface SearchProps {
81 listingType: ListingType;
87 interface SearchState {
91 listingType: ListingType;
95 searchResponse?: SearchResponse;
96 communities: CommunityView[];
97 creatorDetails?: GetPersonDetailsResponse;
99 siteRes: GetSiteResponse;
101 resolveObjectResponse?: ResolveObjectResponse;
104 interface UrlParams {
108 listingType?: ListingType;
109 communityId?: number;
116 data: CommentView | PostView | CommunityView | PersonViewSafe;
120 export class Search extends Component<any, SearchState> {
121 private isoData = setIsoData(this.context);
122 private communityChoices: any;
123 private creatorChoices: any;
124 private subscription?: Subscription;
125 state: SearchState = {
126 q: Search.getSearchQueryFromProps(this.props.match.params.q),
127 type_: Search.getSearchTypeFromProps(this.props.match.params.type),
128 sort: Search.getSortTypeFromProps(this.props.match.params.sort),
129 listingType: Search.getListingTypeFromProps(
130 this.props.match.params.listing_type
132 page: Search.getPageFromProps(this.props.match.params.page),
133 searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
134 communityId: Search.getCommunityIdFromProps(
135 this.props.match.params.community_id
137 creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
139 siteRes: this.isoData.site_res,
143 static getSearchQueryFromProps(q?: string): string | undefined {
144 return q ? decodeURIComponent(q) : undefined;
147 static getSearchTypeFromProps(type_: string): SearchType {
148 return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
151 static getSortTypeFromProps(sort: string): SortType {
152 return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
155 static getListingTypeFromProps(listingType: string): ListingType {
156 return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
159 static getCommunityIdFromProps(id: string): number {
160 return id ? Number(id) : 0;
163 static getCreatorIdFromProps(id: string): number {
164 return id ? Number(id) : 0;
167 static getPageFromProps(page: string): number {
168 return page ? Number(page) : 1;
171 constructor(props: any, context: any) {
172 super(props, context);
174 this.handleSortChange = this.handleSortChange.bind(this);
175 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
176 this.handlePageChange = this.handlePageChange.bind(this);
178 this.parseMessage = this.parseMessage.bind(this);
179 this.subscription = wsSubscribe(this.parseMessage);
181 // Only fetch the data if coming from another route
182 if (this.isoData.path == this.context.router.route.match.url) {
183 let communityRes = this.isoData.routeData[0] as
184 | GetCommunityResponse
186 let communitiesRes = this.isoData.routeData[1] as
187 | ListCommunitiesResponse
189 // This can be single or multiple communities given
190 if (communitiesRes) {
193 communities: communitiesRes.communities,
200 communities: [communityRes.community_view],
206 creatorDetails: this.isoData.routeData[2] as GetPersonDetailsResponse,
209 if (this.state.q != "") {
212 searchResponse: this.isoData.routeData[3] as SearchResponse,
213 resolveObjectResponse: this.isoData
214 .routeData[4] as ResolveObjectResponse,
221 this.fetchCommunities();
229 componentWillUnmount() {
230 this.subscription?.unsubscribe();
231 saveScrollPosition(this.context);
234 componentDidMount() {
235 this.setupCommunityFilter();
236 this.setupCreatorFilter();
239 static getDerivedStateFromProps(props: any): SearchProps {
241 q: Search.getSearchQueryFromProps(props.match.params.q),
242 type_: Search.getSearchTypeFromProps(props.match.params.type),
243 sort: Search.getSortTypeFromProps(props.match.params.sort),
244 listingType: Search.getListingTypeFromProps(
245 props.match.params.listing_type
247 communityId: Search.getCommunityIdFromProps(
248 props.match.params.community_id
250 creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
251 page: Search.getPageFromProps(props.match.params.page),
256 let listCommunitiesForm: ListCommunities = {
257 type_: ListingType.All,
258 sort: SortType.TopAll,
262 WebSocketService.Instance.send(
263 wsClient.listCommunities(listCommunitiesForm)
267 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
268 let pathSplit = req.path.split("/");
269 let promises: Promise<any>[] = [];
272 let communityId = this.getCommunityIdFromProps(pathSplit[11]);
273 let community_id = communityId == 0 ? undefined : communityId;
275 let getCommunityForm: GetCommunity = {
279 promises.push(req.client.getCommunity(getCommunityForm));
280 promises.push(Promise.resolve());
282 let listCommunitiesForm: ListCommunities = {
283 type_: ListingType.All,
284 sort: SortType.TopAll,
288 promises.push(Promise.resolve());
289 promises.push(req.client.listCommunities(listCommunitiesForm));
292 let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
293 let creator_id = creatorId == 0 ? undefined : creatorId;
295 let getCreatorForm: GetPersonDetails = {
296 person_id: creator_id,
299 promises.push(req.client.getPersonDetails(getCreatorForm));
301 promises.push(Promise.resolve());
304 let q = this.getSearchQueryFromProps(pathSplit[3]);
307 let form: SearchForm = {
311 type_: this.getSearchTypeFromProps(pathSplit[5]),
312 sort: this.getSortTypeFromProps(pathSplit[7]),
313 listing_type: this.getListingTypeFromProps(pathSplit[9]),
314 page: this.getPageFromProps(pathSplit[15]),
319 let resolveObjectForm: ResolveObject = {
325 promises.push(req.client.search(form));
326 promises.push(req.client.resolveObject(resolveObjectForm));
328 promises.push(Promise.resolve());
329 promises.push(Promise.resolve());
336 componentDidUpdate(_: any, lastState: SearchState) {
338 lastState.q !== this.state.q ||
339 lastState.type_ !== this.state.type_ ||
340 lastState.sort !== this.state.sort ||
341 lastState.listingType !== this.state.listingType ||
342 lastState.communityId !== this.state.communityId ||
343 lastState.creatorId !== this.state.creatorId ||
344 lastState.page !== this.state.page
349 searchText: this.state.q,
356 get documentTitle(): string {
357 let siteName = this.state.siteRes.site_view.site.name;
359 ? `${i18n.t("search")} - ${this.state.q} - ${siteName}`
360 : `${i18n.t("search")} - ${siteName}`;
365 <div className="container-lg">
367 title={this.documentTitle}
368 path={this.context.router.route.match.url}
370 <h5>{i18n.t("search")}</h5>
373 {this.state.type_ == SearchType.All && this.all()}
374 {this.state.type_ == SearchType.Comments && this.comments()}
375 {this.state.type_ == SearchType.Posts && this.posts()}
376 {this.state.type_ == SearchType.Communities && this.communities()}
377 {this.state.type_ == SearchType.Users && this.users()}
378 {this.state.type_ == SearchType.Url && this.posts()}
379 {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
380 <Paginator page={this.state.page} onChange={this.handlePageChange} />
388 className="form-inline"
389 onSubmit={linkEvent(this, this.handleSearchSubmit)}
393 className="form-control mr-2 mb-2"
394 value={this.state.searchText}
395 placeholder={`${i18n.t("search")}...`}
396 aria-label={i18n.t("search")}
397 onInput={linkEvent(this, this.handleQChange)}
401 <button type="submit" className="btn btn-secondary mr-2 mb-2">
402 {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
410 <div className="mb-2">
412 value={this.state.type_}
413 onChange={linkEvent(this, this.handleTypeChange)}
414 className="custom-select w-auto mb-2"
415 aria-label={i18n.t("type")}
417 <option disabled aria-hidden="true">
420 <option value={SearchType.All}>{i18n.t("all")}</option>
421 <option value={SearchType.Comments}>{i18n.t("comments")}</option>
422 <option value={SearchType.Posts}>{i18n.t("posts")}</option>
423 <option value={SearchType.Communities}>
424 {i18n.t("communities")}
426 <option value={SearchType.Users}>{i18n.t("users")}</option>
427 <option value={SearchType.Url}>{i18n.t("url")}</option>
429 <span className="ml-2">
431 type_={this.state.listingType}
432 showLocal={showLocal(this.isoData)}
434 onChange={this.handleListingTypeChange}
437 <span className="ml-2">
439 sort={this.state.sort}
440 onChange={this.handleSortChange}
445 <div className="form-row">
446 {this.state.communities.length > 0 && this.communityFilter()}
447 {this.creatorFilter()}
453 postViewToCombined(postView: PostView): Combined {
457 published: postView.post.published,
461 commentViewToCombined(commentView: CommentView): Combined {
465 published: commentView.comment.published,
469 communityViewToCombined(communityView: CommunityView): Combined {
471 type_: "communities",
473 published: communityView.community.published,
477 personViewSafeToCombined(personViewSafe: PersonViewSafe): Combined {
480 data: personViewSafe,
481 published: personViewSafe.person.published,
485 buildCombined(): Combined[] {
486 let combined: Combined[] = [];
488 let resolveRes = this.state.resolveObjectResponse;
489 // Push the possible resolve / federated objects first
491 let resolveComment = resolveRes.comment;
492 if (resolveComment) {
493 combined.push(this.commentViewToCombined(resolveComment));
495 let resolvePost = resolveRes.post;
497 combined.push(this.postViewToCombined(resolvePost));
499 let resolveCommunity = resolveRes.community;
500 if (resolveCommunity) {
501 combined.push(this.communityViewToCombined(resolveCommunity));
503 let resolveUser = resolveRes.person;
505 combined.push(this.personViewSafeToCombined(resolveUser));
509 // Push the search results
510 let searchRes = this.state.searchResponse;
514 searchRes.comments?.map(e => this.commentViewToCombined(e))
518 searchRes.posts?.map(e => this.postViewToCombined(e))
522 searchRes.communities?.map(e => this.communityViewToCombined(e))
526 searchRes.users?.map(e => this.personViewSafeToCombined(e))
531 if (this.state.sort == SortType.New) {
532 combined.sort((a, b) => b.published.localeCompare(a.published));
536 ((b.data as CommentView | PostView).counts.score |
537 (b.data as CommunityView).counts.subscribers |
538 (b.data as PersonViewSafe).counts.comment_score) -
539 ((a.data as CommentView | PostView).counts.score |
540 (a.data as CommunityView).counts.subscribers |
541 (a.data as PersonViewSafe).counts.comment_score)
548 let combined = this.buildCombined();
552 <div key={i.published} className="row">
553 <div className="col-12">
554 {i.type_ == "posts" && (
556 key={(i.data as PostView).post.id}
557 post_view={i.data as PostView}
559 enableDownvotes={enableDownvotes(this.state.siteRes)}
560 enableNsfw={enableNsfw(this.state.siteRes)}
561 allLanguages={this.state.siteRes.all_languages}
562 siteLanguages={this.state.siteRes.discussion_languages}
566 {i.type_ == "comments" && (
568 key={(i.data as CommentView).comment.id}
571 comment_view: i.data as CommentView,
576 viewType={CommentViewType.Flat}
580 enableDownvotes={enableDownvotes(this.state.siteRes)}
581 allLanguages={this.state.siteRes.all_languages}
582 siteLanguages={this.state.siteRes.discussion_languages}
585 {i.type_ == "communities" && (
586 <div>{this.communityListing(i.data as CommunityView)}</div>
588 {i.type_ == "users" && (
589 <div>{this.personListing(i.data as PersonViewSafe)}</div>
599 let comments: CommentView[] = [];
600 pushNotNull(comments, this.state.resolveObjectResponse?.comment);
601 pushNotNull(comments, this.state.searchResponse?.comments);
605 nodes={commentsToFlatNodes(comments)}
606 viewType={CommentViewType.Flat}
610 enableDownvotes={enableDownvotes(this.state.siteRes)}
611 allLanguages={this.state.siteRes.all_languages}
612 siteLanguages={this.state.siteRes.discussion_languages}
618 let posts: PostView[] = [];
620 pushNotNull(posts, this.state.resolveObjectResponse?.post);
621 pushNotNull(posts, this.state.searchResponse?.posts);
626 <div key={pv.post.id} className="row">
627 <div className="col-12">
631 enableDownvotes={enableDownvotes(this.state.siteRes)}
632 enableNsfw={enableNsfw(this.state.siteRes)}
633 allLanguages={this.state.siteRes.all_languages}
634 siteLanguages={this.state.siteRes.discussion_languages}
645 let communities: CommunityView[] = [];
647 pushNotNull(communities, this.state.resolveObjectResponse?.community);
648 pushNotNull(communities, this.state.searchResponse?.communities);
652 {communities.map(cv => (
653 <div key={cv.community.id} className="row">
654 <div className="col-12">{this.communityListing(cv)}</div>
662 let users: PersonViewSafe[] = [];
664 pushNotNull(users, this.state.resolveObjectResponse?.person);
665 pushNotNull(users, this.state.searchResponse?.users);
670 <div key={pvs.person.id} className="row">
671 <div className="col-12">{this.personListing(pvs)}</div>
678 communityListing(community_view: CommunityView) {
682 <CommunityLink community={community_view.community} />
685 ${i18n.t("number_of_subscribers", {
686 count: community_view.counts.subscribers,
687 formattedCount: numToSI(community_view.counts.subscribers),
694 personListing(person_view: PersonViewSafe) {
698 <PersonListing person={person_view.person} showApubName />
700 <span>{` - ${i18n.t("number_of_comments", {
701 count: person_view.counts.comment_count,
702 formattedCount: numToSI(person_view.counts.comment_count),
710 <div className="form-group col-sm-6">
711 <label className="col-form-label" htmlFor="community-filter">
712 {i18n.t("community")}
716 className="form-control"
717 id="community-filter"
718 value={this.state.communityId}
720 <option value="0">{i18n.t("all")}</option>
721 {this.state.communities.map(cv => (
722 <option key={cv.community.id} value={cv.community.id}>
723 {communitySelectName(cv)}
733 let creatorPv = this.state.creatorDetails?.person_view;
735 <div className="form-group col-sm-6">
736 <label className="col-form-label" htmlFor="creator-filter">
737 {capitalizeFirstLetter(i18n.t("creator"))}
741 className="form-control"
743 value={this.state.creatorId}
745 <option value="0">{i18n.t("all")}</option>
747 <option value={creatorPv.person.id}>
748 {personSelectName(creatorPv)}
757 resultsCount(): number {
758 let r = this.state.searchResponse;
763 r.communities?.length +
767 let resolveRes = this.state.resolveObjectResponse;
768 let resObjCount = resolveRes
771 resolveRes.community ||
777 return resObjCount + searchCount;
780 handlePageChange(page: number) {
781 this.updateUrl({ page });
786 this.state.communityId == 0 ? undefined : this.state.communityId;
788 this.state.creatorId == 0 ? undefined : this.state.creatorId;
790 let auth = myAuth(false);
791 if (this.state.q && this.state.q != "") {
792 let form: SearchForm = {
796 type_: this.state.type_,
797 sort: this.state.sort,
798 listing_type: this.state.listingType,
799 page: this.state.page,
804 let resolveObjectForm: ResolveObject = {
810 searchResponse: undefined,
811 resolveObjectResponse: undefined,
814 WebSocketService.Instance.send(wsClient.search(form));
815 WebSocketService.Instance.send(wsClient.resolveObject(resolveObjectForm));
819 setupCommunityFilter() {
821 let selectId: any = document.getElementById("community-filter");
823 this.communityChoices = new Choices(selectId, choicesConfig);
824 this.communityChoices.passedElement.element.addEventListener(
827 this.handleCommunityFilterChange(Number(e.detail.choice.value));
831 this.communityChoices.passedElement.element.addEventListener(
833 debounce(async (e: any) => {
835 let communities = (await fetchCommunities(e.detail.value))
837 let choices = communities.map(cv => communityToChoice(cv));
838 choices.unshift({ value: "0", label: i18n.t("all") });
839 this.communityChoices.setChoices(choices, "value", "label", true);
850 setupCreatorFilter() {
852 let selectId: any = document.getElementById("creator-filter");
854 this.creatorChoices = new Choices(selectId, choicesConfig);
855 this.creatorChoices.passedElement.element.addEventListener(
858 this.handleCreatorFilterChange(Number(e.detail.choice.value));
862 this.creatorChoices.passedElement.element.addEventListener(
864 debounce(async (e: any) => {
866 let creators = (await fetchUsers(e.detail.value)).users;
867 let choices = creators.map(pvs => personToChoice(pvs));
868 choices.unshift({ value: "0", label: i18n.t("all") });
869 this.creatorChoices.setChoices(choices, "value", "label", true);
880 handleSortChange(val: SortType) {
881 this.updateUrl({ sort: val, page: 1 });
884 handleTypeChange(i: Search, event: any) {
886 type_: SearchType[event.target.value],
891 handleListingTypeChange(val: ListingType) {
898 handleCommunityFilterChange(communityId: number) {
905 handleCreatorFilterChange(creatorId: number) {
912 handleSearchSubmit(i: Search, event: any) {
913 event.preventDefault();
915 q: i.state.searchText,
916 type_: i.state.type_,
917 listingType: i.state.listingType,
918 communityId: i.state.communityId,
919 creatorId: i.state.creatorId,
925 handleQChange(i: Search, event: any) {
926 i.setState({ searchText: event.target.value });
929 updateUrl(paramUpdates: UrlParams) {
930 const qStr = paramUpdates.q || this.state.q;
931 const qStrEncoded = encodeURIComponent(qStr || "");
932 const typeStr = paramUpdates.type_ || this.state.type_;
933 const listingTypeStr = paramUpdates.listingType || this.state.listingType;
934 const sortStr = paramUpdates.sort || this.state.sort;
936 paramUpdates.communityId == 0
938 : paramUpdates.communityId || this.state.communityId;
940 paramUpdates.creatorId == 0
942 : paramUpdates.creatorId || this.state.creatorId;
943 const page = paramUpdates.page || this.state.page;
944 this.props.history.push(
945 `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
949 parseMessage(msg: any) {
951 let op = wsUserOp(msg);
953 if (msg.error == "couldnt_find_object") {
955 resolveObjectResponse: {},
957 this.checkFinishedLoading();
959 toast(i18n.t(msg.error), "danger");
962 } else if (op == UserOperation.Search) {
963 let data = wsJsonToRes<SearchResponse>(msg);
964 this.setState({ searchResponse: data });
965 window.scrollTo(0, 0);
966 this.checkFinishedLoading();
967 restoreScrollPosition(this.context);
968 } else if (op == UserOperation.CreateCommentLike) {
969 let data = wsJsonToRes<CommentResponse>(msg);
970 createCommentLikeRes(
972 this.state.searchResponse?.comments
974 this.setState(this.state);
975 } else if (op == UserOperation.CreatePostLike) {
976 let data = wsJsonToRes<PostResponse>(msg);
977 createPostLikeFindRes(data.post_view, this.state.searchResponse?.posts);
978 this.setState(this.state);
979 } else if (op == UserOperation.ListCommunities) {
980 let data = wsJsonToRes<ListCommunitiesResponse>(msg);
981 this.setState({ communities: data.communities });
982 this.setupCommunityFilter();
983 } else if (op == UserOperation.ResolveObject) {
984 let data = wsJsonToRes<ResolveObjectResponse>(msg);
985 this.setState({ resolveObjectResponse: data });
986 this.checkFinishedLoading();
990 checkFinishedLoading() {
991 if (this.state.searchResponse && this.state.resolveObjectResponse) {
992 this.setState({ loading: false });