diff --git a/.eslintrc.json b/.eslintrc.json index 651ba006b..f289dccbe 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -49,6 +49,7 @@ "prettier/prettier": "error", "quote-props": 0, "unicorn/filename-case": 0, - "jsx-a11y/media-has-caption": 0 + "jsx-a11y/media-has-caption": 0, + "jsx-a11y/label-has-associated-control": 0 } } diff --git a/lemmy-translations b/lemmy-translations index f9783d686..c88dd1e3b 160000 --- a/lemmy-translations +++ b/lemmy-translations @@ -1 +1 @@ -Subproject commit f9783d686637197a389b8f10a907e0533c55b688 +Subproject commit c88dd1e3b36ee1617f1b86acf94c1b7946e97cd4 diff --git a/package.json b/package.json index 4c36ed405..f1d3c2bf9 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "classnames": "^2.5.1", "clean-webpack-plugin": "^4.0.0", "cookie": "^0.6.0", + "cookie-parser": "^1.4.6", "copy-webpack-plugin": "^12.0.2", "css-loader": "^6.10.0", "date-fns": "^3.6.0", @@ -94,6 +95,7 @@ "@types/autosize": "^4.0.3", "@types/bootstrap": "^5.2.10", "@types/cookie": "^0.6.0", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", "@types/html-to-text": "^9.0.4", "@types/lodash.isequal": "^4.5.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ad393bd2..98b531367 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: cookie: specifier: ^0.6.0 version: 0.6.0 + cookie-parser: + specifier: ^1.4.6 + version: 1.4.6 copy-webpack-plugin: specifier: ^12.0.2 version: 12.0.2(webpack@5.91.0(webpack-cli@5.1.4)) @@ -210,6 +213,9 @@ importers: '@types/cookie': specifier: ^0.6.0 version: 0.6.0 + '@types/cookie-parser': + specifier: ^1.4.7 + version: 1.4.7 '@types/express': specifier: ^4.17.21 version: 4.17.21 @@ -1207,6 +1213,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.7': + resolution: {integrity: sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -1856,9 +1865,17 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.6: + resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==} + engines: {node: '>= 0.8.0'} + cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie@0.4.1: + resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==} + engines: {node: '>= 0.6'} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -5742,6 +5759,10 @@ snapshots: dependencies: '@types/node': 20.11.30 + '@types/cookie-parser@1.4.7': + dependencies: + '@types/express': 4.17.21 + '@types/cookie@0.6.0': {} '@types/eslint-scope@3.7.7': @@ -6479,8 +6500,15 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.6: + dependencies: + cookie: 0.4.1 + cookie-signature: 1.0.6 + cookie-signature@1.0.6: {} + cookie@0.4.1: {} + cookie@0.6.0: {} copy-webpack-plugin@11.0.0(webpack@5.91.0(webpack-cli@5.1.4)): diff --git a/src/assets/symbols.svg b/src/assets/symbols.svg index 64002a32f..87d633617 100644 --- a/src/assets/symbols.svg +++ b/src/assets/symbols.svg @@ -292,5 +292,8 @@ + + + diff --git a/src/client/index.tsx b/src/client/index.tsx index db0a9ef76..270311898 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,7 +1,7 @@ import { initializeSite } from "@utils/app"; import { hydrate } from "inferno-hydrate"; import { BrowserRouter } from "inferno-router"; -import { App } from "../shared/components/app/app"; +import App from "../shared/components/app/app"; import { lazyHighlightjs } from "../shared/lazy-highlightjs"; import { loadUserLanguage } from "../shared/services/I18NextService"; import { verifyDynamicImports } from "../shared/dynamic-imports"; diff --git a/src/server/handlers/catch-all-handler.tsx b/src/server/handlers/catch-all-handler.tsx index 33d4d7a6e..88b06bdfd 100644 --- a/src/server/handlers/catch-all-handler.tsx +++ b/src/server/handlers/catch-all-handler.tsx @@ -6,7 +6,7 @@ import { StaticRouter, matchPath } from "inferno-router"; import { Match } from "inferno-router/dist/Route"; import { renderToString } from "inferno-server"; import { GetSiteResponse, LemmyHttp } from "lemmy-js-client"; -import { App } from "../../shared/components/app/app"; +import App from "../../shared/components/app/app"; import { InitialFetchRequest, IsoDataOptionalSite, @@ -28,6 +28,7 @@ import { } from "../../shared/services/"; import { parsePath } from "history"; import { getQueryString } from "@utils/helpers"; +import { adultConsentCookieKey } from "../../shared/config"; export default async (req: Request, res: Response) => { try { @@ -142,6 +143,9 @@ export default async (req: Request, res: Response) => { site_res: site, routeData, errorPageData, + showAdultConsentModal: + !!site?.site_view.site.content_warning && + !(site.my_user || req.cookies[adultConsentCookieKey]), }; const wrapper = ( diff --git a/src/server/index.tsx b/src/server/index.tsx index 34fc9cccf..cbbd027b8 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -14,8 +14,10 @@ import ThemesListHandler from "./handlers/themes-list-handler"; import { setCacheControl, setDefaultCsp } from "./middleware"; import CodeThemeHandler from "./handlers/code-theme-handler"; import { verifyDynamicImports } from "../shared/dynamic-imports"; +import cookieParser from "cookie-parser"; const server = express(); +server.use(cookieParser()); const [hostname, port] = process.env["LEMMY_UI_HOST"] ? process.env["LEMMY_UI_HOST"].split(":") diff --git a/src/server/utils/create-ssr-html.tsx b/src/server/utils/create-ssr-html.tsx index c47e1dae2..cb0ec615d 100644 --- a/src/server/utils/create-ssr-html.tsx +++ b/src/server/utils/create-ssr-html.tsx @@ -80,13 +80,14 @@ export async function createSsrHtml( ${lazyScripts} - ${erudaStr} @@ -97,6 +98,16 @@ export async function createSsrHtml( ${helmet.title.toString()} ${helmet.meta.toString()} + + diff --git a/src/shared/components/app/app.tsx b/src/shared/components/app/app.tsx index 1538b0450..4d1cbea26 100644 --- a/src/shared/components/app/app.tsx +++ b/src/shared/components/app/app.tsx @@ -1,5 +1,5 @@ import { isAnonymousPath, isAuthPath, setIsoData } from "@utils/app"; -import { Component, RefObject, createRef, linkEvent } from "inferno"; +import { Component, createRef, linkEvent } from "inferno"; import { Provider } from "inferno-i18next-dess"; import { Route, Switch } from "inferno-router"; import { IsoDataOptionalSite } from "../../interfaces"; @@ -13,42 +13,39 @@ import { Navbar } from "./navbar"; import "./styles.scss"; import { Theme } from "./theme"; import AnonymousGuard from "../common/anonymous-guard"; -import { destroyTippy, setupTippy } from "../../tippy"; +import AdultConsentModal from "../common/adult-consent-modal"; -export class App extends Component { +function handleJumpToContent(event) { + event.preventDefault(); +} + +export default class App extends Component { private isoData: IsoDataOptionalSite = setIsoData(this.context); - private readonly mainContentRef: RefObject; private readonly rootRef = createRef(); - constructor(props: any, context: any) { - super(props, context); - this.mainContentRef = createRef(); - } - - componentDidMount(): void { - setupTippy(this.rootRef); - } - - componentWillUnmount(): void { - destroyTippy(); - } - - handleJumpToContent(event) { - event.preventDefault(); - this.mainContentRef.current?.focus(); - } render() { const siteRes = this.isoData.site_res; const siteView = siteRes?.site_view; return ( - <> - -
+ + {/* This fragment is required to avoid an SSR error*/} + <> + {this.isoData.showAdultConsentModal && ( + + )} +
@@ -115,8 +112,8 @@ export class App extends Component {
-
- + + ); } } diff --git a/src/shared/components/common/adult-consent-modal.tsx b/src/shared/components/common/adult-consent-modal.tsx new file mode 100644 index 000000000..ca73e895e --- /dev/null +++ b/src/shared/components/common/adult-consent-modal.tsx @@ -0,0 +1,141 @@ +import { Component, LinkedEvent, createRef, linkEvent } from "inferno"; +import { modalMixin } from "../mixins/modal-mixin"; +import { adultConsentCookieKey } from "../../config"; +import { mdToHtml } from "../../markdown"; +import { I18NextService } from "../../services"; +import { isHttps } from "@utils/env"; +import { IsoData } from "../../interfaces"; +import { setIsoData } from "@utils/app"; + +interface AdultConsentModalProps { + contentWarning: string; + show: boolean; + onContinue: LinkedEvent | null; + onBack: LinkedEvent | null; + redirectCountdown: number; +} + +@modalMixin +class AdultConsentModalInner extends Component { + readonly modalDivRef = createRef(); + readonly continueButtonRef = createRef(); + + render() { + const { contentWarning, onContinue, onBack, redirectCountdown } = + this.props; + + return ( +
+
+
+
+

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

+
+ {redirectCountdown === Infinity ? ( +
+ this.forceUpdate(), + )} + /> + ) : ( +
+ {I18NextService.i18n.t("sending_back_message", { + seconds: redirectCountdown, + })} +
+ )} +
+ + +
+
+
+
+ ); + } + + handleShow() { + this.continueButtonRef.current?.focus(); + } +} + +interface AdultConsentModalState { + show: boolean; + redirectCountdown: number; +} + +function handleAdultConsent(i: AdultConsentModal) { + document.cookie = `${adultConsentCookieKey}=true; Path=/; SameSite=Strict${isHttps() ? "; Secure" : ""}`; + i.setState({ show: false }); + location.reload(); +} + +function handleAdultConsentGoBack(i: AdultConsentModal) { + i.setState({ redirectCountdown: 5 }); + + i.redirectTimeout = setInterval(() => { + i.setState(prev => ({ + ...prev, + redirectCountdown: prev.redirectCountdown - 1, + })); + }, 1000); +} + +export default class AdultConsentModal extends Component< + Pick, + AdultConsentModalState +> { + private isoData: IsoData = setIsoData(this.context); + redirectTimeout: NodeJS.Timeout; + state: AdultConsentModalState = { + show: this.isoData.showAdultConsentModal, + redirectCountdown: Infinity, + }; + + componentDidUpdate() { + if (this.state.redirectCountdown === 0) { + this.context.router.history.back(); + } + } + + componentWillUnmount() { + clearInterval(this.redirectTimeout); + } + + render() { + const { redirectCountdown, show } = this.state; + + return ( + + ); + } +} diff --git a/src/shared/components/common/pictrs-image.tsx b/src/shared/components/common/pictrs-image.tsx index 261b6ee68..9a5f450e7 100644 --- a/src/shared/components/common/pictrs-image.tsx +++ b/src/shared/components/common/pictrs-image.tsx @@ -2,6 +2,8 @@ import classNames from "classnames"; import { Component } from "inferno"; import { UserService } from "../../services"; +import { setIsoData } from "@utils/app"; +import { IsoData } from "../../interfaces"; const iconThumbnailSize = 96; const thumbnailSize = 256; @@ -19,48 +21,46 @@ interface PictrsImageProps { } export class PictrsImage extends Component { - constructor(props: any, context: any) { - super(props, context); - } + private readonly isoData: IsoData = setIsoData(this.context); 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 = nsfw && user_blur_nsfw; + const blurImage = + nsfw && + (UserService.Instance.myUserInfo?.local_user_view.local_user.blur_nsfw ?? + true); return ( - - - - - {this.alt()} - + !this.isoData.showAdultConsentModal && ( + + + + + {this.alt()} + + ) ); } diff --git a/src/shared/components/common/post-hidden-select.tsx b/src/shared/components/common/post-hidden-select.tsx index 6bb9e3230..e59069e1e 100644 --- a/src/shared/components/common/post-hidden-select.tsx +++ b/src/shared/components/common/post-hidden-select.tsx @@ -5,9 +5,6 @@ import { tippyMixin } from "../mixins/tippy-mixin"; import { Component, linkEvent } from "inferno"; import { I18NextService } from "../../services/I18NextService"; -// Need to disable this rule because ESLint flat out lies about labels not -// having an associated control in this component -/* eslint-disable jsx-a11y/label-has-associated-control */ interface PostHiddenSelectProps { showHidden?: StringBoolean; onShowHiddenChange: (hidden?: StringBoolean) => void; diff --git a/src/shared/components/home/signup.tsx b/src/shared/components/home/signup.tsx index b0398ef97..a6705bdcd 100644 --- a/src/shared/components/home/signup.tsx +++ b/src/shared/components/home/signup.tsx @@ -57,7 +57,7 @@ export class Signup extends Component< registerRes: EMPTY_REQUEST, captchaRes: EMPTY_REQUEST, form: { - show_nsfw: false, + show_nsfw: !!this.isoData.site_res.site_view.site.content_warning, }, captchaPlaying: false, siteRes: this.isoData.site_res, diff --git a/src/shared/components/home/site-form.tsx b/src/shared/components/home/site-form.tsx index 18af5fa21..430365598 100644 --- a/src/shared/components/home/site-form.tsx +++ b/src/shared/components/home/site-form.tsx @@ -86,6 +86,7 @@ export class SiteForm extends Component { allowed_instances: this.props.allowedInstances?.map(i => i.domain), blocked_instances: this.props.blockedInstances?.map(i => i.domain), blocked_urls: this.props.siteRes.blocked_urls.map(u => u.url), + content_warning: this.props.siteRes.site_view.site.content_warning, }; } @@ -116,6 +117,8 @@ export class SiteForm extends Component { this.handleInstanceTextChange = this.handleInstanceTextChange.bind(this); this.handleBlockedUrlsUpdate = this.handleBlockedUrlsUpdate.bind(this); + this.handleSiteContentWarningChange = + this.handleSiteContentWarningChange.bind(this); } render() { @@ -269,6 +272,26 @@ export class SiteForm extends Component {
+ {this.state.siteForm.enable_nsfw && ( +
+
+ + {I18NextService.i18n.t("content_warning_setting_blurb")} +
+ +
+ +
+
+ )}