1 import { None, Option, Some } from "@sniptt/monads";
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";
34 capitalizeFirstLetter,
40 createPostLikeFindRes,
52 restoreScrollPosition,
53 routeListingTypeToEnum,
54 routeSearchTypeToEnum,
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 { 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";
75 Choices = require("choices.js");
78 interface SearchProps {
82 listingType: ListingType;
88 interface SearchState {
92 listingType: ListingType;
96 searchResponse: Option<SearchResponse>;
97 communities: CommunityView[];
98 creatorDetails: Option<GetPersonDetailsResponse>;
100 siteRes: GetSiteResponse;
102 resolveObjectResponse: Option<ResolveObjectResponse>;
105 interface UrlParams {
109 listingType?: ListingType;
110 communityId?: number;
117 data: CommentView | PostView | CommunityView | PersonViewSafe;
121 export class Search extends Component<any, SearchState> {
122 private isoData = setIsoData(
124 GetCommunityResponse,
125 ListCommunitiesResponse,
126 GetPersonDetailsResponse,
128 ResolveObjectResponse
130 private communityChoices: any;
131 private creatorChoices: any;
132 private subscription: Subscription;
133 private emptyState: SearchState = {
134 q: Search.getSearchQueryFromProps(this.props.match.params.q),
135 type_: Search.getSearchTypeFromProps(this.props.match.params.type),
136 sort: Search.getSortTypeFromProps(this.props.match.params.sort),
137 listingType: Search.getListingTypeFromProps(
138 this.props.match.params.listing_type
140 page: Search.getPageFromProps(this.props.match.params.page),
141 searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
142 communityId: Search.getCommunityIdFromProps(
143 this.props.match.params.community_id
145 creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
146 searchResponse: None,
147 resolveObjectResponse: None,
148 creatorDetails: None,
150 siteRes: this.isoData.site_res,
154 static getSearchQueryFromProps(q: string): string {
155 return decodeURIComponent(q) || "";
158 static getSearchTypeFromProps(type_: string): SearchType {
159 return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
162 static getSortTypeFromProps(sort: string): SortType {
163 return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
166 static getListingTypeFromProps(listingType: string): ListingType {
167 return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
170 static getCommunityIdFromProps(id: string): number {
171 return id ? Number(id) : 0;
174 static getCreatorIdFromProps(id: string): number {
175 return id ? Number(id) : 0;
178 static getPageFromProps(page: string): number {
179 return page ? Number(page) : 1;
182 constructor(props: any, context: any) {
183 super(props, context);
185 this.state = this.emptyState;
186 this.handleSortChange = this.handleSortChange.bind(this);
187 this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
188 this.handlePageChange = this.handlePageChange.bind(this);
190 this.parseMessage = this.parseMessage.bind(this);
191 this.subscription = wsSubscribe(this.parseMessage);
193 // Only fetch the data if coming from another route
194 if (this.isoData.path == this.context.router.route.match.url) {
195 let communityRes = Some(
196 this.isoData.routeData[0] as GetCommunityResponse
198 let communitiesRes = Some(
199 this.isoData.routeData[1] as ListCommunitiesResponse
202 // This can be single or multiple communities given
203 if (communitiesRes.isSome()) {
206 communities: communitiesRes.unwrap().communities,
210 if (communityRes.isSome()) {
213 communities: [communityRes.unwrap().community_view],
219 creatorDetails: Some(
220 this.isoData.routeData[2] as GetPersonDetailsResponse
224 if (this.state.q != "") {
227 searchResponse: Some(this.isoData.routeData[3] as SearchResponse),
228 resolveObjectResponse: Some(
229 this.isoData.routeData[4] as ResolveObjectResponse
237 this.fetchCommunities();
242 componentWillUnmount() {
243 this.subscription.unsubscribe();
244 saveScrollPosition(this.context);
247 componentDidMount() {
248 this.setupCommunityFilter();
249 this.setupCreatorFilter();
252 static getDerivedStateFromProps(props: any): SearchProps {
254 q: Search.getSearchQueryFromProps(props.match.params.q),
255 type_: Search.getSearchTypeFromProps(props.match.params.type),
256 sort: Search.getSortTypeFromProps(props.match.params.sort),
257 listingType: Search.getListingTypeFromProps(
258 props.match.params.listing_type
260 communityId: Search.getCommunityIdFromProps(
261 props.match.params.community_id
263 creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
264 page: Search.getPageFromProps(props.match.params.page),
269 let listCommunitiesForm = new ListCommunities({
270 type_: Some(ListingType.All),
271 sort: Some(SortType.TopAll),
272 limit: Some(fetchLimit),
274 auth: auth(false).ok(),
276 WebSocketService.Instance.send(
277 wsClient.listCommunities(listCommunitiesForm)
281 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
282 let pathSplit = req.path.split("/");
283 let promises: Promise<any>[] = [];
285 let communityId = this.getCommunityIdFromProps(pathSplit[11]);
286 let community_id: Option<number> =
287 communityId == 0 ? None : Some(communityId);
290 let getCommunityForm = new GetCommunity({
295 promises.push(req.client.getCommunity(getCommunityForm));
296 promises.push(Promise.resolve());
299 let listCommunitiesForm = new ListCommunities({
300 type_: Some(ListingType.All),
301 sort: Some(SortType.TopAll),
302 limit: Some(fetchLimit),
306 promises.push(Promise.resolve());
307 promises.push(req.client.listCommunities(listCommunitiesForm));
311 let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
312 let creator_id: Option<number> = creatorId == 0 ? None : Some(creatorId);
315 let getCreatorForm = new GetPersonDetails({
325 promises.push(req.client.getPersonDetails(getCreatorForm));
328 promises.push(Promise.resolve());
332 let form = new SearchForm({
333 q: this.getSearchQueryFromProps(pathSplit[3]),
335 community_name: None,
337 type_: Some(this.getSearchTypeFromProps(pathSplit[5])),
338 sort: Some(this.getSortTypeFromProps(pathSplit[7])),
339 listing_type: Some(this.getListingTypeFromProps(pathSplit[9])),
340 page: Some(this.getPageFromProps(pathSplit[15])),
341 limit: Some(fetchLimit),
345 let resolveObjectForm = new ResolveObject({
346 q: this.getSearchQueryFromProps(pathSplit[3]),
351 promises.push(req.client.search(form));
352 promises.push(req.client.resolveObject(resolveObjectForm));
354 promises.push(Promise.resolve());
355 promises.push(Promise.resolve());
361 componentDidUpdate(_: any, lastState: SearchState) {
363 lastState.q !== this.state.q ||
364 lastState.type_ !== this.state.type_ ||
365 lastState.sort !== this.state.sort ||
366 lastState.listingType !== this.state.listingType ||
367 lastState.communityId !== this.state.communityId ||
368 lastState.creatorId !== this.state.creatorId ||
369 lastState.page !== this.state.page
373 searchText: this.state.q,
374 searchResponse: None,
375 resolveObjectResponse: None,
381 get documentTitle(): string {
382 let siteName = this.state.siteRes.site_view.site.name;
384 ? `${i18n.t("search")} - ${this.state.q} - ${siteName}`
385 : `${i18n.t("search")} - ${siteName}`;
390 <div className="container-lg">
392 title={this.documentTitle}
393 path={this.context.router.route.match.url}
397 <h5>{i18n.t("search")}</h5>
400 {this.state.type_ == SearchType.All && this.all()}
401 {this.state.type_ == SearchType.Comments && this.comments()}
402 {this.state.type_ == SearchType.Posts && this.posts()}
403 {this.state.type_ == SearchType.Communities && this.communities()}
404 {this.state.type_ == SearchType.Users && this.users()}
405 {this.state.type_ == SearchType.Url && this.posts()}
406 {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
407 <Paginator page={this.state.page} onChange={this.handlePageChange} />
415 className="form-inline"
416 onSubmit={linkEvent(this, this.handleSearchSubmit)}
420 className="form-control mr-2 mb-2"
421 value={this.state.searchText}
422 placeholder={`${i18n.t("search")}...`}
423 aria-label={i18n.t("search")}
424 onInput={linkEvent(this, this.handleQChange)}
428 <button type="submit" className="btn btn-secondary mr-2 mb-2">
429 {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
437 <div className="mb-2">
439 value={this.state.type_}
440 onChange={linkEvent(this, this.handleTypeChange)}
441 className="custom-select w-auto mb-2"
442 aria-label={i18n.t("type")}
444 <option disabled aria-hidden="true">
447 <option value={SearchType.All}>{i18n.t("all")}</option>
448 <option value={SearchType.Comments}>{i18n.t("comments")}</option>
449 <option value={SearchType.Posts}>{i18n.t("posts")}</option>
450 <option value={SearchType.Communities}>
451 {i18n.t("communities")}
453 <option value={SearchType.Users}>{i18n.t("users")}</option>
454 <option value={SearchType.Url}>{i18n.t("url")}</option>
456 <span className="ml-2">
458 type_={this.state.listingType}
459 showLocal={showLocal(this.isoData)}
461 onChange={this.handleListingTypeChange}
464 <span className="ml-2">
466 sort={this.state.sort}
467 onChange={this.handleSortChange}
472 <div className="form-row">
473 {this.state.communities.length > 0 && this.communityFilter()}
474 {this.creatorFilter()}
480 postViewToCombined(postView: PostView): Combined {
484 published: postView.post.published,
488 commentViewToCombined(commentView: CommentView): Combined {
492 published: commentView.comment.published,
496 communityViewToCombined(communityView: CommunityView): Combined {
498 type_: "communities",
500 published: communityView.community.published,
504 personViewSafeToCombined(personViewSafe: PersonViewSafe): Combined {
507 data: personViewSafe,
508 published: personViewSafe.person.published,
512 buildCombined(): Combined[] {
513 let combined: Combined[] = [];
515 // Push the possible resolve / federated objects first
516 this.state.resolveObjectResponse.match({
518 let resolveComment = res.comment;
519 if (resolveComment.isSome()) {
520 combined.push(this.commentViewToCombined(resolveComment.unwrap()));
522 let resolvePost = res.post;
523 if (resolvePost.isSome()) {
524 combined.push(this.postViewToCombined(resolvePost.unwrap()));
526 let resolveCommunity = res.community;
527 if (resolveCommunity.isSome()) {
529 this.communityViewToCombined(resolveCommunity.unwrap())
532 let resolveUser = res.person;
533 if (resolveUser.isSome()) {
534 combined.push(this.personViewSafeToCombined(resolveUser.unwrap()));
540 // Push the search results
541 this.state.searchResponse.match({
545 res.comments?.map(e => this.commentViewToCombined(e))
549 res.posts?.map(e => this.postViewToCombined(e))
553 res.communities?.map(e => this.communityViewToCombined(e))
557 res.users?.map(e => this.personViewSafeToCombined(e))
564 if (this.state.sort == SortType.New) {
565 combined.sort((a, b) => b.published.localeCompare(a.published));
569 ((b.data as CommentView | PostView).counts.score |
570 (b.data as CommunityView).counts.subscribers |
571 (b.data as PersonViewSafe).counts.comment_score) -
572 ((a.data as CommentView | PostView).counts.score |
573 (a.data as CommunityView).counts.subscribers |
574 (a.data as PersonViewSafe).counts.comment_score)
581 let combined = this.buildCombined();
585 <div key={i.published} className="row">
586 <div className="col-12">
587 {i.type_ == "posts" && (
589 key={(i.data as PostView).post.id}
590 post_view={i.data as PostView}
595 enableDownvotes={enableDownvotes(this.state.siteRes)}
596 enableNsfw={enableNsfw(this.state.siteRes)}
597 allLanguages={this.state.siteRes.all_languages}
598 siteLanguages={this.state.siteRes.discussion_languages}
602 {i.type_ == "comments" && (
604 key={(i.data as CommentView).comment.id}
607 comment_view: i.data as CommentView,
612 viewType={CommentViewType.Flat}
616 maxCommentsShown={None}
619 enableDownvotes={enableDownvotes(this.state.siteRes)}
620 allLanguages={this.state.siteRes.all_languages}
621 siteLanguages={this.state.siteRes.discussion_languages}
624 {i.type_ == "communities" && (
625 <div>{this.communityListing(i.data as CommunityView)}</div>
627 {i.type_ == "users" && (
628 <div>{this.personListing(i.data as PersonViewSafe)}</div>
638 let comments: CommentView[] = [];
640 this.state.resolveObjectResponse.match({
641 some: res => pushNotNull(comments, res.comment),
644 this.state.searchResponse.match({
645 some: res => pushNotNull(comments, res.comments),
651 nodes={commentsToFlatNodes(comments)}
652 viewType={CommentViewType.Flat}
658 maxCommentsShown={None}
659 enableDownvotes={enableDownvotes(this.state.siteRes)}
660 allLanguages={this.state.siteRes.all_languages}
661 siteLanguages={this.state.siteRes.discussion_languages}
667 let posts: PostView[] = [];
669 this.state.resolveObjectResponse.match({
670 some: res => pushNotNull(posts, res.post),
673 this.state.searchResponse.match({
674 some: res => pushNotNull(posts, res.posts),
681 <div key={pv.post.id} className="row">
682 <div className="col-12">
689 enableDownvotes={enableDownvotes(this.state.siteRes)}
690 enableNsfw={enableNsfw(this.state.siteRes)}
691 allLanguages={this.state.siteRes.all_languages}
692 siteLanguages={this.state.siteRes.discussion_languages}
703 let communities: CommunityView[] = [];
705 this.state.resolveObjectResponse.match({
706 some: res => pushNotNull(communities, res.community),
709 this.state.searchResponse.match({
710 some: res => pushNotNull(communities, res.communities),
716 {communities.map(cv => (
717 <div key={cv.community.id} className="row">
718 <div className="col-12">{this.communityListing(cv)}</div>
726 let users: PersonViewSafe[] = [];
728 this.state.resolveObjectResponse.match({
729 some: res => pushNotNull(users, res.person),
732 this.state.searchResponse.match({
733 some: res => pushNotNull(users, res.users),
740 <div key={pvs.person.id} className="row">
741 <div className="col-12">{this.personListing(pvs)}</div>
748 communityListing(community_view: CommunityView) {
752 <CommunityLink community={community_view.community} />
755 ${i18n.t("number_of_subscribers", {
756 count: community_view.counts.subscribers,
757 formattedCount: numToSI(community_view.counts.subscribers),
764 personListing(person_view: PersonViewSafe) {
768 <PersonListing person={person_view.person} showApubName />
770 <span>{` - ${i18n.t("number_of_comments", {
771 count: person_view.counts.comment_count,
772 formattedCount: numToSI(person_view.counts.comment_count),
780 <div className="form-group col-sm-6">
781 <label className="col-form-label" htmlFor="community-filter">
782 {i18n.t("community")}
786 className="form-control"
787 id="community-filter"
788 value={this.state.communityId}
790 <option value="0">{i18n.t("all")}</option>
791 {this.state.communities.map(cv => (
792 <option key={cv.community.id} value={cv.community.id}>
793 {communitySelectName(cv)}
804 <div className="form-group col-sm-6">
805 <label className="col-form-label" htmlFor="creator-filter">
806 {capitalizeFirstLetter(i18n.t("creator"))}
810 className="form-control"
812 value={this.state.creatorId}
814 <option value="0">{i18n.t("all")}</option>
815 {this.state.creatorDetails.match({
817 <option value={creator.person_view.person.id}>
818 {personSelectName(creator.person_view)}
829 resultsCount(): number {
830 let searchCount = this.state.searchResponse
835 r.communities?.length +
840 let resObjCount = this.state.resolveObjectResponse
841 .map(r => (r.post || r.person || r.community || r.comment ? 1 : 0))
844 return resObjCount + searchCount;
847 handlePageChange(page: number) {
848 this.updateUrl({ page });
852 let community_id: Option<number> =
853 this.state.communityId == 0 ? None : Some(this.state.communityId);
854 let creator_id: Option<number> =
855 this.state.creatorId == 0 ? None : Some(this.state.creatorId);
857 let form = new SearchForm({
860 community_name: None,
862 type_: Some(this.state.type_),
863 sort: Some(this.state.sort),
864 listing_type: Some(this.state.listingType),
865 page: Some(this.state.page),
866 limit: Some(fetchLimit),
867 auth: auth(false).ok(),
870 let resolveObjectForm = new ResolveObject({
872 auth: auth(false).ok(),
875 if (this.state.q != "") {
877 searchResponse: None,
878 resolveObjectResponse: None,
881 WebSocketService.Instance.send(wsClient.search(form));
882 WebSocketService.Instance.send(wsClient.resolveObject(resolveObjectForm));
886 setupCommunityFilter() {
888 let selectId: any = document.getElementById("community-filter");
890 this.communityChoices = new Choices(selectId, choicesConfig);
891 this.communityChoices.passedElement.element.addEventListener(
894 this.handleCommunityFilterChange(Number(e.detail.choice.value));
898 this.communityChoices.passedElement.element.addEventListener(
900 debounce(async (e: any) => {
902 let communities = (await fetchCommunities(e.detail.value))
904 let choices = communities.map(cv => communityToChoice(cv));
905 choices.unshift({ value: "0", label: i18n.t("all") });
906 this.communityChoices.setChoices(choices, "value", "label", true);
917 setupCreatorFilter() {
919 let selectId: any = document.getElementById("creator-filter");
921 this.creatorChoices = new Choices(selectId, choicesConfig);
922 this.creatorChoices.passedElement.element.addEventListener(
925 this.handleCreatorFilterChange(Number(e.detail.choice.value));
929 this.creatorChoices.passedElement.element.addEventListener(
931 debounce(async (e: any) => {
933 let creators = (await fetchUsers(e.detail.value)).users;
934 let choices = creators.map(pvs => personToChoice(pvs));
935 choices.unshift({ value: "0", label: i18n.t("all") });
936 this.creatorChoices.setChoices(choices, "value", "label", true);
947 handleSortChange(val: SortType) {
948 this.updateUrl({ sort: val, page: 1 });
951 handleTypeChange(i: Search, event: any) {
953 type_: SearchType[event.target.value],
958 handleListingTypeChange(val: ListingType) {
965 handleCommunityFilterChange(communityId: number) {
972 handleCreatorFilterChange(creatorId: number) {
979 handleSearchSubmit(i: Search, event: any) {
980 event.preventDefault();
982 q: i.state.searchText,
983 type_: i.state.type_,
984 listingType: i.state.listingType,
985 communityId: i.state.communityId,
986 creatorId: i.state.creatorId,
992 handleQChange(i: Search, event: any) {
993 i.setState({ searchText: event.target.value });
996 updateUrl(paramUpdates: UrlParams) {
997 const qStr = paramUpdates.q || this.state.q;
998 const qStrEncoded = encodeURIComponent(qStr);
999 const typeStr = paramUpdates.type_ || this.state.type_;
1000 const listingTypeStr = paramUpdates.listingType || this.state.listingType;
1001 const sortStr = paramUpdates.sort || this.state.sort;
1003 paramUpdates.communityId == 0
1005 : paramUpdates.communityId || this.state.communityId;
1007 paramUpdates.creatorId == 0
1009 : paramUpdates.creatorId || this.state.creatorId;
1010 const page = paramUpdates.page || this.state.page;
1011 this.props.history.push(
1012 `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
1016 parseMessage(msg: any) {
1018 let op = wsUserOp(msg);
1020 if (msg.error == "couldnt_find_object") {
1022 resolveObjectResponse: Some({
1029 this.checkFinishedLoading();
1031 toast(i18n.t(msg.error), "danger");
1034 } else if (op == UserOperation.Search) {
1035 let data = wsJsonToRes<SearchResponse>(msg, SearchResponse);
1036 this.setState({ searchResponse: Some(data) });
1037 window.scrollTo(0, 0);
1038 this.checkFinishedLoading();
1039 restoreScrollPosition(this.context);
1040 } else if (op == UserOperation.CreateCommentLike) {
1041 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
1042 createCommentLikeRes(
1044 this.state.searchResponse.map(r => r.comments).unwrapOr([])
1046 this.setState(this.state);
1047 } else if (op == UserOperation.CreatePostLike) {
1048 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
1049 createPostLikeFindRes(
1051 this.state.searchResponse.map(r => r.posts).unwrapOr([])
1053 this.setState(this.state);
1054 } else if (op == UserOperation.ListCommunities) {
1055 let data = wsJsonToRes<ListCommunitiesResponse>(
1057 ListCommunitiesResponse
1059 this.setState({ communities: data.communities });
1060 this.setupCommunityFilter();
1061 } else if (op == UserOperation.ResolveObject) {
1062 let data = wsJsonToRes<ResolveObjectResponse>(msg, ResolveObjectResponse);
1063 this.setState({ resolveObjectResponse: Some(data) });
1064 this.checkFinishedLoading();
1068 checkFinishedLoading() {
1070 this.state.searchResponse.isSome() &&
1071 this.state.resolveObjectResponse.isSome()
1073 this.setState({ loading: false });