mirror of
https://github.com/LemmyNet/lemmy-ui.git
synced 2024-11-25 13:51:13 +00:00
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:
parent
65e669035d
commit
d9fe7d1488
19 changed files with 674 additions and 208 deletions
|
@ -1 +1 @@
|
|||
Subproject commit a0f95fc29b7501156b6d8bbb504b1e787b5769e7
|
||||
Subproject commit de9de2c53bee034d3824ecaa9a2104f8f341332e
|
|
@ -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);
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -75,7 +75,7 @@ export class App extends Component<AppProps, any> {
|
|||
<div tabIndex={-1}>
|
||||
{RouteComponent &&
|
||||
(isAuthPath(path ?? "") ? (
|
||||
<AuthGuard>
|
||||
<AuthGuard {...routeProps}>
|
||||
<RouteComponent {...routeProps} />
|
||||
</AuthGuard>
|
||||
) : (
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
34
src/shared/components/common/loading-ellipses.tsx
Normal file
34
src/shared/components/common/loading-ellipses.tsx
Normal 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 + ".",
|
||||
}));
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
227
src/shared/components/common/subscribe-button.tsx
Normal file
227
src/shared/components/common/subscribe-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
),
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
221
src/shared/components/remote-fetch.tsx
Normal file
221
src/shared/components/remote-fetch.tsx
Normal 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;
|
||||
}
|
||||
}
|
|
@ -29,6 +29,7 @@ export interface InitialFetchRequest<T extends ParsedQs = ParsedQs> {
|
|||
path: string;
|
||||
query: T;
|
||||
site: GetSiteResponse;
|
||||
auth?: string;
|
||||
}
|
||||
|
||||
export interface PostFormParams {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
export const VERSION = "unknown version";
|
||||
export const VERSION = "unknown version" as string;
|
||||
|
|
|
@ -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");
|
||||
|
|
Loading…
Reference in a new issue