Make confirm popup for adult consent (#2419)

* Make confirm popup for adult consent

* Fix import

* Fix blur and adjust user settings

* Make confirmation popup more stylish

* Add setting to site settings form

* Fix modal bug

* Put adult consent logic all in one place

* Make modal use markdown

* Fix consent modal showing up for currently logged in admin

* Add go-back redirect countdown

* Center modal title

* Handle enable_nsfw correctly

* Blur background of modal to hide spicy things

* Add translations
This commit is contained in:
SleeplessOne1917 2024-04-18 23:54:16 +00:00 committed by GitHub
parent c1fbba2768
commit 643c1f6f01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 295 additions and 75 deletions

View File

@ -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
}
}

@ -1 +1 @@
Subproject commit f9783d686637197a389b8f10a907e0533c55b688
Subproject commit c88dd1e3b36ee1617f1b86acf94c1b7946e97cd4

View File

@ -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",

View File

@ -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)):

View File

@ -292,5 +292,8 @@
<line x1="16" y1="216" x2="240" y2="216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
<path d="M152,216V160a8,8,0,0,0-8-8H112a8,8,0,0,0-8,8v56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
</symbol>
<symbol id="icon-info" fill="currentColor" viewBox="0 0 256 256">
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z" />
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -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";

View File

@ -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 = (

View File

@ -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(":")

View File

@ -80,13 +80,14 @@ export async function createSsrHtml(
<html ${helmet.htmlAttributes.toString()}>
<head>
<script nonce="${cspNonce}">
window.isoData = ${serialize(isoData)};
if (!document.documentElement.hasAttribute("data-bs-theme")) {
const light = window.matchMedia("(prefers-color-scheme: light)").matches;
document.documentElement.setAttribute("data-bs-theme", light ? "light" : "dark");
}
</script>
${lazyScripts}
<script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script>
<!-- A remote debugging utility for mobile -->
${erudaStr}
@ -97,6 +98,16 @@ export async function createSsrHtml(
${helmet.title.toString()}
${helmet.meta.toString()}
<style>
#app[data-adult-consent] {
filter: blur(10px);
-webkit-filter: blur(10px);
-moz-filter: blur(10px);
-o-filter: blur(10px);
-ms-filter: blur(10px);
}
</style>
<!-- Required meta tags -->
<meta name="Description" content="Lemmy">
<meta charset="utf-8">

View File

@ -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<any, any> {
function handleJumpToContent(event) {
event.preventDefault();
}
export default class App extends Component<any, any> {
private isoData: IsoDataOptionalSite = setIsoData(this.context);
private readonly mainContentRef: RefObject<HTMLElement>;
private readonly rootRef = createRef<HTMLDivElement>();
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 (
<>
<Provider i18next={I18NextService.i18n}>
<div id="app" className="lemmy-site" ref={this.rootRef}>
<Provider i18next={I18NextService.i18n}>
{/* This fragment is required to avoid an SSR error*/}
<>
{this.isoData.showAdultConsentModal && (
<AdultConsentModal
contentWarning={siteView!.site.content_warning!}
/>
)}
<div
id="app"
className="lemmy-site"
ref={this.rootRef}
data-adult-consent={this.isoData.showAdultConsentModal || null}
>
<button
type="button"
className="btn skip-link bg-light position-absolute start-0 z-3"
onClick={linkEvent(this, this.handleJumpToContent)}
onClick={linkEvent(this, handleJumpToContent)}
>
{I18NextService.i18n.t("jump_to_content", "Jump to content")}
</button>
@ -115,8 +112,8 @@ export class App extends Component<any, any> {
</div>
<Footer site={siteRes} />
</div>
</Provider>
</>
</>
</Provider>
);
}
}

View File

@ -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<any, Event> | null;
onBack: LinkedEvent<any, Event> | null;
redirectCountdown: number;
}
@modalMixin
class AdultConsentModalInner extends Component<AdultConsentModalProps, any> {
readonly modalDivRef = createRef<HTMLDivElement>();
readonly continueButtonRef = createRef<HTMLButtonElement>();
render() {
const { contentWarning, onContinue, onBack, redirectCountdown } =
this.props;
return (
<div
className="modal"
id="adultConsentModal"
tabIndex={-1}
aria-hidden
aria-label="Content warning"
data-bs-backdrop="static"
ref={this.modalDivRef}
>
<div
className="modal-dialog modal-fullscreen-sm-down"
data-bs-backdrop="static"
>
<div className="modal-content">
<header className="modal-header justify-content-center">
<h3 className="modal-title">
{I18NextService.i18n.t("content_warning")}
</h3>
</header>
{redirectCountdown === Infinity ? (
<div
className="modal-body text-center align-middle text-body"
dangerouslySetInnerHTML={mdToHtml(contentWarning, () =>
this.forceUpdate(),
)}
/>
) : (
<div className="modal-body text-center align-middle text-body">
{I18NextService.i18n.t("sending_back_message", {
seconds: redirectCountdown,
})}
</div>
)}
<footer className="modal-footer">
<button
type="button"
className="btn btn-success"
onClick={onContinue}
ref={this.continueButtonRef}
>
{I18NextService.i18n.t("continue")}
</button>
<button type="button" className="btn btn-danger" onClick={onBack}>
{I18NextService.i18n.t("go_back")}
</button>
</footer>
</div>
</div>
</div>
);
}
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<AdultConsentModalProps, "contentWarning">,
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 (
<AdultConsentModalInner
contentWarning={this.props.contentWarning}
show={show}
redirectCountdown={redirectCountdown}
onBack={linkEvent(this, handleAdultConsentGoBack)}
onContinue={linkEvent(this, handleAdultConsent)}
/>
);
}
}

View File

@ -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<PictrsImageProps, any> {
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 (
<picture>
<source srcSet={this.src("webp")} type="image/webp" />
<source srcSet={src} />
<source srcSet={this.src("jpg")} type="image/jpeg" />
<img
src={src}
alt={this.alt()}
title={this.alt()}
loading="lazy"
className={classNames("overflow-hidden pictrs-image", {
"img-fluid": !(icon || iconOverlay),
banner,
"thumbnail rounded object-fit-cover":
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":
iconOverlay,
"avatar-pushup": pushup,
"card-img-top": cardTop,
})}
/>
</picture>
!this.isoData.showAdultConsentModal && (
<picture>
<source srcSet={this.src("webp")} type="image/webp" />
<source srcSet={src} />
<source srcSet={this.src("jpg")} type="image/jpeg" />
<img
src={src}
alt={this.alt()}
title={this.alt()}
loading="lazy"
className={classNames("overflow-hidden pictrs-image", {
"img-fluid": !(icon || iconOverlay),
banner,
"thumbnail rounded object-fit-cover":
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 && blurImage,
"img-blur-thumb": thumbnail && blurImage,
"ms-2 mb-0 rounded-circle object-fit-cover avatar-overlay":
iconOverlay,
"avatar-pushup": pushup,
"card-img-top": cardTop,
})}
/>
</picture>
)
);
}

View File

@ -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;

View File

@ -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,

View File

@ -86,6 +86,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
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<SiteFormProps, SiteFormState> {
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<SiteFormProps, SiteFormState> {
</div>
</div>
</div>
{this.state.siteForm.enable_nsfw && (
<div className="mb-3 row">
<div className="alert small alert-info" role="alert">
<Icon icon="info" classes="icon-inline me-2" />
{I18NextService.i18n.t("content_warning_setting_blurb")}
</div>
<label className="col-12 col-form-label">
{I18NextService.i18n.t("content_warning")}
</label>
<div className="col-12">
<MarkdownTextArea
initialContent={this.state.siteForm.content_warning}
onContentChange={this.handleSiteContentWarningChange}
hideNavigationWarnings
allLanguages={[]}
siteLanguages={[]}
/>
</div>
</div>
)}
<div className="mb-3 row">
<div className="col-12">
<label
@ -1012,4 +1035,8 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
},
}));
}
handleSiteContentWarningChange(val: string) {
this.setState(s => ((s.siteForm.content_warning = val), s));
}
}

View File

@ -918,7 +918,11 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
className="form-check-input"
id="user-blur-nsfw"
type="checkbox"
checked={this.state.saveUserSettingsForm.blur_nsfw}
disabled={!this.state.saveUserSettingsForm.show_nsfw}
checked={
this.state.saveUserSettingsForm.blur_nsfw &&
this.state.saveUserSettingsForm.show_nsfw
}
onChange={linkEvent(this, this.handleBlurNsfwChange)}
/>
<label className="form-check-label" htmlFor="user-blur-nsfw">

View File

@ -1,4 +1,4 @@
import { myAuth } from "@utils/app";
import { myAuth, setIsoData } from "@utils/app";
import { canShare, share } from "@utils/browser";
import { getExternalHost, getHttpBase } from "@utils/env";
import { futureDaysToUnixTime, hostname } from "@utils/helpers";
@ -33,7 +33,7 @@ import {
TransferCommunity,
} from "lemmy-js-client";
import { relTags } from "../../config";
import { VoteContentType } from "../../interfaces";
import { IsoDataOptionalSite, VoteContentType } from "../../interfaces";
import { mdToHtml, mdToHtmlInline } from "../../markdown";
import { I18NextService, UserService } from "../../services";
import { tippyMixin } from "../mixins/tippy-mixin";
@ -98,9 +98,10 @@ interface PostListingProps {
@tippyMixin
export class PostListing extends Component<PostListingProps, PostListingState> {
private readonly isoData: IsoDataOptionalSite = setIsoData(this.context);
state: PostListingState = {
showEdit: false,
imageExpanded: false,
imageExpanded: !!this.isoData.site_res?.site_view.site.content_warning,
viewSource: false,
showAdvanced: false,
showBody: false,

View File

@ -28,6 +28,7 @@ export const fetchLimit = 20;
export const relTags = "noopener nofollow";
export const emDash = "\u2014";
export const authCookieName = "jwt";
export const adultConsentCookieKey = "adultConsent";
// No. of max displayed communities per
// page on route "/communities"

View File

@ -16,6 +16,7 @@ export interface IsoData<T extends RouteData = any> {
routeData: T;
site_res: GetSiteResponse;
errorPageData?: ErrorPageData;
showAdultConsentModal: boolean;
}
export type IsoDataOptionalSite<T extends RouteData = any> = Partial<