diff --git a/lemmy-translations b/lemmy-translations index a0f95fc2..de9de2c5 160000 --- a/lemmy-translations +++ b/lemmy-translations @@ -1 +1 @@ -Subproject commit a0f95fc29b7501156b6d8bbb504b1e787b5769e7 +Subproject commit de9de2c53bee034d3824ecaa9a2104f8f341332e diff --git a/src/client/index.tsx b/src/client/index.tsx index 6978c9c3..ffe52ce8 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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); diff --git a/src/server/handlers/catch-all-handler.tsx b/src/server/handlers/catch-all-handler.tsx index cfbe95b1..6e93a1ae 100644 --- a/src/server/handlers/catch-all-handler.tsx +++ b/src/server/handlers/catch-all-handler.tsx @@ -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") { diff --git a/src/shared/components/app/app.tsx b/src/shared/components/app/app.tsx index fc6f0a84..b2f0df43 100644 --- a/src/shared/components/app/app.tsx +++ b/src/shared/components/app/app.tsx @@ -75,7 +75,7 @@ export class App extends Component {
{RouteComponent && (isAuthPath(path ?? "") ? ( - + ) : ( diff --git a/src/shared/components/common/auth-guard.tsx b/src/shared/components/common/auth-guard.tsx index e79a541e..03352901 100644 --- a/src/shared/components/common/auth-guard.tsx +++ b/src/shared/components/common/auth-guard.tsx @@ -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 ; - } else { - return props.children; +interface AuthGuardState { + hasRedirected: boolean; +} + +class AuthGuard extends Component< + RouteComponentProps>, + AuthGuardState +> { + state = { + hasRedirected: false, + } as AuthGuardState; + + constructor( + props: RouteComponentProps>, + 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 : ; } } diff --git a/src/shared/components/common/loading-ellipses.tsx b/src/shared/components/common/loading-ellipses.tsx new file mode 100644 index 00000000..ba96101f --- /dev/null +++ b/src/shared/components/common/loading-ellipses.tsx @@ -0,0 +1,34 @@ +import { Component } from "inferno"; + +interface LoadingEllipsesState { + ellipses: string; +} + +export class LoadingEllipses extends Component { + 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 + ".", + })); + }; +} diff --git a/src/shared/components/common/pictrs-image.tsx b/src/shared/components/common/pictrs-image.tsx index ab22db0f..56167967 100644 --- a/src/shared/components/common/pictrs-image.tsx +++ b/src/shared/components/common/pictrs-image.tsx @@ -15,6 +15,7 @@ interface PictrsImageProps { nsfw?: boolean; iconOverlay?: boolean; pushup?: boolean; + cardTop?: boolean; } export class PictrsImage extends Component { @@ -23,37 +24,40 @@ export class PictrsImage extends Component { } 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 ( - + {this.alt()} diff --git a/src/shared/components/common/searchable-select.tsx b/src/shared/components/common/searchable-select.tsx index f5ad3925..d935a917 100644 --- a/src/shared/components/common/searchable-select.tsx +++ b/src/shared/components/common/searchable-select.tsx @@ -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) { @@ -70,12 +70,10 @@ export class SearchableSelect extends Component< > { searchInputRef: RefObject = createRef(); toggleButtonRef: RefObject = 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 (
@@ -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")} + + + ) : ( + options[selectedIndex].label + )}
@@ -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); - } - } } diff --git a/src/shared/components/common/subscribe-button.tsx b/src/shared/components/common/subscribe-button.tsx new file mode 100644 index 00000000..9ae4426f --- /dev/null +++ b/src/shared/components/common/subscribe-button.tsx @@ -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 ( + <> + + + + ); + } + + return ( + + ); +} + +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 ( +
+
+
+
+

+ {I18NextService.i18n.t("remote_follow_modal_title")} +

+
+
+ + +
+
+ + +
+
+
+
+ ); + } +} diff --git a/src/shared/components/community/communities.tsx b/src/shared/components/community/communities.tsx index 70490284..b36e1c8a 100644 --- a/src/shared/components/community/communities.tsx +++ b/src/shared/components/community/communities.tsx @@ -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 { {numToSI(cv.counts.comments)} - {cv.subscribed === "Subscribed" && ( - - )} - {cv.subscribed === "NotSubscribed" && ( - - )} - {cv.subscribed === "Pending" && ( -
- {I18NextService.i18n.t("subscribe_pending")} -
- )} + ), diff --git a/src/shared/components/community/sidebar.tsx b/src/shared/components/community/sidebar.tsx index b9fb4603..6fd6bfa6 100644 --- a/src/shared/components/community/sidebar.tsx +++ b/src/shared/components/community/sidebar.tsx @@ -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 { 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 (