Rework query parsing (#2396)

* Pass parsed query params as props to components

* Pass parsed query params to fetchInitialData

* Pass router Match to fetchInitialData

* Cast individual routes to their concrete types

Adds an IRoutePropsWithFetch definition for routes with getQueryParams
or fetchInitialData to cause compiler errors when the types no longer
match.

* Don't double decode query parameters.

Problem: A search for "%ab" produces a url with "%25ab". Refreshing
the page results in URLSearchParams turning "%25ab" back into "%ab".
decodeURIComponent() then complains about "%ab" being malformed.

This removes decodeURIComponent() calls for query parameters and
composes all query strings with getQueryString(), which now uses
URLSearchParams. Query parsing already goes through getQueryParams()
which also uses URLSearchParams.

* Fix for PictrsImage when src also has query params

* Small getQueryParams cleanup
This commit is contained in:
matc-pub 2024-03-27 14:25:59 +01:00 committed by GitHub
parent 579aea40d0
commit 70e382b3d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 695 additions and 368 deletions

View file

@ -3,6 +3,7 @@ import { getHttpBaseInternal } from "@utils/env";
import { ErrorPageData } from "@utils/types";
import type { Request, Response } from "express";
import { StaticRouter, matchPath } from "inferno-router";
import { Match } from "inferno-router/dist/Route";
import { renderToString } from "inferno-server";
import { GetSiteResponse, LemmyHttp } from "lemmy-js-client";
import { App } from "../../shared/components/app/app";
@ -25,6 +26,8 @@ import {
LanguageService,
UserService,
} from "../../shared/services/";
import { parsePath } from "history";
import { getQueryString } from "@utils/helpers";
export default async (req: Request, res: Response) => {
try {
@ -40,7 +43,10 @@ export default async (req: Request, res: Response) => {
.sort((a, b) => b.q - a.q)
.map(x => (x.lang === "*" ? "en" : x.lang)) ?? [];
const activeRoute = routes.find(route => matchPath(req.path, route));
let match: Match<any> | null | undefined;
const activeRoute = routes.find(
route => (match = matchPath(req.path, route)),
);
const headers = setForwardedHeaders(req.headers);
const auth = getJwtCookie(req.headers);
@ -49,7 +55,7 @@ export default async (req: Request, res: Response) => {
new LemmyHttp(getHttpBaseInternal(), { headers }),
);
const { path, url, query } = req;
const { path, url } = req;
// Get site data first
// This bypasses errors, so that the client can hit the error on its own,
@ -71,7 +77,7 @@ export default async (req: Request, res: Response) => {
}
if (!auth && isAuthPath(path)) {
return res.redirect(`/login?prev=${encodeURIComponent(url)}`);
return res.redirect(`/login${getQueryString({ prev: url })}`);
}
if (try_site.state === "success") {
@ -83,10 +89,12 @@ export default async (req: Request, res: Response) => {
return res.redirect("/setup");
}
if (site && activeRoute?.fetchInitialData) {
const initialFetchReq: InitialFetchRequest = {
if (site && activeRoute?.fetchInitialData && match) {
const { search } = parsePath(url);
const initialFetchReq: InitialFetchRequest<Record<string, any>> = {
path,
query,
query: activeRoute.getQueryParams?.(search, site) ?? {},
match,
site,
headers,
};

View file

@ -49,7 +49,12 @@ export class App extends Component<any, any> {
<div className="mt-4 p-0 fl-1">
<Switch>
{routes.map(
({ path, component: RouteComponent, fetchInitialData }) => (
({
path,
component: RouteComponent,
fetchInitialData,
getQueryParams,
}) => (
<Route
key={path}
path={path}
@ -59,20 +64,34 @@ export class App extends Component<any, any> {
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 {...routeProps} />
<RouteComponent {...queryProps} />
</AuthGuard>
) : isAnonymousPath(path ?? "") ? (
<AnonymousGuard>
<RouteComponent {...routeProps} />
<RouteComponent {...queryProps} />
</AnonymousGuard>
) : (
<RouteComponent {...routeProps} />
<RouteComponent {...queryProps} />
))}
</div>
</ErrorGuard>

View file

@ -2,6 +2,7 @@ import { Component } from "inferno";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { UserService } from "../../services";
import { Spinner } from "./icon";
import { getQueryString } from "@utils/helpers";
interface AuthGuardState {
hasRedirected: boolean;
@ -26,7 +27,7 @@ class AuthGuard extends Component<
if (!UserService.Instance.myUserInfo) {
const { pathname, search } = this.props.location;
this.context.router.history.replace(
`/login?prev=${encodeURIComponent(pathname + search)}`,
`/login${getQueryString({ prev: pathname + search })}`,
);
} else {
this.setState({ hasRedirected: true });

View file

@ -68,28 +68,31 @@ export class PictrsImage extends Component<PictrsImageProps, any> {
// sample url:
// http://localhost:8535/pictrs/image/file.png?thumbnail=256&format=jpg
const split = this.props.src.split("/pictrs/image/");
// If theres not multiple, then its not a pictrs image
if (split.length === 1) {
let url: URL | undefined;
try {
url = new URL(this.props.src);
} catch {
return this.props.src;
}
const host = split[0];
const path = split[1];
const params = { format };
if (this.props.thumbnail) {
params["thumbnail"] = thumbnailSize;
} else if (this.props.icon) {
params["thumbnail"] = iconThumbnailSize;
// If theres no match, then its not a pictrs image
if (!url.pathname.includes("/pictrs/image/")) {
return this.props.src;
}
const paramsStr = new URLSearchParams(params).toString();
const out = `${host}/pictrs/image/${path}?${paramsStr}`;
// Keeps original search params. Could probably do `url.search = ""` here.
return out;
url.searchParams.set("format", format);
if (this.props.thumbnail) {
url.searchParams.set("thumbnail", thumbnailSize.toString());
} else if (this.props.icon) {
url.searchParams.set("thumbnail", iconThumbnailSize.toString());
} else {
url.searchParams.delete("thumbnail");
}
return url.href;
}
alt(): string {

View file

@ -1,4 +1,4 @@
import { validInstanceTLD } from "@utils/helpers";
import { getQueryString, validInstanceTLD } from "@utils/helpers";
import classNames from "classnames";
import { NoOptionI18nKeys } from "i18next";
import { Component, MouseEventHandler, linkEvent } from "inferno";
@ -134,8 +134,8 @@ function submitRemoteFollow(
instanceText = `http${VERSION !== "dev" ? "s" : ""}://${instanceText}`;
}
window.location.href = `${instanceText}/activitypub/externalInteraction?uri=${encodeURIComponent(
communityActorId,
window.location.href = `${instanceText}/activitypub/externalInteraction${getQueryString(
{ uri: communityActorId },
)}`;
}

View file

@ -36,6 +36,8 @@ import { CommunityLink } from "./community-link";
import { communityLimit } from "../../config";
import { SubscribeButton } from "../common/subscribe-button";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
type CommunitiesData = RouteDataResponse<{
listCommunitiesResponse: ListCommunitiesResponse;
@ -62,15 +64,30 @@ function getSortTypeFromQuery(type?: string): SortType {
return type ? (type as SortType) : "TopMonth";
}
function getCommunitiesQueryParams() {
return getQueryParams<CommunitiesProps>({
listingType: getListingTypeFromQuery,
sort: getSortTypeFromQuery,
page: getPageFromString,
});
export function getCommunitiesQueryParams(source?: string): CommunitiesProps {
return getQueryParams<CommunitiesProps>(
{
listingType: getListingTypeFromQuery,
sort: getSortTypeFromQuery,
page: getPageFromString,
},
source,
);
}
export class Communities extends Component<any, CommunitiesState> {
type CommunitiesPathProps = Record<string, never>;
type CommunitiesRouteProps = RouteComponentProps<CommunitiesPathProps> &
CommunitiesProps;
export type CommunitiesFetchConfig = IRoutePropsWithFetch<
CommunitiesData,
CommunitiesPathProps,
CommunitiesProps
>;
export class Communities extends Component<
CommunitiesRouteProps,
CommunitiesState
> {
private isoData = setIsoData<CommunitiesData>(this.context);
state: CommunitiesState = {
listCommunitiesResponse: EMPTY_REQUEST,
@ -79,7 +96,7 @@ export class Communities extends Component<any, CommunitiesState> {
isIsomorphic: false,
};
constructor(props: any, context: any) {
constructor(props: CommunitiesRouteProps, context: any) {
super(props, context);
this.handlePageChange = this.handlePageChange.bind(this);
this.handleSortChange = this.handleSortChange.bind(this);
@ -118,7 +135,7 @@ export class Communities extends Component<any, CommunitiesState> {
</h5>
);
case "success": {
const { listingType, sort, page } = getCommunitiesQueryParams();
const { listingType, sort, page } = this.props;
return (
<div>
<h1 className="h4 mb-4">
@ -268,7 +285,7 @@ export class Communities extends Component<any, CommunitiesState> {
listingType: urlListingType,
sort: urlSort,
page: urlPage,
} = getCommunitiesQueryParams();
} = this.props;
const queryParams: QueryParams<CommunitiesProps> = {
listingType: listingType ?? urlListingType,
@ -302,10 +319,10 @@ export class Communities extends Component<any, CommunitiesState> {
handleSearchSubmit(i: Communities, event: any) {
event.preventDefault();
const searchParamEncoded = encodeURIComponent(i.state.searchText);
const { listingType } = getCommunitiesQueryParams();
const searchParamEncoded = i.state.searchText;
const { listingType } = i.props;
i.context.router.history.push(
`/search?q=${searchParamEncoded}&type=Communities&listingType=${listingType}`,
`/search${getQueryString({ q: searchParamEncoded, type: "Communities", listingType })}`,
);
}
@ -313,16 +330,17 @@ export class Communities extends Component<any, CommunitiesState> {
headers,
query: { listingType, sort, page },
}: InitialFetchRequest<
QueryParams<CommunitiesProps>
CommunitiesPathProps,
CommunitiesProps
>): Promise<CommunitiesData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),
);
const listCommunitiesForm: ListCommunities = {
type_: getListingTypeFromQuery(listingType),
sort: getSortTypeFromQuery(sort),
type_: listingType,
sort,
limit: communityLimit,
page: getPageFromString(page),
page,
};
return {
@ -346,7 +364,7 @@ export class Communities extends Component<any, CommunitiesState> {
async refetch() {
this.setState({ listCommunitiesResponse: LOADING_REQUEST });
const { listingType, sort, page } = getCommunitiesQueryParams();
const { listingType, sort, page } = this.props;
this.setState({
listCommunitiesResponse: await HttpService.client.listCommunities({

View file

@ -101,6 +101,7 @@ import { CommunityLink } from "./community-link";
import { PaginatorCursor } from "../common/paginator-cursor";
import { getHttpBaseInternal } from "../../utils/env";
import { Sidebar } from "./sidebar";
import { IRoutePropsWithFetch } from "../../routes";
type CommunityData = RouteDataResponse<{
communityRes: GetCommunityResponse;
@ -124,12 +125,26 @@ interface CommunityProps {
pageCursor?: PaginationCursor;
}
function getCommunityQueryParams() {
return getQueryParams<CommunityProps>({
dataType: getDataTypeFromQuery,
pageCursor: cursor => cursor,
sort: getSortTypeFromQuery,
});
type Fallbacks = { sort: SortType };
export function getCommunityQueryParams(
source: string | undefined,
siteRes: GetSiteResponse,
) {
const myUserInfo = siteRes.my_user ?? UserService.Instance.myUserInfo;
const local_user = myUserInfo?.local_user_view.local_user;
const local_site = siteRes.site_view.local_site;
return getQueryParams<CommunityProps, Fallbacks>(
{
dataType: getDataTypeFromQuery,
pageCursor: (cursor?: string) => cursor,
sort: getSortTypeFromQuery,
},
source,
{
sort: local_user?.default_sort_type ?? local_site.default_sort_type,
},
);
}
function getDataTypeFromQuery(type?: string): DataType {
@ -144,10 +159,16 @@ function getSortTypeFromQuery(type?: string): SortType {
return type ? (type as SortType) : mySortType ?? "Active";
}
export class Community extends Component<
RouteComponentProps<{ name: string }>,
State
> {
type CommunityPathProps = { name: string };
type CommunityRouteProps = RouteComponentProps<CommunityPathProps> &
CommunityProps;
export type CommunityFetchConfig = IRoutePropsWithFetch<
CommunityData,
CommunityPathProps,
CommunityProps
>;
export class Community extends Component<CommunityRouteProps, State> {
private isoData = setIsoData<CommunityData>(this.context);
state: State = {
communityRes: EMPTY_REQUEST,
@ -159,7 +180,7 @@ export class Community extends Component<
isIsomorphic: false,
};
private readonly mainContentRef: RefObject<HTMLElement>;
constructor(props: RouteComponentProps<{ name: string }>, context: any) {
constructor(props: CommunityRouteProps, context: any) {
super(props, context);
this.handleSortChange = this.handleSortChange.bind(this);
@ -234,25 +255,22 @@ export class Community extends Component<
static async fetchInitialData({
headers,
path,
query: { dataType: urlDataType, pageCursor, sort: urlSort },
}: InitialFetchRequest<QueryParams<CommunityProps>>): Promise<
Promise<CommunityData>
> {
query: { dataType, pageCursor, sort },
match: {
params: { name: communityName },
},
}: InitialFetchRequest<
CommunityPathProps,
CommunityProps
>): Promise<CommunityData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),
);
const pathSplit = path.split("/");
const communityName = pathSplit[2];
const communityForm: GetCommunity = {
name: communityName,
};
const dataType = getDataTypeFromQuery(urlDataType);
const sort = getSortTypeFromQuery(urlSort);
let postsFetch: Promise<RequestState<GetPostsResponse>> =
Promise.resolve(EMPTY_REQUEST);
let commentsFetch: Promise<RequestState<GetCommentsResponse>> =
@ -411,7 +429,7 @@ export class Community extends Component<
}
listings(communityRes: GetCommunityResponse) {
const { dataType } = getCommunityQueryParams();
const { dataType } = this.props;
const { site_res } = this.isoData;
if (dataType === DataType.Post) {
@ -534,7 +552,7 @@ export class Community extends Component<
// let communityRss = this.state.communityRes.map(r =>
// communityRSSUrl(r.community_view.community.actor_id, this.state.sort)
// );
const { dataType, sort } = getCommunityQueryParams();
const { dataType, sort } = this.props;
const communityRss = res
? communityRSSUrl(res.community_view.community.actor_id, sort)
: undefined;
@ -592,7 +610,7 @@ export class Community extends Component<
}
async updateUrl({ dataType, pageCursor, sort }: Partial<CommunityProps>) {
const { dataType: urlDataType, sort: urlSort } = getCommunityQueryParams();
const { dataType: urlDataType, sort: urlSort } = this.props;
const queryParams: QueryParams<CommunityProps> = {
dataType: getDataTypeString(dataType ?? urlDataType),
@ -608,7 +626,7 @@ export class Community extends Component<
}
async fetchData() {
const { dataType, pageCursor, sort } = getCommunityQueryParams();
const { dataType, pageCursor, sort } = this.props;
const { name } = this.props.match.params;
if (dataType === DataType.Post) {

View file

@ -1,4 +1,4 @@
import { hostname } from "@utils/helpers";
import { getQueryString, hostname } from "@utils/helpers";
import { amAdmin, amMod, amTopMod } from "@utils/roles";
import { Component, InfernoNode, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess";
@ -287,7 +287,10 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
className={`btn btn-secondary d-block mb-2 w-100 ${
cv.community.deleted || cv.community.removed ? "no-click" : ""
}`}
to={`/create_post?communityId=${cv.community.id}`}
to={
"/create_post" +
getQueryString({ communityId: cv.community.id.toString() })
}
>
{I18NextService.i18n.t("create_a_post")}
</Link>

View file

@ -34,6 +34,8 @@ import RateLimitForm from "./rate-limit-form";
import { SiteForm } from "./site-form";
import { TaglineForm } from "./tagline-form";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
type AdminSettingsData = RouteDataResponse<{
bannedRes: BannedPersonsResponse;
@ -52,7 +54,18 @@ interface AdminSettingsState {
isIsomorphic: boolean;
}
export class AdminSettings extends Component<any, AdminSettingsState> {
type AdminSettingsRouteProps = RouteComponentProps<Record<string, never>> &
Record<string, never>;
export type AdminSettingsFetchConfig = IRoutePropsWithFetch<
AdminSettingsData,
Record<string, never>,
Record<string, never>
>;
export class AdminSettings extends Component<
AdminSettingsRouteProps,
AdminSettingsState
> {
private isoData = setIsoData<AdminSettingsData>(this.context);
state: AdminSettingsState = {
siteRes: this.isoData.site_res,

View file

@ -100,6 +100,8 @@ import { PostListings } from "../post/post-listings";
import { SiteSidebar } from "./site-sidebar";
import { PaginatorCursor } from "../common/paginator-cursor";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
interface HomeState {
postsRes: RequestState<GetPostsResponse>;
@ -129,23 +131,22 @@ type HomeData = RouteDataResponse<{
trendingCommunitiesRes: ListCommunitiesResponse;
}>;
function getRss(listingType: ListingType) {
const { sort } = getHomeQueryParams();
function getRss(listingType: ListingType, sort: SortType) {
let rss: string | undefined = undefined;
const queryString = getQueryString({ sort });
switch (listingType) {
case "All": {
rss = `/feeds/all.xml?sort=${sort}`;
rss = "/feeds/all.xml" + queryString;
break;
}
case "Local": {
rss = `/feeds/local.xml?sort=${sort}`;
rss = "/feeds/local.xml" + queryString;
break;
}
case "Subscribed": {
const auth = myAuth();
rss = auth ? `/feeds/front/${auth}.xml?sort=${sort}` : undefined;
rss = auth ? `/feeds/front/${auth}.xml${queryString}` : undefined;
break;
}
}
@ -167,31 +168,46 @@ function getDataTypeFromQuery(type?: string): DataType {
}
function getListingTypeFromQuery(
type?: string,
myUserInfo = UserService.Instance.myUserInfo,
): ListingType | undefined {
const myListingType =
myUserInfo?.local_user_view?.local_user?.default_listing_type;
return type ? (type as ListingType) : myListingType;
type: string | undefined,
fallback: ListingType,
): ListingType {
return type ? (type as ListingType) : fallback;
}
function getSortTypeFromQuery(
type?: string,
myUserInfo = UserService.Instance.myUserInfo,
type: string | undefined,
fallback: SortType,
): SortType {
const mySortType = myUserInfo?.local_user_view?.local_user?.default_sort_type;
return (type ? (type as SortType) : mySortType) ?? "Active";
return type ? (type as SortType) : fallback;
}
function getHomeQueryParams() {
return getQueryParams<HomeProps>({
sort: getSortTypeFromQuery,
listingType: getListingTypeFromQuery,
pageCursor: cursor => cursor,
dataType: getDataTypeFromQuery,
});
type Fallbacks = {
sort: SortType;
listingType: ListingType;
};
export function getHomeQueryParams(
source: string | undefined,
siteRes: GetSiteResponse,
): HomeProps {
const myUserInfo = siteRes.my_user ?? UserService.Instance.myUserInfo;
const local_user = myUserInfo?.local_user_view.local_user;
const local_site = siteRes.site_view.local_site;
return getQueryParams<HomeProps, Fallbacks>(
{
sort: getSortTypeFromQuery,
listingType: getListingTypeFromQuery,
pageCursor: (cursor?: string) => cursor,
dataType: getDataTypeFromQuery,
},
source,
{
sort: local_user?.default_sort_type ?? local_site.default_sort_type,
listingType:
local_user?.default_listing_type ??
local_site.default_post_listing_type,
},
);
}
const MobileButton = ({
@ -224,7 +240,15 @@ const LinkButton = ({
</Link>
);
export class Home extends Component<any, HomeState> {
type HomePathProps = Record<string, never>;
type HomeRouteProps = RouteComponentProps<HomePathProps> & HomeProps;
export type HomeFetchConfig = IRoutePropsWithFetch<
HomeData,
HomePathProps,
HomeProps
>;
export class Home extends Component<HomeRouteProps, HomeState> {
private isoData = setIsoData<HomeData>(this.context);
state: HomeState = {
postsRes: EMPTY_REQUEST,
@ -310,20 +334,13 @@ export class Home extends Component<any, HomeState> {
}
static async fetchInitialData({
query: { dataType: urlDataType, listingType, pageCursor, sort: urlSort },
site,
query: { listingType, dataType, sort, pageCursor },
headers,
}: InitialFetchRequest<QueryParams<HomeProps>>): Promise<HomeData> {
}: InitialFetchRequest<HomePathProps, HomeProps>): Promise<HomeData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),
);
const dataType = getDataTypeFromQuery(urlDataType);
const type_ =
getListingTypeFromQuery(listingType, site.my_user) ??
site.site_view.local_site.default_post_listing_type;
const sort = getSortTypeFromQuery(urlSort, site.my_user);
let postsFetch: Promise<RequestState<GetPostsResponse>> =
Promise.resolve(EMPTY_REQUEST);
let commentsFetch: Promise<RequestState<GetCommentsResponse>> =
@ -331,7 +348,7 @@ export class Home extends Component<any, HomeState> {
if (dataType === DataType.Post) {
const getPostsForm: GetPosts = {
type_,
type_: listingType,
page_cursor: pageCursor,
limit: fetchLimit,
sort,
@ -343,7 +360,7 @@ export class Home extends Component<any, HomeState> {
const getCommentsForm: GetComments = {
limit: fetchLimit,
sort: postToCommentSortType(sort),
type_,
type_: listingType,
saved_only: false,
};
@ -635,7 +652,7 @@ export class Home extends Component<any, HomeState> {
dataType: urlDataType,
listingType: urlListingType,
sort: urlSort,
} = getHomeQueryParams();
} = this.props;
const queryParams: QueryParams<HomeProps> = {
dataType: getDataTypeString(dataType ?? urlDataType),
@ -679,7 +696,7 @@ export class Home extends Component<any, HomeState> {
}
get listings() {
const { dataType } = getHomeQueryParams();
const { dataType } = this.props;
const siteRes = this.state.siteRes;
if (dataType === DataType.Post) {
@ -771,7 +788,7 @@ export class Home extends Component<any, HomeState> {
}
get selects() {
const { listingType, dataType, sort } = getHomeQueryParams();
const { listingType, dataType, sort } = this.props;
return (
<div className="row align-items-center mb-3 g-3">
@ -799,6 +816,7 @@ export class Home extends Component<any, HomeState> {
{getRss(
listingType ??
this.state.siteRes.site_view.local_site.default_post_listing_type,
sort,
)}
</div>
</div>
@ -817,7 +835,7 @@ export class Home extends Component<any, HomeState> {
}
async fetchData() {
const { dataType, pageCursor, listingType, sort } = getHomeQueryParams();
const { dataType, pageCursor, listingType, sort } = this.props;
if (dataType === DataType.Post) {
this.setState({ postsRes: LOADING_REQUEST });

View file

@ -22,6 +22,8 @@ import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import Tabs from "../common/tabs";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
type InstancesData = RouteDataResponse<{
federatedInstancesResponse: GetFederatedInstancesResponse;
@ -33,7 +35,15 @@ interface InstancesState {
isIsomorphic: boolean;
}
export class Instances extends Component<any, InstancesState> {
type InstancesRouteProps = RouteComponentProps<Record<string, never>> &
Record<string, never>;
export type InstancesFetchConfig = IRoutePropsWithFetch<
InstancesData,
Record<string, never>,
Record<string, never>
>;
export class Instances extends Component<InstancesRouteProps, InstancesState> {
private isoData = setIsoData<InstancesData>(this.context);
state: InstancesState = {
instancesRes: EMPTY_REQUEST,

View file

@ -17,17 +17,21 @@ import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
import TotpModal from "../common/totp-modal";
import { UnreadCounterService } from "../../services";
import { RouteData } from "../../interfaces";
import { IRoutePropsWithFetch } from "../../routes";
interface LoginProps {
prev?: string;
}
const getLoginQueryParams = () =>
getQueryParams<LoginProps>({
prev(param) {
return param ? decodeURIComponent(param) : undefined;
export function getLoginQueryParams(source?: string): LoginProps {
return getQueryParams<LoginProps>(
{
prev: (param?: string) => param,
},
});
source,
);
}
interface State {
loginRes: RequestState<LoginResponse>;
@ -50,7 +54,7 @@ async function handleLoginSuccess(i: Login, loginRes: LoginResponse) {
refreshTheme();
}
const { prev } = getLoginQueryParams();
const { prev } = i.props;
prev
? i.props.history.replace(prev)
@ -114,10 +118,14 @@ function handleClose2faModal(i: Login) {
i.setState({ show2faModal: false });
}
export class Login extends Component<
RouteComponentProps<Record<string, never>>,
State
> {
type LoginRouteProps = RouteComponentProps<Record<string, never>> & LoginProps;
export type LoginFetchConfig = IRoutePropsWithFetch<
RouteData,
Record<string, never>,
LoginProps
>;
export class Login extends Component<LoginRouteProps, State> {
private isoData = setIsoData(this.context);
state: State = {

View file

@ -63,6 +63,7 @@ import { SearchableSelect } from "./common/searchable-select";
import { CommunityLink } from "./community/community-link";
import { PersonListing } from "./person/person-listing";
import { getHttpBaseInternal } from "../utils/env";
import { IRoutePropsWithFetch } from "../routes";
type FilterType = "mod" | "user";
@ -97,13 +98,17 @@ interface ModlogType {
when_: string;
}
const getModlogQueryParams = () =>
getQueryParams<ModlogProps>({
actionType: getActionFromString,
modId: getIdFromString,
userId: getIdFromString,
page: getPageFromString,
});
export function getModlogQueryParams(source?: string): ModlogProps {
return getQueryParams<ModlogProps>(
{
actionType: getActionFromString,
modId: getIdFromString,
userId: getIdFromString,
page: getPageFromString,
},
source,
);
}
interface ModlogState {
res: RequestState<GetModlogResponse>;
@ -117,8 +122,8 @@ interface ModlogState {
interface ModlogProps {
page: number;
userId?: number | null;
modId?: number | null;
userId?: number;
modId?: number;
actionType: ModlogActionType;
}
@ -632,10 +637,15 @@ async function createNewOptions({
}
}
export class Modlog extends Component<
RouteComponentProps<{ communityId?: string }>,
ModlogState
> {
type ModlogPathProps = { communityId?: string };
type ModlogRouteProps = RouteComponentProps<ModlogPathProps> & ModlogProps;
export type ModlogFetchConfig = IRoutePropsWithFetch<
ModlogData,
ModlogPathProps,
ModlogProps
>;
export class Modlog extends Component<ModlogRouteProps, ModlogState> {
private isoData = setIsoData<ModlogData>(this.context);
state: ModlogState = {
@ -648,10 +658,7 @@ export class Modlog extends Component<
isIsomorphic: false,
};
constructor(
props: RouteComponentProps<{ communityId?: string }>,
context: any,
) {
constructor(props: ModlogRouteProps, context: any) {
super(props, context);
this.handlePageChange = this.handlePageChange.bind(this);
this.handleUserChange = this.handleUserChange.bind(this);
@ -687,7 +694,7 @@ export class Modlog extends Component<
async componentDidMount() {
if (!this.state.isIsomorphic) {
const { modId, userId } = getModlogQueryParams();
const { modId, userId } = this.props;
const promises = [this.refetch()];
if (userId) {
@ -774,7 +781,7 @@ export class Modlog extends Component<
userSearchOptions,
modSearchOptions,
} = this.state;
const { actionType, modId, userId } = getModlogQueryParams();
const { actionType, modId, userId } = this.props;
return (
<div className="modlog container-lg">
@ -873,7 +880,7 @@ export class Modlog extends Component<
</h5>
);
case "success": {
const page = getModlogQueryParams().page;
const page = this.props.page;
return (
<div className="table-responsive">
<table id="modlog_table" className="table table-sm table-hover">
@ -909,15 +916,15 @@ export class Modlog extends Component<
}
handleUserChange(option: Choice) {
this.updateUrl({ userId: getIdFromString(option.value) ?? null, page: 1 });
this.updateUrl({ userId: getIdFromString(option.value), page: 1 });
}
handleModChange(option: Choice) {
this.updateUrl({ modId: getIdFromString(option.value) ?? null, page: 1 });
this.updateUrl({ modId: getIdFromString(option.value), page: 1 });
}
handleSearchUsers = debounce(async (text: string) => {
const { userId } = getModlogQueryParams();
const { userId } = this.props;
const { userSearchOptions } = this.state;
this.setState({ loadingUserSearch: true });
@ -934,7 +941,7 @@ export class Modlog extends Component<
});
handleSearchMods = debounce(async (text: string) => {
const { modId } = getModlogQueryParams();
const { modId } = this.props;
const { modSearchOptions } = this.state;
this.setState({ loadingModSearch: true });
@ -956,7 +963,7 @@ export class Modlog extends Component<
actionType: urlActionType,
modId: urlModId,
userId: urlUserId,
} = getModlogQueryParams();
} = this.props;
const queryParams: QueryParams<ModlogProps> = {
page: (page ?? urlPage).toString(),
@ -977,7 +984,7 @@ export class Modlog extends Component<
}
async refetch() {
const { actionType, page, modId, userId } = getModlogQueryParams();
const { actionType, page, modId, userId } = this.props;
const { communityId: urlCommunityId } = this.props.match.params;
const communityId = getIdFromString(urlCommunityId);
@ -988,10 +995,10 @@ export class Modlog extends Component<
page,
limit: fetchLimit,
type_: actionType,
other_person_id: userId ?? undefined,
other_person_id: userId,
mod_person_id: !this.isoData.site_res.site_view.local_site
.hide_modlog_mod_names
? modId ?? undefined
? modId
: undefined,
}),
});
@ -1008,25 +1015,25 @@ export class Modlog extends Component<
static async fetchInitialData({
headers,
path,
query: { modId: urlModId, page, userId: urlUserId, actionType },
query: { page, userId, modId: modId_, actionType },
match: {
params: { communityId: urlCommunityId },
},
site,
}: InitialFetchRequest<QueryParams<ModlogProps>>): Promise<ModlogData> {
}: InitialFetchRequest<ModlogPathProps, ModlogProps>): Promise<ModlogData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),
);
const pathSplit = path.split("/");
const communityId = getIdFromString(pathSplit[2]);
const communityId = getIdFromString(urlCommunityId);
const modId = !site.site_view.local_site.hide_modlog_mod_names
? getIdFromString(urlModId)
? modId_
: undefined;
const userId = getIdFromString(urlUserId);
const modlogForm: GetModlog = {
page: getPageFromString(page),
page,
limit: fetchLimit,
community_id: communityId,
type_: getActionFromString(actionType),
type_: actionType,
mod_person_id: modId,
other_person_id: userId,
};

View file

@ -80,6 +80,8 @@ import { Icon, Spinner } from "../common/icon";
import { Paginator } from "../common/paginator";
import { PrivateMessage } from "../private_message/private-message";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
enum UnreadOrAll {
Unread,
@ -126,7 +128,15 @@ interface InboxState {
isIsomorphic: boolean;
}
export class Inbox extends Component<any, InboxState> {
type InboxRouteProps = RouteComponentProps<Record<string, never>> &
Record<string, never>;
export type InboxFetchConfig = IRoutePropsWithFetch<
InboxData,
Record<string, never>,
Record<string, never>
>;
export class Inbox extends Component<InboxRouteProps, InboxState> {
private isoData = setIsoData<InboxData>(this.context);
state: InboxState = {
unreadOrAll: UnreadOrAll.Unread,

View file

@ -94,6 +94,7 @@ import { CommunityLink } from "../community/community-link";
import { PersonDetails } from "./person-details";
import { PersonListing } from "./person-listing";
import { getHttpBaseInternal } from "../../utils/env";
import { IRoutePropsWithFetch } from "../../routes";
type ProfileData = RouteDataResponse<{
personResponse: GetPersonDetailsResponse;
@ -117,12 +118,15 @@ interface ProfileProps {
page: number;
}
function getProfileQueryParams() {
return getQueryParams<ProfileProps>({
view: getViewFromProps,
page: getPageFromString,
sort: getSortTypeFromQuery,
});
export function getProfileQueryParams(source?: string): ProfileProps {
return getQueryParams<ProfileProps>(
{
view: getViewFromProps,
page: getPageFromString,
sort: getSortTypeFromQuery,
},
source,
);
}
function getSortTypeFromQuery(sort?: string): SortType {
@ -171,10 +175,15 @@ function isPersonBlocked(personRes: RequestState<GetPersonDetailsResponse>) {
);
}
export class Profile extends Component<
RouteComponentProps<{ username: string }>,
ProfileState
> {
type ProfilePathProps = { username: string };
type ProfileRouteProps = RouteComponentProps<ProfilePathProps> & ProfileProps;
export type ProfileFetchConfig = IRoutePropsWithFetch<
ProfileData,
ProfilePathProps,
ProfileProps
>;
export class Profile extends Component<ProfileRouteProps, ProfileState> {
private isoData = setIsoData<ProfileData>(this.context);
state: ProfileState = {
personRes: EMPTY_REQUEST,
@ -186,7 +195,7 @@ export class Profile extends Component<
isIsomorphic: false,
};
constructor(props: RouteComponentProps<{ username: string }>, context: any) {
constructor(props: ProfileRouteProps, context: any) {
super(props, context);
this.handleSortChange = this.handleSortChange.bind(this);
@ -248,7 +257,7 @@ export class Profile extends Component<
}
async fetchUserData() {
const { page, sort, view } = getProfileQueryParams();
const { page, sort, view } = this.props;
this.setState({ personRes: LOADING_REQUEST });
const personRes = await HttpService.client.getPersonDetails({
@ -278,22 +287,23 @@ export class Profile extends Component<
static async fetchInitialData({
headers,
path,
query: { page, sort, view: urlView },
}: InitialFetchRequest<QueryParams<ProfileProps>>): Promise<ProfileData> {
query: { view, sort, page },
match: {
params: { username },
},
}: InitialFetchRequest<
ProfilePathProps,
ProfileProps
>): Promise<ProfileData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),
);
const pathSplit = path.split("/");
const username = pathSplit[2];
const view = getViewFromProps(urlView);
const form: GetPersonDetails = {
username: username,
sort: getSortTypeFromQuery(sort),
sort,
saved_only: view === PersonDetailsView.Saved,
page: getPageFromString(page),
page,
limit: fetchLimit,
};
@ -321,7 +331,7 @@ export class Profile extends Component<
case "success": {
const siteRes = this.state.siteRes;
const personRes = this.state.personRes.data;
const { page, sort, view } = getProfileQueryParams();
const { page, sort, view } = this.props;
return (
<div className="row">
@ -415,7 +425,7 @@ export class Profile extends Component<
}
getRadio(view: PersonDetailsView) {
const { view: urlView } = getProfileQueryParams();
const { view: urlView } = this.props;
const active = view === urlView;
const radioId = randomStr();
@ -442,10 +452,10 @@ export class Profile extends Component<
}
get selects() {
const { sort } = getProfileQueryParams();
const { sort } = this.props;
const { username } = this.props.match.params;
const profileRss = `/feeds/u/${username}.xml?sort=${sort}`;
const profileRss = `/feeds/u/${username}.xml${getQueryString({ sort })}`;
return (
<div className="mb-2">
@ -713,11 +723,7 @@ export class Profile extends Component<
}
async updateUrl({ page, sort, view }: Partial<ProfileProps>) {
const {
page: urlPage,
sort: urlSort,
view: urlView,
} = getProfileQueryParams();
const { page: urlPage, sort: urlSort, view: urlView } = this.props;
const queryParams: QueryParams<ProfileProps> = {
page: (page ?? urlPage).toString(),

View file

@ -27,6 +27,8 @@ import { Paginator } from "../common/paginator";
import { RegistrationApplication } from "../common/registration-application";
import { UnreadCounterService } from "../../services";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
enum RegistrationState {
Unread,
@ -46,8 +48,18 @@ interface RegistrationApplicationsState {
isIsomorphic: boolean;
}
type RegistrationApplicationsRouteProps = RouteComponentProps<
Record<string, never>
> &
Record<string, never>;
export type RegistrationApplicationsFetchConfig = IRoutePropsWithFetch<
RegistrationApplicationsData,
Record<string, never>,
Record<string, never>
>;
export class RegistrationApplications extends Component<
any,
RegistrationApplicationsRouteProps,
RegistrationApplicationsState
> {
private isoData = setIsoData<RegistrationApplicationsData>(this.context);

View file

@ -50,6 +50,8 @@ import { PostReport } from "../post/post-report";
import { PrivateMessageReport } from "../private_message/private-message-report";
import { UnreadCounterService } from "../../services";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
enum UnreadOrAll {
Unread,
@ -93,7 +95,15 @@ interface ReportsState {
isIsomorphic: boolean;
}
export class Reports extends Component<any, ReportsState> {
type ReportsRouteProps = RouteComponentProps<Record<string, never>> &
Record<string, never>;
export type ReportsFetchConfig = IRoutePropsWithFetch<
ReportsData,
Record<string, never>,
Record<string, never>
>;
export class Reports extends Component<ReportsRouteProps, ReportsState> {
private isoData = setIsoData<ReportsData>(this.context);
state: ReportsState = {
commentReportsRes: EMPTY_REQUEST,

View file

@ -68,6 +68,8 @@ import TotpModal from "../common/totp-modal";
import { LoadingEllipses } from "../common/loading-ellipses";
import { refreshTheme, setThemeOverride } from "../../utils/browser";
import { getHttpBaseInternal } from "../../utils/env";
import { IRoutePropsWithFetch } from "../../routes";
import { RouteComponentProps } from "inferno-router/dist/Route";
type SettingsData = RouteDataResponse<{
instancesRes: GetFederatedInstancesResponse;
@ -193,7 +195,15 @@ function handleClose2faModal(i: Settings) {
i.setState({ show2faModal: false });
}
export class Settings extends Component<any, SettingsState> {
type SettingsRouteProps = RouteComponentProps<Record<string, never>> &
Record<string, never>;
export type SettingsFetchConfig = IRoutePropsWithFetch<
SettingsData,
Record<string, never>,
Record<string, never>
>;
export class Settings extends Component<SettingsRouteProps, SettingsState> {
private isoData = setIsoData<SettingsData>(this.context);
exportSettingsLink = createRef<HTMLAnchorElement>();

View file

@ -5,7 +5,6 @@ import {
setIsoData,
} from "@utils/app";
import { getIdFromString, getQueryParams } from "@utils/helpers";
import type { QueryParams } from "@utils/types";
import { Choice, RouteDataResponse } from "@utils/types";
import { Component } from "inferno";
import { RouteComponentProps } from "inferno-router/dist/Route";
@ -30,6 +29,7 @@ import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import { PostForm } from "./post-form";
import { getHttpBaseInternal } from "../../utils/env";
import { IRoutePropsWithFetch } from "../../routes";
export interface CreatePostProps {
communityId?: number;
@ -40,10 +40,13 @@ type CreatePostData = RouteDataResponse<{
initialCommunitiesRes: ListCommunitiesResponse;
}>;
function getCreatePostQueryParams() {
return getQueryParams<CreatePostProps>({
communityId: getIdFromString,
});
export function getCreatePostQueryParams(source?: string): CreatePostProps {
return getQueryParams<CreatePostProps>(
{
communityId: getIdFromString,
},
source,
);
}
function fetchCommunitiesForOptions(client: WrappedLemmyHttp) {
@ -58,8 +61,17 @@ interface CreatePostState {
isIsomorphic: boolean;
}
type CreatePostPathProps = Record<string, never>;
type CreatePostRouteProps = RouteComponentProps<CreatePostPathProps> &
CreatePostProps;
export type CreatePostFetchConfig = IRoutePropsWithFetch<
CreatePostData,
CreatePostPathProps,
CreatePostProps
>;
export class CreatePost extends Component<
RouteComponentProps<Record<string, never>>,
CreatePostRouteProps,
CreatePostState
> {
private isoData = setIsoData<CreatePostData>(this.context);
@ -70,7 +82,7 @@ export class CreatePost extends Component<
isIsomorphic: false,
};
constructor(props: RouteComponentProps<Record<string, never>>, context: any) {
constructor(props: CreatePostRouteProps, context: any) {
super(props, context);
this.handlePostCreate = this.handlePostCreate.bind(this);
@ -102,9 +114,7 @@ export class CreatePost extends Component<
}
}
async fetchCommunity() {
const { communityId } = getCreatePostQueryParams();
async fetchCommunity({ communityId }: CreatePostProps) {
if (communityId) {
const res = await HttpService.client.getCommunity({
id: communityId,
@ -121,7 +131,7 @@ export class CreatePost extends Component<
async componentDidMount() {
// TODO test this
if (!this.state.isIsomorphic) {
const { communityId } = getCreatePostQueryParams();
const { communityId } = this.props;
const initialCommunitiesRes = await fetchCommunitiesForOptions(
HttpService.client,
@ -134,7 +144,7 @@ export class CreatePost extends Component<
if (
communityId?.toString() !== this.state.selectedCommunityChoice?.value
) {
await this.fetchCommunity();
await this.fetchCommunity({ communityId });
} else if (!communityId) {
this.setState({
selectedCommunityChoice: undefined,
@ -199,15 +209,13 @@ export class CreatePost extends Component<
}
async updateUrl({ communityId }: Partial<CreatePostProps>) {
const { communityId: urlCommunityId } = getCreatePostQueryParams();
const locationState = this.props.history.location.state as
| PostFormParams
| undefined;
const url = new URL(location.href);
const newId = (communityId ?? urlCommunityId)?.toString();
const newId = communityId?.toString();
if (newId !== undefined) {
url.searchParams.set("communityId", newId);
@ -215,9 +223,10 @@ export class CreatePost extends Component<
url.searchParams.delete("communityId");
}
history.replaceState(locationState, "", url);
// This bypasses the router and doesn't update the query props.
window.history.replaceState(locationState, "", url);
await this.fetchCommunity();
await this.fetchCommunity({ communityId });
}
handleSelectedCommunityChange(choice: Choice) {
@ -243,7 +252,8 @@ export class CreatePost extends Component<
headers,
query: { communityId },
}: InitialFetchRequest<
QueryParams<CreatePostProps>
CreatePostPathProps,
CreatePostProps
>): Promise<CreatePostData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),
@ -255,7 +265,7 @@ export class CreatePost extends Component<
if (communityId) {
const form: GetCommunity = {
id: getIdFromString(communityId),
id: communityId,
};
data.communityResponse = await client.getCommunity(form);

View file

@ -3,6 +3,7 @@ import {
capitalizeFirstLetter,
debounce,
getIdFromString,
getQueryString,
validTitle,
validURL,
} from "@utils/helpers";
@ -380,18 +381,14 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
archive.org {I18NextService.i18n.t("archive_link")}
</a>
<a
href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
url,
)}`}
href={`${ghostArchiveUrl}/search${getQueryString({ term: url })}`}
className="me-2 d-inline-block float-right text-muted small fw-bold"
rel={relTags}
>
ghostarchive.org {I18NextService.i18n.t("archive_link")}
</a>
<a
href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
url,
)}`}
href={`${archiveTodayUrl}/${getQueryString({ run: "1", url })}`}
className="me-2 d-inline-block float-right text-muted small fw-bold"
rel={relTags}
>

View file

@ -98,6 +98,8 @@ import { Icon, Spinner } from "../common/icon";
import { Sidebar } from "../community/sidebar";
import { PostListing } from "./post-listing";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
const commentsShownInterval = 15;
@ -122,7 +124,18 @@ interface PostState {
isIsomorphic: boolean;
}
export class Post extends Component<any, PostState> {
type PostPathProps =
| { post_id: string; comment_id: never }
| { post_id: never; comment_id: string };
type PostRouteProps = RouteComponentProps<PostPathProps> &
Record<string, never>;
export type PostFetchConfig = IRoutePropsWithFetch<
PostData,
PostPathProps,
Record<string, never>
>;
export class Post extends Component<PostRouteProps, PostState> {
private isoData = setIsoData<PostData>(this.context);
private commentScrollDebounced: () => void;
state: PostState = {
@ -235,15 +248,13 @@ export class Post extends Component<any, PostState> {
static async fetchInitialData({
headers,
path,
}: InitialFetchRequest): Promise<PostData> {
match,
}: InitialFetchRequest<PostPathProps>): Promise<PostData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),
);
const pathSplit = path.split("/");
const pathType = pathSplit.at(1);
const id = pathSplit.at(2) ? Number(pathSplit.at(2)) : undefined;
const postId = getIdFromProps({ match });
const commentId = getCommentIdFromProps({ match });
const postForm: GetPost = {};
@ -254,14 +265,11 @@ export class Post extends Component<any, PostState> {
saved_only: false,
};
// Set the correct id based on the path type
if (pathType === "post") {
postForm.id = id;
commentsForm.post_id = id;
} else {
postForm.comment_id = id;
commentsForm.parent_id = id;
}
postForm.id = postId;
postForm.comment_id = commentId;
commentsForm.post_id = postId;
commentsForm.parent_id = commentId;
const [postRes, commentsRes] = await Promise.all([
client.getPost(postForm),

View file

@ -22,6 +22,8 @@ import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import { PrivateMessageForm } from "./private-message-form";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
type CreatePrivateMessageData = RouteDataResponse<{
recipientDetailsResponse: GetPersonDetailsResponse;
@ -34,8 +36,17 @@ interface CreatePrivateMessageState {
isIsomorphic: boolean;
}
type CreatePrivateMessagePathProps = { recipient_id: string };
type CreatePrivateMessageRouteProps =
RouteComponentProps<CreatePrivateMessagePathProps> & Record<string, never>;
export type CreatePrivateMessageFetchConfig = IRoutePropsWithFetch<
CreatePrivateMessageData,
CreatePrivateMessagePathProps,
Record<string, never>
>;
export class CreatePrivateMessage extends Component<
any,
CreatePrivateMessageRouteProps,
CreatePrivateMessageState
> {
private isoData = setIsoData<CreatePrivateMessageData>(this.context);
@ -69,12 +80,12 @@ export class CreatePrivateMessage extends Component<
static async fetchInitialData({
headers,
path,
}: InitialFetchRequest): Promise<CreatePrivateMessageData> {
match,
}: InitialFetchRequest<CreatePrivateMessagePathProps>): Promise<CreatePrivateMessageData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),
);
const person_id = Number(path.split("/").pop());
const person_id = getRecipientIdFromProps({ match });
const form: GetPersonDetails = {
person_id,

View file

@ -1,6 +1,6 @@
import { setIsoData } from "@utils/app";
import { getQueryParams } from "@utils/helpers";
import { QueryParams, RouteDataResponse } from "@utils/types";
import { RouteDataResponse } from "@utils/types";
import { Component, linkEvent } from "inferno";
import {
CommunityView,
@ -22,6 +22,8 @@ import { PictrsImage } from "./common/pictrs-image";
import { SubscribeButton } from "./common/subscribe-button";
import { CommunityLink } from "./community/community-link";
import { getHttpBaseInternal } from "../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../routes";
interface RemoteFetchProps {
uri?: string;
@ -37,16 +39,19 @@ interface RemoteFetchState {
followCommunityLoading: boolean;
}
const getUriFromQuery = (uri?: string): string | undefined =>
uri ? decodeURIComponent(uri) : undefined;
const getUriFromQuery = (uri?: string): string | undefined => uri;
const getRemoteFetchQueryParams = () =>
getQueryParams<RemoteFetchProps>({
uri: getUriFromQuery,
});
export function getRemoteFetchQueryParams(source?: string): RemoteFetchProps {
return getQueryParams<RemoteFetchProps>(
{
uri: getUriFromQuery,
},
source,
);
}
function uriToQuery(uri: string) {
const match = decodeURIComponent(uri).match(/https?:\/\/(.+)\/c\/(.+)/);
const match = uri.match(/https?:\/\/(.+)\/c\/(.+)/);
return match ? `!${match[2]}@${match[1]}` : "";
}
@ -83,7 +88,19 @@ async function handleToggleFollow(i: RemoteFetch, follow: boolean) {
const handleFollow = (i: RemoteFetch) => handleToggleFollow(i, true);
const handleUnfollow = (i: RemoteFetch) => handleToggleFollow(i, false);
export class RemoteFetch extends Component<any, RemoteFetchState> {
type RemoteFetchPathProps = Record<string, never>;
type RemoteFetchRouteProps = RouteComponentProps<RemoteFetchPathProps> &
RemoteFetchProps;
export type RemoteFetchFetchConfig = IRoutePropsWithFetch<
RemoteFetchData,
RemoteFetchPathProps,
RemoteFetchProps
>;
export class RemoteFetch extends Component<
RemoteFetchRouteProps,
RemoteFetchState
> {
private isoData = setIsoData<RemoteFetchData>(this.context);
state: RemoteFetchState = {
resolveObjectRes: EMPTY_REQUEST,
@ -91,7 +108,7 @@ export class RemoteFetch extends Component<any, RemoteFetchState> {
followCommunityLoading: false,
};
constructor(props: any, context: any) {
constructor(props: RemoteFetchRouteProps, context: any) {
super(props, context);
if (FirstLoadService.isFirstLoad) {
@ -107,7 +124,7 @@ export class RemoteFetch extends Component<any, RemoteFetchState> {
async componentDidMount() {
if (!this.state.isIsomorphic) {
const { uri } = getRemoteFetchQueryParams();
const { uri } = this.props;
if (uri) {
this.setState({ resolveObjectRes: LOADING_REQUEST });
@ -139,7 +156,7 @@ export class RemoteFetch extends Component<any, RemoteFetchState> {
get content() {
const res = this.state.resolveObjectRes;
const { uri } = getRemoteFetchQueryParams();
const { uri } = this.props;
const remoteCommunityName = uri ? uriToQuery(uri) : "remote community";
switch (res.state) {
@ -204,7 +221,7 @@ export class RemoteFetch extends Component<any, RemoteFetchState> {
}
get documentTitle(): string {
const { uri } = getRemoteFetchQueryParams();
const { uri } = this.props;
const name = this.isoData.site_res.site_view.site.name;
return `${I18NextService.i18n.t("remote_follow")} - ${
uri ? `${uri} - ` : ""
@ -215,7 +232,8 @@ export class RemoteFetch extends Component<any, RemoteFetchState> {
headers,
query: { uri },
}: InitialFetchRequest<
QueryParams<RemoteFetchProps>
RemoteFetchPathProps,
RemoteFetchProps
>): Promise<RemoteFetchData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),

View file

@ -67,14 +67,16 @@ import { CommunityLink } from "./community/community-link";
import { PersonListing } from "./person/person-listing";
import { PostListing } from "./post/post-listing";
import { getHttpBaseInternal } from "../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../routes";
interface SearchProps {
q?: string;
type: SearchType;
sort: SortType;
listingType: ListingType;
communityId?: number | null;
creatorId?: number | null;
communityId?: number;
creatorId?: number;
page: number;
}
@ -112,19 +114,22 @@ const defaultListingType = "All";
const searchTypes = ["All", "Comments", "Posts", "Communities", "Users", "Url"];
const getSearchQueryParams = () =>
getQueryParams<SearchProps>({
q: getSearchQueryFromQuery,
type: getSearchTypeFromQuery,
sort: getSortTypeFromQuery,
listingType: getListingTypeFromQuery,
communityId: getIdFromString,
creatorId: getIdFromString,
page: getPageFromString,
});
export function getSearchQueryParams(source?: string): SearchProps {
return getQueryParams<SearchProps>(
{
q: getSearchQueryFromQuery,
type: getSearchTypeFromQuery,
sort: getSortTypeFromQuery,
listingType: getListingTypeFromQuery,
communityId: getIdFromString,
creatorId: getIdFromString,
page: getPageFromString,
},
source,
);
}
const getSearchQueryFromQuery = (q?: string): string | undefined =>
q ? decodeURIComponent(q) : undefined;
const getSearchQueryFromQuery = (q?: string): string | undefined => q;
function getSearchTypeFromQuery(type_?: string): SearchType {
return type_ ? (type_ as SearchType) : defaultSearchType;
@ -240,7 +245,15 @@ function getListing(
);
}
export class Search extends Component<any, SearchState> {
type SearchPathProps = Record<string, never>;
type SearchRouteProps = RouteComponentProps<SearchPathProps> & SearchProps;
export type SearchFetchConfig = IRoutePropsWithFetch<
SearchData,
SearchPathProps,
SearchProps
>;
export class Search extends Component<SearchRouteProps, SearchState> {
private isoData = setIsoData<SearchData>(this.context);
searchInput = createRef<HTMLInputElement>();
@ -255,7 +268,7 @@ export class Search extends Component<any, SearchState> {
isIsomorphic: false,
};
constructor(props: any, context: any) {
constructor(props: SearchRouteProps, context: any) {
super(props, context);
this.handleSortChange = this.handleSortChange.bind(this);
@ -265,7 +278,7 @@ export class Search extends Component<any, SearchState> {
this.handleCommunityFilterChange.bind(this);
this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
const { q } = getSearchQueryParams();
const { q } = this.props;
this.state.searchText = q;
@ -335,7 +348,7 @@ export class Search extends Component<any, SearchState> {
}),
];
const { communityId, creatorId } = getSearchQueryParams();
const { communityId, creatorId } = this.props;
if (communityId) {
promises.push(
@ -390,12 +403,19 @@ export class Search extends Component<any, SearchState> {
static async fetchInitialData({
headers,
query: { communityId, creatorId, q, type, sort, listingType, page },
}: InitialFetchRequest<QueryParams<SearchProps>>): Promise<SearchData> {
query: {
q: query,
type: searchType,
sort,
listingType: listing_type,
communityId: community_id,
creatorId: creator_id,
page,
},
}: InitialFetchRequest<SearchPathProps, SearchProps>): Promise<SearchData> {
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { headers }),
);
const community_id = getIdFromString(communityId);
let communityResponse: RequestState<GetCommunityResponse> = EMPTY_REQUEST;
if (community_id) {
const getCommunityForm: GetCommunity = {
@ -411,7 +431,6 @@ export class Search extends Component<any, SearchState> {
limit: fetchLimit,
});
const creator_id = getIdFromString(creatorId);
let creatorDetailsResponse: RequestState<GetPersonDetailsResponse> =
EMPTY_REQUEST;
if (creator_id) {
@ -422,8 +441,6 @@ export class Search extends Component<any, SearchState> {
creatorDetailsResponse = await client.getPersonDetails(getCreatorForm);
}
const query = getSearchQueryFromQuery(q);
let searchResponse: RequestState<SearchResponse> = EMPTY_REQUEST;
let resolveObjectResponse: RequestState<ResolveObjectResponse> =
EMPTY_REQUEST;
@ -433,10 +450,10 @@ export class Search extends Component<any, SearchState> {
q: query,
community_id,
creator_id,
type_: getSearchTypeFromQuery(type),
sort: getSortTypeFromQuery(sort),
listing_type: getListingTypeFromQuery(listingType),
page: getPageFromString(page),
type_: searchType,
sort,
listing_type,
page,
limit: fetchLimit,
};
@ -466,13 +483,13 @@ export class Search extends Component<any, SearchState> {
}
get documentTitle(): string {
const { q } = getSearchQueryParams();
const { q } = this.props;
const name = this.state.siteRes.site_view.site.name;
return `${I18NextService.i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
}
render() {
const { type, page } = getSearchQueryParams();
const { type, page } = this.props;
return (
<div className="search container-lg">
@ -555,8 +572,7 @@ export class Search extends Component<any, SearchState> {
}
get selects() {
const { type, listingType, sort, communityId, creatorId } =
getSearchQueryParams();
const { type, listingType, sort, communityId, creatorId } = this.props;
const {
communitySearchOptions,
creatorSearchOptions,
@ -664,7 +680,7 @@ export class Search extends Component<any, SearchState> {
);
}
const { sort } = getSearchQueryParams();
const { sort } = this.props;
// Sort it
if (sort === "New") {
@ -959,7 +975,7 @@ export class Search extends Component<any, SearchState> {
async search() {
const { searchText: q } = this.state;
const { communityId, creatorId, type, sort, listingType, page } =
getSearchQueryParams();
this.props;
if (q) {
this.setState({ searchRes: LOADING_REQUEST });
@ -991,7 +1007,7 @@ export class Search extends Component<any, SearchState> {
handleCreatorSearch = debounce(async (text: string) => {
if (text.length > 0) {
const { creatorId } = getSearchQueryParams();
const { creatorId } = this.props;
const { creatorSearchOptions } = this.state;
this.setState({ searchCreatorLoading: true });
@ -1009,7 +1025,7 @@ export class Search extends Component<any, SearchState> {
handleCommunitySearch = debounce(async (text: string) => {
if (text.length > 0) {
const { communityId } = getSearchQueryParams();
const { communityId } = this.props;
const { communitySearchOptions } = this.state;
this.setState({
@ -1053,14 +1069,14 @@ export class Search extends Component<any, SearchState> {
handleCommunityFilterChange({ value }: Choice) {
this.updateUrl({
communityId: getIdFromString(value) ?? null,
communityId: getIdFromString(value),
page: 1,
});
}
handleCreatorFilterChange({ value }: Choice) {
this.updateUrl({
creatorId: getIdFromString(value) ?? null,
creatorId: getIdFromString(value),
page: 1,
});
}
@ -1095,13 +1111,9 @@ export class Search extends Component<any, SearchState> {
sort: urlSort,
creatorId: urlCreatorId,
page: urlPage,
} = getSearchQueryParams();
} = this.props;
let query = q ?? this.state.searchText ?? urlQ;
if (query && query.length > 0) {
query = encodeURIComponent(query);
}
const query = q ?? this.state.searchText ?? urlQ;
const queryParams: QueryParams<SearchProps> = {
q: query,

View file

@ -5,8 +5,8 @@ import {
GetSiteResponse,
PersonMention,
} from "lemmy-js-client";
import type { ParsedQs } from "qs";
import { RequestState } from "./services/HttpService";
import { Match } from "inferno-router/dist/Route";
/**
* This contains serialized data, it needs to be deserialized before use.
@ -30,9 +30,13 @@ declare global {
}
}
export interface InitialFetchRequest<T extends ParsedQs = ParsedQs> {
export interface InitialFetchRequest<
P extends Record<string, string> = Record<string, never>,
T extends Record<string, any> = Record<string, never>,
> {
path: string;
query: T;
match: Match<P>;
site: GetSiteResponse;
headers: { [key: string]: string };
}

View file

@ -1,45 +1,107 @@
import { IRouteProps } from "inferno-router/dist/Route";
import { Communities } from "./components/community/communities";
import { Community } from "./components/community/community";
import { IRouteProps, RouteComponentProps } from "inferno-router/dist/Route";
import {
Communities,
CommunitiesFetchConfig,
getCommunitiesQueryParams,
} from "./components/community/communities";
import {
Community,
CommunityFetchConfig,
getCommunityQueryParams,
} from "./components/community/community";
import { CreateCommunity } from "./components/community/create-community";
import { AdminSettings } from "./components/home/admin-settings";
import { Home } from "./components/home/home";
import { Instances } from "./components/home/instances";
import {
AdminSettings,
AdminSettingsFetchConfig,
} from "./components/home/admin-settings";
import {
Home,
HomeFetchConfig,
getHomeQueryParams,
} from "./components/home/home";
import { Instances, InstancesFetchConfig } from "./components/home/instances";
import { Legal } from "./components/home/legal";
import { Login } from "./components/home/login";
import {
Login,
LoginFetchConfig,
getLoginQueryParams,
} from "./components/home/login";
import { LoginReset } from "./components/home/login-reset";
import { Setup } from "./components/home/setup";
import { Signup } from "./components/home/signup";
import { Modlog } from "./components/modlog";
import { Inbox } from "./components/person/inbox";
import {
Modlog,
ModlogFetchConfig,
getModlogQueryParams,
} from "./components/modlog";
import { Inbox, InboxFetchConfig } from "./components/person/inbox";
import { PasswordChange } from "./components/person/password-change";
import { Profile } from "./components/person/profile";
import { RegistrationApplications } from "./components/person/registration-applications";
import { Reports } from "./components/person/reports";
import { Settings } from "./components/person/settings";
import {
Profile,
ProfileFetchConfig,
getProfileQueryParams,
} from "./components/person/profile";
import {
RegistrationApplications,
RegistrationApplicationsFetchConfig,
} from "./components/person/registration-applications";
import { Reports, ReportsFetchConfig } from "./components/person/reports";
import { Settings, SettingsFetchConfig } from "./components/person/settings";
import { VerifyEmail } from "./components/person/verify-email";
import { CreatePost } from "./components/post/create-post";
import { Post } from "./components/post/post";
import { CreatePrivateMessage } from "./components/private_message/create-private-message";
import { RemoteFetch } from "./components/remote-fetch";
import { Search } from "./components/search";
import {
CreatePostFetchConfig,
CreatePost,
getCreatePostQueryParams,
} from "./components/post/create-post";
import { Post, PostFetchConfig } from "./components/post/post";
import {
CreatePrivateMessage,
CreatePrivateMessageFetchConfig,
} from "./components/private_message/create-private-message";
import {
RemoteFetch,
RemoteFetchFetchConfig,
getRemoteFetchQueryParams,
} from "./components/remote-fetch";
import {
Search,
SearchFetchConfig,
getSearchQueryParams,
} from "./components/search";
import { InitialFetchRequest, RouteData } from "./interfaces";
import { GetSiteResponse } from "lemmy-js-client";
import { Inferno } from "inferno";
interface IRoutePropsWithFetch<T extends RouteData> extends IRouteProps {
fetchInitialData?(req: InitialFetchRequest): Promise<T>;
export interface IRoutePropsWithFetch<
DataT extends RouteData,
PathPropsT extends Record<string, string>,
QueryPropsT extends Record<string, any>,
> extends IRouteProps {
fetchInitialData?(
req: InitialFetchRequest<PathPropsT, QueryPropsT>,
): Promise<DataT>;
getQueryParams?(
source: string | undefined,
siteRes: GetSiteResponse,
): QueryPropsT;
component: Inferno.ComponentClass<
RouteComponentProps<PathPropsT> & QueryPropsT
>;
}
export const routes: IRoutePropsWithFetch<Record<string, any>>[] = [
export const routes: IRoutePropsWithFetch<RouteData, any, any>[] = [
{
path: `/`,
component: Home,
fetchInitialData: Home.fetchInitialData,
exact: true,
},
getQueryParams: getHomeQueryParams,
} as HomeFetchConfig,
{
path: `/login`,
component: Login,
},
getQueryParams: getLoginQueryParams,
} as LoginFetchConfig,
{
path: `/login_reset`,
component: LoginReset,
@ -52,7 +114,8 @@ export const routes: IRoutePropsWithFetch<Record<string, any>>[] = [
path: `/create_post`,
component: CreatePost,
fetchInitialData: CreatePost.fetchInitialData,
},
getQueryParams: getCreatePostQueryParams,
} as CreatePostFetchConfig,
{
path: `/create_community`,
component: CreateCommunity,
@ -61,73 +124,79 @@ export const routes: IRoutePropsWithFetch<Record<string, any>>[] = [
path: `/create_private_message/:recipient_id`,
component: CreatePrivateMessage,
fetchInitialData: CreatePrivateMessage.fetchInitialData,
},
} as CreatePrivateMessageFetchConfig,
{
path: `/communities`,
component: Communities,
fetchInitialData: Communities.fetchInitialData,
},
getQueryParams: getCommunitiesQueryParams,
} as CommunitiesFetchConfig,
{
path: `/post/:post_id`,
component: Post,
fetchInitialData: Post.fetchInitialData,
},
} as PostFetchConfig,
{
path: `/comment/:comment_id`,
component: Post,
fetchInitialData: Post.fetchInitialData,
},
} as PostFetchConfig,
{
path: `/c/:name`,
component: Community,
fetchInitialData: Community.fetchInitialData,
},
getQueryParams: getCommunityQueryParams,
} as CommunityFetchConfig,
{
path: `/u/:username`,
component: Profile,
fetchInitialData: Profile.fetchInitialData,
},
getQueryParams: getProfileQueryParams,
} as ProfileFetchConfig,
{
path: `/inbox`,
component: Inbox,
fetchInitialData: Inbox.fetchInitialData,
},
} as InboxFetchConfig,
{
path: `/settings`,
component: Settings,
fetchInitialData: Settings.fetchInitialData,
},
} as SettingsFetchConfig,
{
path: `/modlog/:communityId`,
component: Modlog,
fetchInitialData: Modlog.fetchInitialData,
},
getQueryParams: getModlogQueryParams,
} as ModlogFetchConfig,
{
path: `/modlog`,
component: Modlog,
fetchInitialData: Modlog.fetchInitialData,
},
getQueryParams: getModlogQueryParams,
} as ModlogFetchConfig,
{ path: `/setup`, component: Setup },
{
path: `/admin`,
component: AdminSettings,
fetchInitialData: AdminSettings.fetchInitialData,
},
} as AdminSettingsFetchConfig,
{
path: `/reports`,
component: Reports,
fetchInitialData: Reports.fetchInitialData,
},
} as ReportsFetchConfig,
{
path: `/registration_applications`,
component: RegistrationApplications,
fetchInitialData: RegistrationApplications.fetchInitialData,
},
} as RegistrationApplicationsFetchConfig,
{
path: `/search`,
component: Search,
fetchInitialData: Search.fetchInitialData,
},
getQueryParams: getSearchQueryParams,
} as SearchFetchConfig,
{
path: `/password_change/:token`,
component: PasswordChange,
@ -140,11 +209,12 @@ export const routes: IRoutePropsWithFetch<Record<string, any>>[] = [
path: `/instances`,
component: Instances,
fetchInitialData: Instances.fetchInitialData,
},
} as InstancesFetchConfig,
{ path: `/legal`, component: Legal },
{
path: "/activitypub/externalInteraction",
component: RemoteFetch,
fetchInitialData: RemoteFetch.fetchInitialData,
},
getQueryParams: getRemoteFetchQueryParams,
} as RemoteFetchFetchConfig,
];

View file

@ -1,4 +1,6 @@
import { getQueryString } from "@utils/helpers";
export default function communityRSSUrl(actorId: string, sort: string): string {
const url = new URL(actorId);
return `${url.origin}/feeds${url.pathname}.xml?sort=${sort}`;
return `${url.origin}/feeds${url.pathname}.xml${getQueryString({ sort })}`;
}

View file

@ -1,4 +1,8 @@
export default function getCommentIdFromProps(props: any): number | undefined {
import { RouteComponentProps } from "inferno-router/dist/Route";
export default function getCommentIdFromProps(
props: Pick<RouteComponentProps<{ comment_id?: string }>, "match">,
): number | undefined {
const id = props.match.params.comment_id;
return id ? Number(id) : undefined;
}

View file

@ -1,4 +1,8 @@
export default function getIdFromProps(props: any): number | undefined {
import { RouteComponentProps } from "inferno-router/dist/Route";
export default function getIdFromProps(
props: Pick<RouteComponentProps<{ post_id?: string }>, "match">,
): number | undefined {
const id = props.match.params.post_id;
return id ? Number(id) : undefined;
}

View file

@ -1,4 +1,8 @@
export default function getRecipientIdFromProps(props: any): number {
import { RouteComponentProps } from "inferno-router/dist/Route";
export default function getRecipientIdFromProps(
props: Pick<RouteComponentProps<{ recipient_id: string }>, "match">,
): number {
return props.match.params.recipient_id
? Number(props.match.params.recipient_id)
: 1;

View file

@ -1,21 +1,28 @@
import { isBrowser } from "@utils/browser";
type Empty = NonNullable<unknown>;
type QueryMapping<PropsT, FallbacksT extends Empty> = {
[K in keyof PropsT]-?: (
input: string | undefined,
fallback: K extends keyof FallbacksT ? FallbacksT[K] : undefined,
) => PropsT[K];
};
export default function getQueryParams<
T extends Record<string, any>,
>(processors: {
[K in keyof T]: (param: string) => T[K];
}): T {
if (isBrowser()) {
const searchParams = new URLSearchParams(window.location.search);
PropsT,
FallbacksT extends Empty = Empty,
>(
processors: QueryMapping<PropsT, FallbacksT>,
source?: string,
fallbacks: FallbacksT = {} as FallbacksT,
): PropsT {
const searchParams = new URLSearchParams(source);
return Array.from(Object.entries(processors)).reduce(
(acc, [key, process]) => ({
...acc,
[key]: process(searchParams.get(key)),
}),
{} as T,
const ret: Partial<PropsT> = {};
for (const key in processors) {
ret[key as string] = processors[key](
searchParams.get(key) ?? undefined,
fallbacks[key as string],
);
}
return {} as T;
return ret as PropsT;
}

View file

@ -1,10 +1,12 @@
export default function getQueryString<
T extends Record<string, string | undefined>,
>(obj: T) {
return Object.entries(obj)
const searchParams = new URLSearchParams();
Object.entries(obj)
.filter(([, val]) => val !== undefined && val !== null)
.reduce(
(acc, [key, val], index) => `${acc}${index > 0 ? "&" : ""}${key}=${val}`,
"?",
);
.forEach(([key, val]) => searchParams.set(key, val ?? ""));
if (searchParams.size) {
return "?" + searchParams.toString();
}
return "";
}