Render more while reloading only some resources (#2480)

* AdminSettings remove unused currentTab state

* Fix amAdmin check in reports fetchInitialData

* Make CreatePost render earlier

* Include children of auth and anonymous guard in first render.

* Convert DidMount to WillMount where things don't depend on the DOM

`componentDidMount` is called after the first render. A lot of
components used it to call `setState`, which causes a second render.

* Keep route components mounted during same route navigation

Not sure why this wasn't the case without this change. The only
difference here is that the same array is reused in every render.

* Disable mounted same route navigation by default

* Enable mounted same route navigation for some routes

* Render more while loading

* Prettier markup

* Make Post use query params and reload comments independently

* Fix issue with <Prompt /> for forms that remain mounted after "leaving".

* Make Search not rerender the results on every keystroke

* Discard old requests

These used to (mostly) arrive at the old already unmounted components.
Now they would render briefly until the latest response is received.

* Move non breaking space to modlog

* Make show optional for modals
This commit is contained in:
matc-pub 2024-05-22 21:46:13 +02:00 committed by GitHub
parent 937fd3eb4e
commit b7fe70d8c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1529 additions and 934 deletions

View File

@ -32,6 +32,64 @@ export default class App extends Component<any, any> {
destroyTippy();
}
routes = routes.map(
({
path,
component: RouteComponent,
fetchInitialData,
getQueryParams,
mountedSameRouteNavKey,
}) => (
<Route
key={path}
path={path}
exact
component={routeProps => {
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 (
<ErrorGuard>
<div tabIndex={-1}>
{RouteComponent &&
(isAuthPath(path ?? "") ? (
<AuthGuard {...routeProps}>
<RouteComponent key={key} {...queryProps} />
</AuthGuard>
) : isAnonymousPath(path ?? "") ? (
<AnonymousGuard>
<RouteComponent key={key} {...queryProps} />
</AnonymousGuard>
) : (
<RouteComponent key={key} {...queryProps} />
))}
</div>
</ErrorGuard>
);
}}
/>
),
);
render() {
const siteRes = this.isoData.site_res;
const siteView = siteRes?.site_view;
@ -64,58 +122,7 @@ export default class App extends Component<any, any> {
<Navbar siteRes={siteRes} />
<div className="mt-4 p-0 fl-1">
<Switch>
{routes.map(
({
path,
component: RouteComponent,
fetchInitialData,
getQueryParams,
}) => (
<Route
key={path}
path={path}
exact
component={routeProps => {
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 (
<ErrorGuard>
<div tabIndex={-1}>
{RouteComponent &&
(isAuthPath(path ?? "") ? (
<AuthGuard {...routeProps}>
<RouteComponent {...queryProps} />
</AuthGuard>
) : isAnonymousPath(path ?? "") ? (
<AnonymousGuard>
<RouteComponent {...queryProps} />
</AnonymousGuard>
) : (
<RouteComponent {...queryProps} />
))}
</div>
</ErrorGuard>
);
}}
/>
),
)}
{this.routes}
<Route component={ErrorPage} />
</Switch>
</div>

View File

@ -63,7 +63,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
this.handleOutsideMenuClick = this.handleOutsideMenuClick.bind(this);
}
async componentDidMount() {
async componentWillMount() {
// Subscribe to jwt changes
if (isBrowser()) {
// On the first load, check the unreads

View File

@ -502,7 +502,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
<>
<Link
className={classnames}
to={`/comment/${
to={`/post/${cv.post.id}/${
(this.props.showContext && getCommentParentId(cv.comment)) ||
cv.comment.id
}`}

View File

@ -1,30 +1,25 @@
import { Component } from "inferno";
import { UserService } from "../../services";
import { Spinner } from "./icon";
import { isBrowser } from "@utils/browser";
interface AnonymousGuardState {
hasRedirected: boolean;
}
class AnonymousGuard extends Component<any, AnonymousGuardState> {
state = {
hasRedirected: false,
} as AnonymousGuardState;
class AnonymousGuard extends Component<any, any> {
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 : <Spinner />;
return !this.hasAuth() ? this.props.children : <Spinner />;
}
}

View File

@ -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<Record<string, string>>,
AuthGuardState
any
> {
state = {
hasRedirected: false,
} as AuthGuardState;
constructor(
props: RouteComponentProps<Record<string, string>>,
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 : <Spinner />;
return this.hasAuth() ? this.props.children : <Spinner />;
}
}

View File

@ -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 && (

View File

@ -39,7 +39,7 @@ function handleSearch(i: SearchableSelect, e: ChangeEvent<HTMLInputElement>) {
}
function focusSearch(i: SearchableSelect) {
if (i.toggleButtonRef.current?.ariaExpanded !== "true") {
if (i.toggleButtonRef.current?.ariaExpanded === "true") {
i.searchInputRef.current?.focus();
if (i.props.onSearch) {

View File

@ -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<HTMLDivElement>();
inputRef = createRef<HTMLInputElement>();
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}
>
<div className="modal-dialog modal-dialog-centered modal-fullscreen-sm-down">
<div className="modal-content">
@ -203,6 +198,7 @@ class RemoteFetchModal extends Component<
required
enterKeyHint="go"
inputMode="url"
ref={this.inputRef}
/>
</form>
<footer className="modal-footer">

View File

@ -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();
}
}

View File

@ -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<
</h5>
);
case "success": {
const { listingType, sort, page } = this.props;
return (
<div>
<h1 className="h4 mb-4">
{I18NextService.i18n.t("list_of_communities")}
</h1>
<div className="row g-3 align-items-center mb-2">
<div className="col-auto">
<ListingTypeSelect
type_={listingType}
showLocal={showLocal(this.isoData)}
showSubscribed
onChange={this.handleListingTypeChange}
/>
</div>
<div className="col-auto me-auto">
<SortSelect sort={sort} onChange={this.handleSortChange} />
</div>
<div className="col-auto">{this.searchForm()}</div>
</div>
<div className="table-responsive">
<table
id="community_table"
className="table table-sm table-hover"
>
<thead className="pointer">
<tr>
<th>{I18NextService.i18n.t("name")}</th>
<th className="text-right">
{I18NextService.i18n.t("subscribers")}
</th>
<th className="text-right">
{I18NextService.i18n.t("users")} /{" "}
{I18NextService.i18n.t("month")}
</th>
<th className="text-right d-none d-lg-table-cell">
{I18NextService.i18n.t("posts")}
</th>
<th className="text-right d-none d-lg-table-cell">
{I18NextService.i18n.t("comments")}
</th>
<th></th>
</tr>
</thead>
<tbody>
{this.state.listCommunitiesResponse.data.communities.map(
cv => (
<tr key={cv.community.id}>
<td>
<CommunityLink community={cv.community} />
</td>
<td className="text-right">
{numToSI(cv.counts.subscribers)}
</td>
<td className="text-right">
{numToSI(cv.counts.users_active_month)}
</td>
<td className="text-right d-none d-lg-table-cell">
{numToSI(cv.counts.posts)}
</td>
<td className="text-right d-none d-lg-table-cell">
{numToSI(cv.counts.comments)}
</td>
<td className="text-right">
<SubscribeButton
communityView={cv}
onFollow={linkEvent(
{
i: this,
communityId: cv.community.id,
follow: true,
},
this.handleFollow,
)}
onUnFollow={linkEvent(
{
i: this,
communityId: cv.community.id,
follow: false,
},
this.handleFollow,
)}
isLink
/>
</td>
</tr>
),
)}
</tbody>
</table>
</div>
<Paginator
page={page}
onChange={this.handlePageChange}
nextDisabled={
communityLimit >
this.state.listCommunitiesResponse.data.communities.length
}
/>
</div>
<table id="community_table" className="table table-sm table-hover">
<thead className="pointer">
<tr>
<th>{I18NextService.i18n.t("name")}</th>
<th className="text-right">
{I18NextService.i18n.t("subscribers")}
</th>
<th className="text-right">
{I18NextService.i18n.t("users")} /{" "}
{I18NextService.i18n.t("month")}
</th>
<th className="text-right d-none d-lg-table-cell">
{I18NextService.i18n.t("posts")}
</th>
<th className="text-right d-none d-lg-table-cell">
{I18NextService.i18n.t("comments")}
</th>
<th></th>
</tr>
</thead>
<tbody>
{this.state.listCommunitiesResponse.data.communities.map(cv => (
<tr key={cv.community.id}>
<td>
<CommunityLink community={cv.community} />
</td>
<td className="text-right">
{numToSI(cv.counts.subscribers)}
</td>
<td className="text-right">
{numToSI(cv.counts.users_active_month)}
</td>
<td className="text-right d-none d-lg-table-cell">
{numToSI(cv.counts.posts)}
</td>
<td className="text-right d-none d-lg-table-cell">
{numToSI(cv.counts.comments)}
</td>
<td className="text-right">
<SubscribeButton
communityView={cv}
onFollow={linkEvent(
{
i: this,
communityId: cv.community.id,
follow: true,
},
this.handleFollow,
)}
onUnFollow={linkEvent(
{
i: this,
communityId: cv.community.id,
follow: false,
},
this.handleFollow,
)}
isLink
/>
</td>
</tr>
))}
</tbody>
</table>
);
}
}
}
render() {
const { listingType, sort, page } = this.props;
return (
<div className="communities container-lg">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
{this.renderListings()}
<div>
<h1 className="h4 mb-4">
{I18NextService.i18n.t("list_of_communities")}
</h1>
<div className="row g-3 align-items-center mb-2">
<div className="col-auto">
<ListingTypeSelect
type_={listingType}
showLocal={showLocal(this.isoData)}
showSubscribed
onChange={this.handleListingTypeChange}
/>
</div>
<div className="col-auto me-auto">
<SortSelect sort={sort} onChange={this.handleSortChange} />
</div>
<div className="col-auto">{this.searchForm()}</div>
</div>
<div className="table-responsive">{this.renderListingsTable()}</div>
<Paginator
page={page}
onChange={this.handlePageChange}
nextDisabled={
this.state.listCommunitiesResponse.state !== "success" ||
communityLimit >
this.state.listCommunitiesResponse.data.communities.length
}
/>
</div>
</div>
);
}
@ -287,22 +286,16 @@ export class Communities extends Component<
);
}
async updateUrl({ listingType, sort, page }: Partial<CommunitiesProps>) {
const {
listingType: urlListingType,
sort: urlSort,
page: urlPage,
} = this.props;
async updateUrl(props: Partial<CommunitiesProps>) {
const { listingType, sort, page } = { ...this.props, ...props };
const queryParams: QueryParams<CommunitiesProps> = {
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<CommunityResponse>) {

View File

@ -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<CommunityRouteProps, State> {
}
}
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<CommunityRouteProps, State> {
}
renderCommunity() {
switch (this.state.communityRes.state) {
case "loading":
return (
<h5>
<Spinner large />
</h5>
);
case "success": {
const res = this.state.communityRes.data;
const res =
this.state.communityRes.state === "success" &&
this.state.communityRes.data;
return (
<>
{res && (
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
canonicalPath={res.community_view.community.actor_id}
description={res.community_view.community.description}
image={res.community_view.community.icon}
/>
)}
return (
<>
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
canonicalPath={res.community_view.community.actor_id}
description={res.community_view.community.description}
image={res.community_view.community.icon}
{this.communityInfo()}
<div className="d-block d-md-none">
<button
className="btn btn-secondary d-inline-block mb-2 me-3"
onClick={linkEvent(this, this.handleShowSidebarMobile)}
>
{I18NextService.i18n.t("sidebar")}{" "}
<Icon
icon={
this.state.showSidebarMobile ? `minus-square` : `plus-square`
}
classes="icon-inline"
/>
<div className="row">
<main
className="col-12 col-md-8 col-lg-9"
ref={this.mainContentRef}
>
{this.communityInfo(res)}
<div className="d-block d-md-none">
<button
className="btn btn-secondary d-inline-block mb-2 me-3"
onClick={linkEvent(this, this.handleShowSidebarMobile)}
>
{I18NextService.i18n.t("sidebar")}{" "}
<Icon
icon={
this.state.showSidebarMobile
? `minus-square`
: `plus-square`
}
classes="icon-inline"
/>
</button>
{this.state.showSidebarMobile && this.sidebar(res)}
</div>
{this.selects(res)}
{this.listings(res)}
<PaginatorCursor
nextPage={this.getNextPage}
onNext={this.handlePageNext}
/>
</main>
<aside className="d-none d-md-block col-md-4 col-lg-3">
{this.sidebar(res)}
</aside>
</div>
</>
);
}
}
</button>
{this.state.showSidebarMobile && this.sidebar()}
</div>
</>
);
}
render() {
return (
<div className="community container-lg">{this.renderCommunity()}</div>
<div className="community container-lg">
<div className="row">
<main className="col-12 col-md-8 col-lg-9" ref={this.mainContentRef}>
{this.renderCommunity()}
{this.selects()}
{this.listings()}
<PaginatorCursor
nextPage={this.getNextPage}
onNext={this.handlePageNext}
/>
</main>
<aside className="d-none d-md-block col-md-4 col-lg-3">
{this.sidebar()}
</aside>
</div>
</div>
);
}
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<CommunityRouteProps, State> {
);
}
listings(communityRes: GetCommunityResponse) {
listings() {
const { dataType } = this.props;
const siteRes = this.isoData.site_res;
@ -496,6 +517,9 @@ export class Community extends Component<CommunityRouteProps, State> {
);
}
} else {
if (this.state.communityRes.state !== "success") {
return;
}
switch (this.state.commentsRes.state) {
case "loading":
return <CommentsLoadingSkeleton />;
@ -509,7 +533,7 @@ export class Community extends Component<CommunityRouteProps, State> {
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<CommunityRouteProps, State> {
}
}
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 && (
<div className="mb-2">
<div className="mb-2">
{community && (
<BannerIconHeader banner={community.banner} icon={community.icon} />
<div>
<h1
className="h4 mb-0 overflow-wrap-anywhere d-inline"
data-tippy-content={
community.posting_restricted_to_mods
? I18NextService.i18n.t("community_locked")
: ""
}
>
{community.title}
</h1>
{community.posting_restricted_to_mods && (
<Icon icon="lock" inline classes="text-danger fs-4 ms-2" />
)}
<div>
<h1
className="h4 mb-0 overflow-wrap-anywhere d-inline"
data-tippy-content={
community?.posting_restricted_to_mods
? I18NextService.i18n.t("community_locked")
: ""
}
>
{community?.title ?? (
<>
{urlCommunityName}
<LoadingEllipses />
</>
)}
</div>
</h1>
{community?.posting_restricted_to_mods && (
<Icon icon="lock" inline classes="text-danger fs-4 ms-2" />
)}
</div>
{(community && (
<CommunityLink
community={community}
realLink
@ -566,12 +602,16 @@ export class Community extends Component<CommunityRouteProps, State> {
muted
hideAvatar
/>
</div>
)
)) ??
urlCommunityName}
</div>
);
}
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<CommunityRouteProps, State> {
}));
}
async updateUrl({
dataType,
pageCursor,
sort,
showHidden,
}: Partial<CommunityProps>) {
async updateUrl(props: Partial<CommunityProps>) {
const {
dataType: urlDataType,
sort: urlSort,
showHidden: urlShowHidden,
} = this.props;
const queryParams: QueryParams<CommunityProps> = {
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<CommunityProps> = {
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 });
}
}
}

View File

@ -80,6 +80,21 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
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 {

View File

@ -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<GetFederatedInstancesResponse>;
bannedRes: RequestState<BannedPersonsResponse>;
leaveAdminTeamRes: RequestState<GetSiteResponse>;
@ -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({

View File

@ -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<GetPostsResponse>;
@ -344,14 +345,28 @@ export class Home extends Component<HomeRouteProps, HomeState> {
)?.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<HomeRouteProps, HomeState> {
);
}
async updateUrl({
dataType,
listingType,
pageCursor,
sort,
showHidden,
}: Partial<HomeProps>) {
const {
dataType: urlDataType,
listingType: urlListingType,
sort: urlSort,
showHidden: urlShowHidden,
} = this.props;
async updateUrl(props: Partial<HomeProps>) {
const { dataType, listingType, pageCursor, sort, showHidden } = {
...this.props,
...props,
};
const queryParams: QueryParams<HomeProps> = {
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<HomeRouteProps, HomeState> {
});
}
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 });
}
}
}

View File

@ -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<InstancesRouteProps, InstancesState> {
}
}
async componentDidMount() {
if (!this.state.isIsomorphic) {
async componentWillMount() {
if (!this.state.isIsomorphic && isBrowser()) {
await this.fetchInstances();
}
}

View File

@ -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 {

View File

@ -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();
}
}

View File

@ -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<P, S> & {
readonly modalDivRef: RefObject<HTMLDivElement>;

View File

@ -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<P, S>,
>(base: Base, _context?: ClassDecoratorContext<Base>) {

View File

@ -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<ModlogRouteProps, ModlogState> {
}
}
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<ModlogRouteProps, ModlogState> {
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 (
<div className="modlog container-lg">
@ -816,15 +847,26 @@ export class Modlog extends Component<ModlogRouteProps, ModlogState> {
#<strong>#</strong>#
</T>
</div>
{this.state.communityRes.state === "success" && (
{communityId && (
<h5>
<Link
className="text-body"
to={`/c/${this.state.communityRes.data.community_view.community.name}`}
>
/c/{this.state.communityRes.data.community_view.community.name}{" "}
</Link>
<span>{I18NextService.i18n.t("modlog")}</span>
{communityResp ? (
<>
<Link
className="text-body"
to={`/c/${communityResp.community_view.community.name}`}
>
/c/{communityResp.community_view.community.name}
</Link>{" "}
<span>{I18NextService.i18n.t("modlog")}</span>
</>
) : (
communityState === "loading" && (
<>
<LoadingEllipses />
&nbsp;
</>
)
)}
</h5>
)}
<div className="row mb-2">
@ -935,6 +977,10 @@ export class Modlog extends Component<ModlogRouteProps, ModlogState> {
}
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<ModlogRouteProps, ModlogState> {
});
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<ModlogRouteProps, ModlogState> {
});
});
async updateUrl({ actionType, modId, page, userId }: Partial<ModlogProps>) {
async updateUrl(props: Partial<ModlogProps>) {
const {
page: urlPage,
actionType: urlActionType,
modId: urlModId,
userId: urlUserId,
} = this.props;
actionType,
modId,
page,
userId,
match: {
params: { communityId },
},
} = { ...this.props, ...props };
const queryParams: QueryParams<ModlogProps> = {
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 });
}
}

View File

@ -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<InboxRouteProps, InboxState> {
}
}
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<InboxRouteProps, InboxState> {
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();
}

View File

@ -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<GetPersonDetailsResponse>;
// personRes and personDetailsRes point to `===` identical data. This allows
// to render the start of the profile while the new details are loading.
personDetailsRes: RequestState<GetPersonDetailsResponse>;
uploadsRes: RequestState<ListMediaResponse>;
personBlocked: boolean;
banReason?: string;
@ -195,6 +200,7 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
private isoData = setIsoData<ProfileData>(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<ProfileRouteProps, ProfileState> {
};
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<ProfileRouteProps, ProfileState> {
this.state = {
...this.state,
personRes,
personDetailsRes: personRes,
uploadsRes,
isIsomorphic: true,
personBlocked: isPersonBlocked(personRes),
@ -260,37 +272,96 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
}
}
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<ProfileRouteProps, ProfileState> {
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 (
<div className="row">
<div className="col-12 col-md-8">
@ -403,50 +478,59 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
{this.renderUploadsRes()}
<PersonDetails
personRes={personRes}
admins={siteRes.admins}
sort={sort}
page={page}
limit={fetchLimit}
finished={this.state.finished}
enableDownvotes={enableDownvotes(siteRes)}
voteDisplayMode={voteDisplayMode(siteRes)}
enableNsfw={enableNsfw(siteRes)}
view={view}
onPageChange={this.handlePageChange}
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
// TODO all the forms here
onSaveComment={this.handleSaveComment}
onBlockPerson={this.handleBlockPersonAlt}
onDeleteComment={this.handleDeleteComment}
onRemoveComment={this.handleRemoveComment}
onCommentVote={this.handleCommentVote}
onCommentReport={this.handleCommentReport}
onDistinguishComment={this.handleDistinguishComment}
onAddModToCommunity={this.handleAddModToCommunity}
onAddAdmin={this.handleAddAdmin}
onTransferCommunity={this.handleTransferCommunity}
onPurgeComment={this.handlePurgeComment}
onPurgePerson={this.handlePurgePerson}
onCommentReplyRead={this.handleCommentReplyRead}
onPersonMentionRead={this.handlePersonMentionRead}
onBanPersonFromCommunity={this.handleBanFromCommunity}
onBanPerson={this.handleBanPerson}
onCreateComment={this.handleCreateComment}
onEditComment={this.handleEditComment}
onPostEdit={this.handlePostEdit}
onPostVote={this.handlePostVote}
onPostReport={this.handlePostReport}
onLockPost={this.handleLockPost}
onDeletePost={this.handleDeletePost}
onRemovePost={this.handleRemovePost}
onSavePost={this.handleSavePost}
onPurgePost={this.handlePurgePost}
onFeaturePost={this.handleFeaturePost}
onMarkPostAsRead={() => {}}
/>
{personDetailsState === "loading" &&
this.props.view !== PersonDetailsView.Uploads ? (
<h5>
<Spinner large />
</h5>
) : (
personDetailsRes && (
<PersonDetails
personRes={personDetailsRes}
admins={siteRes.admins}
sort={sort}
page={page}
limit={fetchLimit}
finished={this.state.finished}
enableDownvotes={enableDownvotes(siteRes)}
voteDisplayMode={voteDisplayMode(siteRes)}
enableNsfw={enableNsfw(siteRes)}
view={view}
onPageChange={this.handlePageChange}
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
// TODO all the forms here
onSaveComment={this.handleSaveComment}
onBlockPerson={this.handleBlockPersonAlt}
onDeleteComment={this.handleDeleteComment}
onRemoveComment={this.handleRemoveComment}
onCommentVote={this.handleCommentVote}
onCommentReport={this.handleCommentReport}
onDistinguishComment={this.handleDistinguishComment}
onAddModToCommunity={this.handleAddModToCommunity}
onAddAdmin={this.handleAddAdmin}
onTransferCommunity={this.handleTransferCommunity}
onPurgeComment={this.handlePurgeComment}
onPurgePerson={this.handlePurgePerson}
onCommentReplyRead={this.handleCommentReplyRead}
onPersonMentionRead={this.handlePersonMentionRead}
onBanPersonFromCommunity={this.handleBanFromCommunity}
onBanPerson={this.handleBanPerson}
onCreateComment={this.handleCreateComment}
onEditComment={this.handleEditComment}
onPostEdit={this.handlePostEdit}
onPostVote={this.handlePostVote}
onPostReport={this.handlePostReport}
onLockPost={this.handleLockPost}
onDeletePost={this.handleDeletePost}
onRemovePost={this.handleRemovePost}
onSavePost={this.handleSavePost}
onPurgePost={this.handlePurgePost}
onFeaturePost={this.handleFeaturePost}
onMarkPostAsRead={() => {}}
/>
)
)}
</div>
<div className="col-12 col-md-4">
@ -788,19 +872,23 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
);
}
async updateUrl({ page, sort, view }: Partial<ProfileProps>) {
const { page: urlPage, sort: urlSort, view: urlView } = this.props;
async updateUrl(props: Partial<ProfileRouteProps>) {
const {
page,
sort,
view,
match: {
params: { username },
},
} = { ...this.props, ...props };
const queryParams: QueryParams<ProfileProps> = {
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) {

View File

@ -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 (
<h5>
<Spinner large />
</h5>
);
case "success": {
const apps = this.state.appsRes.data.registration_applications;
return (
<div className="row">
<div className="col-12">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
<h1 className="h4 mb-4">
{I18NextService.i18n.t("registration_applications")}
</h1>
{this.selects()}
const appsState = this.state.appsRes.state;
const apps =
appsState === "success" &&
this.state.appsRes.data.registration_applications;
return (
<div className="row">
<div className="col-12">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
<h1 className="h4 mb-4">
{I18NextService.i18n.t("registration_applications")}
</h1>
{this.selects()}
{apps ? (
<>
{this.applicationList(apps)}
<Paginator
page={this.state.page}
onChange={this.handlePageChange}
nextDisabled={fetchLimit > apps.length}
/>
</div>
</div>
);
}
}
</>
) : (
appsState === "loading" && (
<div className="text-center">
<Spinner large />
</div>
)
)}
</div>
</div>
);
}
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) {

View File

@ -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<ReportsRouteProps, ReportsState> {
}
}
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<ReportsRouteProps, ReportsState> {
}
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 (
<h5>
<Spinner large />
</h5>
);
}
return (
<div>
{this.buildCombined.map(i => (
{combined.map(i => (
<>
<hr />
{this.renderItemType(i)}
@ -575,6 +589,7 @@ export class Reports extends Component<ReportsRouteProps, ReportsState> {
static async fetchInitialData({
headers,
site,
}: InitialFetchRequest): Promise<ReportsData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),
@ -601,7 +616,7 @@ export class Reports extends Component<ReportsRouteProps, ReportsState> {
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<ReportsRouteProps, ReportsState> {
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<ReportsRouteProps, ReportsState> {
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) {

View File

@ -348,7 +348,7 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
}
}
async componentDidMount() {
async componentWillMount() {
this.setState({ themeList: await fetchThemeList() });
if (!this.state.isIsomorphic) {

View File

@ -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<SuccessResponse>;
@ -52,8 +53,10 @@ export class VerifyEmail extends Component<
}
}
async componentDidMount() {
await this.verify();
async componentWillMount() {
if (isBrowser()) {
await this.verify();
}
}
get documentTitle(): string {

View File

@ -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<CreatePostData>(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(

View File

@ -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<PostFormProps, PostFormState> {
submitted: false,
};
postTitleRef = createRef<HTMLTextAreaElement>();
constructor(props: PostFormProps, context: any) {
super(props, context);
this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this));
@ -306,17 +309,19 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
}
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<PostFormProps, PostFormState> {
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<PostFormProps, PostFormState> {
rows={1}
minLength={3}
maxLength={MAX_POST_TITLE_LENGTH}
ref={this.postTitleRef}
/>
{!validTitle(this.state.form.name) && (
<div className="invalid-feedback">

View File

@ -137,7 +137,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
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<PostListingProps, PostListingState> {
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 {

View File

@ -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<GetPostResponse>;
commentsRes: RequestState<GetCommentsResponse>;
commentSort: CommentSortType;
commentViewType: CommentViewType;
scrolled?: boolean;
siteRes: GetSiteResponse;
commentSectionRef?: RefObject<HTMLDivElement>;
showSidebarMobile: boolean;
maxCommentsShown: number;
finished: Map<CommentId, boolean | undefined>;
isIsomorphic: boolean;
}
type PostPathProps =
| { post_id: string; comment_id: never }
| { post_id: never; comment_id: string };
type PostRouteProps = RouteComponentProps<PostPathProps> &
Record<string, never>;
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<PostProps>(
{
scrollToComments: (s?: string) => !!s,
sort: getCommentSortTypeFromQuery,
view: getCommentViewTypeFromQuery,
},
source,
);
}
type PostPathProps = { post_id?: string; comment_id?: string };
type PostRouteProps = RouteComponentProps<PostPathProps> & PostProps;
type PartialPostRouteProps = Partial<
PostProps & { match: { params: PostPathProps } }
>;
export type PostFetchConfig = IRoutePropsWithFetch<
PostData,
PostPathProps,
Record<string, never>
PostProps
>;
@scrollMixin
export class Post extends Component<PostRouteProps, PostState> {
private isoData = setIsoData<PostData>(this.context);
private commentScrollDebounced: () => void;
private shouldScrollToComments: boolean = false;
private commentSectionRef = createRef<HTMLDivElement>();
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<PostRouteProps, PostState> {
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<PostRouteProps, PostState> {
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<PostProps> = {
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<PostPathProps>): Promise<PostData> {
query: { sort },
}: InitialFetchRequest<PostPathProps, PostProps>): Promise<PostData> {
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<PostRouteProps, PostState> {
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<PostRouteProps, PostState> {
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<PostRouteProps, PostState> {
onHidePost={this.handleHidePost}
onScrollIntoCommentsClick={this.handleScrollIntoCommentsClick}
/>
<div ref={this.state.commentSectionRef} className="mb-2" />
<div ref={this.commentSectionRef} className="mb-2" />
{/* 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
) && (
<CommentForm
key={
this.context.router.history.location.key
// reset on new location, otherwise <Prompt /> 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<PostRouteProps, PostState> {
{this.state.showSidebarMobile && this.sidebar()}
</div>
{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()}
</main>
<aside className="d-none d-md-block col-md-4 col-lg-3">
{this.sidebar()}
@ -482,13 +648,13 @@ export class Post extends Component<PostRouteProps, PostState> {
type="radio"
className="btn-check"
value={"Hot"}
checked={this.state.commentSort === "Hot"}
checked={this.props.sort === "Hot"}
onChange={linkEvent(this, this.handleCommentSortChange)}
/>
<label
htmlFor={`${radioId}-hot`}
className={classNames("btn btn-outline-secondary pointer", {
active: this.state.commentSort === "Hot",
active: this.props.sort === "Hot",
})}
>
{I18NextService.i18n.t("hot")}
@ -498,13 +664,13 @@ export class Post extends Component<PostRouteProps, PostState> {
type="radio"
className="btn-check"
value={"Top"}
checked={this.state.commentSort === "Top"}
checked={this.props.sort === "Top"}
onChange={linkEvent(this, this.handleCommentSortChange)}
/>
<label
htmlFor={`${radioId}-top`}
className={classNames("btn btn-outline-secondary pointer", {
active: this.state.commentSort === "Top",
active: this.props.sort === "Top",
})}
>
{I18NextService.i18n.t("top")}
@ -514,13 +680,13 @@ export class Post extends Component<PostRouteProps, PostState> {
type="radio"
className="btn-check"
value={"Controversial"}
checked={this.state.commentSort === "Controversial"}
checked={this.props.sort === "Controversial"}
onChange={linkEvent(this, this.handleCommentSortChange)}
/>
<label
htmlFor={`${radioId}-controversial`}
className={classNames("btn btn-outline-secondary pointer", {
active: this.state.commentSort === "Controversial",
active: this.props.sort === "Controversial",
})}
>
{I18NextService.i18n.t("controversial")}
@ -530,13 +696,13 @@ export class Post extends Component<PostRouteProps, PostState> {
type="radio"
className="btn-check"
value={"New"}
checked={this.state.commentSort === "New"}
checked={this.props.sort === "New"}
onChange={linkEvent(this, this.handleCommentSortChange)}
/>
<label
htmlFor={`${radioId}-new`}
className={classNames("btn btn-outline-secondary pointer", {
active: this.state.commentSort === "New",
active: this.props.sort === "New",
})}
>
{I18NextService.i18n.t("new")}
@ -546,13 +712,13 @@ export class Post extends Component<PostRouteProps, PostState> {
type="radio"
className="btn-check"
value={"Old"}
checked={this.state.commentSort === "Old"}
checked={this.props.sort === "Old"}
onChange={linkEvent(this, this.handleCommentSortChange)}
/>
<label
htmlFor={`${radioId}-old`}
className={classNames("btn btn-outline-secondary pointer", {
active: this.state.commentSort === "Old",
active: this.props.sort === "Old",
})}
>
{I18NextService.i18n.t("old")}
@ -564,13 +730,13 @@ export class Post extends Component<PostRouteProps, PostState> {
type="radio"
className="btn-check"
value={CommentViewType.Flat}
checked={this.state.commentViewType === CommentViewType.Flat}
checked={this.props.view === CommentViewType.Flat}
onChange={linkEvent(this, this.handleCommentViewTypeChange)}
/>
<label
htmlFor={`${radioId}-chat`}
className={classNames("btn btn-outline-secondary pointer", {
active: this.state.commentViewType === CommentViewType.Flat,
active: this.props.view === CommentViewType.Flat,
})}
>
{I18NextService.i18n.t("chat")}
@ -581,6 +747,14 @@ export class Post extends Component<PostRouteProps, PostState> {
}
commentsFlat() {
if (this.state.commentsRes.state === "loading") {
return (
<div className="text-center">
<Spinner large />
</div>
);
}
// These are already sorted by new
const commentsRes = this.state.commentsRes;
const postRes = this.state.postRes;
@ -590,8 +764,8 @@ export class Post extends Component<PostRouteProps, PostState> {
return (
<div>
<CommentNodes
nodes={commentsToFlatNodes(commentsRes.data.comments)}
viewType={this.state.commentViewType}
nodes={this.sortedFlatNodes()}
viewType={this.props.view}
maxCommentsShown={this.state.maxCommentsShown}
isTopLevel
locked={postRes.data.post_view.post.locked}
@ -652,7 +826,29 @@ export class Post extends Component<PostRouteProps, PostState> {
}
}
sortedFlatNodes(): CommentNodeI[] {
if (this.state.commentsRes.state !== "success") {
return [];
}
const nodeToDate = (node: CommentNodeI) =>
node.comment_view.comment.published;
const nodes = commentsToFlatNodes(this.state.commentsRes.data.comments);
if (this.props.sort === "New") {
return nodes.sort((a, b) => compareDesc(nodeToDate(a), nodeToDate(b)));
} else {
return nodes.sort((a, b) => compareAsc(nodeToDate(a), nodeToDate(b)));
}
}
commentsTree() {
if (this.state.commentsRes.state === "loading") {
return (
<div className="text-center">
<Spinner large />
</div>
);
}
const res = this.state.postRes;
const firstComment = this.commentTree().at(0)?.comment_view.comment;
const depth = getDepthFromComment(firstComment);
@ -662,11 +858,11 @@ export class Post extends Component<PostRouteProps, PostState> {
return (
res.state === "success" && (
<div>
{!!this.state.commentId && (
{!!getCommentIdFromProps(this.props) && (
<>
<button
className="ps-0 d-block btn btn-link text-muted"
onClick={linkEvent(this, this.handleViewPost)}
onClick={linkEvent(this, this.handleViewAllComments)}
>
{I18NextService.i18n.t("view_all_comments")}
</button>
@ -682,7 +878,7 @@ export class Post extends Component<PostRouteProps, PostState> {
)}
<CommentNodes
nodes={this.commentTree()}
viewType={this.state.commentViewType}
viewType={this.props.view}
maxCommentsShown={this.state.maxCommentsShown}
locked={res.data.post_view.post.locked}
moderators={res.data.moderators}
@ -719,50 +915,69 @@ export class Post extends Component<PostRouteProps, PostState> {
commentTree(): CommentNodeI[] {
if (this.state.commentsRes.state === "success") {
return buildCommentsTree(
this.state.commentsRes.data.comments,
!!this.state.commentId,
);
} else {
return [];
const comments = this.state.commentsRes.data.comments;
if (comments.length) {
return buildCommentsTree(comments, !!getCommentIdFromProps(this.props));
}
}
return [];
}
async handleCommentSortChange(i: Post, event: any) {
i.setState({
commentSort: event.target.value as CommentSortType,
commentViewType: CommentViewType.Tree,
commentsRes: LOADING_REQUEST,
postRes: LOADING_REQUEST,
});
await i.fetchPost();
const sort = event.target.value as CommentSortType;
const flattenable = sort === "New" || sort === "Old";
if (flattenable || i.props.view !== CommentViewType.Flat) {
i.updateUrl({ sort });
} else {
i.updateUrl({ sort, view: CommentViewType.Tree });
}
}
handleCommentViewTypeChange(i: Post, event: any) {
i.setState({
commentViewType: Number(event.target.value),
commentSort: "New",
});
const flattenable = i.props.sort === "New" || i.props.sort === "Old";
const view: CommentViewType = Number(event.target.value);
if (flattenable || view !== CommentViewType.Flat) {
i.updateUrl({ view });
} else {
i.updateUrl({ view, sort: "New" });
}
}
handleShowSidebarMobile(i: Post) {
i.setState({ showSidebarMobile: !i.state.showSidebarMobile });
}
handleViewPost(i: Post) {
if (i.state.postRes.state === "success") {
const id = i.state.postRes.data.post_view.post.id;
i.context.router.history.push(`/post/${id}`);
handleViewAllComments(i: Post) {
const id =
getIdFromProps(i.props) ||
(i.state.postRes.state === "success" &&
i.state.postRes.data.post_view.post.id);
if (id) {
i.updateUrl({
match: { params: { post_id: id.toString() } },
});
}
}
handleViewContext(i: Post) {
if (i.state.commentsRes.state === "success") {
const parentId = getCommentParentId(
i.state.commentsRes.data.comments.at(0)?.comment,
const commentId = getCommentIdFromProps(i.props);
const commentView = i.state.commentsRes.data.comments.find(
c => c.comment.id === commentId,
);
if (parentId) {
i.context.router.history.push(`/comment/${parentId}`);
const parentId = getCommentParentId(commentView?.comment);
const postId = commentView?.post.id;
if (parentId && postId) {
i.updateUrl({
match: {
params: {
post_id: postId.toString(),
comment_id: parentId.toString(),
},
},
});
}
}
}

View File

@ -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 CreatePrivateMessageData = RouteDataResponse<{
recipientDetailsResponse: GetPersonDetailsResponse;
@ -79,8 +80,8 @@ export class CreatePrivateMessage extends Component<
}
}
async componentDidMount() {
if (!this.state.isIsomorphic) {
async componentWillMount() {
if (!this.state.isIsomorphic && isBrowser()) {
await this.fetchPersonDetails();
}
}

View File

@ -25,6 +25,7 @@ import { CommunityLink } from "./community/community-link";
import { getHttpBaseInternal } from "../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../routes";
import { isBrowser } from "@utils/browser";
interface RemoteFetchProps {
uri?: string;
@ -128,8 +129,8 @@ export class RemoteFetch extends Component<
}
}
async componentDidMount() {
if (!this.state.isIsomorphic) {
async componentWillMount() {
if (!this.state.isIsomorphic && isBrowser()) {
const { uri } = this.props;
if (uri) {

View File

@ -5,7 +5,6 @@ import {
enableNsfw,
fetchCommunities,
fetchUsers,
getUpdatedSearchId,
myAuth,
personToChoice,
setIsoData,
@ -71,6 +70,7 @@ import { PostListing } from "./post/post-listing";
import { getHttpBaseInternal } from "../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../routes";
import { isBrowser } from "@utils/browser";
interface SearchProps {
q?: string;
@ -96,7 +96,6 @@ interface SearchState {
searchRes: RequestState<SearchResponse>;
resolveObjectRes: RequestState<ResolveObjectResponse>;
siteRes: GetSiteResponse;
searchText?: string;
communitySearchOptions: Choice[];
creatorSearchOptions: Choice[];
searchCreatorLoading: boolean;
@ -285,10 +284,6 @@ export class Search extends Component<SearchRouteProps, SearchState> {
this.handleCommunityFilterChange.bind(this);
this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
const { q } = this.props;
this.state.searchText = q;
// Only fetch the data if coming from another route
if (FirstLoadService.isFirstLoad) {
const {
@ -329,81 +324,142 @@ export class Search extends Component<SearchRouteProps, SearchState> {
}
}
async componentDidMount() {
if (this.props.history.action !== "POP") {
componentWillMount() {
if (!this.state.isIsomorphic && isBrowser()) {
this.fetchAll(this.props);
}
}
componentDidMount() {
if (this.props.history.action !== "POP" || this.state.isIsomorphic) {
this.searchInput.current?.select();
}
}
if (!this.state.isIsomorphic) {
this.setState({
searchCommunitiesLoading: true,
searchCreatorLoading: true,
});
componentWillReceiveProps(nextProps: SearchRouteProps) {
if (nextProps.communityId !== this.props.communityId) {
this.fetchSelectedCommunity(nextProps);
}
if (nextProps.creatorId !== this.props.creatorId) {
this.fetchSelectedCreator(nextProps);
}
this.search(nextProps);
}
const promises = [
HttpService.client
.listCommunities({
type_: defaultListingType,
sort: defaultSortType,
limit: fetchLimit,
})
.then(res => {
if (res.state === "success") {
this.setState({
communitySearchOptions:
res.data.communities.map(communityToChoice),
});
}
}),
];
componentDidUpdate(prevProps: SearchRouteProps) {
if (this.props.location.key !== prevProps.location.key) {
if (this.props.history.action !== "POP") {
this.searchInput.current?.select();
}
}
}
const { communityId, creatorId } = this.props;
fetchDefaultCommunitiesToken?: symbol;
async fetchDefaultCommunities({
communityId,
}: Pick<SearchRouteProps, "communityId">) {
const token = (this.fetchDefaultCommunitiesToken = Symbol());
this.setState({
searchCommunitiesLoading: true,
});
if (communityId) {
promises.push(
HttpService.client.getCommunity({ id: communityId }).then(res => {
if (res.state === "success") {
this.setState(prev => {
prev.communitySearchOptions.unshift(
communityToChoice(res.data.community_view),
);
const res = await HttpService.client.listCommunities({
type_: defaultListingType,
sort: defaultSortType,
limit: fetchLimit,
});
return prev;
});
}
}),
if (token !== this.fetchDefaultCommunitiesToken) {
return;
}
if (res.state === "success") {
const retainSelected: false | undefined | Choice =
!res.data.communities.some(cv => cv.community.id === communityId) &&
this.state.communitySearchOptions.find(
choice => choice.value === communityId?.toString(),
);
}
if (creatorId) {
promises.push(
HttpService.client
.getPersonDetails({
person_id: creatorId,
})
.then(res => {
if (res.state === "success") {
this.setState(prev => {
prev.creatorSearchOptions.push(
personToChoice(res.data.person_view),
);
});
}
}),
);
}
if (this.state.searchText) {
promises.push(this.search());
}
await Promise.all(promises);
const choices = res.data.communities.map(communityToChoice);
this.setState({
searchCommunitiesLoading: false,
searchCreatorLoading: false,
communitySearchOptions: retainSelected
? [retainSelected, ...choices]
: choices,
});
}
this.setState({
searchCommunitiesLoading: false,
});
}
fetchSelectedCommunityToken?: symbol;
async fetchSelectedCommunity({
communityId,
}: Pick<SearchRouteProps, "communityId">) {
const token = (this.fetchSelectedCommunityToken = Symbol());
const needsSelectedCommunity = () => {
return !this.state.communitySearchOptions.some(
choice => choice.value === communityId?.toString(),
);
};
if (communityId && needsSelectedCommunity()) {
const res = await HttpService.client.getCommunity({ id: communityId });
if (
res.state === "success" &&
needsSelectedCommunity() &&
token === this.fetchSelectedCommunityToken
) {
this.setState(prev => {
prev.communitySearchOptions.unshift(
communityToChoice(res.data.community_view),
);
return prev;
});
}
}
}
fetchSelectedCreatorToken?: symbol;
async fetchSelectedCreator({
creatorId,
}: Pick<SearchRouteProps, "creatorId">) {
const token = (this.fetchSelectedCreatorToken = Symbol());
const needsSelectedCreator = () => {
return !this.state.creatorSearchOptions.some(
choice => choice.value === creatorId?.toString(),
);
};
if (!creatorId || !needsSelectedCreator()) {
return;
}
this.setState({ searchCreatorLoading: true });
const res = await HttpService.client.getPersonDetails({
person_id: creatorId,
});
if (token !== this.fetchSelectedCreatorToken) {
return;
}
if (res.state === "success" && needsSelectedCreator()) {
this.setState(prev => {
prev.creatorSearchOptions.push(personToChoice(res.data.person_view));
});
}
this.setState({ searchCreatorLoading: false });
}
async fetchAll(props: SearchRouteProps) {
await Promise.all([
this.fetchDefaultCommunities(props),
this.fetchSelectedCommunity(props),
this.fetchSelectedCreator(props),
this.search(props),
]);
}
static async fetchInitialData({
@ -551,13 +607,15 @@ export class Search extends Component<SearchRouteProps, SearchState> {
onSubmit={linkEvent(this, this.handleSearchSubmit)}
>
<div className="col-auto flex-grow-1 flex-sm-grow-0">
{/* key is necessary for defaultValue to update when props.q changes,
e.g. back button. */}
<input
key={this.context.router.history.location.key}
type="text"
className="form-control me-2 mb-2 col-sm-8"
value={this.state.searchText}
defaultValue={this.props.q ?? ""}
placeholder={`${I18NextService.i18n.t("search")}...`}
aria-label={I18NextService.i18n.t("search")}
onInput={linkEvent(this, this.handleQChange)}
required
minLength={1}
ref={this.searchInput}
@ -984,34 +1042,39 @@ export class Search extends Component<SearchRouteProps, SearchState> {
return resObjCount + searchCount;
}
async search() {
const { searchText: q } = this.state;
const { communityId, creatorId, type, sort, listingType, page } =
this.props;
searchToken?: symbol;
async search(props: SearchRouteProps) {
const token = (this.searchToken = Symbol());
const { q, communityId, creatorId, type, sort, listingType, page } = props;
if (q) {
this.setState({ searchRes: LOADING_REQUEST });
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,
}),
const searchRes = await HttpService.client.search({
q,
community_id: communityId ?? undefined,
creator_id: creatorId ?? undefined,
type_: type,
sort,
listing_type: listingType,
page,
limit: fetchLimit,
});
if (token !== this.searchToken) {
return;
}
this.setState({ searchRes });
if (myAuth()) {
this.setState({ resolveObjectRes: LOADING_REQUEST });
this.setState({
resolveObjectRes: await HttpService.client.resolveObject({
q,
}),
const resolveObjectRes = await HttpService.client.resolveObject({
q,
});
if (token === this.searchToken) {
this.setState({ resolveObjectRes });
}
}
} else {
this.setState({ searchRes: EMPTY_REQUEST });
}
}
@ -1053,8 +1116,12 @@ export class Search extends Component<SearchRouteProps, SearchState> {
}
});
getQ(): string | undefined {
return this.searchInput.current?.value ?? this.props.q;
}
handleSortChange(sort: SortType) {
this.updateUrl({ sort, page: 1 });
this.updateUrl({ sort, page: 1, q: this.getQ() });
}
handleTypeChange(i: Search, event: any) {
@ -1063,6 +1130,7 @@ export class Search extends Component<SearchRouteProps, SearchState> {
i.updateUrl({
type,
page: 1,
q: i.getQ(),
});
}
@ -1074,20 +1142,23 @@ export class Search extends Component<SearchRouteProps, SearchState> {
this.updateUrl({
listingType,
page: 1,
q: this.getQ(),
});
}
handleCommunityFilterChange({ value }: Choice) {
this.updateUrl({
communityId: getIdFromString(value) ?? 0,
communityId: getIdFromString(value),
page: 1,
q: this.getQ(),
});
}
handleCreatorFilterChange({ value }: Choice) {
this.updateUrl({
creatorId: getIdFromString(value) ?? 0,
creatorId: getIdFromString(value),
page: 1,
q: this.getQ(),
});
}
@ -1095,44 +1166,25 @@ export class Search extends Component<SearchRouteProps, SearchState> {
event.preventDefault();
i.updateUrl({
q: i.state.searchText,
q: i.getQ(),
page: 1,
});
}
handleQChange(i: Search, event: any) {
i.setState({ searchText: event.target.value });
}
async updateUrl({
q,
type,
listingType,
sort,
communityId,
creatorId,
page,
}: Partial<SearchProps>) {
const {
q: urlQ,
type: urlType,
listingType: urlListingType,
communityId: urlCommunityId,
sort: urlSort,
creatorId: urlCreatorId,
page: urlPage,
} = this.props;
const query = q ?? this.state.searchText ?? urlQ;
async updateUrl(props: Partial<SearchProps>) {
const { q, type, listingType, sort, communityId, creatorId, page } = {
...this.props,
...props,
};
const queryParams: QueryParams<SearchProps> = {
q: query,
type: type ?? urlType,
listingType: listingType ?? urlListingType,
communityId: getUpdatedSearchId(communityId, urlCommunityId),
creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
page: (page ?? urlPage).toString(),
sort: sort ?? urlSort,
q,
type: type,
listingType: listingType,
communityId: communityId?.toString(),
creatorId: creatorId?.toString(),
page: page?.toString(),
sort: sort,
};
this.props.history.push(`/search${getQueryString(queryParams)}`);

View File

@ -53,7 +53,11 @@ import {
CreatePost,
getCreatePostQueryParams,
} from "./components/post/create-post";
import { Post, PostFetchConfig } from "./components/post/post";
import {
Post,
PostFetchConfig,
getPostQueryParams,
} from "./components/post/post";
import {
CreatePrivateMessage,
CreatePrivateMessageFetchConfig,
@ -87,6 +91,7 @@ export interface IRoutePropsWithFetch<
component: Inferno.ComponentClass<
RouteComponentProps<PathPropsT> & QueryPropsT
>;
mountedSameRouteNavKey?: string;
}
export const routes: IRoutePropsWithFetch<RouteData, any, any>[] = [
@ -96,6 +101,7 @@ export const routes: IRoutePropsWithFetch<RouteData, any, any>[] = [
fetchInitialData: Home.fetchInitialData,
exact: true,
getQueryParams: getHomeQueryParams,
mountedSameRouteNavKey: "home",
} as HomeFetchConfig,
{
path: `/login`,
@ -130,28 +136,38 @@ export const routes: IRoutePropsWithFetch<RouteData, any, any>[] = [
component: Communities,
fetchInitialData: Communities.fetchInitialData,
getQueryParams: getCommunitiesQueryParams,
mountedSameRouteNavKey: "communities",
} as CommunitiesFetchConfig,
{
path: `/post/:post_id`,
// "/comment/:post_id?/:comment_id" would be preferable as direct comment
// link, but it looks like a Route can't match multiple paths and a
// component can't stay mounted across routes.
path: `/post/:post_id/:comment_id?`,
component: Post,
fetchInitialData: Post.fetchInitialData,
getQueryParams: getPostQueryParams,
mountedSameRouteNavKey: "post",
} as PostFetchConfig,
{
path: `/comment/:comment_id`,
component: Post,
fetchInitialData: Post.fetchInitialData,
getQueryParams: getPostQueryParams,
mountedSameRouteNavKey: "post",
} as PostFetchConfig,
{
path: `/c/:name`,
component: Community,
fetchInitialData: Community.fetchInitialData,
getQueryParams: getCommunityQueryParams,
mountedSameRouteNavKey: "community",
} as CommunityFetchConfig,
{
path: `/u/:username`,
component: Profile,
fetchInitialData: Profile.fetchInitialData,
getQueryParams: getProfileQueryParams,
mountedSameRouteNavKey: "profile",
} as ProfileFetchConfig,
{
path: `/inbox`,
@ -164,16 +180,11 @@ export const routes: IRoutePropsWithFetch<RouteData, any, any>[] = [
fetchInitialData: Settings.fetchInitialData,
} as SettingsFetchConfig,
{
path: `/modlog/:communityId`,
component: Modlog,
fetchInitialData: Modlog.fetchInitialData,
getQueryParams: getModlogQueryParams,
} as ModlogFetchConfig,
{
path: `/modlog`,
path: `/modlog/:communityId?`,
component: Modlog,
fetchInitialData: Modlog.fetchInitialData,
getQueryParams: getModlogQueryParams,
mountedSameRouteNavKey: "modlog",
} as ModlogFetchConfig,
{ path: `/setup`, component: Setup },
{
@ -196,6 +207,7 @@ export const routes: IRoutePropsWithFetch<RouteData, any, any>[] = [
component: Search,
fetchInitialData: Search.fetchInitialData,
getQueryParams: getSearchQueryParams,
mountedSameRouteNavKey: "search",
} as SearchFetchConfig,
{
path: `/password_change/:token`,

View File

@ -1,8 +0,0 @@
export default function getUpdatedSearchId(
id?: number | null,
urlId?: number | null,
) {
return id === null
? undefined
: ((id ?? urlId) === 0 ? undefined : id ?? urlId)?.toString();
}

View File

@ -31,7 +31,6 @@ import getDataTypeString from "./get-data-type-string";
import getDepthFromComment from "./get-depth-from-comment";
import getIdFromProps from "./get-id-from-props";
import getRecipientIdFromProps from "./get-recipient-id-from-props";
import getUpdatedSearchId from "./get-updated-search-id";
import initializeSite from "./initialize-site";
import insertCommentIntoTree from "./insert-comment-into-tree";
import isAuthPath from "./is-auth-path";
@ -89,7 +88,6 @@ export {
getDepthFromComment,
getIdFromProps,
getRecipientIdFromProps,
getUpdatedSearchId,
initializeSite,
insertCommentIntoTree,
isAuthPath,

View File

@ -0,0 +1,14 @@
import { RouteComponentProps } from "inferno-router/dist/Route";
// Intended to allow reloading all the data of the current page by clicking the
// navigation link of the current page.
export default function bareRoutePush<P extends RouteComponentProps<any>>(
prevProps: P,
nextProps: P,
) {
return (
prevProps.location.pathname === nextProps.location.pathname &&
!nextProps.location.search &&
nextProps.history.action === "PUSH"
);
}

View File

@ -1,3 +1,4 @@
import bareRoutePush from "./bare-route-push";
import capitalizeFirstLetter from "./capitalize-first-letter";
import debounce from "./debounce";
import editListImmutable from "./edit-list-immutable";
@ -27,6 +28,7 @@ import dedupByProperty from "./dedup-by-property";
import getApubName from "./apub-name";
export {
bareRoutePush,
cakeDate,
capitalizeFirstLetter,
debounce,