Merge branch 'main' into vote_display_mode

This commit is contained in:
Dessalines 2024-04-18 20:18:36 -04:00
commit 54b889a053
25 changed files with 6495 additions and 4522 deletions

View file

@ -49,6 +49,7 @@
"prettier/prettier": "error", "prettier/prettier": "error",
"quote-props": 0, "quote-props": 0,
"unicorn/filename-case": 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 e78c744abe5c3824f9ca7de7f1ee086468385ee0 Subproject commit c88dd1e3b36ee1617f1b86acf94c1b7946e97cd4

View file

@ -42,6 +42,7 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clean-webpack-plugin": "^4.0.0", "clean-webpack-plugin": "^4.0.0",
"cookie": "^0.6.0", "cookie": "^0.6.0",
"cookie-parser": "^1.4.6",
"copy-webpack-plugin": "^12.0.2", "copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.10.0", "css-loader": "^6.10.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
@ -60,7 +61,7 @@
"inferno-router": "^8.2.3", "inferno-router": "^8.2.3",
"inferno-server": "^8.2.3", "inferno-server": "^8.2.3",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lemmy-js-client": "0.19.4-alpha.16", "lemmy-js-client": "0.19.4-alpha.18",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-bidi": "^0.1.0", "markdown-it-bidi": "^0.1.0",
@ -94,6 +95,7 @@
"@types/autosize": "^4.0.3", "@types/autosize": "^4.0.3",
"@types/bootstrap": "^5.2.10", "@types/bootstrap": "^5.2.10",
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/html-to-text": "^9.0.4", "@types/html-to-text": "^9.0.4",
"@types/lodash.isequal": "^4.5.8", "@types/lodash.isequal": "^4.5.8",
@ -139,7 +141,7 @@
"sortpack" "sortpack"
] ]
}, },
"packageManager": "pnpm@8.14.3", "packageManager": "pnpm@9.0.1+sha256.46d50ee2afecb42b185ebbd662dc7bdd52ef5be56bf035bb615cab81a75345df",
"engineStrict": true, "engineStrict": true,
"importSort": { "importSort": {
".js, .jsx, .ts, .tsx": { ".js, .jsx, .ts, .tsx": {

File diff suppressed because it is too large Load diff

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"/> <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"/> <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>
<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> </defs>
</svg> </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 { initializeSite } from "@utils/app";
import { hydrate } from "inferno-hydrate"; import { hydrate } from "inferno-hydrate";
import { BrowserRouter } from "inferno-router"; 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 { lazyHighlightjs } from "../shared/lazy-highlightjs";
import { loadUserLanguage } from "../shared/services/I18NextService"; import { loadUserLanguage } from "../shared/services/I18NextService";
import { verifyDynamicImports } from "../shared/dynamic-imports"; 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 { Match } from "inferno-router/dist/Route";
import { renderToString } from "inferno-server"; import { renderToString } from "inferno-server";
import { GetSiteResponse, LemmyHttp } from "lemmy-js-client"; import { GetSiteResponse, LemmyHttp } from "lemmy-js-client";
import { App } from "../../shared/components/app/app"; import App from "../../shared/components/app/app";
import { import {
InitialFetchRequest, InitialFetchRequest,
IsoDataOptionalSite, IsoDataOptionalSite,
@ -28,6 +28,7 @@ import {
} from "../../shared/services/"; } from "../../shared/services/";
import { parsePath } from "history"; import { parsePath } from "history";
import { getQueryString } from "@utils/helpers"; import { getQueryString } from "@utils/helpers";
import { adultConsentCookieKey } from "../../shared/config";
export default async (req: Request, res: Response) => { export default async (req: Request, res: Response) => {
try { try {
@ -142,6 +143,9 @@ export default async (req: Request, res: Response) => {
site_res: site, site_res: site,
routeData, routeData,
errorPageData, errorPageData,
showAdultConsentModal:
!!site?.site_view.site.content_warning &&
!(site.my_user || req.cookies[adultConsentCookieKey]),
}; };
const wrapper = ( const wrapper = (

View file

@ -14,8 +14,10 @@ import ThemesListHandler from "./handlers/themes-list-handler";
import { setCacheControl, setDefaultCsp } from "./middleware"; import { setCacheControl, setDefaultCsp } from "./middleware";
import CodeThemeHandler from "./handlers/code-theme-handler"; import CodeThemeHandler from "./handlers/code-theme-handler";
import { verifyDynamicImports } from "../shared/dynamic-imports"; import { verifyDynamicImports } from "../shared/dynamic-imports";
import cookieParser from "cookie-parser";
const server = express(); const server = express();
server.use(cookieParser());
const [hostname, port] = process.env["LEMMY_UI_HOST"] const [hostname, port] = process.env["LEMMY_UI_HOST"]
? process.env["LEMMY_UI_HOST"].split(":") ? process.env["LEMMY_UI_HOST"].split(":")

View file

@ -80,13 +80,14 @@ export async function createSsrHtml(
<html ${helmet.htmlAttributes.toString()}> <html ${helmet.htmlAttributes.toString()}>
<head> <head>
<script nonce="${cspNonce}"> <script nonce="${cspNonce}">
window.isoData = ${serialize(isoData)};
if (!document.documentElement.hasAttribute("data-bs-theme")) { if (!document.documentElement.hasAttribute("data-bs-theme")) {
const light = window.matchMedia("(prefers-color-scheme: light)").matches; const light = window.matchMedia("(prefers-color-scheme: light)").matches;
document.documentElement.setAttribute("data-bs-theme", light ? "light" : "dark"); document.documentElement.setAttribute("data-bs-theme", light ? "light" : "dark");
} }
</script> </script>
${lazyScripts} ${lazyScripts}
<script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script>
<!-- A remote debugging utility for mobile --> <!-- A remote debugging utility for mobile -->
${erudaStr} ${erudaStr}
@ -97,6 +98,16 @@ export async function createSsrHtml(
${helmet.title.toString()} ${helmet.title.toString()}
${helmet.meta.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 --> <!-- Required meta tags -->
<meta name="Description" content="Lemmy"> <meta name="Description" content="Lemmy">
<meta charset="utf-8"> <meta charset="utf-8">

View file

@ -1,5 +1,5 @@
import { isAnonymousPath, isAuthPath, setIsoData } from "@utils/app"; 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 { Provider } from "inferno-i18next-dess";
import { Route, Switch } from "inferno-router"; import { Route, Switch } from "inferno-router";
import { IsoDataOptionalSite } from "../../interfaces"; import { IsoDataOptionalSite } from "../../interfaces";
@ -13,42 +13,39 @@ import { Navbar } from "./navbar";
import "./styles.scss"; import "./styles.scss";
import { Theme } from "./theme"; import { Theme } from "./theme";
import AnonymousGuard from "../common/anonymous-guard"; 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 isoData: IsoDataOptionalSite = setIsoData(this.context);
private readonly mainContentRef: RefObject<HTMLElement>;
private readonly rootRef = createRef<HTMLDivElement>(); 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() { render() {
const siteRes = this.isoData.site_res; const siteRes = this.isoData.site_res;
const siteView = siteRes?.site_view; const siteView = siteRes?.site_view;
return ( return (
<> <Provider i18next={I18NextService.i18n}>
<Provider i18next={I18NextService.i18n}> {/* This fragment is required to avoid an SSR error*/}
<div id="app" className="lemmy-site" ref={this.rootRef}> <>
{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 <button
type="button" type="button"
className="btn skip-link bg-light position-absolute start-0 z-3" 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")} {I18NextService.i18n.t("jump_to_content", "Jump to content")}
</button> </button>
@ -115,8 +112,8 @@ export class App extends Component<any, any> {
</div> </div>
<Footer site={siteRes} /> <Footer site={siteRes} />
</div> </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

@ -36,7 +36,7 @@ export class ImageUploadForm extends Component<
<form className="image-upload-form d-inline"> <form className="image-upload-form d-inline">
{this.props.imageSrc && ( {this.props.imageSrc && (
<span className="d-inline-block position-relative mb-2"> <span className="d-inline-block position-relative mb-2">
{/* TODO: Create "Current Iamge" translation for alt text */} {/* TODO: Create "Current Image" translation for alt text */}
<img <img
alt="" alt=""
src={this.props.imageSrc} src={this.props.imageSrc}

View file

@ -0,0 +1,106 @@
import { Component, InfernoNode, linkEvent } from "inferno";
import { ListMediaResponse, LocalImage } from "lemmy-js-client";
import { HttpService, I18NextService } from "../../services";
import { PersonListing } from "../person/person-listing";
import { tippyMixin } from "../mixins/tippy-mixin";
import { MomentTime } from "./moment-time";
import { PictrsImage } from "./pictrs-image";
import { getHttpBase } from "@utils/env";
import { toast } from "../../toast";
interface Props {
uploads: ListMediaResponse;
showUploader?: boolean;
}
@tippyMixin
export class MediaUploads extends Component<Props, any> {
constructor(props: any, context: any) {
super(props, context);
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & Props>,
): void {
if (this.props !== nextProps) {
this.setState({ loading: false });
}
}
render() {
const images = this.props.uploads.images;
return (
<div className="media-uploads table-responsive">
<table className="table">
<thead>
<tr>
{this.props.showUploader && (
<th>{I18NextService.i18n.t("uploader")}</th>
)}
<th colSpan={3}>{I18NextService.i18n.t("time")}</th>
</tr>
</thead>
<tbody>
{images.map(i => (
<tr key={i.local_image.pictrs_alias}>
{this.props.showUploader && (
<td>
<PersonListing person={i.person} />
</td>
)}
<td>
<MomentTime published={i.local_image.published} />
</td>
<td>
<PictrsImage
src={buildImageUrl(i.local_image.pictrs_alias)}
/>
</td>
<td>{this.deleteImageBtn(i.local_image)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
deleteImageBtn(image: LocalImage) {
return (
<button
onClick={linkEvent(image, this.handleDeleteImage)}
className="btn btn-danger"
>
{I18NextService.i18n.t("delete")}
</button>
);
}
async handleDeleteImage(image: LocalImage) {
const form = {
token: image.pictrs_delete_token,
filename: image.pictrs_alias,
};
const res = await HttpService.client.deleteImage(form);
const filename = image.pictrs_alias;
if (res.state === "success") {
const deletePictureText = I18NextService.i18n.t("picture_deleted", {
filename,
});
toast(deletePictureText);
} else if (res.state === "failed") {
const failedDeletePictureText = I18NextService.i18n.t(
"failed_to_delete_picture",
{
filename,
},
);
toast(failedDeletePictureText, "danger");
}
}
}
function buildImageUrl(pictrsAlias: string): string {
return `${getHttpBase()}/pictrs/image/${pictrsAlias}`;
}

View file

@ -2,6 +2,8 @@ import classNames from "classnames";
import { Component } from "inferno"; import { Component } from "inferno";
import { UserService } from "../../services"; import { UserService } from "../../services";
import { setIsoData } from "@utils/app";
import { IsoData } from "../../interfaces";
const iconThumbnailSize = 96; const iconThumbnailSize = 96;
const thumbnailSize = 256; const thumbnailSize = 256;
@ -19,48 +21,46 @@ interface PictrsImageProps {
} }
export class PictrsImage extends Component<PictrsImageProps, any> { export class PictrsImage extends Component<PictrsImageProps, any> {
constructor(props: any, context: any) { private readonly isoData: IsoData = setIsoData(this.context);
super(props, context);
}
render() { render() {
const { src, icon, iconOverlay, banner, thumbnail, nsfw, pushup, cardTop } = const { src, icon, iconOverlay, banner, thumbnail, nsfw, pushup, cardTop } =
this.props; 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 ( return (
<picture> !this.isoData.showAdultConsentModal && (
<source srcSet={this.src("webp")} type="image/webp" /> <picture>
<source srcSet={src} /> <source srcSet={this.src("webp")} type="image/webp" />
<source srcSet={this.src("jpg")} type="image/jpeg" /> <source srcSet={src} />
<img <source srcSet={this.src("jpg")} type="image/jpeg" />
src={src} <img
alt={this.alt()} src={src}
title={this.alt()} alt={this.alt()}
loading="lazy" title={this.alt()}
className={classNames("overflow-hidden pictrs-image", { loading="lazy"
"img-fluid": !(icon || iconOverlay), className={classNames("overflow-hidden pictrs-image", {
banner, "img-fluid": !(icon || iconOverlay),
"thumbnail rounded object-fit-cover": banner,
thumbnail && !(icon || banner), "thumbnail rounded object-fit-cover":
"img-expanded slight-radius": !(thumbnail || icon), thumbnail && !(icon || banner),
"img-blur": thumbnail && nsfw, "img-expanded slight-radius": !(thumbnail || icon),
"object-fit-cover img-icon me-1": icon, "img-blur": thumbnail && nsfw,
"img-blur-icon": icon && blur_image, "object-fit-cover img-icon me-1": icon,
"img-blur-thumb": thumbnail && blur_image, "img-blur-icon": icon && blurImage,
"ms-2 mb-0 rounded-circle object-fit-cover avatar-overlay": "img-blur-thumb": thumbnail && blurImage,
iconOverlay, "ms-2 mb-0 rounded-circle object-fit-cover avatar-overlay":
"avatar-pushup": pushup, iconOverlay,
"card-img-top": cardTop, "avatar-pushup": pushup,
})} "card-img-top": cardTop,
/> })}
</picture> />
</picture>
)
); );
} }
@ -75,7 +75,7 @@ export class PictrsImage extends Component<PictrsImageProps, any> {
return this.props.src; return this.props.src;
} }
// If theres no match, then its not a pictrs image // If there's no match, then it's not a pictrs image
if (!url.pathname.includes("/pictrs/image/")) { if (!url.pathname.includes("/pictrs/image/")) {
return this.props.src; return this.props.src;
} }

View file

@ -5,9 +5,6 @@ import { tippyMixin } from "../mixins/tippy-mixin";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { I18NextService } from "../../services/I18NextService"; 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 { interface PostHiddenSelectProps {
showHidden?: StringBoolean; showHidden?: StringBoolean;
onShowHiddenChange: (hidden?: StringBoolean) => void; onShowHiddenChange: (hidden?: StringBoolean) => void;

View file

@ -20,7 +20,7 @@ function handleSwitchTab({ ctx, tab }: { ctx: Tabs; tab: string }) {
} }
export default class Tabs extends Component<TabsProps, TabsState> { export default class Tabs extends Component<TabsProps, TabsState> {
constructor(props: TabsProps, context) { constructor(props: TabsProps, context: any) {
super(props, context); super(props, context);
this.state = { this.state = {

View file

@ -13,6 +13,7 @@ import {
GetFederatedInstancesResponse, GetFederatedInstancesResponse,
GetSiteResponse, GetSiteResponse,
LemmyHttp, LemmyHttp,
ListMediaResponse,
PersonView, PersonView,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { InitialFetchRequest } from "../../interfaces"; import { InitialFetchRequest } from "../../interfaces";
@ -37,10 +38,14 @@ import { TaglineForm } from "./tagline-form";
import { getHttpBaseInternal } from "../../utils/env"; import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route"; import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes"; import { IRoutePropsWithFetch } from "../../routes";
import { MediaUploads } from "../common/media-uploads";
import { Paginator } from "../common/paginator";
import { snapToTop } from "@utils/browser";
type AdminSettingsData = RouteDataResponse<{ type AdminSettingsData = RouteDataResponse<{
bannedRes: BannedPersonsResponse; bannedRes: BannedPersonsResponse;
instancesRes: GetFederatedInstancesResponse; instancesRes: GetFederatedInstancesResponse;
uploadsRes: ListMediaResponse;
}>; }>;
interface AdminSettingsState { interface AdminSettingsState {
@ -50,6 +55,8 @@ interface AdminSettingsState {
instancesRes: RequestState<GetFederatedInstancesResponse>; instancesRes: RequestState<GetFederatedInstancesResponse>;
bannedRes: RequestState<BannedPersonsResponse>; bannedRes: RequestState<BannedPersonsResponse>;
leaveAdminTeamRes: RequestState<GetSiteResponse>; leaveAdminTeamRes: RequestState<GetSiteResponse>;
uploadsRes: RequestState<ListMediaResponse>;
uploadsPage: number;
loading: boolean; loading: boolean;
themeList: string[]; themeList: string[];
isIsomorphic: boolean; isIsomorphic: boolean;
@ -76,13 +83,19 @@ export class AdminSettings extends Component<
bannedRes: EMPTY_REQUEST, bannedRes: EMPTY_REQUEST,
instancesRes: EMPTY_REQUEST, instancesRes: EMPTY_REQUEST,
leaveAdminTeamRes: EMPTY_REQUEST, leaveAdminTeamRes: EMPTY_REQUEST,
uploadsRes: EMPTY_REQUEST,
uploadsPage: 1,
loading: false, loading: false,
themeList: [], themeList: [],
isIsomorphic: false, isIsomorphic: false,
}; };
loadingSettled() { loadingSettled() {
return resourcesSettled([this.state.bannedRes, this.state.instancesRes]); return resourcesSettled([
this.state.bannedRes,
this.state.instancesRes,
this.state.uploadsRes,
]);
} }
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -92,15 +105,17 @@ export class AdminSettings extends Component<
this.handleEditEmoji = this.handleEditEmoji.bind(this); this.handleEditEmoji = this.handleEditEmoji.bind(this);
this.handleDeleteEmoji = this.handleDeleteEmoji.bind(this); this.handleDeleteEmoji = this.handleDeleteEmoji.bind(this);
this.handleCreateEmoji = this.handleCreateEmoji.bind(this); this.handleCreateEmoji = this.handleCreateEmoji.bind(this);
this.handleUploadsPageChange = this.handleUploadsPageChange.bind(this);
// Only fetch the data if coming from another route // Only fetch the data if coming from another route
if (FirstLoadService.isFirstLoad) { if (FirstLoadService.isFirstLoad) {
const { bannedRes, instancesRes } = this.isoData.routeData; const { bannedRes, instancesRes, uploadsRes } = this.isoData.routeData;
this.state = { this.state = {
...this.state, ...this.state,
bannedRes, bannedRes,
instancesRes, instancesRes,
uploadsRes,
isIsomorphic: true, isIsomorphic: true,
}; };
} }
@ -115,6 +130,7 @@ export class AdminSettings extends Component<
return { return {
bannedRes: await client.getBannedPersons(), bannedRes: await client.getBannedPersons(),
instancesRes: await client.getFederatedInstances(), instancesRes: await client.getFederatedInstances(),
uploadsRes: await client.listAllMedia(),
}; };
} }
@ -256,6 +272,21 @@ export class AdminSettings extends Component<
</div> </div>
), ),
}, },
{
key: "uploads",
label: I18NextService.i18n.t("uploads"),
getNode: isSelected => (
<div
className={classNames("tab-pane", {
active: isSelected,
})}
role="tabpanel"
id="uploads-tab-pane"
>
{this.uploads()}
</div>
),
},
]} ]}
/> />
</div> </div>
@ -266,22 +297,34 @@ export class AdminSettings extends Component<
this.setState({ this.setState({
bannedRes: LOADING_REQUEST, bannedRes: LOADING_REQUEST,
instancesRes: LOADING_REQUEST, instancesRes: LOADING_REQUEST,
uploadsRes: LOADING_REQUEST,
themeList: [], themeList: [],
}); });
const [bannedRes, instancesRes, themeList] = await Promise.all([ const [bannedRes, instancesRes, uploadsRes, themeList] = await Promise.all([
HttpService.client.getBannedPersons(), HttpService.client.getBannedPersons(),
HttpService.client.getFederatedInstances(), HttpService.client.getFederatedInstances(),
HttpService.client.listAllMedia({
page: this.state.uploadsPage,
}),
fetchThemeList(), fetchThemeList(),
]); ]);
this.setState({ this.setState({
bannedRes, bannedRes,
instancesRes, instancesRes,
uploadsRes,
themeList, themeList,
}); });
} }
async fetchUploadsOnly() {
const uploadsRes = await HttpService.client.listAllMedia({
page: this.state.uploadsPage,
});
this.setState({ uploadsRes });
}
admins() { admins() {
return ( return (
<> <>
@ -341,6 +384,30 @@ export class AdminSettings extends Component<
} }
} }
uploads() {
switch (this.state.uploadsRes.state) {
case "loading":
return (
<h5>
<Spinner large />
</h5>
);
case "success": {
const uploadsRes = this.state.uploadsRes.data;
return (
<div>
<MediaUploads showUploader uploads={uploadsRes} />
<Paginator
page={this.state.uploadsPage}
onChange={this.handleUploadsPageChange}
nextDisabled={false}
/>
</div>
);
}
}
}
async handleEditSite(form: EditSite) { async handleEditSite(form: EditSite) {
this.setState({ loading: true }); this.setState({ loading: true });
@ -397,4 +464,10 @@ export class AdminSettings extends Component<
updateEmojiDataModel(res.data.custom_emoji); updateEmojiDataModel(res.data.custom_emoji);
} }
} }
async handleUploadsPageChange(val: number) {
this.setState({ uploadsPage: val });
snapToTop();
await this.fetchUploadsOnly();
}
} }

View file

@ -57,7 +57,7 @@ export class Signup extends Component<
registerRes: EMPTY_REQUEST, registerRes: EMPTY_REQUEST,
captchaRes: EMPTY_REQUEST, captchaRes: EMPTY_REQUEST,
form: { form: {
show_nsfw: false, show_nsfw: !!this.isoData.site_res.site_view.site.content_warning,
}, },
captchaPlaying: false, captchaPlaying: false,
siteRes: this.isoData.site_res, 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), allowed_instances: this.props.allowedInstances?.map(i => i.domain),
blocked_instances: this.props.blockedInstances?.map(i => i.domain), blocked_instances: this.props.blockedInstances?.map(i => i.domain),
blocked_urls: this.props.siteRes.blocked_urls.map(u => u.url), 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.handleInstanceTextChange = this.handleInstanceTextChange.bind(this);
this.handleBlockedUrlsUpdate = this.handleBlockedUrlsUpdate.bind(this); this.handleBlockedUrlsUpdate = this.handleBlockedUrlsUpdate.bind(this);
this.handleSiteContentWarningChange =
this.handleSiteContentWarningChange.bind(this);
} }
render() { render() {
@ -269,6 +272,26 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
</div> </div>
</div> </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="mb-3 row">
<div className="col-12"> <div className="col-12">
<label <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

@ -57,6 +57,8 @@ import {
GetPersonDetailsResponse, GetPersonDetailsResponse,
GetSiteResponse, GetSiteResponse,
LemmyHttp, LemmyHttp,
ListMedia,
ListMediaResponse,
LockPost, LockPost,
MarkCommentReplyAsRead, MarkCommentReplyAsRead,
MarkPersonMentionAsRead, MarkPersonMentionAsRead,
@ -96,13 +98,16 @@ import { PersonDetails } from "./person-details";
import { PersonListing } from "./person-listing"; import { PersonListing } from "./person-listing";
import { getHttpBaseInternal } from "../../utils/env"; import { getHttpBaseInternal } from "../../utils/env";
import { IRoutePropsWithFetch } from "../../routes"; import { IRoutePropsWithFetch } from "../../routes";
import { MediaUploads } from "../common/media-uploads";
type ProfileData = RouteDataResponse<{ type ProfileData = RouteDataResponse<{
personResponse: GetPersonDetailsResponse; personRes: GetPersonDetailsResponse;
uploadsRes: ListMediaResponse;
}>; }>;
interface ProfileState { interface ProfileState {
personRes: RequestState<GetPersonDetailsResponse>; personRes: RequestState<GetPersonDetailsResponse>;
uploadsRes: RequestState<ListMediaResponse>;
personBlocked: boolean; personBlocked: boolean;
banReason?: string; banReason?: string;
banExpireDays?: number; banExpireDays?: number;
@ -189,6 +194,7 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
private isoData = setIsoData<ProfileData>(this.context); private isoData = setIsoData<ProfileData>(this.context);
state: ProfileState = { state: ProfileState = {
personRes: EMPTY_REQUEST, personRes: EMPTY_REQUEST,
uploadsRes: EMPTY_REQUEST,
personBlocked: false, personBlocked: false,
siteRes: this.isoData.site_res, siteRes: this.isoData.site_res,
showBanDialog: false, showBanDialog: false,
@ -241,10 +247,12 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
// Only fetch the data if coming from another route // Only fetch the data if coming from another route
if (FirstLoadService.isFirstLoad) { if (FirstLoadService.isFirstLoad) {
const personRes = this.isoData.routeData.personResponse; const personRes = this.isoData.routeData.personRes;
const uploadsRes = this.isoData.routeData.uploadsRes;
this.state = { this.state = {
...this.state, ...this.state,
personRes, personRes,
uploadsRes,
isIsomorphic: true, isIsomorphic: true,
personBlocked: isPersonBlocked(personRes), personBlocked: isPersonBlocked(personRes),
}; };
@ -268,10 +276,21 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
page, page,
limit: fetchLimit, limit: fetchLimit,
}); });
this.setState({ this.setState({
personRes, personRes,
personBlocked: isPersonBlocked(personRes), personBlocked: isPersonBlocked(personRes),
}); });
if (view === PersonDetailsView.Uploads) {
this.setState({ uploadsRes: LOADING_REQUEST });
const form: ListMedia = {
page,
limit: fetchLimit,
};
const uploadsRes = await HttpService.client.listMedia(form);
this.setState({ uploadsRes });
}
} }
get amCurrentUser() { get amCurrentUser() {
@ -299,6 +318,16 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
new LemmyHttp(getHttpBaseInternal(), { headers }), new LemmyHttp(getHttpBaseInternal(), { headers }),
); );
let uploadsRes: RequestState<ListMediaResponse> = EMPTY_REQUEST;
if (view === PersonDetailsView.Uploads) {
const form: ListMedia = {
page,
limit: fetchLimit,
};
uploadsRes = await client.listMedia(form);
}
const form: GetPersonDetails = { const form: GetPersonDetails = {
username: username, username: username,
sort, sort,
@ -306,9 +335,11 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
page, page,
limit: fetchLimit, limit: fetchLimit,
}; };
const personRes = await client.getPersonDetails(form);
return { return {
personResponse: await client.getPersonDetails(form), personRes,
uploadsRes,
}; };
} }
@ -320,6 +351,25 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
: siteName; : siteName;
} }
renderUploadsRes() {
switch (this.state.uploadsRes.state) {
case "loading":
return (
<h5>
<Spinner large />
</h5>
);
case "success": {
const uploadsRes = this.state.uploadsRes.data;
return (
<div>
<MediaUploads uploads={uploadsRes} />
</div>
);
}
}
}
renderPersonRes() { renderPersonRes() {
switch (this.state.personRes.state) { switch (this.state.personRes.state) {
case "loading": case "loading":
@ -350,6 +400,8 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
{this.selects} {this.selects}
{this.renderUploadsRes()}
<PersonDetails <PersonDetails
personRes={personRes} personRes={personRes}
admins={siteRes.admins} admins={siteRes.admins}
@ -416,11 +468,12 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
get viewRadios() { get viewRadios() {
return ( return (
<div className="btn-group btn-group-toggle flex-wrap mb-2" role="group"> <div className="btn-group btn-group-toggle flex-wrap" role="group">
{this.getRadio(PersonDetailsView.Overview)} {this.getRadio(PersonDetailsView.Overview)}
{this.getRadio(PersonDetailsView.Comments)} {this.getRadio(PersonDetailsView.Comments)}
{this.getRadio(PersonDetailsView.Posts)} {this.getRadio(PersonDetailsView.Posts)}
{this.amCurrentUser && this.getRadio(PersonDetailsView.Saved)} {this.amCurrentUser && this.getRadio(PersonDetailsView.Saved)}
{this.getRadio(PersonDetailsView.Uploads)}
</div> </div>
); );
} }
@ -459,18 +512,22 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
const profileRss = `/feeds/u/${username}.xml${getQueryString({ sort })}`; const profileRss = `/feeds/u/${username}.xml${getQueryString({ sort })}`;
return ( return (
<div className="mb-2"> <div className="row align-items-center mb-3 g-3">
<span className="me-3">{this.viewRadios}</span> <div className="col-auto">{this.viewRadios}</div>
<SortSelect <div className="col-auto">
sort={sort} <SortSelect
onChange={this.handleSortChange} sort={sort}
hideHot onChange={this.handleSortChange}
hideMostComments hideHot
/> hideMostComments
<a href={profileRss} rel={relTags} title="RSS"> />
<Icon icon="rss" classes="text-muted small mx-2" /> </div>
</a> <div className="col-auto">
<link rel="alternate" type="application/atom+xml" href={profileRss} /> <a href={profileRss} rel={relTags} title="RSS">
<Icon icon="rss" classes="text-muted small ps-0" />
</a>
<link rel="alternate" type="application/atom+xml" href={profileRss} />
</div>
</div> </div>
); );
} }

View file

@ -931,7 +931,11 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
className="form-check-input" className="form-check-input"
id="user-blur-nsfw" id="user-blur-nsfw"
type="checkbox" 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)} onChange={linkEvent(this, this.handleBlurNsfwChange)}
/> />
<label className="form-check-label" htmlFor="user-blur-nsfw"> <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 { canShare, share } from "@utils/browser";
import { getExternalHost, getHttpBase } from "@utils/env"; import { getExternalHost, getHttpBase } from "@utils/env";
import { futureDaysToUnixTime, hostname } from "@utils/helpers"; import { futureDaysToUnixTime, hostname } from "@utils/helpers";
@ -34,7 +34,7 @@ import {
TransferCommunity, TransferCommunity,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { relTags } from "../../config"; import { relTags } from "../../config";
import { VoteContentType } from "../../interfaces"; import { IsoDataOptionalSite, VoteContentType } from "../../interfaces";
import { mdToHtml, mdToHtmlInline } from "../../markdown"; import { mdToHtml, mdToHtmlInline } from "../../markdown";
import { I18NextService, UserService } from "../../services"; import { I18NextService, UserService } from "../../services";
import { tippyMixin } from "../mixins/tippy-mixin"; import { tippyMixin } from "../mixins/tippy-mixin";
@ -100,9 +100,10 @@ interface PostListingProps {
@tippyMixin @tippyMixin
export class PostListing extends Component<PostListingProps, PostListingState> { export class PostListing extends Component<PostListingProps, PostListingState> {
private readonly isoData: IsoDataOptionalSite = setIsoData(this.context);
state: PostListingState = { state: PostListingState = {
showEdit: false, showEdit: false,
imageExpanded: false, imageExpanded: !!this.isoData.site_res?.site_view.site.content_warning,
viewSource: false, viewSource: false,
showAdvanced: false, showAdvanced: false,
showBody: false, showBody: false,

View file

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

View file

@ -16,6 +16,7 @@ export interface IsoData<T extends RouteData = any> {
routeData: T; routeData: T;
site_res: GetSiteResponse; site_res: GetSiteResponse;
errorPageData?: ErrorPageData; errorPageData?: ErrorPageData;
showAdultConsentModal: boolean;
} }
export type IsoDataOptionalSite<T extends RouteData = any> = Partial< export type IsoDataOptionalSite<T extends RouteData = any> = Partial<
@ -67,6 +68,7 @@ export enum PersonDetailsView {
Comments = "Comments", Comments = "Comments",
Posts = "Posts", Posts = "Posts",
Saved = "Saved", Saved = "Saved",
Uploads = "Uploads",
} }
export enum PurgeType { export enum PurgeType {

View file

@ -168,7 +168,7 @@ function bestDateFns(
return locale; return locale;
} }
} }
// Fallback to base langauge first, to avoid mixing languages. // Fallback to base language first, to avoid mixing languages.
return langToLocale(base_lang) ?? localeByCode[EN_US]; return langToLocale(base_lang) ?? localeByCode[EN_US];
} }