diff --git a/src/shared/components/app/app.tsx b/src/shared/components/app/app.tsx index 0f26250e..cb44e505 100644 --- a/src/shared/components/app/app.tsx +++ b/src/shared/components/app/app.tsx @@ -32,6 +32,64 @@ export default class App extends Component { destroyTippy(); } + routes = routes.map( + ({ + path, + component: RouteComponent, + fetchInitialData, + getQueryParams, + mountedSameRouteNavKey, + }) => ( + { + if (!fetchInitialData) { + FirstLoadService.falsify(); + } + + let queryProps = routeProps; + if (getQueryParams && this.isoData.site_res) { + // ErrorGuard will not render its children when + // site_res is missing, this guarantees that props + // will always contain the query params. + queryProps = { + ...routeProps, + ...getQueryParams( + routeProps.location.search, + this.isoData.site_res, + ), + }; + } + + // When key is location.key the component will be recreated when + // navigating to itself. This is usesful to e.g. reset forms. + const key = mountedSameRouteNavKey ?? routeProps.location.key; + + return ( + +
+ {RouteComponent && + (isAuthPath(path ?? "") ? ( + + + + ) : isAnonymousPath(path ?? "") ? ( + + + + ) : ( + + ))} +
+
+ ); + }} + /> + ), + ); + render() { const siteRes = this.isoData.site_res; const siteView = siteRes?.site_view; @@ -64,58 +122,7 @@ export default class App extends Component {
- {routes.map( - ({ - path, - component: RouteComponent, - fetchInitialData, - getQueryParams, - }) => ( - { - if (!fetchInitialData) { - FirstLoadService.falsify(); - } - - let queryProps = routeProps; - if (getQueryParams && this.isoData.site_res) { - // ErrorGuard will not render its children when - // site_res is missing, this guarantees that props - // will always contain the query params. - queryProps = { - ...routeProps, - ...getQueryParams( - routeProps.location.search, - this.isoData.site_res, - ), - }; - } - - return ( - -
- {RouteComponent && - (isAuthPath(path ?? "") ? ( - - - - ) : isAnonymousPath(path ?? "") ? ( - - - - ) : ( - - ))} -
-
- ); - }} - /> - ), - )} + {this.routes}
diff --git a/src/shared/components/app/navbar.tsx b/src/shared/components/app/navbar.tsx index 1cfb40f2..e4ed13dc 100644 --- a/src/shared/components/app/navbar.tsx +++ b/src/shared/components/app/navbar.tsx @@ -63,7 +63,7 @@ export class Navbar extends Component { this.handleOutsideMenuClick = this.handleOutsideMenuClick.bind(this); } - async componentDidMount() { + async componentWillMount() { // Subscribe to jwt changes if (isBrowser()) { // On the first load, check the unreads diff --git a/src/shared/components/comment/comment-node.tsx b/src/shared/components/comment/comment-node.tsx index 52fd300f..b14adc71 100644 --- a/src/shared/components/comment/comment-node.tsx +++ b/src/shared/components/comment/comment-node.tsx @@ -502,7 +502,7 @@ export class CommentNode extends Component { <> { - state = { - hasRedirected: false, - } as AnonymousGuardState; - +class AnonymousGuard extends Component { constructor(props: any, context: any) { super(props, context); } - componentDidMount() { - if (UserService.Instance.myUserInfo) { + hasAuth() { + return UserService.Instance.myUserInfo; + } + + componentWillMount() { + if (this.hasAuth() && isBrowser()) { this.context.router.history.replace(`/`); - } else { - this.setState({ hasRedirected: true }); } } render() { - return this.state.hasRedirected ? this.props.children : ; + return !this.hasAuth() ? this.props.children : ; } } diff --git a/src/shared/components/common/auth-guard.tsx b/src/shared/components/common/auth-guard.tsx index 410e41c8..a6750686 100644 --- a/src/shared/components/common/auth-guard.tsx +++ b/src/shared/components/common/auth-guard.tsx @@ -3,19 +3,12 @@ import { RouteComponentProps } from "inferno-router/dist/Route"; import { UserService } from "../../services"; import { Spinner } from "./icon"; import { getQueryString } from "@utils/helpers"; - -interface AuthGuardState { - hasRedirected: boolean; -} +import { isBrowser } from "@utils/browser"; class AuthGuard extends Component< RouteComponentProps>, - AuthGuardState + any > { - state = { - hasRedirected: false, - } as AuthGuardState; - constructor( props: RouteComponentProps>, context: any, @@ -23,19 +16,21 @@ class AuthGuard extends Component< super(props, context); } - componentDidMount() { - if (!UserService.Instance.myUserInfo) { + hasAuth() { + return UserService.Instance.myUserInfo; + } + + componentWillMount() { + if (!this.hasAuth() && isBrowser()) { const { pathname, search } = this.props.location; this.context.router.history.replace( `/login${getQueryString({ prev: pathname + search })}`, ); - } else { - this.setState({ hasRedirected: true }); } } render() { - return this.state.hasRedirected ? this.props.children : ; + return this.hasAuth() ? this.props.children : ; } } diff --git a/src/shared/components/common/content-actions/content-action-dropdown.tsx b/src/shared/components/common/content-actions/content-action-dropdown.tsx index a7048b21..5c7fbb2d 100644 --- a/src/shared/components/common/content-actions/content-action-dropdown.tsx +++ b/src/shared/components/common/content-actions/content-action-dropdown.tsx @@ -643,7 +643,6 @@ export default class ContentActionDropdown extends Component< type, } = this.props; - // Wait until componentDidMount runs (which only happens on the browser) to prevent sending over a gratuitous amount of markup return ( <> {renderRemoveDialog && ( diff --git a/src/shared/components/common/searchable-select.tsx b/src/shared/components/common/searchable-select.tsx index d935a917..7e935e8e 100644 --- a/src/shared/components/common/searchable-select.tsx +++ b/src/shared/components/common/searchable-select.tsx @@ -39,7 +39,7 @@ function handleSearch(i: SearchableSelect, e: ChangeEvent) { } function focusSearch(i: SearchableSelect) { - if (i.toggleButtonRef.current?.ariaExpanded !== "true") { + if (i.toggleButtonRef.current?.ariaExpanded === "true") { i.searchInputRef.current?.focus(); if (i.props.onSearch) { diff --git a/src/shared/components/common/subscribe-button.tsx b/src/shared/components/common/subscribe-button.tsx index a663eae1..0ba7fda1 100644 --- a/src/shared/components/common/subscribe-button.tsx +++ b/src/shared/components/common/subscribe-button.tsx @@ -1,12 +1,13 @@ import { getQueryString, validInstanceTLD } from "@utils/helpers"; import classNames from "classnames"; import { NoOptionI18nKeys } from "i18next"; -import { Component, MouseEventHandler, linkEvent } from "inferno"; +import { Component, MouseEventHandler, createRef, linkEvent } from "inferno"; import { CommunityView } from "lemmy-js-client"; import { I18NextService, UserService } from "../../services"; import { VERSION } from "../../version"; import { Icon, Spinner } from "./icon"; import { toast } from "../../toast"; +import { modalMixin } from "../mixins/modal-mixin"; interface SubscribeButtonProps { communityView: CommunityView; @@ -93,6 +94,7 @@ export function SubscribeButton({ interface RemoteFetchModalProps { communityActorId: string; + show?: boolean; } interface RemoteFetchModalState { @@ -103,10 +105,6 @@ function handleInput(i: RemoteFetchModal, event: any) { i.setState({ instanceText: event.target.value }); } -function focusInput() { - document.getElementById("remoteFetchInstance")?.focus(); -} - function submitRemoteFollow( { state: { instanceText }, props: { communityActorId } }: RemoteFetchModal, event: Event, @@ -139,6 +137,7 @@ function submitRemoteFollow( )}`; } +@modalMixin class RemoteFetchModal extends Component< RemoteFetchModalProps, RemoteFetchModalState @@ -147,20 +146,15 @@ class RemoteFetchModal extends Component< instanceText: "", }; + modalDivRef = createRef(); + inputRef = createRef(); + constructor(props: any, context: any) { super(props, context); } - componentDidMount() { - document - .getElementById("remoteFetchModal") - ?.addEventListener("shown.bs.modal", focusInput); - } - - componentWillUnmount(): void { - document - .getElementById("remoteFetchModal") - ?.removeEventListener("shown.bs.modal", focusInput); + handleShow() { + this.inputRef.current?.focus(); } render() { @@ -171,6 +165,7 @@ class RemoteFetchModal extends Component< tabIndex={-1} aria-hidden aria-labelledby="#remoteFetchModalTitle" + ref={this.modalDivRef} >
@@ -203,6 +198,7 @@ class RemoteFetchModal extends Component< required enterKeyHint="go" inputMode="url" + ref={this.inputRef} />
diff --git a/src/shared/components/common/view-votes-modal.tsx b/src/shared/components/common/view-votes-modal.tsx index 11a8a151..2bf739ee 100644 --- a/src/shared/components/common/view-votes-modal.tsx +++ b/src/shared/components/common/view-votes-modal.tsx @@ -24,6 +24,7 @@ import { fetchLimit } from "../../config"; import { PersonListing } from "../person/person-listing"; import { modalMixin } from "../mixins/modal-mixin"; import { UserBadges } from "./user-badges"; +import { isBrowser } from "@utils/browser"; interface ViewVotesModalProps { children?: InfernoNode; @@ -96,8 +97,8 @@ export default class ViewVotesModal extends Component< this.handlePageChange = this.handlePageChange.bind(this); } - async componentDidMount() { - if (this.props.show) { + async componentWillMount() { + if (this.props.show && isBrowser()) { await this.refetch(); } } diff --git a/src/shared/components/community/communities.tsx b/src/shared/components/community/communities.tsx index ac2324fb..d91f7969 100644 --- a/src/shared/components/community/communities.tsx +++ b/src/shared/components/community/communities.tsx @@ -40,6 +40,7 @@ import { getHttpBaseInternal } from "../../utils/env"; import { RouteComponentProps } from "inferno-router/dist/Route"; import { IRoutePropsWithFetch } from "../../routes"; import { scrollMixin } from "../mixins/scroll-mixin"; +import { isBrowser } from "@utils/browser"; type CommunitiesData = RouteDataResponse<{ listCommunitiesResponse: ListCommunitiesResponse; @@ -121,19 +122,23 @@ export class Communities extends Component< } } - async componentDidMount() { - if (!this.state.isIsomorphic) { - await this.refetch(); + async componentWillMount() { + if (!this.state.isIsomorphic && isBrowser()) { + await this.refetch(this.props); } } + componentWillReceiveProps(nextProps: CommunitiesRouteProps) { + this.refetch(nextProps); + } + get documentTitle(): string { return `${I18NextService.i18n.t("communities")} - ${ this.state.siteRes.site_view.site.name }`; } - renderListings() { + renderListingsTable() { switch (this.state.listCommunitiesResponse.state) { case "loading": return ( @@ -142,120 +147,114 @@ export class Communities extends Component< ); case "success": { - const { listingType, sort, page } = this.props; return ( -
-

- {I18NextService.i18n.t("list_of_communities")} -

-
-
- -
-
- -
-
{this.searchForm()}
-
- -
- - - - - - - - - - - - - {this.state.listCommunitiesResponse.data.communities.map( - cv => ( - - - - - - - - - ), - )} - -
{I18NextService.i18n.t("name")} - {I18NextService.i18n.t("subscribers")} - - {I18NextService.i18n.t("users")} /{" "} - {I18NextService.i18n.t("month")} - - {I18NextService.i18n.t("posts")} - - {I18NextService.i18n.t("comments")} -
- - - {numToSI(cv.counts.subscribers)} - - {numToSI(cv.counts.users_active_month)} - - {numToSI(cv.counts.posts)} - - {numToSI(cv.counts.comments)} - - -
-
- - this.state.listCommunitiesResponse.data.communities.length - } - /> -
+ + + + + + + + + + + + + {this.state.listCommunitiesResponse.data.communities.map(cv => ( + + + + + + + + + ))} + +
{I18NextService.i18n.t("name")} + {I18NextService.i18n.t("subscribers")} + + {I18NextService.i18n.t("users")} /{" "} + {I18NextService.i18n.t("month")} + + {I18NextService.i18n.t("posts")} + + {I18NextService.i18n.t("comments")} +
+ + + {numToSI(cv.counts.subscribers)} + + {numToSI(cv.counts.users_active_month)} + + {numToSI(cv.counts.posts)} + + {numToSI(cv.counts.comments)} + + +
); } } } render() { + const { listingType, sort, page } = this.props; return (
- {this.renderListings()} +
+

+ {I18NextService.i18n.t("list_of_communities")} +

+
+
+ +
+
+ +
+
{this.searchForm()}
+
+ +
{this.renderListingsTable()}
+ + this.state.listCommunitiesResponse.data.communities.length + } + /> +
); } @@ -287,22 +286,16 @@ export class Communities extends Component< ); } - async updateUrl({ listingType, sort, page }: Partial) { - const { - listingType: urlListingType, - sort: urlSort, - page: urlPage, - } = this.props; + async updateUrl(props: Partial) { + const { listingType, sort, page } = { ...this.props, ...props }; const queryParams: QueryParams = { - listingType: listingType ?? urlListingType, - sort: sort ?? urlSort, - page: (page ?? urlPage)?.toString(), + listingType: listingType, + sort: sort, + page: page?.toString(), }; this.props.history.push(`/communities${getQueryString(queryParams)}`); - - await this.refetch(); } handlePageChange(page: number) { @@ -368,19 +361,19 @@ export class Communities extends Component< data.i.findAndUpdateCommunity(res); } - async refetch() { + fetchToken?: symbol; + async refetch({ listingType, sort, page }: CommunitiesProps) { + const token = (this.fetchToken = Symbol()); this.setState({ listCommunitiesResponse: LOADING_REQUEST }); - - const { listingType, sort, page } = this.props; - - this.setState({ - listCommunitiesResponse: await HttpService.client.listCommunities({ - type_: listingType, - sort: sort, - limit: communityLimit, - page, - }), + const listCommunitiesResponse = await HttpService.client.listCommunities({ + type_: listingType, + sort: sort, + limit: communityLimit, + page, }); + if (token === this.fetchToken) { + this.setState({ listCommunitiesResponse }); + } } findAndUpdateCommunity(res: RequestState) { diff --git a/src/shared/components/community/community.tsx b/src/shared/components/community/community.tsx index 173eca62..59561765 100644 --- a/src/shared/components/community/community.tsx +++ b/src/shared/components/community/community.tsx @@ -19,11 +19,18 @@ import { getQueryParams, getQueryString, resourcesSettled, + bareRoutePush, } from "@utils/helpers"; import { scrollMixin } from "../mixins/scroll-mixin"; import type { QueryParams, StringBoolean } from "@utils/types"; import { RouteDataResponse } from "@utils/types"; -import { Component, RefObject, createRef, linkEvent } from "inferno"; +import { + Component, + InfernoNode, + RefObject, + createRef, + linkEvent, +} from "inferno"; import { RouteComponentProps } from "inferno-router/dist/Route"; import { AddAdmin, @@ -100,7 +107,7 @@ import { CommentNodes } from "../comment/comment-nodes"; import { BannerIconHeader } from "../common/banner-icon-header"; import { DataTypeSelect } from "../common/data-type-select"; import { HtmlTags } from "../common/html-tags"; -import { Icon, Spinner } from "../common/icon"; +import { Icon } from "../common/icon"; import { SortSelect } from "../common/sort-select"; import { SiteSidebar } from "../home/site-sidebar"; import { PostListings } from "../post/post-listings"; @@ -114,6 +121,8 @@ import { import { Sidebar } from "./sidebar"; import { IRoutePropsWithFetch } from "../../routes"; import PostHiddenSelect from "../common/post-hidden-select"; +import { isBrowser } from "@utils/browser"; +import { LoadingEllipses } from "../common/loading-ellipses"; type CommunityData = RouteDataResponse<{ communityRes: GetCommunityResponse; @@ -265,21 +274,39 @@ export class Community extends Component { } } - async fetchCommunity() { + fetchCommunityToken?: symbol; + async fetchCommunity(props: CommunityRouteProps) { + const token = (this.fetchCommunityToken = Symbol()); this.setState({ communityRes: LOADING_REQUEST }); - this.setState({ - communityRes: await HttpService.client.getCommunity({ - name: this.props.match.params.name, - }), + const communityRes = await HttpService.client.getCommunity({ + name: props.match.params.name, }); + if (token === this.fetchCommunityToken) { + this.setState({ communityRes }); + } } - async componentDidMount() { - if (!this.state.isIsomorphic) { - await Promise.all([this.fetchCommunity(), this.fetchData()]); + async componentWillMount() { + if (!this.state.isIsomorphic && isBrowser()) { + await Promise.all([ + this.fetchCommunity(this.props), + this.fetchData(this.props), + ]); } } + componentWillReceiveProps( + nextProps: CommunityRouteProps & { children?: InfernoNode }, + ) { + if ( + bareRoutePush(this.props, nextProps) || + this.props.match.params.name !== nextProps.match.params.name + ) { + this.fetchCommunity(nextProps); + } + this.fetchData(nextProps); + } + static async fetchInitialData({ headers, query: { dataType, pageCursor, sort, showHidden }, @@ -356,73 +383,67 @@ export class Community extends Component { } renderCommunity() { - switch (this.state.communityRes.state) { - case "loading": - return ( -
- -
- ); - case "success": { - const res = this.state.communityRes.data; + const res = + this.state.communityRes.state === "success" && + this.state.communityRes.data; + return ( + <> + {res && ( + + )} - return ( - <> - + - {this.state.showSidebarMobile && this.sidebar(res)} -
- {this.selects(res)} - {this.listings(res)} - - - -
- - ); - } - } + + {this.state.showSidebarMobile && this.sidebar()} + + + ); } render() { return ( -
{this.renderCommunity()}
+
+
+
+ {this.renderCommunity()} + {this.selects()} + {this.listings()} + +
+ +
+
); } - sidebar(res: GetCommunityResponse) { + sidebar() { + if (this.state.communityRes.state !== "success") { + return undefined; + } + const res = this.state.communityRes.data; const siteRes = this.isoData.site_res; // For some reason, this returns an empty vec if it matches the site langs const communityLangs = @@ -456,7 +477,7 @@ export class Community extends Component { ); } - listings(communityRes: GetCommunityResponse) { + listings() { const { dataType } = this.props; const siteRes = this.isoData.site_res; @@ -496,6 +517,9 @@ export class Community extends Component { ); } } else { + if (this.state.communityRes.state !== "success") { + return; + } switch (this.state.commentsRes.state) { case "loading": return ; @@ -509,7 +533,7 @@ export class Community extends Component { showContext enableDownvotes={enableDownvotes(siteRes)} voteDisplayMode={voteDisplayMode(siteRes)} - moderators={communityRes.moderators} + moderators={this.state.communityRes.data.moderators} admins={siteRes.admins} allLanguages={siteRes.all_languages} siteLanguages={siteRes.discussion_languages} @@ -537,28 +561,40 @@ export class Community extends Component { } } - communityInfo(res: GetCommunityResponse) { - const community = res.community_view.community; + communityInfo() { + const res = + (this.state.communityRes.state === "success" && + this.state.communityRes.data) || + undefined; + const community = res && res.community_view.community; + const urlCommunityName = this.props.match.params.name; return ( - community && ( -
+
+ {community && ( -
-

- {community.title} -

- {community.posting_restricted_to_mods && ( - + )} +
+

+ {community?.title ?? ( + <> + {urlCommunityName} + + )} -

+ + {community?.posting_restricted_to_mods && ( + + )} +
+ {(community && ( { muted hideAvatar /> -
- ) + )) ?? + urlCommunityName} +
); } - selects(res: GetCommunityResponse) { + selects() { + const res = + this.state.communityRes.state === "success" && + this.state.communityRes.data; const { dataType, sort, showHidden } = this.props; const communityRss = res ? communityRSSUrl(res.community_view.community.actor_id, sort) @@ -641,60 +681,62 @@ export class Community extends Component { })); } - async updateUrl({ - dataType, - pageCursor, - sort, - showHidden, - }: Partial) { + async updateUrl(props: Partial) { const { - dataType: urlDataType, - sort: urlSort, - showHidden: urlShowHidden, - } = this.props; - - const queryParams: QueryParams = { - dataType: getDataTypeString(dataType ?? urlDataType), - pageCursor: pageCursor, - sort: sort ?? urlSort, - showHidden: showHidden ?? urlShowHidden, + dataType, + pageCursor, + sort, + showHidden, + match: { + params: { name }, + }, + } = { + ...this.props, + ...props, }; - this.props.history.push( - `/c/${this.props.match.params.name}${getQueryString(queryParams)}`, - ); + const queryParams: QueryParams = { + dataType: getDataTypeString(dataType ?? DataType.Post), + pageCursor: pageCursor, + sort: sort, + showHidden: showHidden, + }; - await this.fetchData(); + this.props.history.push(`/c/${name}${getQueryString(queryParams)}`); } - async fetchData() { - const { dataType, pageCursor, sort, showHidden } = this.props; - const { name } = this.props.match.params; + fetchDataToken?: symbol; + async fetchData(props: CommunityRouteProps) { + const token = (this.fetchDataToken = Symbol()); + const { dataType, pageCursor, sort, showHidden } = props; + const { name } = props.match.params; if (dataType === DataType.Post) { - this.setState({ postsRes: LOADING_REQUEST }); - this.setState({ - postsRes: await HttpService.client.getPosts({ - page_cursor: pageCursor, - limit: fetchLimit, - sort, - type_: "All", - community_name: name, - saved_only: false, - show_hidden: showHidden === "true", - }), + this.setState({ postsRes: LOADING_REQUEST, commentsRes: EMPTY_REQUEST }); + const postsRes = await HttpService.client.getPosts({ + page_cursor: pageCursor, + limit: fetchLimit, + sort, + type_: "All", + community_name: name, + saved_only: false, + show_hidden: showHidden === "true", }); + if (token === this.fetchDataToken) { + this.setState({ postsRes }); + } } else { - this.setState({ commentsRes: LOADING_REQUEST }); - this.setState({ - commentsRes: await HttpService.client.getComments({ - limit: fetchLimit, - sort: postToCommentSortType(sort), - type_: "All", - community_name: name, - saved_only: false, - }), + this.setState({ commentsRes: LOADING_REQUEST, postsRes: EMPTY_REQUEST }); + const commentsRes = await HttpService.client.getComments({ + limit: fetchLimit, + sort: postToCommentSortType(sort), + type_: "All", + community_name: name, + saved_only: false, }); + if (token === this.fetchDataToken) { + this.setState({ commentsRes }); + } } } diff --git a/src/shared/components/community/sidebar.tsx b/src/shared/components/community/sidebar.tsx index f3239b37..9adbc13e 100644 --- a/src/shared/components/community/sidebar.tsx +++ b/src/shared/components/community/sidebar.tsx @@ -80,6 +80,21 @@ export class Sidebar extends Component { this.handleEditCancel = this.handleEditCancel.bind(this); } + unlisten = () => {}; + + componentWillMount() { + // Leave edit mode on navigation + this.unlisten = this.context.router.history.listen(() => { + if (this.state.showEdit) { + this.setState({ showEdit: false }); + } + }); + } + + componentWillUnmount(): void { + this.unlisten(); + } + componentWillReceiveProps( nextProps: Readonly<{ children?: InfernoNode } & SidebarProps>, ): void { diff --git a/src/shared/components/home/admin-settings.tsx b/src/shared/components/home/admin-settings.tsx index 9ab0ccc4..4483a9ac 100644 --- a/src/shared/components/home/admin-settings.tsx +++ b/src/shared/components/home/admin-settings.tsx @@ -41,6 +41,7 @@ import { IRoutePropsWithFetch } from "../../routes"; import { MediaUploads } from "../common/media-uploads"; import { Paginator } from "../common/paginator"; import { snapToTop } from "@utils/browser"; +import { isBrowser } from "@utils/browser"; type AdminSettingsData = RouteDataResponse<{ bannedRes: BannedPersonsResponse; @@ -51,7 +52,6 @@ type AdminSettingsData = RouteDataResponse<{ interface AdminSettingsState { siteRes: GetSiteResponse; banned: PersonView[]; - currentTab: string; instancesRes: RequestState; bannedRes: RequestState; leaveAdminTeamRes: RequestState; @@ -79,7 +79,6 @@ export class AdminSettings extends Component< state: AdminSettingsState = { siteRes: this.isoData.site_res, banned: [], - currentTab: "site", bannedRes: EMPTY_REQUEST, instancesRes: EMPTY_REQUEST, leaveAdminTeamRes: EMPTY_REQUEST, @@ -134,12 +133,14 @@ export class AdminSettings extends Component< }; } - async componentDidMount() { - if (!this.state.isIsomorphic) { - await this.fetchData(); - } else { - const themeList = await fetchThemeList(); - this.setState({ themeList }); + async componentWillMount() { + if (isBrowser()) { + if (!this.state.isIsomorphic) { + await this.fetchData(); + } else { + const themeList = await fetchThemeList(); + this.setState({ themeList }); + } } } @@ -431,10 +432,6 @@ export class AdminSettings extends Component< return editRes; } - handleSwitchTab(i: { ctx: AdminSettings; tab: string }) { - i.ctx.setState({ currentTab: i.tab }); - } - async handleLeaveAdminTeam(i: AdminSettings) { i.setState({ leaveAdminTeamRes: LOADING_REQUEST }); this.setState({ diff --git a/src/shared/components/home/home.tsx b/src/shared/components/home/home.tsx index 9b9f721d..1c773ba6 100644 --- a/src/shared/components/home/home.tsx +++ b/src/shared/components/home/home.tsx @@ -19,13 +19,14 @@ import { getQueryString, getRandomFromList, resourcesSettled, + bareRoutePush, } from "@utils/helpers"; import { scrollMixin } from "../mixins/scroll-mixin"; import { canCreateCommunity } from "@utils/roles"; import type { QueryParams, StringBoolean } from "@utils/types"; import { RouteDataResponse } from "@utils/types"; import { NoOptionI18nKeys } from "i18next"; -import { Component, MouseEventHandler, linkEvent } from "inferno"; +import { Component, InfernoNode, MouseEventHandler, linkEvent } from "inferno"; import { T } from "inferno-i18next-dess"; import { Link } from "inferno-router"; import { @@ -112,7 +113,7 @@ import { import { RouteComponentProps } from "inferno-router/dist/Route"; import { IRoutePropsWithFetch } from "../../routes"; import PostHiddenSelect from "../common/post-hidden-select"; -import { snapToTop } from "@utils/browser"; +import { isBrowser, snapToTop } from "@utils/browser"; interface HomeState { postsRes: RequestState; @@ -344,14 +345,28 @@ export class Home extends Component { )?.content; } - async componentDidMount() { + async componentWillMount() { if ( - !this.state.isIsomorphic || - !Object.values(this.isoData.routeData).some( - res => res.state === "success" || res.state === "failed", - ) + (!this.state.isIsomorphic || + !Object.values(this.isoData.routeData).some( + res => res.state === "success" || res.state === "failed", + )) && + isBrowser() ) { - await Promise.all([this.fetchTrendingCommunities(), this.fetchData()]); + await Promise.all([ + this.fetchTrendingCommunities(), + this.fetchData(this.props), + ]); + } + } + + componentWillReceiveProps( + nextProps: HomeRouteProps & { children?: InfernoNode }, + ) { + this.fetchData(nextProps); + + if (bareRoutePush(this.props, nextProps)) { + this.fetchTrendingCommunities(); } } @@ -661,34 +676,23 @@ export class Home extends Component { ); } - async updateUrl({ - dataType, - listingType, - pageCursor, - sort, - showHidden, - }: Partial) { - const { - dataType: urlDataType, - listingType: urlListingType, - sort: urlSort, - showHidden: urlShowHidden, - } = this.props; - + async updateUrl(props: Partial) { + const { dataType, listingType, pageCursor, sort, showHidden } = { + ...this.props, + ...props, + }; const queryParams: QueryParams = { - dataType: getDataTypeString(dataType ?? urlDataType), - listingType: listingType ?? urlListingType, + dataType: getDataTypeString(dataType ?? DataType.Post), + listingType: listingType, pageCursor: pageCursor, - sort: sort ?? urlSort, - showHidden: showHidden ?? urlShowHidden, + sort: sort, + showHidden: showHidden, }; this.props.history.push({ pathname: "/", search: getQueryString(queryParams), }); - - await this.fetchData(); } get posts() { @@ -854,31 +858,39 @@ export class Home extends Component { }); } - async fetchData() { - const { dataType, pageCursor, listingType, sort, showHidden } = this.props; - + fetchDataToken?: symbol; + async fetchData({ + dataType, + pageCursor, + listingType, + sort, + showHidden, + }: HomeProps) { + const token = (this.fetchDataToken = Symbol()); if (dataType === DataType.Post) { - this.setState({ postsRes: LOADING_REQUEST }); - this.setState({ - postsRes: await HttpService.client.getPosts({ - page_cursor: pageCursor, - limit: fetchLimit, - sort, - saved_only: false, - type_: listingType, - show_hidden: showHidden === "true", - }), + this.setState({ postsRes: LOADING_REQUEST, commentsRes: EMPTY_REQUEST }); + const postsRes = await HttpService.client.getPosts({ + page_cursor: pageCursor, + limit: fetchLimit, + sort, + saved_only: false, + type_: listingType, + show_hidden: showHidden === "true", }); + if (token === this.fetchDataToken) { + this.setState({ postsRes }); + } } else { - this.setState({ commentsRes: LOADING_REQUEST }); - this.setState({ - commentsRes: await HttpService.client.getComments({ - limit: fetchLimit, - sort: postToCommentSortType(sort), - saved_only: false, - type_: listingType, - }), + this.setState({ commentsRes: LOADING_REQUEST, postsRes: EMPTY_REQUEST }); + const commentsRes = await HttpService.client.getComments({ + limit: fetchLimit, + sort: postToCommentSortType(sort), + saved_only: false, + type_: listingType, }); + if (token === this.fetchDataToken) { + this.setState({ commentsRes }); + } } } diff --git a/src/shared/components/home/instances.tsx b/src/shared/components/home/instances.tsx index 8dd4e7f8..315504a8 100644 --- a/src/shared/components/home/instances.tsx +++ b/src/shared/components/home/instances.tsx @@ -26,6 +26,7 @@ import { RouteComponentProps } from "inferno-router/dist/Route"; import { IRoutePropsWithFetch } from "../../routes"; import { resourcesSettled } from "@utils/helpers"; import { scrollMixin } from "../mixins/scroll-mixin"; +import { isBrowser } from "@utils/browser"; type InstancesData = RouteDataResponse<{ federatedInstancesResponse: GetFederatedInstancesResponse; @@ -71,8 +72,8 @@ export class Instances extends Component { } } - async componentDidMount() { - if (!this.state.isIsomorphic) { + async componentWillMount() { + if (!this.state.isIsomorphic && isBrowser()) { await this.fetchInstances(); } } diff --git a/src/shared/components/home/setup.tsx b/src/shared/components/home/setup.tsx index 6775f548..e2b9cee3 100644 --- a/src/shared/components/home/setup.tsx +++ b/src/shared/components/home/setup.tsx @@ -19,6 +19,7 @@ import PasswordInput from "../common/password-input"; import { SiteForm } from "./site-form"; import { simpleScrollMixin } from "../mixins/scroll-mixin"; import { RouteComponentProps } from "inferno-router/dist/Route"; +import { isBrowser } from "@utils/browser"; interface State { form: { @@ -61,8 +62,10 @@ export class Setup extends Component< this.handleCreateSite = this.handleCreateSite.bind(this); } - async componentDidMount() { - this.setState({ themeList: await fetchThemeList() }); + async componentWillMount() { + if (isBrowser()) { + this.setState({ themeList: await fetchThemeList() }); + } } get documentTitle(): string { diff --git a/src/shared/components/home/signup.tsx b/src/shared/components/home/signup.tsx index a6705bdc..7c6c9471 100644 --- a/src/shared/components/home/signup.tsx +++ b/src/shared/components/home/signup.tsx @@ -76,8 +76,11 @@ export class Signup extends Component< this.handleAnswerChange = this.handleAnswerChange.bind(this); } - async componentDidMount() { - if (this.state.siteRes.site_view.local_site.captcha_enabled) { + async componentWillMount() { + if ( + this.state.siteRes.site_view.local_site.captcha_enabled && + isBrowser() + ) { await this.fetchCaptcha(); } } diff --git a/src/shared/components/mixins/modal-mixin.ts b/src/shared/components/mixins/modal-mixin.ts index 5108f772..6e7874c7 100644 --- a/src/shared/components/mixins/modal-mixin.ts +++ b/src/shared/components/mixins/modal-mixin.ts @@ -2,7 +2,7 @@ import { Modal } from "bootstrap"; import { Component, InfernoNode, RefObject } from "inferno"; export function modalMixin< - P extends { show: boolean }, + P extends { show?: boolean }, S, Base extends new (...args: any[]) => Component & { readonly modalDivRef: RefObject; diff --git a/src/shared/components/mixins/scroll-mixin.ts b/src/shared/components/mixins/scroll-mixin.ts index e327f3f3..5beeb2c2 100644 --- a/src/shared/components/mixins/scroll-mixin.ts +++ b/src/shared/components/mixins/scroll-mixin.ts @@ -1,6 +1,6 @@ import { isBrowser, nextUserAction, snapToTop } from "../../utils/browser"; import { Component, InfernoNode } from "inferno"; -import { Location } from "history"; +import { Location, History, Action } from "history"; function restoreScrollPosition(props: { location: Location }) { const key: string = props.location.key; @@ -25,7 +25,7 @@ function dropScrollPosition(props: { location: Location }) { } export function scrollMixin< - P extends { location: Location }, + P extends { location: Location; history: History }, S, Base extends new ( ...args: any @@ -68,10 +68,11 @@ export function scrollMixin< nextProps: Readonly<{ children?: InfernoNode } & P>, nextContext: any, ) { - // Currently this is hypothetical. Components unmount before route changes. if (this.props.location.key !== nextProps.location.key) { - this.saveFinalPosition(); - this.reset(); + if (nextProps.history.action !== Action.Replace) { + this.saveFinalPosition(); + this.reset(); + } } return super.componentWillReceiveProps?.(nextProps, nextContext); } @@ -131,7 +132,7 @@ export function scrollMixin< } export function simpleScrollMixin< - P extends { location: Location }, + P extends { location: Location; history: History }, S, Base extends new (...args: any) => Component, >(base: Base, _context?: ClassDecoratorContext) { diff --git a/src/shared/components/modlog.tsx b/src/shared/components/modlog.tsx index 45cbdf6f..d055fbbd 100644 --- a/src/shared/components/modlog.tsx +++ b/src/shared/components/modlog.tsx @@ -1,9 +1,4 @@ -import { - fetchUsers, - getUpdatedSearchId, - personToChoice, - setIsoData, -} from "@utils/app"; +import { fetchUsers, personToChoice, setIsoData } from "@utils/app"; import { debounce, formatPastDate, @@ -12,6 +7,7 @@ import { getQueryParams, getQueryString, resourcesSettled, + bareRoutePush, } from "@utils/helpers"; import { scrollMixin } from "./mixins/scroll-mixin"; import { amAdmin, amMod } from "@utils/roles"; @@ -66,6 +62,8 @@ import { CommunityLink } from "./community/community-link"; import { PersonListing } from "./person/person-listing"; import { getHttpBaseInternal } from "../utils/env"; import { IRoutePropsWithFetch } from "../routes"; +import { isBrowser } from "@utils/browser"; +import { LoadingEllipses } from "./common/loading-ellipses"; type FilterType = "mod" | "user"; @@ -703,40 +701,68 @@ export class Modlog extends Component { } } - async componentDidMount() { - if (!this.state.isIsomorphic) { - const { modId, userId } = this.props; - const promises = [this.refetch()]; + async componentWillMount() { + if (!this.state.isIsomorphic && isBrowser()) { + await Promise.all([ + this.fetchModlog(this.props), + this.fetchCommunity(this.props), + this.fetchUser(this.props), + this.fetchMod(this.props), + ]); + } + } - if (userId) { - promises.push( - HttpService.client - .getPersonDetails({ person_id: userId }) - .then(res => { - if (res.state === "success") { - this.setState({ - userSearchOptions: [personToChoice(res.data.person_view)], - }); - } - }), - ); + componentWillReceiveProps(nextProps: ModlogRouteProps) { + this.fetchModlog(nextProps); + + const reload = bareRoutePush(this.props, nextProps); + + if (nextProps.modId !== this.props.modId || reload) { + this.fetchMod(nextProps); + } + if (nextProps.userId !== this.props.userId || reload) { + this.fetchUser(nextProps); + } + if ( + nextProps.match.params.communityId !== + this.props.match.params.communityId || + reload + ) { + this.fetchCommunity(nextProps); + } + } + + fetchUserToken?: symbol; + async fetchUser(props: ModlogRouteProps) { + const token = (this.fetchUserToken = Symbol()); + const { userId } = props; + + if (userId) { + const res = await HttpService.client.getPersonDetails({ + person_id: userId, + }); + if (res.state === "success" && token === this.fetchUserToken) { + this.setState({ + userSearchOptions: [personToChoice(res.data.person_view)], + }); } + } + } - if (modId) { - promises.push( - HttpService.client - .getPersonDetails({ person_id: modId }) - .then(res => { - if (res.state === "success") { - this.setState({ - modSearchOptions: [personToChoice(res.data.person_view)], - }); - } - }), - ); + fetchModToken?: symbol; + async fetchMod(props: ModlogRouteProps) { + const token = (this.fetchModToken = Symbol()); + const { modId } = props; + + if (modId) { + const res = await HttpService.client.getPersonDetails({ + person_id: modId, + }); + if (res.state === "success" && token === this.fetchModToken) { + this.setState({ + modSearchOptions: [personToChoice(res.data.person_view)], + }); } - - await Promise.all(promises); } } @@ -793,6 +819,11 @@ export class Modlog extends Component { modSearchOptions, } = this.state; const { actionType, modId, userId } = this.props; + const { communityId } = this.props.match.params; + + const communityState = this.state.communityRes.state; + const communityResp = + communityState === "success" && this.state.communityRes.data; return (
@@ -816,15 +847,26 @@ export class Modlog extends Component { ###
- {this.state.communityRes.state === "success" && ( + {communityId && (
- - /c/{this.state.communityRes.data.community_view.community.name}{" "} - - {I18NextService.i18n.t("modlog")} + {communityResp ? ( + <> + + /c/{communityResp.community_view.community.name} + {" "} + {I18NextService.i18n.t("modlog")} + + ) : ( + communityState === "loading" && ( + <> + +   + + ) + )}
)}
@@ -935,6 +977,10 @@ export class Modlog extends Component { } handleSearchUsers = debounce(async (text: string) => { + if (!text.length) { + return; + } + const { userId } = this.props; const { userSearchOptions } = this.state; this.setState({ loadingUserSearch: true }); @@ -952,6 +998,10 @@ export class Modlog extends Component { }); handleSearchMods = debounce(async (text: string) => { + if (!text.length) { + return; + } + const { modId } = this.props; const { modSearchOptions } = this.state; this.setState({ loadingModSearch: true }); @@ -968,61 +1018,73 @@ export class Modlog extends Component { }); }); - async updateUrl({ actionType, modId, page, userId }: Partial) { + async updateUrl(props: Partial) { const { - page: urlPage, - actionType: urlActionType, - modId: urlModId, - userId: urlUserId, - } = this.props; + actionType, + modId, + page, + userId, + match: { + params: { communityId }, + }, + } = { ...this.props, ...props }; const queryParams: QueryParams = { - page: (page ?? urlPage).toString(), - actionType: actionType ?? urlActionType, - modId: getUpdatedSearchId(modId, urlModId), - userId: getUpdatedSearchId(userId, urlUserId), + page: page.toString(), + actionType: actionType, + modId: modId?.toString(), + userId: userId?.toString(), }; - const communityId = this.props.match.params.communityId; - this.props.history.push( `/modlog${communityId ? `/${communityId}` : ""}${getQueryString( queryParams, )}`, ); - - await this.refetch(); } - async refetch() { - const { actionType, page, modId, userId, postId, commentId } = this.props; - const { communityId: urlCommunityId } = this.props.match.params; + fetchModlogToken?: symbol; + async fetchModlog(props: ModlogRouteProps) { + const token = (this.fetchModlogToken = Symbol()); + const { actionType, page, modId, userId, postId, commentId } = props; + const { communityId: urlCommunityId } = props.match.params; const communityId = getIdFromString(urlCommunityId); this.setState({ res: LOADING_REQUEST }); - this.setState({ - res: await HttpService.client.getModlog({ - community_id: communityId, - page, - limit: fetchLimit, - type_: actionType, - other_person_id: userId, - mod_person_id: !this.isoData.site_res.site_view.local_site - .hide_modlog_mod_names - ? modId - : undefined, - comment_id: commentId, - post_id: postId, - }), + const res = await HttpService.client.getModlog({ + community_id: communityId, + page, + limit: fetchLimit, + type_: actionType, + other_person_id: userId, + mod_person_id: !this.isoData.site_res.site_view.local_site + .hide_modlog_mod_names + ? modId + : undefined, + comment_id: commentId, + post_id: postId, }); + if (token === this.fetchModlogToken) { + this.setState({ res }); + } + } + + fetchCommunityToken?: symbol; + async fetchCommunity(props: ModlogRouteProps) { + const token = (this.fetchCommunityToken = Symbol()); + const { communityId: urlCommunityId } = props.match.params; + const communityId = getIdFromString(urlCommunityId); if (communityId) { this.setState({ communityRes: LOADING_REQUEST }); - this.setState({ - communityRes: await HttpService.client.getCommunity({ - id: communityId, - }), + const communityRes = await HttpService.client.getCommunity({ + id: communityId, }); + if (token === this.fetchCommunityToken) { + this.setState({ communityRes }); + } + } else { + this.setState({ communityRes: EMPTY_REQUEST }); } } diff --git a/src/shared/components/person/inbox.tsx b/src/shared/components/person/inbox.tsx index 0cd6e01f..aaa1a614 100644 --- a/src/shared/components/person/inbox.tsx +++ b/src/shared/components/person/inbox.tsx @@ -89,6 +89,7 @@ import { getHttpBaseInternal } from "../../utils/env"; import { CommentsLoadingSkeleton } from "../common/loading-skeleton"; import { RouteComponentProps } from "inferno-router/dist/Route"; import { IRoutePropsWithFetch } from "../../routes"; +import { isBrowser } from "@utils/browser"; enum UnreadOrAll { Unread, @@ -213,8 +214,8 @@ export class Inbox extends Component { } } - async componentDidMount() { - if (!this.state.isIsomorphic) { + async componentWillMount() { + if (!this.state.isIsomorphic && isBrowser()) { await this.refetch(); } } @@ -784,40 +785,60 @@ export class Inbox extends Component { return inboxData; } + refetchToken?: symbol; async refetch() { + const token = (this.refetchToken = Symbol()); const sort = this.state.sort; const unread_only = this.state.unreadOrAll === UnreadOrAll.Unread; const page = this.state.page; const limit = fetchLimit; - this.setState({ repliesRes: LOADING_REQUEST }); this.setState({ - repliesRes: await HttpService.client.getReplies({ + repliesRes: LOADING_REQUEST, + mentionsRes: LOADING_REQUEST, + messagesRes: LOADING_REQUEST, + }); + const repliesPromise = HttpService.client + .getReplies({ sort, unread_only, page, limit, - }), - }); + }) + .then(repliesRes => { + if (token === this.refetchToken) { + this.setState({ + repliesRes, + }); + } + }); - this.setState({ mentionsRes: LOADING_REQUEST }); - this.setState({ - mentionsRes: await HttpService.client.getPersonMentions({ + const mentionsPromise = HttpService.client + .getPersonMentions({ sort, unread_only, page, limit, - }), - }); + }) + .then(mentionsRes => { + if (token === this.refetchToken) { + this.setState({ mentionsRes }); + } + }); - this.setState({ messagesRes: LOADING_REQUEST }); - this.setState({ - messagesRes: await HttpService.client.getPrivateMessages({ + const messagesPromise = HttpService.client + .getPrivateMessages({ unread_only, page, limit, - }), - }); + }) + .then(messagesRes => { + if (token === this.refetchToken) { + this.setState({ messagesRes }); + } + }); + + await Promise.all([repliesPromise, mentionsPromise, messagesPromise]); UnreadCounterService.Instance.updateInboxCounts(); } diff --git a/src/shared/components/person/profile.tsx b/src/shared/components/person/profile.tsx index 9b29eafc..861c9367 100644 --- a/src/shared/components/person/profile.tsx +++ b/src/shared/components/person/profile.tsx @@ -19,6 +19,7 @@ import { numToSI, randomStr, resourcesSettled, + bareRoutePush, } from "@utils/helpers"; import { canMod } from "@utils/roles"; import type { QueryParams } from "@utils/types"; @@ -100,6 +101,7 @@ import { getHttpBaseInternal } from "../../utils/env"; import { IRoutePropsWithFetch } from "../../routes"; import { MediaUploads } from "../common/media-uploads"; import { cakeDate } from "@utils/helpers"; +import { isBrowser } from "@utils/browser"; type ProfileData = RouteDataResponse<{ personRes: GetPersonDetailsResponse; @@ -108,6 +110,9 @@ type ProfileData = RouteDataResponse<{ interface ProfileState { personRes: RequestState; + // personRes and personDetailsRes point to `===` identical data. This allows + // to render the start of the profile while the new details are loading. + personDetailsRes: RequestState; uploadsRes: RequestState; personBlocked: boolean; banReason?: string; @@ -195,6 +200,7 @@ export class Profile extends Component { private isoData = setIsoData(this.context); state: ProfileState = { personRes: EMPTY_REQUEST, + personDetailsRes: EMPTY_REQUEST, uploadsRes: EMPTY_REQUEST, personBlocked: false, siteRes: this.isoData.site_res, @@ -205,7 +211,12 @@ export class Profile extends Component { }; loadingSettled() { - return resourcesSettled([this.state.personRes]); + return resourcesSettled([ + this.state.personRes, + this.props.view === PersonDetailsView.Uploads + ? this.state.uploadsRes + : this.state.personDetailsRes, + ]); } constructor(props: ProfileRouteProps, context: any) { @@ -253,6 +264,7 @@ export class Profile extends Component { this.state = { ...this.state, personRes, + personDetailsRes: personRes, uploadsRes, isIsomorphic: true, personBlocked: isPersonBlocked(personRes), @@ -260,37 +272,96 @@ export class Profile extends Component { } } - async componentDidMount() { - if (!this.state.isIsomorphic) { - await this.fetchUserData(); + async componentWillMount() { + if (!this.state.isIsomorphic && isBrowser()) { + await this.fetchUserData(this.props, true); } } - async fetchUserData() { - const { page, sort, view } = this.props; + componentWillReceiveProps(nextProps: ProfileRouteProps) { + // Overview, Posts and Comments views can use the same data. + const sharedViewTypes = [nextProps.view, this.props.view].every( + v => + v === PersonDetailsView.Overview || + v === PersonDetailsView.Posts || + v === PersonDetailsView.Comments, + ); + + const reload = bareRoutePush(this.props, nextProps); + + const newUsername = + nextProps.match.params.username !== this.props.match.params.username; + + if ( + (nextProps.view !== this.props.view && !sharedViewTypes) || + nextProps.sort !== this.props.sort || + nextProps.page !== this.props.page || + newUsername || + reload + ) { + this.fetchUserData(nextProps, reload || newUsername); + } + } + + fetchUploadsToken?: symbol; + async fetchUploads(props: ProfileRouteProps) { + const token = (this.fetchUploadsToken = Symbol()); + const { page } = props; + this.setState({ uploadsRes: LOADING_REQUEST }); + const form: ListMedia = { + // userId? + page, + limit: fetchLimit, + }; + const uploadsRes = await HttpService.client.listMedia(form); + if (token === this.fetchUploadsToken) { + this.setState({ uploadsRes }); + } + } + + fetchUserDataToken?: symbol; + async fetchUserData(props: ProfileRouteProps, showBothLoading = false) { + const token = (this.fetchUploadsToken = this.fetchUserDataToken = Symbol()); + const { page, sort, view } = props; + + if (view === PersonDetailsView.Uploads) { + this.fetchUploads(props); + if (!showBothLoading) { + return; + } + this.setState({ + personRes: LOADING_REQUEST, + personDetailsRes: LOADING_REQUEST, + }); + } else { + if (showBothLoading) { + this.setState({ + personRes: LOADING_REQUEST, + personDetailsRes: LOADING_REQUEST, + uploadsRes: EMPTY_REQUEST, + }); + } else { + this.setState({ + personDetailsRes: LOADING_REQUEST, + uploadsRes: EMPTY_REQUEST, + }); + } + } - this.setState({ personRes: LOADING_REQUEST }); const personRes = await HttpService.client.getPersonDetails({ - username: this.props.match.params.username, + username: props.match.params.username, sort, saved_only: view === PersonDetailsView.Saved, page, limit: fetchLimit, }); - this.setState({ - personRes, - personBlocked: isPersonBlocked(personRes), - }); - - if (view === PersonDetailsView.Uploads) { - this.setState({ uploadsRes: LOADING_REQUEST }); - const form: ListMedia = { - page, - limit: fetchLimit, - }; - const uploadsRes = await HttpService.client.listMedia(form); - this.setState({ uploadsRes }); + if (token === this.fetchUserDataToken) { + this.setState({ + personRes, + personDetailsRes: personRes, + personBlocked: isPersonBlocked(personRes), + }); } } @@ -384,6 +455,10 @@ export class Profile extends Component { const personRes = this.state.personRes.data; const { page, sort, view } = this.props; + const personDetailsState = this.state.personDetailsRes.state; + const personDetailsRes = + personDetailsState === "success" && this.state.personDetailsRes.data; + return (
@@ -403,50 +478,59 @@ export class Profile extends Component { {this.renderUploadsRes()} - {}} - /> + {personDetailsState === "loading" && + this.props.view !== PersonDetailsView.Uploads ? ( +
+ +
+ ) : ( + personDetailsRes && ( + {}} + /> + ) + )}
@@ -788,19 +872,23 @@ export class Profile extends Component { ); } - async updateUrl({ page, sort, view }: Partial) { - const { page: urlPage, sort: urlSort, view: urlView } = this.props; + async updateUrl(props: Partial) { + const { + page, + sort, + view, + match: { + params: { username }, + }, + } = { ...this.props, ...props }; const queryParams: QueryParams = { - page: (page ?? urlPage).toString(), - sort: sort ?? urlSort, - view: view ?? urlView, + page: page?.toString(), + sort, + view, }; - const { username } = this.props.match.params; - this.props.history.push(`/u/${username}${getQueryString(queryParams)}`); - await this.fetchUserData(); } handlePageChange(page: number) { diff --git a/src/shared/components/person/registration-applications.tsx b/src/shared/components/person/registration-applications.tsx index d738f1ce..3c261d1f 100644 --- a/src/shared/components/person/registration-applications.tsx +++ b/src/shared/components/person/registration-applications.tsx @@ -29,6 +29,7 @@ import { UnreadCounterService } from "../../services"; import { getHttpBaseInternal } from "../../utils/env"; import { RouteComponentProps } from "inferno-router/dist/Route"; import { IRoutePropsWithFetch } from "../../routes"; +import { isBrowser } from "@utils/browser"; enum RegistrationState { Unread, @@ -92,8 +93,8 @@ export class RegistrationApplications extends Component< } } - async componentDidMount() { - if (!this.state.isIsomorphic) { + async componentWillMount() { + if (!this.state.isIsomorphic && isBrowser()) { await this.refetch(); } } @@ -108,37 +109,41 @@ export class RegistrationApplications extends Component< } renderApps() { - switch (this.state.appsRes.state) { - case "loading": - return ( -
- -
- ); - case "success": { - const apps = this.state.appsRes.data.registration_applications; - return ( -
-
- -

- {I18NextService.i18n.t("registration_applications")} -

- {this.selects()} + const appsState = this.state.appsRes.state; + const apps = + appsState === "success" && + this.state.appsRes.data.registration_applications; + + return ( +
+
+ +

+ {I18NextService.i18n.t("registration_applications")} +

+ {this.selects()} + {apps ? ( + <> {this.applicationList(apps)} apps.length} /> -
-
- ); - } - } + + ) : ( + appsState === "loading" && ( +
+ +
+ ) + )} +
+
+ ); } render() { @@ -263,19 +268,22 @@ export class RegistrationApplications extends Component< }; } + refetchToken?: symbol; async refetch() { + const token = (this.refetchToken = Symbol()); const unread_only = this.state.registrationState === RegistrationState.Unread; this.setState({ appsRes: LOADING_REQUEST, }); - this.setState({ - appsRes: await HttpService.client.listRegistrationApplications({ - unread_only: unread_only, - page: this.state.page, - limit: fetchLimit, - }), + const appsRes = await HttpService.client.listRegistrationApplications({ + unread_only: unread_only, + page: this.state.page, + limit: fetchLimit, }); + if (token === this.refetchToken) { + this.setState({ appsRes }); + } } async handleApproveApplication(form: ApproveRegistrationApplication) { diff --git a/src/shared/components/person/reports.tsx b/src/shared/components/person/reports.tsx index e815921f..6215051e 100644 --- a/src/shared/components/person/reports.tsx +++ b/src/shared/components/person/reports.tsx @@ -56,6 +56,7 @@ import { UnreadCounterService } from "../../services"; import { getHttpBaseInternal } from "../../utils/env"; import { RouteComponentProps } from "inferno-router/dist/Route"; import { IRoutePropsWithFetch } from "../../routes"; +import { isBrowser } from "@utils/browser"; enum UnreadOrAll { Unread, @@ -160,8 +161,8 @@ export class Reports extends Component { } } - async componentDidMount() { - if (!this.state.isIsomorphic) { + async componentWillMount() { + if (!this.state.isIsomorphic && isBrowser()) { await this.refetch(); } } @@ -452,9 +453,22 @@ export class Reports extends Component { } all() { + const combined = this.buildCombined; + if ( + combined.length === 0 && + (this.state.commentReportsRes.state === "loading" || + this.state.postReportsRes.state === "loading" || + this.state.messageReportsRes.state === "loading") + ) { + return ( +
+ +
+ ); + } return (
- {this.buildCombined.map(i => ( + {combined.map(i => ( <>
{this.renderItemType(i)} @@ -575,6 +589,7 @@ export class Reports extends Component { static async fetchInitialData({ headers, + site, }: InitialFetchRequest): Promise { const client = wrapClient( new LemmyHttp(getHttpBaseInternal(), { headers }), @@ -601,7 +616,7 @@ export class Reports extends Component { messageReportsRes: EMPTY_REQUEST, }; - if (amAdmin()) { + if (amAdmin(site.my_user)) { const privateMessageReportsForm: ListPrivateMessageReports = { unresolved_only, page, @@ -616,7 +631,9 @@ export class Reports extends Component { return data; } + refetchToken?: symbol; async refetch() { + const token = (this.refetchToken = Symbol()); const unresolved_only = this.state.unreadOrAll === UnreadOrAll.Unread; const page = this.state.page; const limit = fetchLimit; @@ -636,17 +653,30 @@ export class Reports extends Component { limit, }; - this.setState({ - commentReportsRes: await HttpService.client.listCommentReports(form), - postReportsRes: await HttpService.client.listPostReports(form), - }); + const commentReportPromise = HttpService.client + .listCommentReports(form) + .then(commentReportsRes => { + if (token === this.refetchToken) { + this.setState({ commentReportsRes }); + } + }); + const postReportPromise = HttpService.client + .listPostReports(form) + .then(postReportsRes => { + if (token === this.refetchToken) { + this.setState({ postReportsRes }); + } + }); if (amAdmin()) { - this.setState({ - messageReportsRes: - await HttpService.client.listPrivateMessageReports(form), - }); + const messageReportsRes = + await HttpService.client.listPrivateMessageReports(form); + if (token === this.refetchToken) { + this.setState({ messageReportsRes }); + } } + + await Promise.all([commentReportPromise, postReportPromise]); } async handleResolveCommentReport(form: ResolveCommentReport) { diff --git a/src/shared/components/person/settings.tsx b/src/shared/components/person/settings.tsx index ff2121df..27958eb9 100644 --- a/src/shared/components/person/settings.tsx +++ b/src/shared/components/person/settings.tsx @@ -348,7 +348,7 @@ export class Settings extends Component { } } - async componentDidMount() { + async componentWillMount() { this.setState({ themeList: await fetchThemeList() }); if (!this.state.isIsomorphic) { diff --git a/src/shared/components/person/verify-email.tsx b/src/shared/components/person/verify-email.tsx index 73a5c99d..5ec238cf 100644 --- a/src/shared/components/person/verify-email.tsx +++ b/src/shared/components/person/verify-email.tsx @@ -13,6 +13,7 @@ import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; import { simpleScrollMixin } from "../mixins/scroll-mixin"; import { RouteComponentProps } from "inferno-router/dist/Route"; +import { isBrowser } from "@utils/browser"; interface State { verifyRes: RequestState; @@ -52,8 +53,10 @@ export class VerifyEmail extends Component< } } - async componentDidMount() { - await this.verify(); + async componentWillMount() { + if (isBrowser()) { + await this.verify(); + } } get documentTitle(): string { diff --git a/src/shared/components/post/create-post.tsx b/src/shared/components/post/create-post.tsx index 26dda7c4..a4e533df 100644 --- a/src/shared/components/post/create-post.tsx +++ b/src/shared/components/post/create-post.tsx @@ -33,6 +33,7 @@ import { getHttpBaseInternal } from "../../utils/env"; import { IRoutePropsWithFetch } from "../../routes"; import { simpleScrollMixin } from "../mixins/scroll-mixin"; import { toast } from "../../toast"; +import { isBrowser } from "@utils/browser"; export interface CreatePostProps { communityId?: number; @@ -81,7 +82,7 @@ export class CreatePost extends Component< private isoData = setIsoData(this.context); state: CreatePostState = { siteRes: this.isoData.site_res, - loading: true, + loading: false, initialCommunitiesRes: EMPTY_REQUEST, isIsomorphic: false, }; @@ -132,9 +133,9 @@ export class CreatePost extends Component< } } - async componentDidMount() { + async componentWillMount() { // TODO test this - if (!this.state.isIsomorphic) { + if (!this.state.isIsomorphic && isBrowser()) { const { communityId } = this.props; const initialCommunitiesRes = await fetchCommunitiesForOptions( diff --git a/src/shared/components/post/post-form.tsx b/src/shared/components/post/post-form.tsx index 771ff3b8..102911e9 100644 --- a/src/shared/components/post/post-form.tsx +++ b/src/shared/components/post/post-form.tsx @@ -10,7 +10,7 @@ import { import { isImage } from "@utils/media"; import { Choice } from "@utils/types"; import autosize from "autosize"; -import { Component, InfernoNode, linkEvent } from "inferno"; +import { Component, InfernoNode, createRef, linkEvent } from "inferno"; import { Prompt } from "inferno-router"; import { CommunityView, @@ -132,8 +132,9 @@ function copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) { ); d.i.setState({ suggestedPostsRes: EMPTY_REQUEST }); setTimeout(() => { - const textarea: any = document.getElementById("post-title"); - autosize.update(textarea); + if (d.i.postTitleRef.current) { + autosize.update(d.i.postTitleRef.current); + } }, 10); } } @@ -248,6 +249,8 @@ export class PostForm extends Component { submitted: false, }; + postTitleRef = createRef(); + constructor(props: PostFormProps, context: any) { super(props, context); this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this)); @@ -306,17 +309,19 @@ export class PostForm extends Component { } componentDidMount() { - const textarea: any = document.getElementById("post-title"); - - if (textarea) { - autosize(textarea); + if (this.postTitleRef.current) { + autosize(this.postTitleRef.current); } } componentWillReceiveProps( nextProps: Readonly<{ children?: InfernoNode } & PostFormProps>, ): void { - if (this.props !== nextProps) { + if ( + this.props.selectedCommunityChoice?.value !== + nextProps.selectedCommunityChoice?.value && + nextProps.selectedCommunityChoice + ) { this.setState( s => ( (s.form.community_id = getIdFromString( @@ -325,6 +330,22 @@ export class PostForm extends Component { s ), ); + this.setState({ + communitySearchOptions: [nextProps.selectedCommunityChoice].concat( + (nextProps.initialCommunities?.map(communityToChoice) ?? []).filter( + option => option.value !== nextProps.selectedCommunityChoice?.value, + ), + ), + }); + } + if ( + !this.props.initialCommunities?.length && + nextProps.initialCommunities?.length + ) { + this.setState({ + communitySearchOptions: + nextProps.initialCommunities?.map(communityToChoice) ?? [], + }); } } @@ -362,6 +383,7 @@ export class PostForm extends Component { rows={1} minLength={3} maxLength={MAX_POST_TITLE_LENGTH} + ref={this.postTitleRef} /> {!validTitle(this.state.form.name) && (
diff --git a/src/shared/components/post/post-listing.tsx b/src/shared/components/post/post-listing.tsx index 3f9e2c1c..4476d48c 100644 --- a/src/shared/components/post/post-listing.tsx +++ b/src/shared/components/post/post-listing.tsx @@ -137,7 +137,9 @@ export class PostListing extends Component { this.handleHidePost = this.handleHidePost.bind(this); } - componentDidMount(): void { + unlisten = () => {}; + + componentWillMount(): void { if ( UserService.Instance.myUserInfo && !this.isoData.showAdultConsentModal @@ -148,6 +150,17 @@ export class PostListing extends Component { imageExpanded: auto_expand && !(blur_nsfw && this.postView.post.nsfw), }); } + + // Leave edit mode on navigation + this.unlisten = this.context.router.history.listen(() => { + if (this.state.showEdit) { + this.setState({ showEdit: false }); + } + }); + } + + componentWillUnmount(): void { + this.unlisten(); } get postView(): PostView { diff --git a/src/shared/components/post/post.tsx b/src/shared/components/post/post.tsx index f5a90af8..0ae92134 100644 --- a/src/shared/components/post/post.tsx +++ b/src/shared/components/post/post.tsx @@ -18,15 +18,17 @@ import { isBrowser } from "@utils/browser"; import { debounce, getApubName, + getQueryParams, + getQueryString, randomStr, resourcesSettled, + bareRoutePush, } from "@utils/helpers"; import { scrollMixin } from "../mixins/scroll-mixin"; import { isImage } from "@utils/media"; -import { RouteDataResponse } from "@utils/types"; -import autosize from "autosize"; +import { QueryParams, RouteDataResponse } from "@utils/types"; import classNames from "classnames"; -import { Component, RefObject, createRef, linkEvent } from "inferno"; +import { Component, createRef, linkEvent } from "inferno"; import { AddAdmin, AddModToCommunity, @@ -103,6 +105,7 @@ import { PostListing } from "./post-listing"; import { getHttpBaseInternal } from "../../utils/env"; import { RouteComponentProps } from "inferno-router/dist/Route"; import { IRoutePropsWithFetch } from "../../routes"; +import { compareAsc, compareDesc } from "date-fns"; const commentsShownInterval = 15; @@ -112,44 +115,107 @@ type PostData = RouteDataResponse<{ }>; interface PostState { - postId?: number; - commentId?: number; postRes: RequestState; commentsRes: RequestState; - commentSort: CommentSortType; - commentViewType: CommentViewType; - scrolled?: boolean; siteRes: GetSiteResponse; - commentSectionRef?: RefObject; showSidebarMobile: boolean; maxCommentsShown: number; finished: Map; isIsomorphic: boolean; } -type PostPathProps = - | { post_id: string; comment_id: never } - | { post_id: never; comment_id: string }; -type PostRouteProps = RouteComponentProps & - Record; +const defaultCommentSort: CommentSortType = "Hot"; + +function getCommentSortTypeFromQuery(source?: string): CommentSortType { + if (!source) { + return defaultCommentSort; + } + switch (source) { + case "Hot": + case "Top": + case "New": + case "Old": + case "Controversial": + return source; + default: + return defaultCommentSort; + } +} + +function getQueryStringFromCommentSortType( + sort: CommentSortType, +): undefined | string { + if (sort === defaultCommentSort) { + return undefined; + } + return sort; +} + +const defaultCommentView: CommentViewType = CommentViewType.Tree; + +function getCommentViewTypeFromQuery(source?: string): CommentViewType { + switch (source) { + case "Tree": + return CommentViewType.Tree; + case "Flat": + return CommentViewType.Flat; + default: + return defaultCommentView; + } +} + +function getQueryStringFromCommentView( + view: CommentViewType, +): string | undefined { + if (view === defaultCommentView) { + return undefined; + } + switch (view) { + case CommentViewType.Tree: + return "Tree"; + case CommentViewType.Flat: + return "Flat"; + default: + return undefined; + } +} + +interface PostProps { + sort: CommentSortType; + view: CommentViewType; + scrollToComments: boolean; +} +export function getPostQueryParams(source: string | undefined): PostProps { + return getQueryParams( + { + scrollToComments: (s?: string) => !!s, + sort: getCommentSortTypeFromQuery, + view: getCommentViewTypeFromQuery, + }, + source, + ); +} + +type PostPathProps = { post_id?: string; comment_id?: string }; +type PostRouteProps = RouteComponentProps & PostProps; +type PartialPostRouteProps = Partial< + PostProps & { match: { params: PostPathProps } } +>; export type PostFetchConfig = IRoutePropsWithFetch< PostData, PostPathProps, - Record + PostProps >; @scrollMixin export class Post extends Component { private isoData = setIsoData(this.context); private commentScrollDebounced: () => void; + private shouldScrollToComments: boolean = false; + private commentSectionRef = createRef(); state: PostState = { postRes: EMPTY_REQUEST, commentsRes: EMPTY_REQUEST, - postId: getIdFromProps(this.props), - commentId: getCommentIdFromProps(this.props), - commentSort: "Hot", - commentViewType: CommentViewType.Tree, - scrolled: false, siteRes: this.isoData.site_res, showSidebarMobile: false, maxCommentsShown: commentsShownInterval, @@ -201,8 +267,6 @@ export class Post extends Component { this.handleScrollIntoCommentsClick = this.handleScrollIntoCommentsClick.bind(this); - this.state = { ...this.state, commentSectionRef: createRef() }; - // Only fetch the data if coming from another route if (FirstLoadService.isFirstLoad) { const { commentsRes, postRes } = this.isoData.routeData; @@ -213,71 +277,104 @@ export class Post extends Component { commentsRes, isIsomorphic: true, }; - - if (isBrowser()) { - if (this.checkScrollIntoCommentsParam) { - this.scrollIntoCommentSection(); - } - } } } - async fetchPost() { - this.setState({ - postRes: LOADING_REQUEST, - commentsRes: LOADING_REQUEST, + fetchPostToken?: symbol; + async fetchPost(props: PostRouteProps) { + const token = (this.fetchPostToken = Symbol()); + this.setState({ postRes: LOADING_REQUEST }); + const postRes = await HttpService.client.getPost({ + id: getIdFromProps(props), + comment_id: getCommentIdFromProps(props), }); + if (token === this.fetchPostToken) { + this.setState({ postRes }); + } + } - const [postRes, commentsRes] = await Promise.all([ - await HttpService.client.getPost({ - id: this.state.postId, - comment_id: this.state.commentId, - }), - HttpService.client.getComments({ - post_id: this.state.postId, - parent_id: this.state.commentId, - max_depth: commentTreeMaxDepth, - sort: this.state.commentSort, - type_: "All", - saved_only: false, - }), - ]); - - this.setState({ - postRes, - commentsRes, + fetchCommentsToken?: symbol; + async fetchComments(props: PostRouteProps) { + const token = (this.fetchCommentsToken = Symbol()); + const { sort } = props; + this.setState({ commentsRes: LOADING_REQUEST }); + const commentsRes = await HttpService.client.getComments({ + post_id: getIdFromProps(props), + parent_id: getCommentIdFromProps(props), + max_depth: commentTreeMaxDepth, + sort, + type_: "All", + saved_only: false, }); + if (token === this.fetchCommentsToken) { + this.setState({ commentsRes }); + } + } - if (this.checkScrollIntoCommentsParam) { - this.scrollIntoCommentSection(); + updateUrl(props: PartialPostRouteProps, replace = false) { + const { + view, + sort, + match: { + params: { comment_id, post_id }, + }, + } = { + ...this.props, + ...props, + }; + + const query: QueryParams = { + sort: getQueryStringFromCommentSortType(sort), + view: getQueryStringFromCommentView(view), + }; + + // Not inheriting old scrollToComments + if (props.scrollToComments) { + query.scrollToComments = true.toString(); + } + + let pathname: string | undefined; + if (comment_id && post_id) { + pathname = `/post/${post_id}/${comment_id}`; + } else if (comment_id) { + pathname = `/comment/${comment_id}`; + } else { + pathname = `/post/${post_id}`; + } + + const location = { pathname, search: getQueryString(query) }; + if (replace || this.props.location.pathname === pathname) { + this.props.history.replace(location); + } else { + this.props.history.push(location); } } static async fetchInitialData({ headers, match, - }: InitialFetchRequest): Promise { + query: { sort }, + }: InitialFetchRequest): Promise { const client = wrapClient( new LemmyHttp(getHttpBaseInternal(), { headers }), ); const postId = getIdFromProps({ match }); const commentId = getCommentIdFromProps({ match }); - const postForm: GetPost = {}; + const postForm: GetPost = { + id: postId, + comment_id: commentId, + }; const commentsForm: GetComments = { + post_id: postId, + parent_id: commentId, max_depth: commentTreeMaxDepth, - sort: "Hot", + sort, type_: "All", saved_only: false, }; - postForm.id = postId; - postForm.comment_id = commentId; - - commentsForm.post_id = postId; - commentsForm.parent_id = commentId; - const [postRes, commentsRes] = await Promise.all([ client.getPost(postForm), client.getComments(commentsForm), @@ -293,15 +390,79 @@ export class Post extends Component { document.removeEventListener("scroll", this.commentScrollDebounced); } - async componentDidMount() { - if (!this.state.isIsomorphic) { - await this.fetchPost(); + async componentWillMount() { + if (isBrowser()) { + this.shouldScrollToComments = this.props.scrollToComments; + if (!this.state.isIsomorphic) { + await Promise.all([ + this.fetchPost(this.props), + this.fetchComments(this.props), + ]); + } } + } - autosize(document.querySelectorAll("textarea")); - + componentDidMount() { this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100); document.addEventListener("scroll", this.commentScrollDebounced); + + if (this.state.isIsomorphic) { + this.maybeScrollToComments(); + } + } + + componentWillReceiveProps(nextProps: PostRouteProps): void { + const { post_id: nextPost, comment_id: nextComment } = + nextProps.match.params; + const { post_id: prevPost, comment_id: prevComment } = + this.props.match.params; + + const newOrder = + this.props.sort !== nextProps.sort || this.props.view !== nextProps.view; + + // For comment links restore sort type from current props. + if ( + nextPost === prevPost && + nextComment && + newOrder && + !nextProps.location.search && + nextProps.history.action === "PUSH" + ) { + this.updateUrl({ match: nextProps.match }, true); + return; + } + + const needPost = + prevPost !== nextPost || + (bareRoutePush(this.props, nextProps) && !nextComment); + const needComments = + needPost || + prevComment !== nextComment || + nextProps.sort !== this.props.sort; + + if (needPost) { + this.fetchPost(nextProps); + } + if (needComments) { + this.fetchComments(nextProps); + } + + if ( + nextProps.scrollToComments && + this.props.scrollToComments !== nextProps.scrollToComments + ) { + this.shouldScrollToComments = true; + } + } + + componentDidUpdate(): void { + if ( + this.commentSectionRef.current && + this.state.postRes.state === "success" && + this.state.commentsRes.state === "success" + ) { + this.maybeScrollToComments(); + } } handleScrollIntoCommentsClick(e: MouseEvent) { @@ -309,16 +470,18 @@ export class Post extends Component { e.preventDefault(); } - get checkScrollIntoCommentsParam() { - return ( - Boolean( - new URLSearchParams(this.props.location.search).get("scrollToComments"), - ) && this.props.history.action !== "POP" - ); + maybeScrollToComments() { + if (this.shouldScrollToComments) { + this.shouldScrollToComments = false; + if (this.props.history.action !== "POP" || this.state.isIsomorphic) { + this.scrollIntoCommentSection(); + } + } } scrollIntoCommentSection() { - this.state.commentSectionRef?.current?.scrollIntoView(); + // This doesn't work when in a background tab in firefox. + this.commentSectionRef.current?.scrollIntoView(); } isBottom(el: Element): boolean { @@ -413,13 +576,18 @@ export class Post extends Component { onHidePost={this.handleHidePost} onScrollIntoCommentsClick={this.handleScrollIntoCommentsClick} /> -
+
{/* Only show the top level comment form if its not a context view */} {!( - this.state.commentId || res.post_view.banned_from_community + getCommentIdFromProps(this.props) || + res.post_view.banned_from_community ) && ( stops working + } node={res.post_view.post.id} disabled={res.post_view.post.locked} allLanguages={siteRes.all_languages} @@ -447,10 +615,8 @@ export class Post extends Component { {this.state.showSidebarMobile && this.sidebar()}
{this.sortRadios()} - {this.state.commentViewType === CommentViewType.Tree && - this.commentsTree()} - {this.state.commentViewType === CommentViewType.Flat && - this.commentsFlat()} + {this.props.view === CommentViewType.Tree && this.commentsTree()} + {this.props.view === CommentViewType.Flat && this.commentsFlat()}