Remote follow (#1875)

* Redirect to page user was trying to access on login

* Make modal

* Make modal look better

* Forgot to include in merge

* Get rid of modal

* Add external interaction page

* Tweak success page

* Add loading screen for remote fetch and refactor loading ellipses

* Add error state for remote fetch page

* Add card to federation success page

* Bring back remote fetch modal

* Add autofocus to remote fetch modal input

* Redirect for remote fetch

* Remove dummy data

* Remove duplicate functions

* Update translations

* Update translations

* Fix linting error

* Fix linting errors

* feat: Add toasts for remote follow error conditions
This commit is contained in:
SleeplessOne1917 2023-09-26 03:32:37 +00:00 committed by GitHub
parent 65e669035d
commit d9fe7d1488
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 674 additions and 208 deletions

@ -1 +1 @@
Subproject commit a0f95fc29b7501156b6d8bbb504b1e787b5769e7
Subproject commit de9de2c53bee034d3824ecaa9a2104f8f341332e

View file

@ -6,6 +6,7 @@ import { UserService } from "../shared/services";
import "bootstrap/js/dist/collapse";
import "bootstrap/js/dist/dropdown";
import "bootstrap/js/dist/modal";
async function startClient() {
initializeSite(window.isoData.site_res);

View file

@ -58,7 +58,7 @@ export default async (req: Request, res: Response) => {
}
if (!auth && isAuthPath(path)) {
return res.redirect("/login");
return res.redirect(`/login?prev=${encodeURIComponent(url)}`);
}
if (try_site.state === "success") {

View file

@ -75,7 +75,7 @@ export class App extends Component<AppProps, any> {
<div tabIndex={-1}>
{RouteComponent &&
(isAuthPath(path ?? "") ? (
<AuthGuard>
<AuthGuard {...routeProps}>
<RouteComponent {...routeProps} />
</AuthGuard>
) : (

View file

@ -1,12 +1,40 @@
import { InfernoNode } from "inferno";
import { Redirect } from "inferno-router";
import { Component } from "inferno";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { UserService } from "../../services";
import { Spinner } from "./icon";
function AuthGuard(props: { children?: InfernoNode }) {
if (!UserService.Instance.myUserInfo) {
return <Redirect to="/login" />;
} else {
return props.children;
interface AuthGuardState {
hasRedirected: boolean;
}
class AuthGuard extends Component<
RouteComponentProps<Record<string, string>>,
AuthGuardState
> {
state = {
hasRedirected: false,
} as AuthGuardState;
constructor(
props: RouteComponentProps<Record<string, string>>,
context: any,
) {
super(props, context);
}
componentDidMount() {
if (!UserService.Instance.myUserInfo) {
const { pathname, search } = this.props.location;
this.context.router.history.replace(
`/login?prev=${encodeURIComponent(pathname + search)}`,
);
} else {
this.setState({ hasRedirected: true });
}
}
render() {
return this.state.hasRedirected ? this.props.children : <Spinner />;
}
}

View file

@ -0,0 +1,34 @@
import { Component } from "inferno";
interface LoadingEllipsesState {
ellipses: string;
}
export class LoadingEllipses extends Component<any, LoadingEllipsesState> {
state: LoadingEllipsesState = {
ellipses: "...",
};
#interval?: NodeJS.Timer;
constructor(props: any, context: any) {
super(props, context);
}
render() {
return this.state.ellipses;
}
componentDidMount() {
this.#interval = setInterval(this.#updateEllipses, 1000);
}
componentWillUnmount() {
clearInterval(this.#interval);
}
#updateEllipses = () => {
this.setState(({ ellipses }) => ({
ellipses: ellipses.length === 3 ? "" : ellipses + ".",
}));
};
}

View file

@ -15,6 +15,7 @@ interface PictrsImageProps {
nsfw?: boolean;
iconOverlay?: boolean;
pushup?: boolean;
cardTop?: boolean;
}
export class PictrsImage extends Component<PictrsImageProps, any> {
@ -23,37 +24,40 @@ export class PictrsImage extends Component<PictrsImageProps, any> {
}
render() {
const { src, icon, iconOverlay, banner, thumbnail, nsfw, pushup, cardTop } =
this.props;
let user_blur_nsfw = true;
if (UserService.Instance.myUserInfo) {
user_blur_nsfw =
UserService.Instance.myUserInfo?.local_user_view.local_user.blur_nsfw;
}
const blur_image = this.props.nsfw && user_blur_nsfw;
const blur_image = nsfw && user_blur_nsfw;
return (
<picture>
<source srcSet={this.src("webp")} type="image/webp" />
<source srcSet={this.props.src} />
<source srcSet={src} />
<source srcSet={this.src("jpg")} type="image/jpeg" />
<img
src={this.props.src}
src={src}
alt={this.alt()}
title={this.alt()}
loading="lazy"
className={classNames("overflow-hidden pictrs-image", {
"img-fluid": !this.props.icon && !this.props.iconOverlay,
banner: this.props.banner,
"img-fluid": !(icon || iconOverlay),
banner,
"thumbnail rounded object-fit-cover":
this.props.thumbnail && !this.props.icon && !this.props.banner,
"img-expanded slight-radius":
!this.props.thumbnail && !this.props.icon,
"img-blur-icon": this.props.icon && blur_image,
"img-blur-thumb": this.props.thumbnail && blur_image,
"object-fit-cover img-icon me-1": this.props.icon,
thumbnail && !(icon || banner),
"img-expanded slight-radius": !(thumbnail || icon),
"img-blur": thumbnail && nsfw,
"object-fit-cover img-icon me-1": icon,
"img-blur-icon": icon && blur_image,
"img-blur-thumb": thumbnail && blur_image,
"ms-2 mb-0 rounded-circle object-fit-cover avatar-overlay":
this.props.iconOverlay,
"avatar-pushup": this.props.pushup,
iconOverlay,
"avatar-pushup": pushup,
"card-img-top": cardTop,
})}
/>
</picture>

View file

@ -9,6 +9,7 @@ import {
} from "inferno";
import { I18NextService } from "../../services";
import { Icon, Spinner } from "./icon";
import { LoadingEllipses } from "./loading-ellipses";
interface SearchableSelectProps {
id: string;
@ -22,7 +23,6 @@ interface SearchableSelectProps {
interface SearchableSelectState {
selectedIndex: number;
searchText: string;
loadingEllipses: string;
}
function handleSearch(i: SearchableSelect, e: ChangeEvent<HTMLInputElement>) {
@ -70,12 +70,10 @@ export class SearchableSelect extends Component<
> {
searchInputRef: RefObject<HTMLInputElement> = createRef();
toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
private loadingEllipsesInterval?: NodeJS.Timer = undefined;
state: SearchableSelectState = {
selectedIndex: 0,
searchText: "",
loadingEllipses: "...",
};
constructor(props: SearchableSelectProps, context: any) {
@ -99,7 +97,7 @@ export class SearchableSelect extends Component<
render() {
const { id, options, onSearch, loading } = this.props;
const { searchText, selectedIndex, loadingEllipses } = this.state;
const { searchText, selectedIndex } = this.state;
return (
<div className="searchable-select dropdown col-12 col-sm-auto flex-grow-1">
@ -116,9 +114,14 @@ export class SearchableSelect extends Component<
onClick={linkEvent(this, focusSearch)}
ref={this.toggleButtonRef}
>
{loading
? `${I18NextService.i18n.t("loading")}${loadingEllipses}`
: options[selectedIndex].label}
{loading ? (
<>
{I18NextService.i18n.t("loading")}
<LoadingEllipses />
</>
) : (
options[selectedIndex].label
)}
</button>
<div className="modlog-choices-font-size dropdown-menu w-100 p-2">
<div className="input-group">
@ -180,24 +183,4 @@ export class SearchableSelect extends Component<
selectedIndex,
};
}
componentDidUpdate() {
const { loading } = this.props;
if (loading && !this.loadingEllipsesInterval) {
this.loadingEllipsesInterval = setInterval(() => {
this.setState(({ loadingEllipses }) => ({
loadingEllipses:
loadingEllipses.length === 3 ? "" : loadingEllipses + ".",
}));
}, 750);
} else if (!loading && this.loadingEllipsesInterval) {
clearInterval(this.loadingEllipsesInterval);
}
}
componentWillUnmount() {
if (this.loadingEllipsesInterval) {
clearInterval(this.loadingEllipsesInterval);
}
}
}

View file

@ -0,0 +1,227 @@
import { validInstanceTLD } from "@utils/helpers";
import classNames from "classnames";
import { NoOptionI18nKeys } from "i18next";
import { Component, MouseEventHandler, 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";
interface SubscribeButtonProps {
communityView: CommunityView;
onFollow: MouseEventHandler;
onUnFollow: MouseEventHandler;
loading?: boolean;
isLink?: boolean;
}
export function SubscribeButton({
communityView: {
subscribed,
community: { actor_id },
},
onFollow,
onUnFollow,
loading = false,
isLink = false,
}: SubscribeButtonProps) {
let i18key: NoOptionI18nKeys;
switch (subscribed) {
case "NotSubscribed": {
i18key = "subscribe";
break;
}
case "Subscribed": {
i18key = "joined";
break;
}
default: {
i18key = "subscribe_pending";
break;
}
}
const buttonClass = classNames(
"btn",
isLink ? "btn-link d-inline-block" : "d-block mb-2 w-100",
);
if (!UserService.Instance.myUserInfo) {
return (
<>
<button
type="button"
className={classNames(buttonClass, {
"btn-secondary": !isLink,
})}
data-bs-toggle="modal"
data-bs-target="#remoteFetchModal"
>
{I18NextService.i18n.t("subscribe")}
</button>
<RemoteFetchModal communityActorId={actor_id} />
</>
);
}
return (
<button
type="button"
className={classNames(buttonClass, {
[`btn-${subscribed === "Pending" ? "warning" : "secondary"}`]: !isLink,
})}
onClick={subscribed === "NotSubscribed" ? onFollow : onUnFollow}
>
{loading ? (
<Spinner />
) : (
<>
{subscribed === "Subscribed" && (
<Icon icon="check" classes="icon-inline me-1" />
)}
{I18NextService.i18n.t(i18key)}
</>
)}
</button>
);
}
interface RemoteFetchModalProps {
communityActorId: string;
}
interface RemoteFetchModalState {
instanceText: string;
}
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,
) {
event.preventDefault();
instanceText = instanceText.trim();
if (!validInstanceTLD(instanceText)) {
toast(
I18NextService.i18n.t("remote_follow_invalid_instance", {
instance: instanceText,
}),
"danger",
);
return;
}
const protocolRegex = /^https?:\/\//;
if (instanceText.replace(protocolRegex, "") === window.location.host) {
toast(I18NextService.i18n.t("remote_follow_local_instance"), "danger");
return;
}
if (!protocolRegex.test(instanceText)) {
instanceText = `http${VERSION !== "dev" ? "s" : ""}://${instanceText}`;
}
window.location.href = `${instanceText}/activitypub/externalInteraction?uri=${encodeURIComponent(
communityActorId,
)}`;
}
class RemoteFetchModal extends Component<
RemoteFetchModalProps,
RemoteFetchModalState
> {
state: RemoteFetchModalState = {
instanceText: "",
};
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);
}
render() {
return (
<div
className="modal fade"
id="remoteFetchModal"
tabIndex={-1}
aria-hidden
aria-labelledby="#remoteFetchModalTitle"
>
<div className="modal-dialog modal-fullscreen-sm-down">
<div className="modal-content">
<header className="modal-header">
<h3 className="modal-title" id="remoteFetchModalTitle">
{I18NextService.i18n.t("remote_follow_modal_title")}
</h3>
<button
type="button"
className="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
/>
</header>
<form
id="remote-fetch-form"
className="modal-body d-flex flex-column justify-content-center"
onSubmit={linkEvent(this, submitRemoteFollow)}
>
<label className="form-label" htmlFor="remoteFetchInstance">
{I18NextService.i18n.t("remote_follow_prompt")}
</label>
<input
type="text"
id="remoteFetchInstance"
className="form-control"
name="instance"
value={this.state.instanceText}
onInput={linkEvent(this, handleInput)}
required
/>
</form>
<footer className="modal-footer">
<button
type="button"
className="btn btn-danger"
data-bs-dismiss="modal"
>
{I18NextService.i18n.t("cancel")}
</button>
<button
type="submit"
className="btn btn-success"
form="remote-fetch-form"
>
{I18NextService.i18n.t("fetch_community")}
</button>
</footer>
</div>
</div>
</div>
);
}
}

View file

@ -27,6 +27,7 @@ import { SortSelect } from "../common/sort-select";
import { CommunityLink } from "./community-link";
import { communityLimit } from "../../config";
import { SubscribeButton } from "../common/subscribe-button";
type CommunitiesData = RouteDataResponse<{
listCommunitiesResponse: ListCommunitiesResponse;
@ -173,41 +174,26 @@ export class Communities extends Component<any, CommunitiesState> {
{numToSI(cv.counts.comments)}
</td>
<td className="text-right">
{cv.subscribed === "Subscribed" && (
<button
className="btn btn-link d-inline-block"
onClick={linkEvent(
{
i: this,
communityId: cv.community.id,
follow: false,
},
this.handleFollow,
)}
>
{I18NextService.i18n.t("unsubscribe")}
</button>
)}
{cv.subscribed === "NotSubscribed" && (
<button
className="btn btn-link d-inline-block"
onClick={linkEvent(
{
i: this,
communityId: cv.community.id,
follow: true,
},
this.handleFollow,
)}
>
{I18NextService.i18n.t("subscribe")}
</button>
)}
{cv.subscribed === "Pending" && (
<div className="text-warning d-inline-block">
{I18NextService.i18n.t("subscribe_pending")}
</div>
)}
<SubscribeButton
communityView={cv}
onFollow={linkEvent(
{
i: this,
communityId: cv.community.id,
follow: false,
},
this.handleFollow,
)}
onUnFollow={linkEvent(
{
i: this,
communityId: cv.community.id,
follow: true,
},
this.handleFollow,
)}
isLink
/>
</td>
</tr>
),

View file

@ -21,6 +21,7 @@ import { I18NextService, UserService } from "../../services";
import { Badges } from "../common/badges";
import { BannerIconHeader } from "../common/banner-icon-header";
import { Icon, PurgeWarning, Spinner } from "../common/icon";
import { SubscribeButton } from "../common/subscribe-button";
import { CommunityForm } from "../community/community-form";
import { CommunityLink } from "../community/community-link";
import { PersonListing } from "../person/person-listing";
@ -122,7 +123,9 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
sidebar() {
const myUSerInfo = UserService.Instance.myUserInfo;
const { name, actor_id } = this.props.community_view.community;
const {
community: { name, actor_id },
} = this.props.community_view;
return (
<aside className="mb-3">
<div id="sidebarContainer">
@ -130,7 +133,12 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
<div className="card-body">
{this.communityTitle()}
{this.props.editable && this.adminButtons()}
{myUSerInfo && this.subscribe()}
<SubscribeButton
communityView={this.props.community_view}
onFollow={linkEvent(this, this.handleFollowCommunity)}
onUnFollow={linkEvent(this, this.handleUnfollowCommunity)}
loading={this.state.followCommunityLoading}
/>
{this.canPost && this.createPost()}
{myUSerInfo && this.blockCommunity()}
{!myUSerInfo && (
@ -229,58 +237,6 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
);
}
subscribe() {
const community_view = this.props.community_view;
if (community_view.subscribed === "NotSubscribed") {
return (
<button
className="btn btn-secondary d-block mb-2 w-100"
onClick={linkEvent(this, this.handleFollowCommunity)}
>
{this.state.followCommunityLoading ? (
<Spinner />
) : (
I18NextService.i18n.t("subscribe")
)}
</button>
);
}
if (community_view.subscribed === "Subscribed") {
return (
<button
className="btn btn-secondary d-block mb-2 w-100"
onClick={linkEvent(this, this.handleUnfollowCommunity)}
>
{this.state.followCommunityLoading ? (
<Spinner />
) : (
<>
<Icon icon="check" classes="icon-inline me-1" />
{I18NextService.i18n.t("joined")}
</>
)}
</button>
);
}
if (community_view.subscribed === "Pending") {
return (
<button
className="btn btn-warning d-block mb-2 w-100"
onClick={linkEvent(this, this.handleUnfollowCommunity)}
>
{this.state.followCommunityLoading ? (
<Spinner />
) : (
I18NextService.i18n.t("subscribe_pending")
)}
</button>
);
}
}
blockCommunity() {
const { subscribed, blocked } = this.props.community_view;

View file

@ -1,6 +1,8 @@
import { setIsoData } from "@utils/app";
import { isBrowser } from "@utils/browser";
import { getQueryParams } from "@utils/helpers";
import { Component, linkEvent } from "inferno";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
import { I18NextService, UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
@ -9,6 +11,17 @@ import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
interface LoginProps {
prev?: string;
}
const getLoginQueryParams = () =>
getQueryParams<LoginProps>({
prev(param) {
return param ? decodeURIComponent(param) : undefined;
},
});
interface State {
loginRes: RequestState<LoginResponse>;
form: {
@ -20,7 +33,73 @@ interface State {
siteRes: GetSiteResponse;
}
export class Login extends Component<any, State> {
async function handleLoginSubmit(i: Login, event: any) {
event.preventDefault();
const { password, totp_2fa_token, username_or_email } = i.state.form;
if (username_or_email && password) {
i.setState({ loginRes: { state: "loading" } });
const loginRes = await HttpService.client.login({
username_or_email,
password,
totp_2fa_token,
});
switch (loginRes.state) {
case "failed": {
if (loginRes.msg === "missing_totp_token") {
i.setState({ showTotp: true });
toast(I18NextService.i18n.t("enter_two_factor_code"), "info");
} else {
toast(I18NextService.i18n.t(loginRes.msg), "danger");
}
i.setState({ loginRes: { state: "failed", msg: loginRes.msg } });
break;
}
case "success": {
UserService.Instance.login({
res: loginRes.data,
});
const site = await HttpService.client.getSite();
if (site.state === "success") {
UserService.Instance.myUserInfo = site.data.my_user;
}
const { prev } = getLoginQueryParams();
prev
? i.props.history.replace(prev)
: i.props.history.action === "PUSH"
? i.props.history.back()
: i.props.history.replace("/");
break;
}
}
}
}
function handleLoginUsernameChange(i: Login, event: any) {
i.setState(
prevState => (prevState.form.username_or_email = event.target.value.trim()),
);
}
function handleLoginTotpChange(i: Login, event: any) {
i.setState(prevState => (prevState.form.totp_2fa_token = event.target.value));
}
function handleLoginPasswordChange(i: Login, event: any) {
i.setState(prevState => (prevState.form.password = event.target.value));
}
export class Login extends Component<
RouteComponentProps<Record<string, never>>,
State
> {
private isoData = setIsoData(this.context);
state: State = {
@ -68,7 +147,7 @@ export class Login extends Component<any, State> {
loginForm() {
return (
<div>
<form onSubmit={linkEvent(this, this.handleLoginSubmit)}>
<form onSubmit={linkEvent(this, handleLoginSubmit)}>
<h1 className="h4 mb-4">{I18NextService.i18n.t("login")}</h1>
<div className="mb-3 row">
<label
@ -83,7 +162,7 @@ export class Login extends Component<any, State> {
className="form-control"
id="login-email-or-username"
value={this.state.form.username_or_email}
onInput={linkEvent(this, this.handleLoginUsernameChange)}
onInput={linkEvent(this, handleLoginUsernameChange)}
autoComplete="email"
required
minLength={3}
@ -94,7 +173,7 @@ export class Login extends Component<any, State> {
<PasswordInput
id="login-password"
value={this.state.form.password}
onInput={linkEvent(this, this.handleLoginPasswordChange)}
onInput={linkEvent(this, handleLoginPasswordChange)}
label={I18NextService.i18n.t("password")}
showForgotLink
/>
@ -116,7 +195,7 @@ export class Login extends Component<any, State> {
pattern="[0-9]*"
autoComplete="one-time-code"
value={this.state.form.totp_2fa_token}
onInput={linkEvent(this, this.handleLoginTotpChange)}
onInput={linkEvent(this, handleLoginTotpChange)}
/>
</div>
</div>
@ -136,64 +215,4 @@ export class Login extends Component<any, State> {
</div>
);
}
async handleLoginSubmit(i: Login, event: any) {
event.preventDefault();
const { password, totp_2fa_token, username_or_email } = i.state.form;
if (username_or_email && password) {
i.setState({ loginRes: { state: "loading" } });
const loginRes = await HttpService.client.login({
username_or_email,
password,
totp_2fa_token,
});
switch (loginRes.state) {
case "failed": {
if (loginRes.msg === "missing_totp_token") {
i.setState({ showTotp: true });
toast(I18NextService.i18n.t("enter_two_factor_code"), "info");
} else {
toast(I18NextService.i18n.t(loginRes.msg), "danger");
}
i.setState({ loginRes: { state: "failed", msg: loginRes.msg } });
break;
}
case "success": {
UserService.Instance.login({
res: loginRes.data,
});
const site = await HttpService.client.getSite();
if (site.state === "success") {
UserService.Instance.myUserInfo = site.data.my_user;
}
i.props.history.action === "PUSH"
? i.props.history.back()
: i.props.history.replace("/");
break;
}
}
}
}
handleLoginUsernameChange(i: Login, event: any) {
i.state.form.username_or_email = event.target.value.trim();
i.setState(i.state);
}
handleLoginTotpChange(i: Login, event: any) {
i.state.form.totp_2fa_token = event.target.value;
i.setState(i.state);
}
handleLoginPasswordChange(i: Login, event: any) {
i.state.form.password = event.target.value;
i.setState(i.state);
}
}

View file

@ -0,0 +1,221 @@
import { setIsoData } from "@utils/app";
import { getQueryParams } from "@utils/helpers";
import { QueryParams, RouteDataResponse } from "@utils/types";
import { Component, linkEvent } from "inferno";
import { CommunityView, ResolveObjectResponse } from "lemmy-js-client";
import { InitialFetchRequest } from "../interfaces";
import { FirstLoadService, HttpService, I18NextService } from "../services";
import { RequestState } from "../services/HttpService";
import { HtmlTags } from "./common/html-tags";
import { Spinner } from "./common/icon";
import { LoadingEllipses } from "./common/loading-ellipses";
import { PictrsImage } from "./common/pictrs-image";
import { SubscribeButton } from "./common/subscribe-button";
import { CommunityLink } from "./community/community-link";
interface RemoteFetchProps {
uri?: string;
}
type RemoteFetchData = RouteDataResponse<{
resolveObjectRes: ResolveObjectResponse;
}>;
interface RemoteFetchState {
resolveObjectRes: RequestState<ResolveObjectResponse>;
isIsomorphic: boolean;
followCommunityLoading: boolean;
}
const getUriFromQuery = (uri?: string): string | undefined =>
uri ? decodeURIComponent(uri) : undefined;
const getRemoteFetchQueryParams = () =>
getQueryParams<RemoteFetchProps>({
uri: getUriFromQuery,
});
function uriToQuery(uri: string) {
const match = decodeURIComponent(uri).match(/https?:\/\/(.+)\/c\/(.+)/);
return match ? `!${match[2]}@${match[1]}` : "";
}
async function handleToggleFollow(i: RemoteFetch, follow: boolean) {
const { resolveObjectRes } = i.state;
if (resolveObjectRes.state === "success" && resolveObjectRes.data.community) {
i.setState({
followCommunityLoading: true,
});
const communityRes = await HttpService.client.followCommunity({
community_id: resolveObjectRes.data.community.community.id,
follow,
});
i.setState(prev => {
if (
communityRes.state === "success" &&
prev.resolveObjectRes.state === "success" &&
prev.resolveObjectRes.data.community
) {
prev.resolveObjectRes.data.community = communityRes.data.community_view;
}
return {
...prev,
followCommunityLoading: false,
};
});
}
}
const handleFollow = (i: RemoteFetch) => handleToggleFollow(i, true);
const handleUnfollow = (i: RemoteFetch) => handleToggleFollow(i, false);
export class RemoteFetch extends Component<any, RemoteFetchState> {
private isoData = setIsoData<RemoteFetchData>(this.context);
state: RemoteFetchState = {
resolveObjectRes: { state: "empty" },
isIsomorphic: false,
followCommunityLoading: false,
};
constructor(props: any, context: any) {
super(props, context);
if (FirstLoadService.isFirstLoad) {
const { resolveObjectRes } = this.isoData.routeData;
this.state = {
...this.state,
isIsomorphic: true,
resolveObjectRes,
};
}
}
async componentDidMount() {
if (!this.state.isIsomorphic) {
const { uri } = getRemoteFetchQueryParams();
if (uri) {
this.setState({ resolveObjectRes: { state: "loading" } });
this.setState({
resolveObjectRes: await HttpService.client.resolveObject({
q: uriToQuery(uri),
}),
});
}
}
}
render() {
return (
<div className="remote-fetch container-lg">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
<div className="row">
<div className="col-12 col-lg-6 offset-lg-3 text-center">
{this.content}
</div>
</div>
</div>
);
}
get content() {
const res = this.state.resolveObjectRes;
const { uri } = getRemoteFetchQueryParams();
const remoteCommunityName = uri ? uriToQuery(uri) : "remote community";
switch (res.state) {
case "success": {
const communityView = res.data.community as CommunityView;
return (
<>
<h1>{I18NextService.i18n.t("community_federated")}</h1>
<div className="card mt-5">
{communityView.community.banner && (
<PictrsImage src={communityView.community.banner} cardTop />
)}
<div className="card-body">
<h2 className="card-title">
<CommunityLink community={communityView.community} />
</h2>
{communityView.community.description && (
<div className="card-text mb-3 preview-lines">
{communityView.community.description}
</div>
)}
<SubscribeButton
communityView={communityView}
onFollow={linkEvent(this, handleFollow)}
onUnFollow={linkEvent(this, handleUnfollow)}
loading={this.state.followCommunityLoading}
/>
</div>
</div>
</>
);
}
case "loading": {
return (
<>
<h1>
{I18NextService.i18n.t("fetching_community", {
community: remoteCommunityName,
})}
<LoadingEllipses />
</h1>
<h5>
<Spinner large />
</h5>
</>
);
}
default: {
return (
<>
<h1>
{I18NextService.i18n.t("could_not_fetch_community", {
community: remoteCommunityName,
})}
</h1>
</>
);
}
}
}
get documentTitle(): string {
const { uri } = getRemoteFetchQueryParams();
const name = this.isoData.site_res.site_view.site.name;
return `${I18NextService.i18n.t("remote_follow")} - ${
uri ? `${uri} - ` : ""
}${name}`;
}
static async fetchInitialData({
auth,
client,
query: { uri },
}: InitialFetchRequest<
QueryParams<RemoteFetchProps>
>): Promise<RemoteFetchData> {
const data: RemoteFetchData = { resolveObjectRes: { state: "empty" } };
if (uri && auth) {
data.resolveObjectRes = await client.resolveObject({
q: uriToQuery(uri),
});
}
return data;
}
}

View file

@ -29,6 +29,7 @@ export interface InitialFetchRequest<T extends ParsedQs = ParsedQs> {
path: string;
query: T;
site: GetSiteResponse;
auth?: string;
}
export interface PostFormParams {

View file

@ -21,6 +21,7 @@ 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 { InitialFetchRequest, RouteData } from "./interfaces";
@ -140,4 +141,9 @@ export const routes: IRoutePropsWithFetch<Record<string, any>>[] = [
fetchInitialData: Instances.fetchInitialData,
},
{ path: `/legal`, component: Legal },
{
path: "/activitypub/externalInteraction",
component: RemoteFetch,
fetchInitialData: RemoteFetch.fetchInitialData,
},
];

View file

@ -1,5 +1,5 @@
export default function isAuthPath(pathname: string) {
return /^\/(create_.*?|inbox|settings|admin|reports|registration_applications)\b/g.test(
return /^\/(create_.*?|inbox|settings|admin|reports|registration_applications|activitypub.*?)\b/g.test(
pathname,
);
}

View file

@ -4,5 +4,5 @@ export default function isAdmin(
creatorId: number,
admins?: PersonView[],
): boolean {
return admins?.map(a => a.person.id).includes(creatorId) ?? false;
return admins?.some(({ person: { id } }) => id === creatorId) ?? false;
}

View file

@ -1 +1 @@
export const VERSION = "unknown version";
export const VERSION = "unknown version" as string;

View file

@ -148,7 +148,7 @@ module.exports = (env, argv) => {
const RunNodeWebpackPlugin = require("run-node-webpack-plugin");
serverConfig.plugins.push(
new RunNodeWebpackPlugin({ runOnlyInWatchMode: true })
new RunNodeWebpackPlugin({ runOnlyInWatchMode: true }),
);
} else if (mode === "none") {
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");