import { commentsToFlatNodes, communityToChoice, enableDownvotes, enableNsfw, fetchCommunities, fetchUsers, getUpdatedSearchId, myAuth, personToChoice, setIsoData, showLocal, } from "@utils/app"; import { isBrowser, restoreScrollPosition, saveScrollPosition, } from "@utils/browser"; import { capitalizeFirstLetter, debounce, getIdFromString, getPageFromString, getQueryParams, getQueryString, numToSI, } from "@utils/helpers"; import type { QueryParams } from "@utils/types"; import { Choice, RouteDataResponse } from "@utils/types"; import type { NoOptionI18nKeys } from "i18next"; import { Component, linkEvent } from "inferno"; import { CommentView, CommunityView, GetCommunity, GetCommunityResponse, GetPersonDetails, GetPersonDetailsResponse, GetSiteResponse, ListCommunities, ListCommunitiesResponse, ListingType, PersonView, PostView, ResolveObject, ResolveObjectResponse, Search as SearchForm, SearchResponse, SearchType, SortType, } from "lemmy-js-client"; import { fetchLimit } from "../config"; import { CommentViewType, InitialFetchRequest } from "../interfaces"; import { FirstLoadService, I18NextService } from "../services"; import { HttpService, RequestState } from "../services/HttpService"; import { CommentNodes } from "./comment/comment-nodes"; import { HtmlTags } from "./common/html-tags"; import { Spinner } from "./common/icon"; import { ListingTypeSelect } from "./common/listing-type-select"; import { Paginator } from "./common/paginator"; import { SearchableSelect } from "./common/searchable-select"; import { SortSelect } from "./common/sort-select"; import { CommunityLink } from "./community/community-link"; import { PersonListing } from "./person/person-listing"; import { PostListing } from "./post/post-listing"; interface SearchProps { q?: string; type: SearchType; sort: SortType; listingType: ListingType; communityId?: number | null; creatorId?: number | null; page: number; } type SearchData = RouteDataResponse<{ communityResponse: GetCommunityResponse; listCommunitiesResponse: ListCommunitiesResponse; creatorDetailsResponse: GetPersonDetailsResponse; searchResponse: SearchResponse; resolveObjectResponse: ResolveObjectResponse; }>; type FilterType = "creator" | "community"; interface SearchState { searchRes: RequestState; resolveObjectRes: RequestState; creatorDetailsRes: RequestState; communitiesRes: RequestState; communityRes: RequestState; siteRes: GetSiteResponse; searchText?: string; communitySearchOptions: Choice[]; creatorSearchOptions: Choice[]; searchCreatorLoading: boolean; searchCommunitiesLoading: boolean; isIsomorphic: boolean; } interface Combined { type_: string; data: CommentView | PostView | CommunityView | PersonView; published: string; } const defaultSearchType = "All"; const defaultSortType = "TopAll"; const defaultListingType = "All"; const searchTypes = ["All", "Comments", "Posts", "Communities", "Users", "Url"]; const getSearchQueryParams = () => getQueryParams({ q: getSearchQueryFromQuery, type: getSearchTypeFromQuery, sort: getSortTypeFromQuery, listingType: getListingTypeFromQuery, communityId: getIdFromString, creatorId: getIdFromString, page: getPageFromString, }); const getSearchQueryFromQuery = (q?: string): string | undefined => q ? decodeURIComponent(q) : undefined; function getSearchTypeFromQuery(type_?: string): SearchType { return type_ ? (type_ as SearchType) : defaultSearchType; } function getSortTypeFromQuery(sort?: string): SortType { return sort ? (sort as SortType) : defaultSortType; } function getListingTypeFromQuery(listingType?: string): ListingType { return listingType ? (listingType as ListingType) : defaultListingType; } function postViewToCombined(data: PostView): Combined { return { type_: "posts", data, published: data.post.published, }; } function commentViewToCombined(data: CommentView): Combined { return { type_: "comments", data, published: data.comment.published, }; } function communityViewToCombined(data: CommunityView): Combined { return { type_: "communities", data, published: data.community.published, }; } function personViewSafeToCombined(data: PersonView): Combined { return { type_: "users", data, published: data.person.published, }; } const Filter = ({ filterType, options, onChange, onSearch, value, loading, }: { filterType: FilterType; options: Choice[]; onSearch: (text: string) => void; onChange: (choice: Choice) => void; value?: number | null; loading: boolean; }) => { return (
); }; const communityListing = ({ community, counts: { subscribers }, }: CommunityView) => getListing( , subscribers, "number_of_subscribers" ); const personListing = ({ person, counts: { comment_count } }: PersonView) => getListing( , comment_count, "number_of_comments" ); function getListing( listing: JSX.ElementClass, count: number, translationKey: "number_of_comments" | "number_of_subscribers" ) { return ( <> {listing} {` - ${I18NextService.i18n.t(translationKey, { count: Number(count), formattedCount: numToSI(count), })}`} ); } export class Search extends Component { private isoData = setIsoData(this.context); state: SearchState = { resolveObjectRes: { state: "empty" }, creatorDetailsRes: { state: "empty" }, communitiesRes: { state: "empty" }, communityRes: { state: "empty" }, siteRes: this.isoData.site_res, creatorSearchOptions: [], communitySearchOptions: [], searchRes: { state: "empty" }, searchCreatorLoading: false, searchCommunitiesLoading: false, isIsomorphic: false, }; constructor(props: any, context: any) { super(props, context); this.handleSortChange = this.handleSortChange.bind(this); this.handleListingTypeChange = this.handleListingTypeChange.bind(this); this.handlePageChange = this.handlePageChange.bind(this); this.handleCommunityFilterChange = this.handleCommunityFilterChange.bind(this); this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this); const { q } = getSearchQueryParams(); this.state = { ...this.state, searchText: q, }; // Only fetch the data if coming from another route if (!isBrowser() || FirstLoadService.isFirstLoad) { const { communityResponse: communityRes, creatorDetailsResponse: creatorDetailsRes, listCommunitiesResponse: communitiesRes, resolveObjectResponse: resolveObjectRes, searchResponse: searchRes, } = this.isoData.routeData; this.state = { ...this.state, isIsomorphic: true, }; if (creatorDetailsRes?.state === "success") { this.state = { ...this.state, creatorSearchOptions: creatorDetailsRes?.state === "success" ? [personToChoice(creatorDetailsRes.data.person_view)] : [], creatorDetailsRes, }; } if (communitiesRes?.state === "success") { this.state = { ...this.state, communitiesRes, }; } if (communityRes?.state === "success") { this.state = { ...this.state, communityRes, }; } if (q !== "") { this.state = { ...this.state, }; if (searchRes?.state === "success") { this.state = { ...this.state, searchRes, }; } if (resolveObjectRes?.state === "success") { this.state = { ...this.state, resolveObjectRes, }; } } } } async componentDidMount() { if (!this.state.isIsomorphic) { const promises = [this.fetchCommunities()]; if (this.state.searchText) { promises.push(this.search()); } await Promise.all(promises); } } async fetchCommunities() { this.setState({ communitiesRes: { state: "loading" } }); this.setState({ communitiesRes: await HttpService.client.listCommunities({ type_: defaultListingType, sort: defaultSortType, limit: fetchLimit, auth: myAuth(), }), }); } componentWillUnmount() { saveScrollPosition(this.context); } static async fetchInitialData({ client, auth, query: { communityId, creatorId, q, type, sort, listingType, page }, }: InitialFetchRequest>): Promise { const community_id = getIdFromString(communityId); let communityResponse: RequestState = { state: "empty", }; let listCommunitiesResponse: RequestState = { state: "empty", }; if (community_id) { const getCommunityForm: GetCommunity = { id: community_id, auth, }; communityResponse = await client.getCommunity(getCommunityForm); } else { const listCommunitiesForm: ListCommunities = { type_: defaultListingType, sort: defaultSortType, limit: fetchLimit, auth, }; listCommunitiesResponse = await client.listCommunities( listCommunitiesForm ); } const creator_id = getIdFromString(creatorId); let creatorDetailsResponse: RequestState = { state: "empty", }; if (creator_id) { const getCreatorForm: GetPersonDetails = { person_id: creator_id, auth, }; creatorDetailsResponse = await client.getPersonDetails(getCreatorForm); } const query = getSearchQueryFromQuery(q); let searchResponse: RequestState = { state: "empty" }; let resolveObjectResponse: RequestState = { state: "empty", }; if (query) { const form: SearchForm = { q: query, community_id, creator_id, type_: getSearchTypeFromQuery(type), sort: getSortTypeFromQuery(sort), listing_type: getListingTypeFromQuery(listingType), page: getPageFromString(page), limit: fetchLimit, auth, }; if (query !== "") { searchResponse = await client.search(form); if (auth) { const resolveObjectForm: ResolveObject = { q: query, auth, }; resolveObjectResponse = await client.resolveObject(resolveObjectForm); } } } return { communityResponse, creatorDetailsResponse, listCommunitiesResponse, resolveObjectResponse, searchResponse, }; } get documentTitle(): string { const { q } = getSearchQueryParams(); const name = this.state.siteRes.site_view.site.name; return `${I18NextService.i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`; } render() { const { type, page } = getSearchQueryParams(); return (
{I18NextService.i18n.t("search")}
{this.selects} {this.searchForm} {this.displayResults(type)} {this.resultsCount === 0 && this.state.searchRes.state === "success" && ( {I18NextService.i18n.t("no_results")} )}
); } displayResults(type: SearchType) { switch (type) { case "All": return this.all; case "Comments": return this.comments; case "Posts": case "Url": return this.posts; case "Communities": return this.communities; case "Users": return this.users; default: return <>; } } get searchForm() { return (
); } get selects() { const { type, listingType, sort, communityId, creatorId } = getSearchQueryParams(); const { communitySearchOptions, creatorSearchOptions, searchCommunitiesLoading, searchCreatorLoading, communitiesRes, } = this.state; const hasCommunities = communitiesRes.state == "success" && communitiesRes.data.communities.length > 0; return (
{hasCommunities && ( )}
); } buildCombined(): Combined[] { const combined: Combined[] = []; const { resolveObjectRes: resolveObjectResponse, searchRes: searchResponse, } = this.state; // Push the possible resolve / federated objects first if (resolveObjectResponse.state == "success") { const { comment, post, community, person } = resolveObjectResponse.data; if (comment) { combined.push(commentViewToCombined(comment)); } if (post) { combined.push(postViewToCombined(post)); } if (community) { combined.push(communityViewToCombined(community)); } if (person) { combined.push(personViewSafeToCombined(person)); } } // Push the search results if (searchResponse.state === "success") { const { comments, posts, communities, users } = searchResponse.data; combined.push( ...[ ...(comments?.map(commentViewToCombined) ?? []), ...(posts?.map(postViewToCombined) ?? []), ...(communities?.map(communityViewToCombined) ?? []), ...(users?.map(personViewSafeToCombined) ?? []), ] ); } const { sort } = getSearchQueryParams(); // Sort it if (sort === "New") { combined.sort((a, b) => b.published.localeCompare(a.published)); } else { combined.sort((a, b) => Number( ((b.data as CommentView | PostView).counts.score | (b.data as CommunityView).counts.subscribers | (b.data as PersonView).counts.comment_score) - ((a.data as CommentView | PostView).counts.score | (a.data as CommunityView).counts.subscribers | (a.data as PersonView).counts.comment_score) ) ); } return combined; } get all() { const combined = this.buildCombined(); return (
{combined.map(i => (
{i.type_ === "posts" && ( {}} onPostVote={() => {}} onPostReport={() => {}} onBlockPerson={() => {}} onLockPost={() => {}} onDeletePost={() => {}} onRemovePost={() => {}} onSavePost={() => {}} onFeaturePost={() => {}} onPurgePerson={() => {}} onPurgePost={() => {}} onBanPersonFromCommunity={() => {}} onBanPerson={() => {}} onAddModToCommunity={() => {}} onAddAdmin={() => {}} onTransferCommunity={() => {}} /> )} {i.type_ === "comments" && ( {}} onBlockPerson={() => {}} onDeleteComment={() => {}} onRemoveComment={() => {}} onCommentVote={() => {}} onCommentReport={() => {}} onDistinguishComment={() => {}} onAddModToCommunity={() => {}} onAddAdmin={() => {}} onTransferCommunity={() => {}} onPurgeComment={() => {}} onPurgePerson={() => {}} onCommentReplyRead={() => {}} onPersonMentionRead={() => {}} onBanPersonFromCommunity={() => {}} onBanPerson={() => {}} onCreateComment={() => Promise.resolve({ state: "empty" })} onEditComment={() => Promise.resolve({ state: "empty" })} /> )} {i.type_ === "communities" && (
{communityListing(i.data as CommunityView)}
)} {i.type_ === "users" && (
{personListing(i.data as PersonView)}
)}
))}
); } get comments() { const { searchRes: searchResponse, resolveObjectRes: resolveObjectResponse, siteRes, } = this.state; const comments = searchResponse.state === "success" ? searchResponse.data.comments : []; if ( resolveObjectResponse.state === "success" && resolveObjectResponse.data.comment ) { comments.unshift(resolveObjectResponse.data.comment); } return ( {}} onBlockPerson={() => {}} onDeleteComment={() => {}} onRemoveComment={() => {}} onCommentVote={() => {}} onCommentReport={() => {}} onDistinguishComment={() => {}} onAddModToCommunity={() => {}} onAddAdmin={() => {}} onTransferCommunity={() => {}} onPurgeComment={() => {}} onPurgePerson={() => {}} onCommentReplyRead={() => {}} onPersonMentionRead={() => {}} onBanPersonFromCommunity={() => {}} onBanPerson={() => {}} onCreateComment={() => Promise.resolve({ state: "empty" })} onEditComment={() => Promise.resolve({ state: "empty" })} /> ); } get posts() { const { searchRes: searchResponse, resolveObjectRes: resolveObjectResponse, siteRes, } = this.state; const posts = searchResponse.state === "success" ? searchResponse.data.posts : []; if ( resolveObjectResponse.state === "success" && resolveObjectResponse.data.post ) { posts.unshift(resolveObjectResponse.data.post); } return ( <> {posts.map(pv => (
{}} onPostVote={() => {}} onPostReport={() => {}} onBlockPerson={() => {}} onLockPost={() => {}} onDeletePost={() => {}} onRemovePost={() => {}} onSavePost={() => {}} onFeaturePost={() => {}} onPurgePerson={() => {}} onPurgePost={() => {}} onBanPersonFromCommunity={() => {}} onBanPerson={() => {}} onAddModToCommunity={() => {}} onAddAdmin={() => {}} onTransferCommunity={() => {}} />
))} ); } get communities() { const { searchRes: searchResponse, resolveObjectRes: resolveObjectResponse, } = this.state; const communities = searchResponse.state === "success" ? searchResponse.data.communities : []; if ( resolveObjectResponse.state === "success" && resolveObjectResponse.data.community ) { communities.unshift(resolveObjectResponse.data.community); } return ( <> {communities.map(cv => (
{communityListing(cv)}
))} ); } get users() { const { searchRes: searchResponse, resolveObjectRes: resolveObjectResponse, } = this.state; const users = searchResponse.state === "success" ? searchResponse.data.users : []; if ( resolveObjectResponse.state === "success" && resolveObjectResponse.data.person ) { users.unshift(resolveObjectResponse.data.person); } return ( <> {users.map(pvs => (
{personListing(pvs)}
))} ); } get resultsCount(): number { const { searchRes: r, resolveObjectRes: resolveRes } = this.state; const searchCount = r.state === "success" ? r.data.posts.length + r.data.comments.length + r.data.communities.length + r.data.users.length : 0; const resObjCount = resolveRes.state === "success" ? resolveRes.data.post || resolveRes.data.person || resolveRes.data.community || resolveRes.data.comment ? 1 : 0 : 0; return resObjCount + searchCount; } async search() { const auth = myAuth(); const { searchText: q } = this.state; const { communityId, creatorId, type, sort, listingType, page } = getSearchQueryParams(); if (q) { this.setState({ searchRes: { state: "loading" } }); this.setState({ searchRes: await HttpService.client.search({ q, community_id: communityId ?? undefined, creator_id: creatorId ?? undefined, type_: type, sort, listing_type: listingType, page, limit: fetchLimit, auth, }), }); window.scrollTo(0, 0); restoreScrollPosition(this.context); if (auth) { this.setState({ resolveObjectRes: { state: "loading" } }); this.setState({ resolveObjectRes: await HttpService.client.resolveObject({ q, auth, }), }); } } } handleCreatorSearch = debounce(async (text: string) => { const { creatorId } = getSearchQueryParams(); const { creatorSearchOptions } = this.state; const newOptions: Choice[] = []; this.setState({ searchCreatorLoading: true }); const selectedChoice = creatorSearchOptions.find( choice => getIdFromString(choice.value) === creatorId ); if (selectedChoice) { newOptions.push(selectedChoice); } if (text.length > 0) { newOptions.push(...(await fetchUsers(text)).map(personToChoice)); } this.setState({ searchCreatorLoading: false, creatorSearchOptions: newOptions, }); }); handleCommunitySearch = debounce(async (text: string) => { const { communityId } = getSearchQueryParams(); const { communitySearchOptions } = this.state; this.setState({ searchCommunitiesLoading: true, }); const newOptions: Choice[] = []; const selectedChoice = communitySearchOptions.find( choice => getIdFromString(choice.value) === communityId ); if (selectedChoice) { newOptions.push(selectedChoice); } if (text.length > 0) { newOptions.push(...(await fetchCommunities(text)).map(communityToChoice)); } this.setState({ searchCommunitiesLoading: false, communitySearchOptions: newOptions, }); }); handleSortChange(sort: SortType) { this.updateUrl({ sort, page: 1 }); } handleTypeChange(i: Search, event: any) { const type = event.target.value as SearchType; i.updateUrl({ type, page: 1, }); } handlePageChange(page: number) { this.updateUrl({ page }); } handleListingTypeChange(listingType: ListingType) { this.updateUrl({ listingType, page: 1, }); } handleCommunityFilterChange({ value }: Choice) { this.updateUrl({ communityId: getIdFromString(value) ?? null, page: 1, }); } handleCreatorFilterChange({ value }: Choice) { this.updateUrl({ creatorId: getIdFromString(value) ?? null, page: 1, }); } handleSearchSubmit(i: Search, event: any) { event.preventDefault(); i.updateUrl({ q: i.state.searchText, page: 1, }); } handleQChange(i: Search, event: any) { i.setState({ searchText: event.target.value }); } async updateUrl({ q, type, listingType, sort, communityId, creatorId, page, }: Partial) { const { q: urlQ, type: urlType, listingType: urlListingType, communityId: urlCommunityId, sort: urlSort, creatorId: urlCreatorId, page: urlPage, } = getSearchQueryParams(); let query = q ?? this.state.searchText ?? urlQ; if (query && query.length > 0) { query = encodeURIComponent(query); } const queryParams: QueryParams = { q: query, type: type ?? urlType, listingType: listingType ?? urlListingType, communityId: getUpdatedSearchId(communityId, urlCommunityId), creatorId: getUpdatedSearchId(creatorId, urlCreatorId), page: (page ?? urlPage).toString(), sort: sort ?? urlSort, }; this.props.history.push(`/search${getQueryString(queryParams)}`); await this.search(); } }