1 import { Component, linkEvent } from "inferno";
9 ListCommunitiesResponse,
15 ResolveObjectResponse,
22 } from "lemmy-js-client";
23 import { Subscription } from "rxjs";
24 import { InitialFetchRequest } from "shared/interfaces";
25 import { i18n } from "../i18next";
26 import { WebSocketService } from "../services";
29 capitalizeFirstLetter,
35 createPostLikeFindRes,
45 restoreScrollPosition,
46 routeListingTypeToEnum,
47 routeSearchTypeToEnum,
59 import { CommentNodes } from "./comment/comment-nodes";
60 import { HtmlTags } from "./common/html-tags";
61 import { Spinner } from "./common/icon";
62 import { ListingTypeSelect } from "./common/listing-type-select";
63 import { Paginator } from "./common/paginator";
64 import { SortSelect } from "./common/sort-select";
65 import { CommunityLink } from "./community/community-link";
66 import { PersonListing } from "./person/person-listing";
67 import { PostListing } from "./post/post-listing";
71 Choices = require("choices.js");
74 interface SearchProps {
78 listingType: ListingType;
84 interface SearchState {
88 listingType: ListingType;
92 searchResponse?: SearchResponse;
93 communities: CommunityView[];
94 creator?: PersonViewSafe;
98 resolveObjectResponse?: ResolveObjectResponse;
101 interface UrlParams {
105 listingType?: ListingType;
106 communityId?: number;
113 data: CommentView | PostView | CommunityView | PersonViewSafe;
117 export class Search extends Component<any, SearchState> {
118 private isoData = setIsoData(this.context);
119 private communityChoices: any;
120 private creatorChoices: any;
121 private subscription: Subscription;
122 private emptyState: SearchState = {
123 q: Search.getSearchQueryFromProps(this.props.match.params.q),
124 type_: Search.getSearchTypeFromProps(this.props.match.params.type),
125 sort: Search.getSortTypeFromProps(this.props.match.params.sort),
126 listingType: Search.getListingTypeFromProps(
127 this.props.match.params.listing_type
129 page: Search.getPageFromProps(this.props.match.params.page),
130 searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
131 communityId: Search.getCommunityIdFromProps(
132 this.props.match.params.community_id
134 creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
135 searchResponse: null,
136 resolveObjectResponse: null,
138 site: this.isoData.site_res.site_view.site,
142 static getSearchQueryFromProps(q: string): string {
143 return decodeURIComponent(q) || "";
146 static getSearchTypeFromProps(type_: string): SearchType {
147 return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
150 static getSortTypeFromProps(sort: string): SortType {
151 return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
154 static getListingTypeFromProps(listingType: string): ListingType {
155 return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
158 static getCommunityIdFromProps(id: string): number {
159 return id ? Number(id) : 0;
162 static getCreatorIdFromProps(id: string): number {
163 return id ? Number(id) : 0;
166 static getPageFromProps(page: string): number {
167 return page ? Number(page) : 1;
170 constructor(props: any, context: any) {
171 super(props, context);
173 this.state = this.emptyState;
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 singleOrMultipleCommunities = this.isoData.routeData[0];
184 if (singleOrMultipleCommunities.communities) {
185 this.state.communities = this.isoData.routeData[0].communities;
187 this.state.communities = [this.isoData.routeData[0].community_view];
190 let creator = this.isoData.routeData[1];
191 if (creator?.person_view) {
192 this.state.creator = this.isoData.routeData[1].person_view;
194 if (this.state.q != "") {
195 this.state.searchResponse = this.isoData.routeData[2];
196 this.state.resolveObjectResponse = this.isoData.routeData[3];
197 this.state.loading = false;
202 this.fetchCommunities();
207 componentWillUnmount() {
208 this.subscription.unsubscribe();
209 saveScrollPosition(this.context);
212 componentDidMount() {
213 this.setupCommunityFilter();
214 this.setupCreatorFilter();
217 static getDerivedStateFromProps(props: any): SearchProps {
219 q: Search.getSearchQueryFromProps(props.match.params.q),
220 type_: Search.getSearchTypeFromProps(props.match.params.type),
221 sort: Search.getSortTypeFromProps(props.match.params.sort),
222 listingType: Search.getListingTypeFromProps(
223 props.match.params.listing_type
225 communityId: Search.getCommunityIdFromProps(
226 props.match.params.community_id
228 creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
229 page: Search.getPageFromProps(props.match.params.page),
234 let listCommunitiesForm: ListCommunities = {
235 type_: ListingType.All,
236 sort: SortType.TopAll,
238 auth: authField(false),
240 WebSocketService.Instance.send(
241 wsClient.listCommunities(listCommunitiesForm)
245 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
246 let pathSplit = req.path.split("/");
247 let promises: Promise<any>[] = [];
249 let communityId = this.getCommunityIdFromProps(pathSplit[11]);
250 if (communityId !== 0) {
251 let getCommunityForm: GetCommunity = {
254 setOptionalAuth(getCommunityForm, req.auth);
255 promises.push(req.client.getCommunity(getCommunityForm));
257 let listCommunitiesForm: ListCommunities = {
258 type_: ListingType.All,
259 sort: SortType.TopAll,
262 setOptionalAuth(listCommunitiesForm, req.auth);
263 promises.push(req.client.listCommunities(listCommunitiesForm));
266 let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
267 if (creatorId !== 0) {
268 let getCreatorForm: GetPersonDetails = {
269 person_id: creatorId,
271 setOptionalAuth(getCreatorForm, req.auth);
272 promises.push(req.client.getPersonDetails(getCreatorForm));
274 promises.push(Promise.resolve());
277 let form: SearchForm = {
278 q: this.getSearchQueryFromProps(pathSplit[3]),
279 type_: this.getSearchTypeFromProps(pathSplit[5]),
280 sort: this.getSortTypeFromProps(pathSplit[7]),
281 listing_type: this.getListingTypeFromProps(pathSplit[9]),
282 page: this.getPageFromProps(pathSplit[15]),
285 if (communityId !== 0) {
286 form.community_id = communityId;
288 if (creatorId !== 0) {
289 form.creator_id = creatorId;
291 setOptionalAuth(form, req.auth);
293 let resolveObjectForm: ResolveObject = {
294 q: this.getSearchQueryFromProps(pathSplit[3]),
296 setOptionalAuth(resolveObjectForm, req.auth);
299 //this.state.loading = false;
300 //this.setState(this.state);
301 promises.push(req.client.search(form));
302 promises.push(req.client.resolveObject(resolveObjectForm));
308 componentDidUpdate(_: any, lastState: SearchState) {
310 lastState.q !== this.state.q ||
311 lastState.type_ !== this.state.type_ ||
312 lastState.sort !== this.state.sort ||
313 lastState.listingType !== this.state.listingType ||
314 lastState.communityId !== this.state.communityId ||
315 lastState.creatorId !== this.state.creatorId ||
316 lastState.page !== this.state.page
320 searchText: this.state.q,
321 searchResponse: null,
322 resolveObjectResponse: null,
328 get documentTitle(): string {
330 return `${i18n.t("search")} - ${this.state.q} - ${this.state.site.name}`;
332 return `${i18n.t("search")} - ${this.state.site.name}`;
338 <div class="container">
340 title={this.documentTitle}
341 path={this.context.router.route.match.url}
343 <h5>{i18n.t("search")}</h5>
346 {this.state.type_ == SearchType.All && this.all()}
347 {this.state.type_ == SearchType.Comments && this.comments()}
348 {this.state.type_ == SearchType.Posts && this.posts()}
349 {this.state.type_ == SearchType.Communities && this.communities()}
350 {this.state.type_ == SearchType.Users && this.users()}
351 {this.state.type_ == SearchType.Url && this.posts()}
352 {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
353 <Paginator page={this.state.page} onChange={this.handlePageChange} />
362 onSubmit={linkEvent(this, this.handleSearchSubmit)}
366 class="form-control mr-2 mb-2"
367 value={this.state.searchText}
368 placeholder={`${i18n.t("search")}...`}
369 aria-label={i18n.t("search")}
370 onInput={linkEvent(this, this.handleQChange)}
374 <button type="submit" class="btn btn-secondary mr-2 mb-2">
375 {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
383 <div className="mb-2">
385 value={this.state.type_}
386 onChange={linkEvent(this, this.handleTypeChange)}
387 class="custom-select w-auto mb-2"
388 aria-label={i18n.t("type")}
390 <option disabled aria-hidden="true">
393 <option value={SearchType.All}>{i18n.t("all")}</option>
394 <option value={SearchType.Comments}>{i18n.t("comments")}</option>
395 <option value={SearchType.Posts}>{i18n.t("posts")}</option>
396 <option value={SearchType.Communities}>
397 {i18n.t("communities")}
399 <option value={SearchType.Users}>{i18n.t("users")}</option>
400 <option value={SearchType.Url}>{i18n.t("url")}</option>
404 type_={this.state.listingType}
405 showLocal={showLocal(this.isoData)}
406 onChange={this.handleListingTypeChange}
411 sort={this.state.sort}
412 onChange={this.handleSortChange}
417 <div class="form-row">
418 {this.state.communities.length > 0 && this.communityFilter()}
419 {this.creatorFilter()}
425 postViewToCombined(postView: PostView): Combined {
429 published: postView.post.published,
433 commentViewToCombined(commentView: CommentView): Combined {
437 published: commentView.comment.published,
441 communityViewToCombined(communityView: CommunityView): Combined {
443 type_: "communities",
445 published: communityView.community.published,
449 personViewSafeToCombined(personViewSafe: PersonViewSafe): Combined {
452 data: personViewSafe,
453 published: personViewSafe.person.published,
457 buildCombined(): Combined[] {
458 let combined: Combined[] = [];
460 // Push the possible resolve / federated objects first
461 let resolveComment = this.state.resolveObjectResponse?.comment;
462 if (resolveComment) {
463 combined.push(this.commentViewToCombined(resolveComment));
465 let resolvePost = this.state.resolveObjectResponse?.post;
467 combined.push(this.postViewToCombined(resolvePost));
469 let resolveCommunity = this.state.resolveObjectResponse?.community;
470 if (resolveCommunity) {
471 combined.push(this.communityViewToCombined(resolveCommunity));
473 let resolveUser = this.state.resolveObjectResponse?.person;
475 combined.push(this.personViewSafeToCombined(resolveUser));
478 // Push the search results
481 this.state.searchResponse?.comments?.map(e =>
482 this.commentViewToCombined(e)
487 this.state.searchResponse?.posts?.map(e => this.postViewToCombined(e))
491 this.state.searchResponse?.communities?.map(e =>
492 this.communityViewToCombined(e)
497 this.state.searchResponse?.users?.map(e =>
498 this.personViewSafeToCombined(e)
503 if (this.state.sort == SortType.New) {
504 combined.sort((a, b) => b.published.localeCompare(a.published));
508 ((b.data as CommentView | PostView).counts.score |
509 (b.data as CommunityView).counts.subscribers |
510 (b.data as PersonViewSafe).counts.comment_score) -
511 ((a.data as CommentView | PostView).counts.score |
512 (a.data as CommunityView).counts.subscribers |
513 (a.data as PersonViewSafe).counts.comment_score)
520 let combined = this.buildCombined();
526 {i.type_ == "posts" && (
528 key={(i.data as PostView).post.id}
529 post_view={i.data as PostView}
531 enableDownvotes={this.state.site.enable_downvotes}
532 enableNsfw={this.state.site.enable_nsfw}
535 {i.type_ == "comments" && (
537 key={(i.data as CommentView).comment.id}
538 nodes={[{ comment_view: i.data as CommentView }]}
541 enableDownvotes={this.state.site.enable_downvotes}
544 {i.type_ == "communities" && (
545 <div>{this.communityListing(i.data as CommunityView)}</div>
547 {i.type_ == "users" && (
548 <div>{this.userListing(i.data as PersonViewSafe)}</div>
558 let comments: CommentView[] = [];
560 pushNotNull(comments, this.state.resolveObjectResponse?.comment);
561 pushNotNull(comments, this.state.searchResponse?.comments);
565 nodes={commentsToFlatNodes(comments)}
568 enableDownvotes={this.state.site.enable_downvotes}
574 let posts: PostView[] = [];
576 pushNotNull(posts, this.state.resolveObjectResponse?.post);
577 pushNotNull(posts, this.state.searchResponse?.posts);
587 enableDownvotes={this.state.site.enable_downvotes}
588 enableNsfw={this.state.site.enable_nsfw}
598 let communities: CommunityView[] = [];
600 pushNotNull(communities, this.state.resolveObjectResponse?.community);
601 pushNotNull(communities, this.state.searchResponse?.communities);
605 {communities.map(community => (
607 <div class="col-12">{this.communityListing(community)}</div>
615 let users: PersonViewSafe[] = [];
617 pushNotNull(users, this.state.resolveObjectResponse?.person);
618 pushNotNull(users, this.state.searchResponse?.users);
624 <div class="col-12">{this.userListing(user)}</div>
631 communityListing(community_view: CommunityView) {
635 <CommunityLink community={community_view.community} />
638 ${i18n.t("number_of_subscribers", {
639 count: community_view.counts.subscribers,
640 formattedCount: numToSI(community_view.counts.subscribers),
647 userListing(person_view: PersonViewSafe) {
650 <PersonListing person={person_view.person} showApubName />
652 <span>{` - ${i18n.t("number_of_comments", {
653 count: person_view.counts.comment_count,
654 formattedCount: numToSI(person_view.counts.comment_count),
661 <div class="form-group col-sm-6">
662 <label class="col-form-label" htmlFor="community-filter">
663 {i18n.t("community")}
668 id="community-filter"
669 value={this.state.communityId}
671 <option value="0">{i18n.t("all")}</option>
672 {this.state.communities.map(cv => (
673 <option value={cv.community.id}>{communitySelectName(cv)}</option>
683 <div class="form-group col-sm-6">
684 <label class="col-form-label" htmlFor="creator-filter">
685 {capitalizeFirstLetter(i18n.t("creator"))}
691 value={this.state.creatorId}
693 <option value="0">{i18n.t("all")}</option>
694 {this.state.creator && (
695 <option value={this.state.creator.person.id}>
696 {personSelectName(this.state.creator)}
705 resultsCount(): number {
706 let res = this.state.searchResponse;
707 let resObj = this.state.resolveObjectResponse;
709 resObj?.post || resObj?.person || resObj?.community || resObj?.comment
714 res?.comments?.length +
715 res?.communities?.length +
721 handlePageChange(page: number) {
722 this.updateUrl({ page });
726 let form: SearchForm = {
728 type_: this.state.type_,
729 sort: this.state.sort,
730 listing_type: this.state.listingType,
731 page: this.state.page,
733 auth: authField(false),
735 if (this.state.communityId !== 0) {
736 form.community_id = this.state.communityId;
738 if (this.state.creatorId !== 0) {
739 form.creator_id = this.state.creatorId;
742 let resolveObjectForm: ResolveObject = {
744 auth: authField(false),
747 if (this.state.q != "") {
748 this.state.searchResponse = null;
749 this.state.resolveObjectResponse = null;
750 this.state.loading = true;
751 this.setState(this.state);
752 WebSocketService.Instance.send(wsClient.search(form));
753 WebSocketService.Instance.send(wsClient.resolveObject(resolveObjectForm));
757 setupCommunityFilter() {
759 let selectId: any = document.getElementById("community-filter");
761 this.communityChoices = new Choices(selectId, choicesConfig);
762 this.communityChoices.passedElement.element.addEventListener(
765 this.handleCommunityFilterChange(Number(e.detail.choice.value));
769 this.communityChoices.passedElement.element.addEventListener(
771 debounce(async (e: any) => {
773 let communities = (await fetchCommunities(e.detail.value))
775 let choices = communities.map(cv => communityToChoice(cv));
776 choices.unshift({ value: "0", label: i18n.t("all") });
777 this.communityChoices.setChoices(choices, "value", "label", true);
788 setupCreatorFilter() {
790 let selectId: any = document.getElementById("creator-filter");
792 this.creatorChoices = new Choices(selectId, choicesConfig);
793 this.creatorChoices.passedElement.element.addEventListener(
796 this.handleCreatorFilterChange(Number(e.detail.choice.value));
800 this.creatorChoices.passedElement.element.addEventListener(
802 debounce(async (e: any) => {
804 let creators = (await fetchUsers(e.detail.value)).users;
805 let choices = creators.map(pvs => personToChoice(pvs));
806 choices.unshift({ value: "0", label: i18n.t("all") });
807 this.creatorChoices.setChoices(choices, "value", "label", true);
818 handleSortChange(val: SortType) {
819 this.updateUrl({ sort: val, page: 1 });
822 handleTypeChange(i: Search, event: any) {
824 type_: SearchType[event.target.value],
829 handleListingTypeChange(val: ListingType) {
836 handleCommunityFilterChange(communityId: number) {
843 handleCreatorFilterChange(creatorId: number) {
850 handleSearchSubmit(i: Search, event: any) {
851 event.preventDefault();
853 q: i.state.searchText,
854 type_: i.state.type_,
855 listingType: i.state.listingType,
856 communityId: i.state.communityId,
857 creatorId: i.state.creatorId,
863 handleQChange(i: Search, event: any) {
864 i.setState({ searchText: event.target.value });
867 updateUrl(paramUpdates: UrlParams) {
868 const qStr = paramUpdates.q || this.state.q;
869 const qStrEncoded = encodeURIComponent(qStr);
870 const typeStr = paramUpdates.type_ || this.state.type_;
871 const listingTypeStr = paramUpdates.listingType || this.state.listingType;
872 const sortStr = paramUpdates.sort || this.state.sort;
874 paramUpdates.communityId == 0
876 : paramUpdates.communityId || this.state.communityId;
878 paramUpdates.creatorId == 0
880 : paramUpdates.creatorId || this.state.creatorId;
881 const page = paramUpdates.page || this.state.page;
882 this.props.history.push(
883 `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
887 parseMessage(msg: any) {
889 let op = wsUserOp(msg);
891 if (msg.error == "couldnt_find_object") {
892 this.state.resolveObjectResponse = {
898 this.checkFinishedLoading();
900 toast(i18n.t(msg.error), "danger");
903 } else if (op == UserOperation.Search) {
904 let data = wsJsonToRes<SearchResponse>(msg).data;
905 this.state.searchResponse = data;
906 window.scrollTo(0, 0);
907 this.checkFinishedLoading();
908 restoreScrollPosition(this.context);
909 } else if (op == UserOperation.CreateCommentLike) {
910 let data = wsJsonToRes<CommentResponse>(msg).data;
911 createCommentLikeRes(
913 this.state.searchResponse?.comments
915 this.setState(this.state);
916 } else if (op == UserOperation.CreatePostLike) {
917 let data = wsJsonToRes<PostResponse>(msg).data;
918 createPostLikeFindRes(data.post_view, this.state.searchResponse?.posts);
919 this.setState(this.state);
920 } else if (op == UserOperation.ListCommunities) {
921 let data = wsJsonToRes<ListCommunitiesResponse>(msg).data;
922 this.state.communities = data.communities;
923 this.setState(this.state);
924 this.setupCommunityFilter();
925 } else if (op == UserOperation.ResolveObject) {
926 let data = wsJsonToRes<ResolveObjectResponse>(msg).data;
927 this.state.resolveObjectResponse = data;
928 this.checkFinishedLoading();
932 checkFinishedLoading() {
934 this.state.searchResponse != null &&
935 this.state.resolveObjectResponse != null
937 this.state.loading = false;
938 this.setState(this.state);