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/collapse";
import "bootstrap/js/dist/dropdown"; import "bootstrap/js/dist/dropdown";
import "bootstrap/js/dist/modal";
async function startClient() { async function startClient() {
initializeSite(window.isoData.site_res); initializeSite(window.isoData.site_res);

View file

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

View file

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

View file

@ -1,12 +1,40 @@
import { InfernoNode } from "inferno"; import { Component } from "inferno";
import { Redirect } from "inferno-router"; import { RouteComponentProps } from "inferno-router/dist/Route";
import { UserService } from "../../services"; import { UserService } from "../../services";
import { Spinner } from "./icon";
function AuthGuard(props: { children?: InfernoNode }) { interface AuthGuardState {
if (!UserService.Instance.myUserInfo) { hasRedirected: boolean;
return <Redirect to="/login" />; }
} else {
return props.children; 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; nsfw?: boolean;
iconOverlay?: boolean; iconOverlay?: boolean;
pushup?: boolean; pushup?: boolean;
cardTop?: boolean;
} }
export class PictrsImage extends Component<PictrsImageProps, any> { export class PictrsImage extends Component<PictrsImageProps, any> {
@ -23,37 +24,40 @@ export class PictrsImage extends Component<PictrsImageProps, any> {
} }
render() { render() {
const { src, icon, iconOverlay, banner, thumbnail, nsfw, pushup, cardTop } =
this.props;
let user_blur_nsfw = true; let user_blur_nsfw = true;
if (UserService.Instance.myUserInfo) { if (UserService.Instance.myUserInfo) {
user_blur_nsfw = user_blur_nsfw =
UserService.Instance.myUserInfo?.local_user_view.local_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 ( return (
<picture> <picture>
<source srcSet={this.src("webp")} type="image/webp" /> <source srcSet={this.src("webp")} type="image/webp" />
<source srcSet={this.props.src} /> <source srcSet={src} />
<source srcSet={this.src("jpg")} type="image/jpeg" /> <source srcSet={this.src("jpg")} type="image/jpeg" />
<img <img
src={this.props.src} src={src}
alt={this.alt()} alt={this.alt()}
title={this.alt()} title={this.alt()}
loading="lazy" loading="lazy"
className={classNames("overflow-hidden pictrs-image", { className={classNames("overflow-hidden pictrs-image", {
"img-fluid": !this.props.icon && !this.props.iconOverlay, "img-fluid": !(icon || iconOverlay),
banner: this.props.banner, banner,
"thumbnail rounded object-fit-cover": "thumbnail rounded object-fit-cover":
this.props.thumbnail && !this.props.icon && !this.props.banner, thumbnail && !(icon || banner),
"img-expanded slight-radius": "img-expanded slight-radius": !(thumbnail || icon),
!this.props.thumbnail && !this.props.icon, "img-blur": thumbnail && nsfw,
"img-blur-icon": this.props.icon && blur_image, "object-fit-cover img-icon me-1": icon,
"img-blur-thumb": this.props.thumbnail && blur_image, "img-blur-icon": icon && blur_image,
"object-fit-cover img-icon me-1": this.props.icon, "img-blur-thumb": thumbnail && blur_image,
"ms-2 mb-0 rounded-circle object-fit-cover avatar-overlay": "ms-2 mb-0 rounded-circle object-fit-cover avatar-overlay":
this.props.iconOverlay, iconOverlay,
"avatar-pushup": this.props.pushup, "avatar-pushup": pushup,
"card-img-top": cardTop,
})} })}
/> />
</picture> </picture>

View file

@ -9,6 +9,7 @@ import {
} from "inferno"; } from "inferno";
import { I18NextService } from "../../services"; import { I18NextService } from "../../services";
import { Icon, Spinner } from "./icon"; import { Icon, Spinner } from "./icon";
import { LoadingEllipses } from "./loading-ellipses";
interface SearchableSelectProps { interface SearchableSelectProps {
id: string; id: string;
@ -22,7 +23,6 @@ interface SearchableSelectProps {
interface SearchableSelectState { interface SearchableSelectState {
selectedIndex: number; selectedIndex: number;
searchText: string; searchText: string;
loadingEllipses: string;
} }
function handleSearch(i: SearchableSelect, e: ChangeEvent<HTMLInputElement>) { function handleSearch(i: SearchableSelect, e: ChangeEvent<HTMLInputElement>) {
@ -70,12 +70,10 @@ export class SearchableSelect extends Component<
> { > {
searchInputRef: RefObject<HTMLInputElement> = createRef(); searchInputRef: RefObject<HTMLInputElement> = createRef();
toggleButtonRef: RefObject<HTMLButtonElement> = createRef(); toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
private loadingEllipsesInterval?: NodeJS.Timer = undefined;
state: SearchableSelectState = { state: SearchableSelectState = {
selectedIndex: 0, selectedIndex: 0,
searchText: "", searchText: "",
loadingEllipses: "...",
}; };
constructor(props: SearchableSelectProps, context: any) { constructor(props: SearchableSelectProps, context: any) {
@ -99,7 +97,7 @@ export class SearchableSelect extends Component<
render() { render() {
const { id, options, onSearch, loading } = this.props; const { id, options, onSearch, loading } = this.props;
const { searchText, selectedIndex, loadingEllipses } = this.state; const { searchText, selectedIndex } = this.state;
return ( return (
<div className="searchable-select dropdown col-12 col-sm-auto flex-grow-1"> <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)} onClick={linkEvent(this, focusSearch)}
ref={this.toggleButtonRef} ref={this.toggleButtonRef}
> >
{loading {loading ? (
? `${I18NextService.i18n.t("loading")}${loadingEllipses}` <>
: options[selectedIndex].label} {I18NextService.i18n.t("loading")}
<LoadingEllipses />
</>
) : (
options[selectedIndex].label
)}
</button> </button>
<div className="modlog-choices-font-size dropdown-menu w-100 p-2"> <div className="modlog-choices-font-size dropdown-menu w-100 p-2">
<div className="input-group"> <div className="input-group">
@ -180,24 +183,4 @@ export class SearchableSelect extends Component<
selectedIndex, 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 { CommunityLink } from "./community-link";
import { communityLimit } from "../../config"; import { communityLimit } from "../../config";
import { SubscribeButton } from "../common/subscribe-button";
type CommunitiesData = RouteDataResponse<{ type CommunitiesData = RouteDataResponse<{
listCommunitiesResponse: ListCommunitiesResponse; listCommunitiesResponse: ListCommunitiesResponse;
@ -173,41 +174,26 @@ export class Communities extends Component<any, CommunitiesState> {
{numToSI(cv.counts.comments)} {numToSI(cv.counts.comments)}
</td> </td>
<td className="text-right"> <td className="text-right">
{cv.subscribed === "Subscribed" && ( <SubscribeButton
<button communityView={cv}
className="btn btn-link d-inline-block" onFollow={linkEvent(
onClick={linkEvent( {
{ i: this,
i: this, communityId: cv.community.id,
communityId: cv.community.id, follow: false,
follow: false, },
}, this.handleFollow,
this.handleFollow, )}
)} onUnFollow={linkEvent(
> {
{I18NextService.i18n.t("unsubscribe")} i: this,
</button> communityId: cv.community.id,
)} follow: true,
{cv.subscribed === "NotSubscribed" && ( },
<button this.handleFollow,
className="btn btn-link d-inline-block" )}
onClick={linkEvent( isLink
{ />
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>
)}
</td> </td>
</tr> </tr>
), ),

View file

@ -21,6 +21,7 @@ import { I18NextService, UserService } from "../../services";
import { Badges } from "../common/badges"; import { Badges } from "../common/badges";
import { BannerIconHeader } from "../common/banner-icon-header"; import { BannerIconHeader } from "../common/banner-icon-header";
import { Icon, PurgeWarning, Spinner } from "../common/icon"; import { Icon, PurgeWarning, Spinner } from "../common/icon";
import { SubscribeButton } from "../common/subscribe-button";
import { CommunityForm } from "../community/community-form"; import { CommunityForm } from "../community/community-form";
import { CommunityLink } from "../community/community-link"; import { CommunityLink } from "../community/community-link";
import { PersonListing } from "../person/person-listing"; import { PersonListing } from "../person/person-listing";
@ -122,7 +123,9 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
sidebar() { sidebar() {
const myUSerInfo = UserService.Instance.myUserInfo; const myUSerInfo = UserService.Instance.myUserInfo;
const { name, actor_id } = this.props.community_view.community; const {
community: { name, actor_id },
} = this.props.community_view;
return ( return (
<aside className="mb-3"> <aside className="mb-3">
<div id="sidebarContainer"> <div id="sidebarContainer">
@ -130,7 +133,12 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
<div className="card-body"> <div className="card-body">
{this.communityTitle()} {this.communityTitle()}
{this.props.editable && this.adminButtons()} {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()} {this.canPost && this.createPost()}
{myUSerInfo && this.blockCommunity()} {myUSerInfo && this.blockCommunity()}
{!myUSerInfo && ( {!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() { blockCommunity() {
const { subscribed, blocked } = this.props.community_view; const { subscribed, blocked } = this.props.community_view;

View file

@ -1,6 +1,8 @@
import { setIsoData } from "@utils/app"; import { setIsoData } from "@utils/app";
import { isBrowser } from "@utils/browser"; import { isBrowser } from "@utils/browser";
import { getQueryParams } from "@utils/helpers";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { GetSiteResponse, LoginResponse } from "lemmy-js-client"; import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
import { I18NextService, UserService } from "../../services"; import { I18NextService, UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService"; import { HttpService, RequestState } from "../../services/HttpService";
@ -9,6 +11,17 @@ import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input"; import PasswordInput from "../common/password-input";
interface LoginProps {
prev?: string;
}
const getLoginQueryParams = () =>
getQueryParams<LoginProps>({
prev(param) {
return param ? decodeURIComponent(param) : undefined;
},
});
interface State { interface State {
loginRes: RequestState<LoginResponse>; loginRes: RequestState<LoginResponse>;
form: { form: {
@ -20,7 +33,73 @@ interface State {
siteRes: GetSiteResponse; 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); private isoData = setIsoData(this.context);
state: State = { state: State = {
@ -68,7 +147,7 @@ export class Login extends Component<any, State> {
loginForm() { loginForm() {
return ( return (
<div> <div>
<form onSubmit={linkEvent(this, this.handleLoginSubmit)}> <form onSubmit={linkEvent(this, handleLoginSubmit)}>
<h1 className="h4 mb-4">{I18NextService.i18n.t("login")}</h1> <h1 className="h4 mb-4">{I18NextService.i18n.t("login")}</h1>
<div className="mb-3 row"> <div className="mb-3 row">
<label <label
@ -83,7 +162,7 @@ export class Login extends Component<any, State> {
className="form-control" className="form-control"
id="login-email-or-username" id="login-email-or-username"
value={this.state.form.username_or_email} value={this.state.form.username_or_email}
onInput={linkEvent(this, this.handleLoginUsernameChange)} onInput={linkEvent(this, handleLoginUsernameChange)}
autoComplete="email" autoComplete="email"
required required
minLength={3} minLength={3}
@ -94,7 +173,7 @@ export class Login extends Component<any, State> {
<PasswordInput <PasswordInput
id="login-password" id="login-password"
value={this.state.form.password} value={this.state.form.password}
onInput={linkEvent(this, this.handleLoginPasswordChange)} onInput={linkEvent(this, handleLoginPasswordChange)}
label={I18NextService.i18n.t("password")} label={I18NextService.i18n.t("password")}
showForgotLink showForgotLink
/> />
@ -116,7 +195,7 @@ export class Login extends Component<any, State> {
pattern="[0-9]*" pattern="[0-9]*"
autoComplete="one-time-code" autoComplete="one-time-code"
value={this.state.form.totp_2fa_token} value={this.state.form.totp_2fa_token}
onInput={linkEvent(this, this.handleLoginTotpChange)} onInput={linkEvent(this, handleLoginTotpChange)}
/> />
</div> </div>
</div> </div>
@ -136,64 +215,4 @@ export class Login extends Component<any, State> {
</div> </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; path: string;
query: T; query: T;
site: GetSiteResponse; site: GetSiteResponse;
auth?: string;
} }
export interface PostFormParams { export interface PostFormParams {

View file

@ -21,6 +21,7 @@ import { VerifyEmail } from "./components/person/verify-email";
import { CreatePost } from "./components/post/create-post"; import { CreatePost } from "./components/post/create-post";
import { Post } from "./components/post/post"; import { Post } from "./components/post/post";
import { CreatePrivateMessage } from "./components/private_message/create-private-message"; import { CreatePrivateMessage } from "./components/private_message/create-private-message";
import { RemoteFetch } from "./components/remote-fetch";
import { Search } from "./components/search"; import { Search } from "./components/search";
import { InitialFetchRequest, RouteData } from "./interfaces"; import { InitialFetchRequest, RouteData } from "./interfaces";
@ -140,4 +141,9 @@ export const routes: IRoutePropsWithFetch<Record<string, any>>[] = [
fetchInitialData: Instances.fetchInitialData, fetchInitialData: Instances.fetchInitialData,
}, },
{ path: `/legal`, component: Legal }, { path: `/legal`, component: Legal },
{
path: "/activitypub/externalInteraction",
component: RemoteFetch,
fetchInitialData: RemoteFetch.fetchInitialData,
},
]; ];

View file

@ -1,5 +1,5 @@
export default function isAuthPath(pathname: string) { 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, pathname,
); );
} }

View file

@ -4,5 +4,5 @@ export default function isAdmin(
creatorId: number, creatorId: number,
admins?: PersonView[], admins?: PersonView[],
): boolean { ): 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"); const RunNodeWebpackPlugin = require("run-node-webpack-plugin");
serverConfig.plugins.push( serverConfig.plugins.push(
new RunNodeWebpackPlugin({ runOnlyInWatchMode: true }) new RunNodeWebpackPlugin({ runOnlyInWatchMode: true }),
); );
} else if (mode === "none") { } else if (mode === "none") {
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");