Merge branch 'main' into feat/create-post-file-upload-a11y

This commit is contained in:
Jay Sitter 2023-07-02 15:24:18 -04:00 committed by GitHub
commit 0bd0a49730
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 26776 additions and 3360 deletions

View file

@ -2,3 +2,4 @@ src/shared/translations
lemmy-translations lemmy-translations
src/assets/css/themes/*.css src/assets/css/themes/*.css
stats.json stats.json
dist

View file

@ -32,3 +32,14 @@ pipeline:
auto_tag: true auto_tag: true
when: when:
event: tag event: tag
nightly_build:
image: woodpeckerci/plugin-docker-buildx
secrets: [docker_username, docker_password]
settings:
repo: dessalines/lemmy-ui
dockerfile: Dockerfile
platforms: linux/amd64
tag: dev
when:
event: cron

View file

@ -27,7 +27,7 @@ COPY .git .git
RUN echo "export const VERSION = '$(git describe --tag)';" > "src/shared/version.ts" RUN echo "export const VERSION = '$(git describe --tag)';" > "src/shared/version.ts"
RUN yarn --production --prefer-offline RUN yarn --production --prefer-offline
RUN yarn build:prod RUN NODE_OPTIONS="--max-old-space-size=8192" yarn build:prod
# Prune the image # Prune the image
RUN node-prune /usr/src/app/node_modules RUN node-prune /usr/src/app/node_modules

View file

@ -20,6 +20,7 @@ COPY generate_translations.js \
COPY lemmy-translations lemmy-translations COPY lemmy-translations lemmy-translations
COPY src src COPY src src
COPY .git .git
# Set UI version # Set UI version
RUN echo "export const VERSION = 'dev';" > "src/shared/version.ts" RUN echo "export const VERSION = 'dev';" > "src/shared/version.ts"

View file

@ -1,6 +1,6 @@
{ {
"name": "lemmy-ui", "name": "lemmy-ui",
"version": "0.18.0", "version": "0.18.1-rc.7",
"description": "An isomorphic UI for lemmy", "description": "An isomorphic UI for lemmy",
"repository": "https://github.com/LemmyNet/lemmy-ui", "repository": "https://github.com/LemmyNet/lemmy-ui",
"license": "AGPL-3.0", "license": "AGPL-3.0",
@ -8,9 +8,9 @@
"scripts": { "scripts": {
"analyze": "webpack --mode=none", "analyze": "webpack --mode=none",
"prebuild:dev": "yarn clean && node generate_translations.js", "prebuild:dev": "yarn clean && node generate_translations.js",
"build:dev": "webpack --mode=development", "build:dev": "webpack --env COMMIT_HASH=$(git rev-parse --short HEAD) --mode=development",
"prebuild:prod": "yarn clean && node generate_translations.js", "prebuild:prod": "yarn clean && node generate_translations.js",
"build:prod": "webpack --mode=production", "build:prod": "webpack --env COMMIT_HASH=$(git rev-parse --short HEAD) --mode=production",
"clean": "yarn run rimraf dist", "clean": "yarn run rimraf dist",
"dev": "yarn build:dev --watch", "dev": "yarn build:dev --watch",
"lint": "yarn translations:generate && tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx \"src/**\" && prettier --check \"src/**/*.{ts,tsx,js,css,scss}\"", "lint": "yarn translations:generate && tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx \"src/**\" && prettier --check \"src/**/*.{ts,tsx,js,css,scss}\"",
@ -48,6 +48,7 @@
"check-password-strength": "^2.0.7", "check-password-strength": "^2.0.7",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"clean-webpack-plugin": "^4.0.0", "clean-webpack-plugin": "^4.0.0",
"cookie": "^0.5.0",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"cross-fetch": "^3.1.5", "cross-fetch": "^3.1.5",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",
@ -65,7 +66,6 @@
"inferno-i18next-dess": "0.0.2", "inferno-i18next-dess": "0.0.2",
"inferno-router": "^8.1.1", "inferno-router": "^8.1.1",
"inferno-server": "^8.1.1", "inferno-server": "^8.1.1",
"isomorphic-cookie": "^1.2.4",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lemmy-js-client": "0.18.0-rc.2", "lemmy-js-client": "0.18.0-rc.2",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
@ -97,6 +97,7 @@
"@babel/core": "^7.21.8", "@babel/core": "^7.21.8",
"@types/autosize": "^4.0.0", "@types/autosize": "^4.0.0",
"@types/bootstrap": "^5.2.6", "@types/bootstrap": "^5.2.6",
"@types/cookie": "^0.5.1",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/html-to-text": "^9.0.0", "@types/html-to-text": "^9.0.0",
"@types/lodash.isequal": "^4.5.6", "@types/lodash.isequal": "^4.5.6",
@ -125,6 +126,7 @@
"style-loader": "^3.3.2", "style-loader": "^3.3.2",
"terser": "^5.17.3", "terser": "^5.17.3",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"typescript-language-server": "^3.3.2",
"webpack-bundle-analyzer": "^4.9.0", "webpack-bundle-analyzer": "^4.9.0",
"webpack-dev-server": "4.15.0" "webpack-dev-server": "4.15.0"
}, },

View file

@ -81,6 +81,7 @@
} }
.vote-bar { .vote-bar {
min-width: 5ch;
margin-top: -6.5px; margin-top: -6.5px;
} }
@ -198,9 +199,9 @@ blockquote {
.thumbnail { .thumbnail {
object-fit: cover; object-fit: cover;
min-height: 60px; aspect-ratio: 1/1;
max-height: 80px; width: 5rem;
width: 100%; height: 5rem;
} }
.thumbnail svg { .thumbnail svg {
@ -360,8 +361,9 @@ br.big {
} }
.img-icon { .img-icon {
width: 2rem; width: calc(var(--bs-body-line-height) * 1em);
height: 2rem; height: calc(var(--bs-body-line-height) * 1em);
border-radius: 0.25em;
} }
.tribute-container ul { .tribute-container ul {

View file

@ -0,0 +1,117 @@
@import "./variables";
// Colors
$white: #f3f3f3;
$gray-200: #ebebeb;
$gray-300: #dee2e6;
$gray-500: #adb5bd;
$gray-600: #666;
$gray-700: #333;
$gray-800: #202020;
$gray-900: #111;
$black: #000;
$blue: #375a7f;
$red: #e74c3c;
$yellow: #f39c12;
$green: #00bc8c;
$cyan: #3498db;
$primary: $green;
$secondary: $gray-700;
$success: $green;
$dark: $gray-300;
$body-color: $gray-200;
$body-bg: $black;
$link-color: $success;
$border-color: rgba($body-color, 0.25);
$mark-bg: $gray-900;
$text-muted: $gray-600;
$yiq-contrasted-threshold: 175;
$font-family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol";
$font-size-base: 0.9375rem;
$h1-font-size: 3rem;
$h2-font-size: 2.5rem;
$h3-font-size: 2rem;
$card-cap-bg: $gray-900;
$card-bg: $gray-900;
$card-color: $gray-300;
$navbar-padding-y: 1rem;
$navbar-dark-color: rgba($white, 0.6);
$navbar-dark-hover-color: $white;
$navbar-light-color: rgba($white, 0.6);
$navbar-light-hover-color: $white;
$navbar-light-active-color: $white;
$navbar-light-toggler-border-color: rgba($gray-900, 0.1);
$navbar-light-brand-color: $white;
$navbar-light-brand-hover-color: $navbar-light-brand-color;
$nav-link-padding-x: 2rem;
$nav-link-disabled-color: $gray-500;
$nav-tabs-border-color: $gray-700;
$nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color
transparent;
$nav-tabs-link-active-color: $white;
$nav-tabs-link-active-border-color: $nav-tabs-border-color
$nav-tabs-border-color transparent;
$input-bg: $gray-900;
$input-color: $white;
$input-disabled-bg: darken($gray-900, 20%);
$input-border-color: $gray-800;
$input-group-addon-color: $gray-800;
$input-group-addon-bg: $gray-800;
$hr-border-color: rgba($body-color, 0.25);
$table-border-color: $gray-700;
$custom-file-color: $gray-500;
$custom-file-border-color: $body-bg;
$dropdown-bg: $gray-900;
$dropdown-border-color: $gray-800;
$dropdown-divider-bg: $gray-700;
$dropdown-link-color: $white;
$dropdown-link-hover-color: $white;
$dropdown-link-hover-bg: $primary;
$pagination-color: $white;
$pagination-bg: $success;
$pagination-border-width: 0;
$pagination-border-color: transparent;
$pagination-hover-color: $white;
$pagination-hover-bg: lighten($success, 10%);
$pagination-hover-border-color: transparent;
$pagination-active-bg: $pagination-hover-bg;
$pagination-active-border-color: transparent;
$pagination-disabled-color: $white;
$pagination-disabled-bg: darken($success, 15%);
$pagination-disabled-border-color: transparent;
$jumbotron-bg: $gray-900;
$popover-bg: $gray-900;
$popover-header-bg: $gray-900;
$toast-background-color: $gray-800;
$toast-header-background-color: $gray-900;
$modal-content-bg: $gray-800;
$modal-content-border-color: $gray-700;
$modal-header-border-color: $gray-700;
$progress-bg: $gray-700;
$list-group-bg: $gray-800;
$list-group-border-color: $gray-700;
$list-group-hover-bg: $gray-700;
$breadcrumb-bg: $gray-700;
$close-color: $white;
$close-text-shadow: none;
$pre-color: inherit;
$custom-select-bg: $gray-700;
$custom-select-color: $white;
$light: $gray-900;

View file

@ -0,0 +1,58 @@
@import "./variables";
// Colors
$white: #fff;
$gray-100: #f8f9fa;
$gray-200: #ebebeb;
$gray-300: #bbb;
$gray-500: #adb5bd;
$gray-800: #303030;
$gray-900: #222;
$blue: #5555ff;
$cyan: #55ffff;
$green: #55ff55;
$indigo: #ff55ff;
$red: #ff5555;
$yellow: #fefe54;
$orange: #a85400;
$pink: #fe54fe;
$purple: #fe5454;
$primary: #fefe54;
$secondary: $gray-900;
$success: #00aa00;
$danger: #aa0000;
$info: #00aaaa;
$warning: #aa00aa;
$light: $gray-800;
$dark: black;
$body-bg: #000084;
$body-color: $gray-300;
$link-hover-color: $white;
$font-family-sans-serif: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
$font-family-monospace: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
$navbar-dark-color: $gray-300;
$navbar-light-brand-color: $gray-300;
$navbar-dark-active-color: $gray-100;
$nav-tabs-link-active-color: $gray-100;
$navbar-dark-hover-color: rgba($gray-300, 0.75);
$navbar-light-disabled-color: $gray-800;
$navbar-light-active-color: $gray-100;
$navbar-light-hover-color: $gray-200;
$navbar-light-color: $gray-300;
$enable-rounded: false;
$input-color: $white;
$input-bg: rgb(102, 102, 102);
$input-placeholder-color: $gray-500;
$input-disabled-bg: $gray-800;
$card-bg: $gray-800;
$card-border-color: $white;
$mark-bg: #463b00;

View file

@ -1,7 +1,3 @@
$link-decoration: none; $link-decoration: none;
$min-contrast-ratio: 3; $min-contrast-ratio: 3;
$font-size-base: 0.875rem; $font-size-base: 0.875rem;
$container-max-widths: (
lg: 1140px,
);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
@import "variables.darkly-pureblack";
@import "../../../../node_modules/bootstrap/scss/bootstrap";

View file

@ -726,7 +726,11 @@ progress {
.container, .container,
.container-fluid, .container-fluid,
.container-lg { .container-xxl,
.container-xl,
.container-lg,
.container-md,
.container-sm {
--bs-gutter-x: 1.5rem; --bs-gutter-x: 1.5rem;
--bs-gutter-y: 0; --bs-gutter-y: 0;
width: 100%; width: 100%;
@ -736,11 +740,31 @@ progress {
margin-left: auto; margin-left: auto;
} }
@media (min-width: 576px) {
.container-sm, .container {
max-width: 540px;
}
}
@media (min-width: 768px) {
.container-md, .container-sm, .container {
max-width: 720px;
}
}
@media (min-width: 992px) { @media (min-width: 992px) {
.container-lg, .container-md, .container-sm, .container { .container-lg, .container-md, .container-sm, .container {
max-width: 960px;
}
}
@media (min-width: 1200px) {
.container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1140px; max-width: 1140px;
} }
} }
@media (min-width: 1400px) {
.container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1320px;
}
}
:root { :root {
--bs-breakpoint-xs: 0; --bs-breakpoint-xs: 0;
--bs-breakpoint-sm: 576px; --bs-breakpoint-sm: 576px;
@ -3867,7 +3891,11 @@ textarea.form-control-lg {
} }
.navbar > .container, .navbar > .container,
.navbar > .container-fluid, .navbar > .container-fluid,
.navbar > .container-lg { .navbar > .container-sm,
.navbar > .container-md,
.navbar > .container-lg,
.navbar > .container-xl,
.navbar > .container-xxl {
display: flex; display: flex;
flex-wrap: inherit; flex-wrap: inherit;
align-items: center; align-items: center;

View file

@ -726,7 +726,11 @@ progress {
.container, .container,
.container-fluid, .container-fluid,
.container-lg { .container-xxl,
.container-xl,
.container-lg,
.container-md,
.container-sm {
--bs-gutter-x: 1.5rem; --bs-gutter-x: 1.5rem;
--bs-gutter-y: 0; --bs-gutter-y: 0;
width: 100%; width: 100%;
@ -736,11 +740,31 @@ progress {
margin-left: auto; margin-left: auto;
} }
@media (min-width: 576px) {
.container-sm, .container {
max-width: 540px;
}
}
@media (min-width: 768px) {
.container-md, .container-sm, .container {
max-width: 720px;
}
}
@media (min-width: 992px) { @media (min-width: 992px) {
.container-lg, .container-md, .container-sm, .container { .container-lg, .container-md, .container-sm, .container {
max-width: 960px;
}
}
@media (min-width: 1200px) {
.container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1140px; max-width: 1140px;
} }
} }
@media (min-width: 1400px) {
.container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1320px;
}
}
:root { :root {
--bs-breakpoint-xs: 0; --bs-breakpoint-xs: 0;
--bs-breakpoint-sm: 576px; --bs-breakpoint-sm: 576px;
@ -3867,7 +3891,11 @@ textarea.form-control-lg {
} }
.navbar > .container, .navbar > .container,
.navbar > .container-fluid, .navbar > .container-fluid,
.navbar > .container-lg { .navbar > .container-sm,
.navbar > .container-md,
.navbar > .container-lg,
.navbar > .container-xl,
.navbar > .container-xxl {
display: flex; display: flex;
flex-wrap: inherit; flex-wrap: inherit;
align-items: center; align-items: center;

11594
src/assets/css/themes/i386.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
@import "variables.i386";
@import "../../../../node_modules/bootstrap/scss/bootstrap";
.btn-outline-secondary {
color: $gray-500;
}
.dropdown-item.active,
.dropdown-item:hover,
option:disabled {
color: $secondary;
}
.input-group-text {
background: $gray-500;
}

View file

@ -725,7 +725,11 @@ progress {
.container, .container,
.container-fluid, .container-fluid,
.container-lg { .container-xxl,
.container-xl,
.container-lg,
.container-md,
.container-sm {
--bs-gutter-x: 1.5rem; --bs-gutter-x: 1.5rem;
--bs-gutter-y: 0; --bs-gutter-y: 0;
width: 100%; width: 100%;
@ -735,11 +739,31 @@ progress {
margin-left: auto; margin-left: auto;
} }
@media (min-width: 576px) {
.container-sm, .container {
max-width: 540px;
}
}
@media (min-width: 768px) {
.container-md, .container-sm, .container {
max-width: 720px;
}
}
@media (min-width: 992px) { @media (min-width: 992px) {
.container-lg, .container-md, .container-sm, .container { .container-lg, .container-md, .container-sm, .container {
max-width: 960px;
}
}
@media (min-width: 1200px) {
.container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1140px; max-width: 1140px;
} }
} }
@media (min-width: 1400px) {
.container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1320px;
}
}
:root { :root {
--bs-breakpoint-xs: 0; --bs-breakpoint-xs: 0;
--bs-breakpoint-sm: 576px; --bs-breakpoint-sm: 576px;
@ -3866,7 +3890,11 @@ textarea.form-control-lg {
} }
.navbar > .container, .navbar > .container,
.navbar > .container-fluid, .navbar > .container-fluid,
.navbar > .container-lg { .navbar > .container-sm,
.navbar > .container-md,
.navbar > .container-lg,
.navbar > .container-xl,
.navbar > .container-xxl {
display: flex; display: flex;
flex-wrap: inherit; flex-wrap: inherit;
align-items: center; align-items: center;

View file

@ -725,7 +725,11 @@ progress {
.container, .container,
.container-fluid, .container-fluid,
.container-lg { .container-xxl,
.container-xl,
.container-lg,
.container-md,
.container-sm {
--bs-gutter-x: 1.5rem; --bs-gutter-x: 1.5rem;
--bs-gutter-y: 0; --bs-gutter-y: 0;
width: 100%; width: 100%;
@ -735,11 +739,31 @@ progress {
margin-left: auto; margin-left: auto;
} }
@media (min-width: 576px) {
.container-sm, .container {
max-width: 540px;
}
}
@media (min-width: 768px) {
.container-md, .container-sm, .container {
max-width: 720px;
}
}
@media (min-width: 992px) { @media (min-width: 992px) {
.container-lg, .container-md, .container-sm, .container { .container-lg, .container-md, .container-sm, .container {
max-width: 960px;
}
}
@media (min-width: 1200px) {
.container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1140px; max-width: 1140px;
} }
} }
@media (min-width: 1400px) {
.container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1320px;
}
}
:root { :root {
--bs-breakpoint-xs: 0; --bs-breakpoint-xs: 0;
--bs-breakpoint-sm: 576px; --bs-breakpoint-sm: 576px;
@ -3866,7 +3890,11 @@ textarea.form-control-lg {
} }
.navbar > .container, .navbar > .container,
.navbar > .container-fluid, .navbar > .container-fluid,
.navbar > .container-lg { .navbar > .container-sm,
.navbar > .container-md,
.navbar > .container-lg,
.navbar > .container-xl,
.navbar > .container-xxl {
display: flex; display: flex;
flex-wrap: inherit; flex-wrap: inherit;
align-items: center; align-items: center;

View file

@ -1,11 +1,11 @@
import { initializeSite, isAuthPath } from "@utils/app"; import { initializeSite, isAuthPath } from "@utils/app";
import { getHttpBaseInternal } from "@utils/env"; import { getHttpBaseInternal } from "@utils/env";
import { ErrorPageData } from "@utils/types"; import { ErrorPageData } from "@utils/types";
import * as cookie from "cookie";
import fetch from "cross-fetch"; import fetch from "cross-fetch";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { StaticRouter, matchPath } from "inferno-router"; import { StaticRouter, matchPath } from "inferno-router";
import { renderToString } from "inferno-server"; import { renderToString } from "inferno-server";
import IsomorphicCookie from "isomorphic-cookie";
import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client"; import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client";
import { App } from "../../shared/components/app/app"; import { App } from "../../shared/components/app/app";
import { import {
@ -25,11 +25,15 @@ import { setForwardedHeaders } from "../utils/set-forwarded-headers";
export default async (req: Request, res: Response) => { export default async (req: Request, res: Response) => {
try { try {
const activeRoute = routes.find(route => matchPath(req.path, route)); const activeRoute = routes.find(route => matchPath(req.path, route));
let auth: string | undefined = IsomorphicCookie.load("jwt", req);
let auth = req.headers.cookie
? cookie.parse(req.headers.cookie).jwt
: undefined;
const getSiteForm: GetSite = { auth }; const getSiteForm: GetSite = { auth };
const headers = setForwardedHeaders(req.headers); const headers = setForwardedHeaders(req.headers);
const client = wrapClient( const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { fetchFunction: fetch, headers }) new LemmyHttp(getHttpBaseInternal(), { fetchFunction: fetch, headers })
); );
@ -43,6 +47,7 @@ export default async (req: Request, res: Response) => {
let routeData: RouteData = {}; let routeData: RouteData = {};
let errorPageData: ErrorPageData | undefined = undefined; let errorPageData: ErrorPageData | undefined = undefined;
let try_site = await client.getSite(getSiteForm); let try_site = await client.getSite(getSiteForm);
if (try_site.state === "failed" && try_site.msg == "not_logged_in") { if (try_site.state === "failed" && try_site.msg == "not_logged_in") {
console.error( console.error(
"Incorrect JWT token, skipping auth so frontend can remove jwt cookie" "Incorrect JWT token, skipping auth so frontend can remove jwt cookie"
@ -75,7 +80,12 @@ export default async (req: Request, res: Response) => {
routeData = await activeRoute.fetchInitialData(initialFetchReq); routeData = await activeRoute.fetchInitialData(initialFetchReq);
} }
if (!activeRoute) {
res.status(404);
}
} else if (try_site.state === "failed") { } else if (try_site.state === "failed") {
res.status(500);
errorPageData = getErrorPageData(new Error(try_site.msg), site); errorPageData = getErrorPageData(new Error(try_site.msg), site);
} }
@ -86,9 +96,11 @@ export default async (req: Request, res: Response) => {
// Redirect to the 404 if there's an API error // Redirect to the 404 if there's an API error
if (error) { if (error) {
console.error(error.msg); console.error(error.msg);
if (error.msg === "instance_is_private") { if (error.msg === "instance_is_private") {
return res.redirect(`/signup`); return res.redirect(`/signup`);
} else { } else {
res.status(500);
errorPageData = getErrorPageData(new Error(error.msg), site); errorPageData = getErrorPageData(new Error(error.msg), site);
} }
} }
@ -113,6 +125,7 @@ export default async (req: Request, res: Response) => {
// If an error is caught here, the error page couldn't even be rendered // If an error is caught here, the error page couldn't even be rendered
console.error(err); console.error(err);
res.statusCode = 500; res.statusCode = 500;
return res.send( return res.send(
process.env.NODE_ENV === "development" ? err.message : "Server error" process.env.NODE_ENV === "development" ? err.message : "Server error"
); );

View file

@ -0,0 +1,17 @@
import type { Response } from "express";
export default async ({ res }: { res: Response }) => {
res.setHeader("content-type", "text/plain; charset=utf-8");
res.send(
`Contact: mailto:security@lemmy.ml
Contact: mailto:admin@` +
process.env.LEMMY_UI_LEMMY_EXTERNAL_HOST +
`
Contact: mailto:security@` +
process.env.LEMMY_UI_LEMMY_EXTERNAL_HOST +
`
Expires: 2024-01-01T04:59:00.000Z
`
);
};

View file

@ -1,14 +1,16 @@
import { setupDateFns } from "@utils/app"; import { setupDateFns } from "@utils/app";
import { getStaticDir } from "@utils/env";
import express from "express"; import express from "express";
import path from "path"; import path from "path";
import process from "process"; import process from "process";
import CatchAllHandler from "./handlers/catch-all-handler"; import CatchAllHandler from "./handlers/catch-all-handler";
import ManifestHandler from "./handlers/manifest-handler"; import ManifestHandler from "./handlers/manifest-handler";
import RobotsHandler from "./handlers/robots-handler"; import RobotsHandler from "./handlers/robots-handler";
import SecurityHandler from "./handlers/security-handler";
import ServiceWorkerHandler from "./handlers/service-worker-handler"; import ServiceWorkerHandler from "./handlers/service-worker-handler";
import ThemeHandler from "./handlers/theme-handler"; import ThemeHandler from "./handlers/theme-handler";
import ThemesListHandler from "./handlers/themes-list-handler"; import ThemesListHandler from "./handlers/themes-list-handler";
import setDefaultCsp from "./middleware/set-default-csp"; import { setCacheControl, setDefaultCsp } from "./middleware";
const server = express(); const server = express();
@ -18,12 +20,20 @@ const [hostname, port] = process.env["LEMMY_UI_HOST"]
server.use(express.json()); server.use(express.json());
server.use(express.urlencoded({ extended: false })); server.use(express.urlencoded({ extended: false }));
server.use("/static", express.static(path.resolve("./dist"))); server.use(
getStaticDir(),
express.static(path.resolve("./dist"), {
maxAge: 24 * 60 * 60 * 1000, // 1 day
immutable: true,
})
);
server.use(setCacheControl);
if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) { if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) {
server.use(setDefaultCsp); server.use(setDefaultCsp);
} }
server.get("/.well-known/security.txt", SecurityHandler);
server.get("/robots.txt", RobotsHandler); server.get("/robots.txt", RobotsHandler);
server.get("/service-worker.js", ServiceWorkerHandler); server.get("/service-worker.js", ServiceWorkerHandler);
server.get("/manifest.webmanifest", ManifestHandler); server.get("/manifest.webmanifest", ManifestHandler);

53
src/server/middleware.ts Normal file
View file

@ -0,0 +1,53 @@
import type { NextFunction, Request, Response } from "express";
import { hasJwtCookie } from "./utils/has-jwt-cookie";
export function setDefaultCsp({
res,
next,
}: {
res: Response;
next: NextFunction;
}) {
res.setHeader(
"Content-Security-Policy",
`default-src 'self'; manifest-src *; connect-src *; img-src * data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'self'; frame-src *; media-src * data:`
);
next();
}
// Set cache-control headers. If user is logged in, set `private` to prevent storing data in
// shared caches (eg nginx) and leaking of private data. If user is not logged in, allow caching
// all responses for 5 seconds to reduce load on backend and database. The specific cache
// interval is rather arbitrary and could be set higher (less server load) or lower (fresher data).
//
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
export function setCacheControl(
req: Request,
res: Response,
next: NextFunction
) {
if (process.env.NODE_ENV !== "production") {
return next();
}
let caching: string;
if (
req.path.match(/\.(js|css|txt|manifest\.webmanifest)\/?$/) ||
req.path.includes("/css/themelist")
) {
// Static content gets cached publicly for a day
caching = "public, max-age=86400";
} else {
if (hasJwtCookie(req)) {
caching = "private";
} else {
caching = "public, max-age=5";
}
}
res.setHeader("Cache-Control", caching);
next();
}

View file

@ -1,10 +0,0 @@
import type { NextFunction, Response } from "express";
export default function ({ res, next }: { res: Response; next: NextFunction }) {
res.setHeader(
"Content-Security-Policy",
`default-src 'self'; manifest-src *; connect-src *; img-src * data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'self'; frame-src *; media-src * data:`
);
next();
}

View file

@ -8,9 +8,11 @@ const themes: ReadonlyArray<string> = [
"darkly", "darkly",
"darkly-red", "darkly-red",
"darkly-compact", "darkly-compact",
"darkly-pureblack",
"litely", "litely",
"litely-red", "litely-red",
"litely-compact", "litely-compact",
"i386",
]; ];
export async function buildThemeList(): Promise<ReadonlyArray<string>> { export async function buildThemeList(): Promise<ReadonlyArray<string>> {

View file

@ -1,3 +1,4 @@
import { getStaticDir } from "@utils/env";
import { Helmet } from "inferno-helmet"; import { Helmet } from "inferno-helmet";
import { renderToString } from "inferno-server"; import { renderToString } from "inferno-server";
import serialize from "serialize-javascript"; import serialize from "serialize-javascript";
@ -23,7 +24,7 @@ export async function createSsrHtml(
if (!appleTouchIcon) { if (!appleTouchIcon) {
appleTouchIcon = site?.site_view.site.icon appleTouchIcon = site?.site_view.site.icon
? `data:image/png;base64,${sharp( ? `data:image/png;base64,${await sharp(
await fetchIconPng(site.site_view.site.icon) await fetchIconPng(site.site_view.site.icon)
) )
.resize(180, 180) .resize(180, 180)
@ -87,7 +88,7 @@ export async function createSsrHtml(
<link rel="apple-touch-startup-image" href=${appleTouchIcon} /> <link rel="apple-touch-startup-image" href=${appleTouchIcon} />
<!-- Styles --> <!-- Styles -->
<link rel="stylesheet" type="text/css" href="/static/styles/styles.css" /> <link rel="stylesheet" type="text/css" href="${getStaticDir()}/styles/styles.css" />
<!-- Current theme and more --> <!-- Current theme and more -->
${helmet.link.toString() || fallbackTheme} ${helmet.link.toString() || fallbackTheme}
@ -102,7 +103,7 @@ export async function createSsrHtml(
</noscript> </noscript>
<div id='root'>${root}</div> <div id='root'>${root}</div>
<script defer src='/static/js/client.js'></script> <script defer src='${getStaticDir()}/js/client.js'></script>
</body> </body>
</html> </html>
`; `;

View file

@ -0,0 +1,6 @@
import * as cookie from "cookie";
import type { Request } from "express";
export function hasJwtCookie(req: Request): boolean {
return Boolean(cookie.parse(req.headers.cookie ?? "").jwt?.length);
}

View file

@ -79,256 +79,246 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
const siteView = this.props.siteRes?.site_view; const siteView = this.props.siteRes?.site_view;
const person = UserService.Instance.myUserInfo?.local_user_view.person; const person = UserService.Instance.myUserInfo?.local_user_view.person;
return ( return (
<nav <div className="shadow-sm">
className="navbar navbar-expand-md navbar-light shadow-sm p-0 px-3 container-lg" <nav
id="navbar" className="navbar navbar-expand-md navbar-light p-0 px-3 container-lg"
> id="navbar"
<NavLink
id="navTitle"
to="/"
title={siteView?.site.description ?? siteView?.site.name}
className="d-flex align-items-center navbar-brand me-md-3"
onMouseUp={linkEvent(this, handleCollapseClick)}
> >
{siteView?.site.icon && showAvatars() && ( <NavLink
<PictrsImage src={siteView.site.icon} icon /> id="navTitle"
)} to="/"
{siteView?.site.name} title={siteView?.site.description ?? siteView?.site.name}
</NavLink> className="d-flex align-items-center navbar-brand me-md-3"
{person && ( onMouseUp={linkEvent(this, handleCollapseClick)}
<ul className="navbar-nav d-flex flex-row ms-auto d-md-none"> >
<li id="navMessages" className="nav-item nav-item-icon"> {siteView?.site.icon && showAvatars() && (
<NavLink <PictrsImage src={siteView.site.icon} icon />
to="/inbox" )}
className="p-1 nav-link border-0 nav-messages" {siteView?.site.name}
title={I18NextService.i18n.t("unread_messages", { </NavLink>
count: Number(this.state.unreadApplicationCountRes.state), {person && (
formattedCount: numToSI(this.unreadInboxCount), <ul className="navbar-nav d-flex flex-row ms-auto d-md-none">
})} <li id="navMessages" className="nav-item nav-item-icon">
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="bell" />
{this.unreadInboxCount > 0 && (
<span className="mx-1 badge text-bg-light">
{numToSI(this.unreadInboxCount)}
</span>
)}
</NavLink>
</li>
{this.moderatesSomething && (
<li className="nav-item nav-item-icon">
<NavLink <NavLink
to="/reports" to="/inbox"
className="p-1 nav-link border-0" className="p-1 nav-link border-0 nav-messages"
title={I18NextService.i18n.t("unread_reports", { title={I18NextService.i18n.t("unread_messages", {
count: Number(this.unreadReportCount), count: Number(this.state.unreadApplicationCountRes.state),
formattedCount: numToSI(this.unreadReportCount), formattedCount: numToSI(this.unreadInboxCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="shield" /> <Icon icon="bell" />
{this.unreadReportCount > 0 && ( {this.unreadInboxCount > 0 && (
<span className="mx-1 badge text-bg-light"> <span className="mx-1 badge text-bg-light">
{numToSI(this.unreadReportCount)} {numToSI(this.unreadInboxCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
</li> </li>
)} {this.moderatesSomething && (
{amAdmin() && ( <li className="nav-item nav-item-icon">
<li className="nav-item nav-item-icon">
<NavLink
to="/registration_applications"
className="p-1 nav-link border-0"
title={I18NextService.i18n.t(
"unread_registration_applications",
{
count: Number(this.unreadApplicationCount),
formattedCount: numToSI(this.unreadApplicationCount),
}
)}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="clipboard" />
{this.unreadApplicationCount > 0 && (
<span className="mx-1 badge text-bg-light">
{numToSI(this.unreadApplicationCount)}
</span>
)}
</NavLink>
</li>
)}
</ul>
)}
<button
className="navbar-toggler border-0 p-1"
type="button"
aria-label="menu"
data-tippy-content={I18NextService.i18n.t("expand_here")}
data-bs-toggle="collapse"
data-bs-target="#navbarDropdown"
aria-controls="navbarDropdown"
aria-expanded="false"
ref={this.collapseButtonRef}
>
<Icon icon="menu" />
</button>
<div
className="collapse navbar-collapse my-2"
id="navbarDropdown"
ref={this.mobileMenuRef}
>
<ul id="navbarLinks" className="me-auto navbar-nav">
<li className="nav-item">
<NavLink
to="/communities"
className="nav-link"
title={I18NextService.i18n.t("communities")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
{I18NextService.i18n.t("communities")}
</NavLink>
</li>
<li className="nav-item">
{/* TODO make sure this works: https://github.com/infernojs/inferno/issues/1608 */}
<NavLink
to={{
pathname: "/create_post",
search: "",
hash: "",
key: "",
state: { prevPath: this.currentLocation },
}}
className="nav-link"
title={I18NextService.i18n.t("create_post")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
{I18NextService.i18n.t("create_post")}
</NavLink>
</li>
{this.props.siteRes && canCreateCommunity(this.props.siteRes) && (
<li className="nav-item">
<NavLink
to="/create_community"
className="nav-link"
title={I18NextService.i18n.t("create_community")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
{I18NextService.i18n.t("create_community")}
</NavLink>
</li>
)}
<li className="nav-item">
<a
className="nav-link d-inline-flex align-items-center d-md-inline-block"
title={I18NextService.i18n.t("support_lemmy")}
href={donateLemmyUrl}
>
<Icon icon="heart" classes="small" />
<span className="d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("support_lemmy")}
</span>
</a>
</li>
</ul>
<ul id="navbarIcons" className="navbar-nav">
<li id="navSearch" className="nav-item">
<NavLink
to="/search"
className="nav-link d-inline-flex align-items-center d-md-inline-block"
title={I18NextService.i18n.t("search")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="search" />
<span className="d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("search")}
</span>
</NavLink>
</li>
{amAdmin() && (
<li id="navAdmin" className="nav-item">
<NavLink
to="/admin"
className="nav-link d-inline-flex align-items-center d-md-inline-block"
title={I18NextService.i18n.t("admin_settings")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="settings" />
<span className="d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("admin_settings")}
</span>
</NavLink>
</li>
)}
{person ? (
<>
<li id="navMessages" className="nav-item">
<NavLink <NavLink
className="nav-link d-inline-flex align-items-center d-md-inline-block" to="/reports"
to="/inbox" className="p-1 nav-link border-0"
title={I18NextService.i18n.t("unread_messages", { title={I18NextService.i18n.t("unread_reports", {
count: Number(this.unreadInboxCount), count: Number(this.unreadReportCount),
formattedCount: numToSI(this.unreadInboxCount), formattedCount: numToSI(this.unreadReportCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="bell" /> <Icon icon="shield" />
<span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0"> {this.unreadReportCount > 0 && (
{I18NextService.i18n.t("unread_messages", {
count: Number(this.unreadInboxCount),
formattedCount: numToSI(this.unreadInboxCount),
})}
</span>
{this.unreadInboxCount > 0 && (
<span className="mx-1 badge text-bg-light"> <span className="mx-1 badge text-bg-light">
{numToSI(this.unreadInboxCount)} {numToSI(this.unreadReportCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
</li> </li>
{this.moderatesSomething && ( )}
<li id="navModeration" className="nav-item"> {amAdmin() && (
<li className="nav-item nav-item-icon">
<NavLink
to="/registration_applications"
className="p-1 nav-link border-0"
title={I18NextService.i18n.t(
"unread_registration_applications",
{
count: Number(this.unreadApplicationCount),
formattedCount: numToSI(this.unreadApplicationCount),
}
)}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="clipboard" />
{this.unreadApplicationCount > 0 && (
<span className="mx-1 badge text-bg-light">
{numToSI(this.unreadApplicationCount)}
</span>
)}
</NavLink>
</li>
)}
</ul>
)}
<button
className="navbar-toggler border-0 p-1"
type="button"
aria-label="menu"
data-tippy-content={I18NextService.i18n.t("expand_here")}
data-bs-toggle="collapse"
data-bs-target="#navbarDropdown"
aria-controls="navbarDropdown"
aria-expanded="false"
ref={this.collapseButtonRef}
>
<Icon icon="menu" />
</button>
<div
className="collapse navbar-collapse my-2"
id="navbarDropdown"
ref={this.mobileMenuRef}
>
<ul id="navbarLinks" className="me-auto navbar-nav">
<li className="nav-item">
<NavLink
to="/communities"
className="nav-link"
title={I18NextService.i18n.t("communities")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
{I18NextService.i18n.t("communities")}
</NavLink>
</li>
<li className="nav-item">
{/* TODO make sure this works: https://github.com/infernojs/inferno/issues/1608 */}
<NavLink
to={{
pathname: "/create_post",
search: "",
hash: "",
key: "",
state: { prevPath: this.currentLocation },
}}
className="nav-link"
title={I18NextService.i18n.t("create_post")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
{I18NextService.i18n.t("create_post")}
</NavLink>
</li>
{this.props.siteRes && canCreateCommunity(this.props.siteRes) && (
<li className="nav-item">
<NavLink
to="/create_community"
className="nav-link"
title={I18NextService.i18n.t("create_community")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
{I18NextService.i18n.t("create_community")}
</NavLink>
</li>
)}
<li className="nav-item">
<a
className="nav-link d-inline-flex align-items-center d-md-inline-block"
title={I18NextService.i18n.t("support_lemmy")}
href={donateLemmyUrl}
>
<Icon icon="heart" classes="small" />
<span className="d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("support_lemmy")}
</span>
</a>
</li>
</ul>
<ul id="navbarIcons" className="navbar-nav">
<li id="navSearch" className="nav-item">
<NavLink
to="/search"
className="nav-link d-inline-flex align-items-center d-md-inline-block"
title={I18NextService.i18n.t("search")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="search" />
<span className="d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("search")}
</span>
</NavLink>
</li>
{amAdmin() && (
<li id="navAdmin" className="nav-item">
<NavLink
to="/admin"
className="nav-link d-inline-flex align-items-center d-md-inline-block"
title={I18NextService.i18n.t("admin_settings")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="settings" />
<span className="d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("admin_settings")}
</span>
</NavLink>
</li>
)}
{person ? (
<>
<li id="navMessages" className="nav-item">
<NavLink <NavLink
className="nav-link d-inline-flex align-items-center d-md-inline-block" className="nav-link d-inline-flex align-items-center d-md-inline-block"
to="/reports" to="/inbox"
title={I18NextService.i18n.t("unread_reports", { title={I18NextService.i18n.t("unread_messages", {
count: Number(this.unreadReportCount), count: Number(this.unreadInboxCount),
formattedCount: numToSI(this.unreadReportCount), formattedCount: numToSI(this.unreadInboxCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="shield" /> <Icon icon="bell" />
<span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0"> <span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("unread_reports", { {I18NextService.i18n.t("unread_messages", {
count: Number(this.unreadReportCount), count: Number(this.unreadInboxCount),
formattedCount: numToSI(this.unreadReportCount), formattedCount: numToSI(this.unreadInboxCount),
})} })}
</span> </span>
{this.unreadReportCount > 0 && ( {this.unreadInboxCount > 0 && (
<span className="mx-1 badge text-bg-light"> <span className="mx-1 badge text-bg-light">
{numToSI(this.unreadReportCount)} {numToSI(this.unreadInboxCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
</li> </li>
)} {this.moderatesSomething && (
{amAdmin() && ( <li id="navModeration" className="nav-item">
<li id="navApplications" className="nav-item"> <NavLink
<NavLink className="nav-link d-inline-flex align-items-center d-md-inline-block"
to="/registration_applications" to="/reports"
className="nav-link d-inline-flex align-items-center d-md-inline-block" title={I18NextService.i18n.t("unread_reports", {
title={I18NextService.i18n.t( count: Number(this.unreadReportCount),
"unread_registration_applications", formattedCount: numToSI(this.unreadReportCount),
{ })}
count: Number(this.unreadApplicationCount), onMouseUp={linkEvent(this, handleCollapseClick)}
formattedCount: numToSI(this.unreadApplicationCount), >
} <Icon icon="shield" />
)} <span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0">
onMouseUp={linkEvent(this, handleCollapseClick)} {I18NextService.i18n.t("unread_reports", {
> count: Number(this.unreadReportCount),
<Icon icon="clipboard" /> formattedCount: numToSI(this.unreadReportCount),
<span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0"> })}
{I18NextService.i18n.t( </span>
{this.unreadReportCount > 0 && (
<span className="mx-1 badge text-bg-light">
{numToSI(this.unreadReportCount)}
</span>
)}
</NavLink>
</li>
)}
{amAdmin() && (
<li id="navApplications" className="nav-item">
<NavLink
to="/registration_applications"
className="nav-link d-inline-flex align-items-center d-md-inline-block"
title={I18NextService.i18n.t(
"unread_registration_applications", "unread_registration_applications",
{ {
count: Number(this.unreadApplicationCount), count: Number(this.unreadApplicationCount),
@ -337,97 +327,111 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
), ),
} }
)} )}
</span> onMouseUp={linkEvent(this, handleCollapseClick)}
{this.unreadApplicationCount > 0 && ( >
<span className="mx-1 badge text-bg-light"> <Icon icon="clipboard" />
{numToSI(this.unreadApplicationCount)} <span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t(
"unread_registration_applications",
{
count: Number(this.unreadApplicationCount),
formattedCount: numToSI(
this.unreadApplicationCount
),
}
)}
</span> </span>
)} {this.unreadApplicationCount > 0 && (
<span className="mx-1 badge text-bg-light">
{numToSI(this.unreadApplicationCount)}
</span>
)}
</NavLink>
</li>
)}
{person && (
<li id="dropdownUser" className="dropdown">
<button
type="button"
className="btn dropdown-toggle"
aria-expanded="false"
data-bs-toggle="dropdown"
>
{showAvatars() && person.avatar && (
<PictrsImage src={person.avatar} icon />
)}
{person.display_name ?? person.name}
</button>
<ul
className="dropdown-menu"
style={{ "min-width": "fit-content" }}
>
<li>
<NavLink
to={`/u/${person.name}`}
className="dropdown-item px-2"
title={I18NextService.i18n.t("profile")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="user" classes="me-1" />
{I18NextService.i18n.t("profile")}
</NavLink>
</li>
<li>
<NavLink
to="/settings"
className="dropdown-item px-2"
title={I18NextService.i18n.t("settings")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="settings" classes="me-1" />
{I18NextService.i18n.t("settings")}
</NavLink>
</li>
<li>
<hr className="dropdown-divider" />
</li>
<li>
<button
className="dropdown-item btn btn-link px-2"
onClick={linkEvent(this, handleLogOut)}
>
<Icon icon="log-out" classes="me-1" />
{I18NextService.i18n.t("logout")}
</button>
</li>
</ul>
</li>
)}
</>
) : (
<>
<li className="nav-item">
<NavLink
to="/login"
className="nav-link"
title={I18NextService.i18n.t("login")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
{I18NextService.i18n.t("login")}
</NavLink> </NavLink>
</li> </li>
)} <li className="nav-item">
{person && ( <NavLink
<li id="dropdownUser" className="dropdown"> to="/signup"
<button className="nav-link"
type="button" title={I18NextService.i18n.t("sign_up")}
className="btn dropdown-toggle" onMouseUp={linkEvent(this, handleCollapseClick)}
aria-expanded="false"
data-bs-toggle="dropdown"
> >
{showAvatars() && person.avatar && ( {I18NextService.i18n.t("sign_up")}
<PictrsImage src={person.avatar} icon /> </NavLink>
)}
{person.display_name ?? person.name}
</button>
<ul
className="dropdown-menu"
style={{ "min-width": "fit-content" }}
>
<li>
<NavLink
to={`/u/${person.name}`}
className="dropdown-item px-2"
title={I18NextService.i18n.t("profile")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="user" classes="me-1" />
{I18NextService.i18n.t("profile")}
</NavLink>
</li>
<li>
<NavLink
to="/settings"
className="dropdown-item px-2"
title={I18NextService.i18n.t("settings")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="settings" classes="me-1" />
{I18NextService.i18n.t("settings")}
</NavLink>
</li>
<li>
<hr className="dropdown-divider" />
</li>
<li>
<button
className="dropdown-item btn btn-link px-2"
onClick={linkEvent(this, handleLogOut)}
>
<Icon icon="log-out" classes="me-1" />
{I18NextService.i18n.t("logout")}
</button>
</li>
</ul>
</li> </li>
)} </>
</> )}
) : ( </ul>
<> </div>
<li className="nav-item"> </nav>
<NavLink </div>
to="/login"
className="nav-link"
title={I18NextService.i18n.t("login")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
{I18NextService.i18n.t("login")}
</NavLink>
</li>
<li className="nav-item">
<NavLink
to="/signup"
className="nav-link"
title={I18NextService.i18n.t("sign_up")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
{I18NextService.i18n.t("sign_up")}
</NavLink>
</li>
</>
)}
</ul>
</div>
</nav>
); );
} }

View file

@ -1,5 +1,4 @@
import { myAuthRequired } from "@utils/app"; import { myAuthRequired } from "@utils/app";
import getUserInterfaceLangId from "@utils/app/user-interface-language";
import { capitalizeFirstLetter } from "@utils/helpers"; import { capitalizeFirstLetter } from "@utils/helpers";
import { Component } from "inferno"; import { Component } from "inferno";
import { T } from "inferno-i18next-dess"; import { T } from "inferno-i18next-dess";
@ -41,8 +40,6 @@ export class CommentForm extends Component<CommentFormProps, any> {
: undefined : undefined
: undefined; : undefined;
const userInterfaceLangId = getUserInterfaceLangId(this.props.allLanguages);
return ( return (
<div <div
className={["comment-form", "mb-3", this.props.containerClass].join( className={["comment-form", "mb-3", this.props.containerClass].join(
@ -52,7 +49,6 @@ export class CommentForm extends Component<CommentFormProps, any> {
{UserService.Instance.myUserInfo ? ( {UserService.Instance.myUserInfo ? (
<MarkdownTextArea <MarkdownTextArea
initialContent={initialContent} initialContent={initialContent}
initialLanguageId={userInterfaceLangId}
showLanguage showLanguage
buttonTitle={this.buttonTitle} buttonTitle={this.buttonTitle}
finished={this.props.finished} finished={this.props.finished}

View file

@ -1,6 +1,7 @@
import { import {
colorList, colorList,
getCommentParentId, getCommentParentId,
getRoleLabelPill,
myAuth, myAuth,
myAuthRequired, myAuthRequired,
showScores, showScores,
@ -308,32 +309,43 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
classes="icon-inline" classes="icon-inline"
/> />
</button> </button>
<span className="me-2"> <span className="me-2">
<PersonListing person={cv.creator} /> <PersonListing person={cv.creator} />
</span> </span>
{cv.comment.distinguished && ( {cv.comment.distinguished && (
<Icon icon="shield" inline classes="text-danger me-2" /> <Icon icon="shield" inline classes="text-danger me-2" />
)} )}
{this.isPostCreator && (
<div className="badge text-bg-light d-none d-sm-inline me-2"> {this.isPostCreator &&
{I18NextService.i18n.t("creator")} getRoleLabelPill({
</div> label: I18NextService.i18n.t("op").toUpperCase(),
)} tooltip: I18NextService.i18n.t("creator"),
{isMod_ && ( classes: "text-bg-info",
<div className="badge text-bg-light d-none d-sm-inline me-2"> shrink: false,
{I18NextService.i18n.t("mod")} })}
</div>
)} {isMod_ &&
{isAdmin_ && ( getRoleLabelPill({
<div className="badge text-bg-light d-none d-sm-inline me-2"> label: I18NextService.i18n.t("mod"),
{I18NextService.i18n.t("admin")} tooltip: I18NextService.i18n.t("mod"),
</div> classes: "text-bg-primary",
)} })}
{cv.creator.bot_account && (
<div className="badge text-bg-light d-none d-sm-inline me-2"> {isAdmin_ &&
{I18NextService.i18n.t("bot_account").toLowerCase()} getRoleLabelPill({
</div> label: I18NextService.i18n.t("admin"),
)} tooltip: I18NextService.i18n.t("admin"),
classes: "text-bg-danger",
})}
{cv.creator.bot_account &&
getRoleLabelPill({
label: I18NextService.i18n.t("bot_account").toLowerCase(),
tooltip: I18NextService.i18n.t("bot_account"),
})}
{this.props.showCommunity && ( {this.props.showCommunity && (
<> <>
<span className="mx-1">{I18NextService.i18n.t("to")}</span> <span className="mx-1">{I18NextService.i18n.t("to")}</span>
@ -344,7 +356,9 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
</Link> </Link>
</> </>
)} )}
{this.linkBtn(true)}
{this.getLinkButton(true)}
{cv.comment.language_id !== 0 && ( {cv.comment.language_id !== 0 && (
<span className="badge text-bg-light d-none d-sm-inline me-2"> <span className="badge text-bg-light d-none d-sm-inline me-2">
{ {
@ -410,7 +424,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
/> />
)} )}
<div className="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted fw-bold"> <div className="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted fw-bold">
{this.props.showContext && this.linkBtn()} {this.props.showContext && this.getLinkButton()}
{this.props.markable && ( {this.props.markable && (
<button <button
className="btn btn-link btn-animate text-muted" className="btn btn-link btn-animate text-muted"
@ -1186,7 +1200,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
} }
} }
linkBtn(small = false) { getLinkButton(small = false) {
const cv = this.commentView; const cv = this.commentView;
const classnames = classNames("btn btn-link btn-animate text-muted", { const classnames = classNames("btn btn-link btn-animate text-muted", {

View file

@ -1,3 +1,4 @@
import { getStaticDir } from "@utils/env";
import classNames from "classnames"; import classNames from "classnames";
import { Component } from "inferno"; import { Component } from "inferno";
import { I18NextService } from "../../services"; import { I18NextService } from "../../services";
@ -23,7 +24,9 @@ export class Icon extends Component<IconProps, any> {
})} })}
> >
<use <use
xlinkHref={`/static/assets/symbols.svg#icon-${this.props.icon}`} xlinkHref={`${getStaticDir()}/assets/symbols.svg#icon-${
this.props.icon
}`}
></use> ></use>
<div className="visually-hidden"> <div className="visually-hidden">
<title>{this.props.icon}</title> <title>{this.props.icon}</title>

View file

@ -80,6 +80,8 @@ export class ImageUploadForm extends Component<
if (res.state === "success") { if (res.state === "success") {
if (res.data.msg === "ok") { if (res.data.msg === "ok") {
i.props.onUpload(res.data.url as string); i.props.onUpload(res.data.url as string);
} else if (res.data.msg === "too_large") {
toast(I18NextService.i18n.t("upload_too_large"), "danger");
} else { } else {
toast(JSON.stringify(res), "danger"); toast(JSON.stringify(res), "danger");
} }

View file

@ -49,7 +49,7 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
return this.props.iconVersion ? ( return this.props.iconVersion ? (
this.selectBtn this.selectBtn
) : ( ) : (
<div className="language-select row mb-3"> <div className="language-select mb-3">
<label <label
className={classNames( className={classNames(
"col-form-label", "col-form-label",

View file

@ -159,13 +159,16 @@ export class MarkdownTextArea extends Component<
<div className="mb-3 row"> <div className="mb-3 row">
<div className="col-12"> <div className="col-12">
<div className="rounded bg-light border"> <div className="rounded bg-light border">
<div className="d-flex flex-wrap border-bottom"> <div
className={classNames("d-flex flex-wrap border-bottom", {
"no-click": this.isDisabled,
})}
>
{this.getFormatButton("bold", this.handleInsertBold)} {this.getFormatButton("bold", this.handleInsertBold)}
{this.getFormatButton("italic", this.handleInsertItalic)} {this.getFormatButton("italic", this.handleInsertItalic)}
{this.getFormatButton("link", this.handleInsertLink)} {this.getFormatButton("link", this.handleInsertLink)}
<EmojiPicker <EmojiPicker
onEmojiClick={e => this.handleEmoji(this, e)} onEmojiClick={e => this.handleEmoji(this, e)}
disabled={this.isDisabled}
></EmojiPicker> ></EmojiPicker>
<form className="btn btn-sm text-muted fw-bold"> <form className="btn btn-sm text-muted fw-bold">
<label <label
@ -191,9 +194,7 @@ export class MarkdownTextArea extends Component<
name="file" name="file"
className="d-none" className="d-none"
multiple multiple
disabled={ disabled={!UserService.Instance.myUserInfo}
!UserService.Instance.myUserInfo || this.isDisabled
}
onChange={linkEvent(this, this.handleImageUpload)} onChange={linkEvent(this, this.handleImageUpload)}
/> />
</form> </form>
@ -276,12 +277,8 @@ export class MarkdownTextArea extends Component<
<LanguageSelect <LanguageSelect
iconVersion iconVersion
allLanguages={this.props.allLanguages} allLanguages={this.props.allLanguages}
// Only set the selected language ID if it exists as an option
// in the dropdown; otherwise, set it to 0 (Undetermined)
selectedLanguageIds={ selectedLanguageIds={
languageId && this.props.siteLanguages.includes(languageId) languageId ? Array.of(languageId) : undefined
? [languageId]
: [0]
} }
siteLanguages={this.props.siteLanguages} siteLanguages={this.props.siteLanguages}
onChange={this.handleLanguageChange} onChange={this.handleLanguageChange}
@ -355,7 +352,6 @@ export class MarkdownTextArea extends Component<
data-tippy-content={I18NextService.i18n.t(type)} data-tippy-content={I18NextService.i18n.t(type)}
aria-label={I18NextService.i18n.t(type)} aria-label={I18NextService.i18n.t(type)}
onClick={linkEvent(this, handleClick)} onClick={linkEvent(this, handleClick)}
disabled={this.isDisabled}
> >
<Icon icon={iconType} classes="icon-inline" /> <Icon icon={iconType} classes="icon-inline" />
</button> </button>
@ -450,6 +446,10 @@ export class MarkdownTextArea extends Component<
const textarea: any = document.getElementById(i.id); const textarea: any = document.getElementById(i.id);
autosize.update(textarea); autosize.update(textarea);
pictrsDeleteToast(image.name, res.data.delete_url as string); pictrsDeleteToast(image.name, res.data.delete_url as string);
} else if (res.data.msg === "too_large") {
toast(I18NextService.i18n.t("upload_too_large"), "danger");
i.setState({ imageUploadStatus: undefined });
throw JSON.stringify(res.data);
} else { } else {
throw JSON.stringify(res.data); throw JSON.stringify(res.data);
} }
@ -476,7 +476,7 @@ export class MarkdownTextArea extends Component<
// Keybind handler // Keybind handler
// Keybinds inspired by github comment area // Keybinds inspired by github comment area
handleKeyBinds(i: MarkdownTextArea, event: KeyboardEvent) { handleKeyBinds(i: MarkdownTextArea, event: KeyboardEvent) {
if (event.ctrlKey) { if (event.ctrlKey || event.metaKey) {
switch (event.key) { switch (event.key) {
case "k": { case "k": {
i.handleInsertLink(i, event); i.handleInsertLink(i, event);
@ -705,18 +705,20 @@ export class MarkdownTextArea extends Component<
quoteInsert() { quoteInsert() {
const textarea: any = document.getElementById(this.id); const textarea: any = document.getElementById(this.id);
const selectedText = window.getSelection()?.toString(); const selectedText = window.getSelection()?.toString();
const { content } = this.state; let { content } = this.state;
if (selectedText) { if (selectedText) {
const quotedText = const quotedText =
selectedText selectedText
.split("\n") .split("\n")
.map(t => `> ${t}`) .map(t => `> ${t}`)
.join("\n") + "\n\n"; .join("\n") + "\n\n";
if (!content) { if (!content) {
this.setState({ content: "" }); content = "";
} else { } else {
this.setState({ content: `${content}\n` }); content = `${content}\n\n`;
} }
this.setState({ this.setState({
content: `${content}${quotedText}`, content: `${content}${quotedText}`,
}); });

View file

@ -1,5 +1,5 @@
import { capitalizeFirstLetter, formatPastDate } from "@utils/helpers"; import { capitalizeFirstLetter, formatPastDate } from "@utils/helpers";
import format from "date-fns/format"; import { format } from "date-fns";
import parseISO from "date-fns/parseISO"; import parseISO from "date-fns/parseISO";
import { Component } from "inferno"; import { Component } from "inferno";
import { I18NextService } from "../../services"; import { I18NextService } from "../../services";
@ -13,7 +13,8 @@ interface MomentTimeProps {
} }
function formatDate(input: string) { function formatDate(input: string) {
return format(parseISO(input), "PPPPpppp"); const parsed = parseISO(input + "Z");
return format(parsed, "PPPPpppp");
} }
export class MomentTime extends Component<MomentTimeProps, any> { export class MomentTime extends Component<MomentTimeProps, any> {

View file

@ -39,7 +39,7 @@ export class PictrsImage extends Component<PictrsImageProps, any> {
"img-expanded slight-radius": "img-expanded slight-radius":
!this.props.thumbnail && !this.props.icon, !this.props.thumbnail && !this.props.icon,
"img-blur": this.props.thumbnail && this.props.nsfw, "img-blur": this.props.thumbnail && this.props.nsfw,
"rounded-circle img-cover img-icon me-2": this.props.icon, "img-cover img-icon me-1": this.props.icon,
"ms-2 mb-0 rounded-circle img-cover avatar-overlay": "ms-2 mb-0 rounded-circle img-cover avatar-overlay":
this.props.iconOverlay, this.props.iconOverlay,
"avatar-pushup": this.props.pushup, "avatar-pushup": this.props.pushup,

View file

@ -174,7 +174,7 @@ export class VoteButtons extends Component<VoteButtonsProps, VoteButtonsState> {
render() { render() {
return ( return (
<div className="vote-bar col-1 pe-0 small text-center"> <div className="vote-bar small text-center">
<button <button
type="button" type="button"
className={`btn-animate btn btn-link p-0 ${ className={`btn-animate btn btn-link p-0 ${
@ -193,7 +193,7 @@ export class VoteButtons extends Component<VoteButtonsProps, VoteButtonsState> {
</button> </button>
{showScores() ? ( {showScores() ? (
<div <div
className="unselectable pointer text-muted px-1 post-score" className="unselectable pointer text-muted post-score"
data-tippy-content={tippy(this.props.counts)} data-tippy-content={tippy(this.props.counts)}
> >
{numToSI(this.props.counts.score)} {numToSI(this.props.counts.score)}

View file

@ -284,7 +284,9 @@ export class Communities extends Component<any, CommunitiesState> {
handleSearchSubmit(i: Communities, event: any) { handleSearchSubmit(i: Communities, event: any) {
event.preventDefault(); event.preventDefault();
const searchParamEncoded = encodeURIComponent(i.state.searchText); const searchParamEncoded = encodeURIComponent(i.state.searchText);
i.context.router.history.push(`/search?q=${searchParamEncoded}`); i.context.router.history.push(
`/search?q=${searchParamEncoded}&type=Communities`
);
} }
static async fetchInitialData({ static async fetchInitialData({

View file

@ -317,7 +317,10 @@ export class Community extends Component<
/> />
<div className="row"> <div className="row">
<main className="col-12 col-md-8" ref={this.mainContentRef}> <main
className="col-12 col-md-8 col-lg-9"
ref={this.mainContentRef}
>
{this.communityInfo(res)} {this.communityInfo(res)}
<div className="d-block d-md-none"> <div className="d-block d-md-none">
<button <button
@ -340,7 +343,7 @@ export class Community extends Component<
{this.listings(res)} {this.listings(res)}
<Paginator page={page} onChange={this.handlePageChange} /> <Paginator page={page} onChange={this.handlePageChange} />
</main> </main>
<aside className="d-none d-md-block col-md-4"> <aside className="d-none d-md-block col-md-4 col-lg-3">
{this.sidebar(res)} {this.sidebar(res)}
</aside> </aside>
</div> </div>

View file

@ -166,7 +166,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
communityTitle() { communityTitle() {
const community = this.props.community_view.community; const community = this.props.community_view.community;
const subscribed = this.props.community_view.subscribed;
return ( return (
<div> <div>
<h5 className="mb-0"> <h5 className="mb-0">
@ -176,33 +176,6 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
<span className="me-2"> <span className="me-2">
<CommunityLink community={community} hideAvatar /> <CommunityLink community={community} hideAvatar />
</span> </span>
{subscribed === "Subscribed" && (
<button
className="btn btn-secondary btn-sm me-2"
onClick={linkEvent(this, this.handleUnfollowCommunity)}
>
{this.state.followCommunityLoading ? (
<Spinner />
) : (
<>
<Icon icon="check" classes="icon-inline text-success me-1" />
{I18NextService.i18n.t("joined")}
</>
)}
</button>
)}
{subscribed === "Pending" && (
<button
className="btn btn-warning me-2"
onClick={linkEvent(this, this.handleUnfollowCommunity)}
>
{this.state.followCommunityLoading ? (
<Spinner />
) : (
I18NextService.i18n.t("subscribe_pending")
)}
</button>
)}
{community.removed && ( {community.removed && (
<small className="me-2 text-muted fst-italic"> <small className="me-2 text-muted fst-italic">
{I18NextService.i18n.t("removed")} {I18NextService.i18n.t("removed")}
@ -259,40 +232,70 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
subscribe() { subscribe() {
const community_view = this.props.community_view; const community_view = this.props.community_view;
return (
<> if (community_view.subscribed === "NotSubscribed") {
{community_view.subscribed == "NotSubscribed" && ( return (
<button <button
className="btn btn-secondary d-block mb-2 w-100" className="btn btn-secondary d-block mb-2 w-100"
onClick={linkEvent(this, this.handleFollowCommunity)} onClick={linkEvent(this, this.handleFollowCommunity)}
> >
{this.state.followCommunityLoading ? ( {this.state.followCommunityLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
I18NextService.i18n.t("subscribe") I18NextService.i18n.t("subscribe")
)} )}
</button> </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 text-success 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() { blockCommunity() {
const { subscribed, blocked } = this.props.community_view; const { subscribed, blocked } = this.props.community_view;
return ( return (
<> subscribed === "NotSubscribed" && (
{subscribed == "NotSubscribed" && ( <button
<button className="btn btn-danger d-block mb-2 w-100"
className="btn btn-danger d-block mb-2 w-100" onClick={linkEvent(this, this.handleBlockCommunity)}
onClick={linkEvent(this, this.handleBlockCommunity)} >
> {I18NextService.i18n.t(
{I18NextService.i18n.t( blocked ? "unblock_community" : "block_community"
blocked ? "unblock_community" : "block_community" )}
)} </button>
</button> )
)}
</>
); );
} }

View file

@ -512,6 +512,8 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
{ form: form, index: index, overrideValue: res.data.url as string }, { form: form, index: index, overrideValue: res.data.url as string },
event event
); );
} else if (res.data.msg === "too_large") {
toast(I18NextService.i18n.t("upload_too_large"), "danger");
} else { } else {
toast(JSON.stringify(res), "danger"); toast(JSON.stringify(res), "danger");
} }

View file

@ -279,13 +279,15 @@ export class Home extends Component<any, HomeState> {
trendingCommunitiesRes, trendingCommunitiesRes,
commentsRes, commentsRes,
postsRes, postsRes,
tagline: getRandomFromList(this.state?.siteRes?.taglines ?? [])
?.content,
isIsomorphic: true, isIsomorphic: true,
}; };
HomeCacheService.postsRes = postsRes; HomeCacheService.postsRes = postsRes;
} }
this.state.tagline = getRandomFromList(
this.state?.siteRes?.taglines ?? []
)?.content;
} }
componentWillUnmount() { componentWillUnmount() {
@ -387,7 +389,7 @@ export class Home extends Component<any, HomeState> {
/> />
{site_setup && ( {site_setup && (
<div className="row"> <div className="row">
<main role="main" className="col-12 col-md-8"> <main role="main" className="col-12 col-md-8 col-lg-9">
{tagline && ( {tagline && (
<div <div
id="tagline" id="tagline"
@ -397,7 +399,7 @@ export class Home extends Component<any, HomeState> {
<div className="d-block d-md-none">{this.mobileView}</div> <div className="d-block d-md-none">{this.mobileView}</div>
{this.posts} {this.posts}
</main> </main>
<aside className="d-none d-md-block col-md-4"> <aside className="d-none d-md-block col-md-4 col-lg-3">
{this.mySidebar} {this.mySidebar}
</aside> </aside>
</div> </div>

View file

@ -205,9 +205,7 @@ export class Setup extends Component<any, State> {
const data = i.state.registerRes.data; const data = i.state.registerRes.data;
UserService.Instance.login(data); UserService.Instance.login(data);
if (UserService.Instance.jwtInfo) { i.setState({ doneRegisteringUser: true });
i.setState({ doneRegisteringUser: true });
}
} }
} }
} }

View file

@ -4,6 +4,7 @@ import {
Component, Component,
InfernoKeyboardEvent, InfernoKeyboardEvent,
InfernoMouseEvent, InfernoMouseEvent,
InfernoNode,
linkEvent, linkEvent,
} from "inferno"; } from "inferno";
import { import {
@ -13,6 +14,7 @@ import {
Instance, Instance,
ListingType, ListingType,
} from "lemmy-js-client"; } from "lemmy-js-client";
import deepEqual from "lodash.isequal";
import { I18NextService } from "../../services"; import { I18NextService } from "../../services";
import { Icon, Spinner } from "../common/icon"; import { Icon, Spinner } from "../common/icon";
import { ImageUploadForm } from "../common/image-upload-form"; import { ImageUploadForm } from "../common/image-upload-form";
@ -55,6 +57,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
initSiteForm(): EditSite { initSiteForm(): EditSite {
const site = this.props.siteRes.site_view.site; const site = this.props.siteRes.site_view.site;
const ls = this.props.siteRes.site_view.local_site; const ls = this.props.siteRes.site_view.local_site;
return { return {
name: site.name, name: site.name,
sidebar: site.sidebar, sidebar: site.sidebar,
@ -623,6 +626,19 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
); );
} }
componentDidUpdate(
prevProps: Readonly<{ children?: InfernoNode } & SiteFormProps>
) {
if (
!(
deepEqual(prevProps.allowedInstances, this.props.allowedInstances) ||
deepEqual(prevProps.blockedInstances, this.props.blockedInstances)
)
) {
this.setState({ siteForm: this.initSiteForm() });
}
}
federatedInstanceSelect(key: InstanceKey) { federatedInstanceSelect(key: InstanceKey) {
const id = `create_site_${key}`; const id = `create_site_${key}`;
const value = this.state.instance_select[key]; const value = this.state.instance_select[key];

View file

@ -141,7 +141,7 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
handleEditTaglineClick(d: { i: TaglineForm; index: number }, event: any) { handleEditTaglineClick(d: { i: TaglineForm; index: number }, event: any) {
event.preventDefault(); event.preventDefault();
if (this.state.editingRow == d.index) { if (d.i.state.editingRow == d.index) {
d.i.setState({ editingRow: undefined }); d.i.setState({ editingRow: undefined });
} else { } else {
d.i.setState({ editingRow: d.index }); d.i.setState({ editingRow: d.index });

View file

@ -1,4 +1,5 @@
import { showAvatars } from "@utils/app"; import { showAvatars } from "@utils/app";
import { getStaticDir } from "@utils/env";
import { hostname, isCakeDay } from "@utils/helpers"; import { hostname, isCakeDay } from "@utils/helpers";
import classNames from "classnames"; import classNames from "classnames";
import { Component } from "inferno"; import { Component } from "inferno";
@ -88,7 +89,7 @@ export class PersonListing extends Component<PersonListingProps, any> {
!this.props.person.banned && !this.props.person.banned &&
showAvatars() && ( showAvatars() && (
<PictrsImage <PictrsImage
src={avatar ?? "/static/assets/icons/icon-96x96.png"} src={avatar ?? `${getStaticDir()}/assets/icons/icon-96x96.png`}
icon icon
/> />
)} )}

View file

@ -5,6 +5,7 @@ import {
enableDownvotes, enableDownvotes,
enableNsfw, enableNsfw,
getCommentParentId, getCommentParentId,
getRoleLabelPill,
myAuth, myAuth,
myAuthRequired, myAuthRequired,
setIsoData, setIsoData,
@ -484,23 +485,43 @@ export class Profile extends Component<
/> />
</li> </li>
{isBanned(pv.person) && ( {isBanned(pv.person) && (
<li className="list-inline-item badge text-bg-danger"> <li className="list-inline-item">
{I18NextService.i18n.t("banned")} {getRoleLabelPill({
label: I18NextService.i18n.t("banned"),
tooltip: I18NextService.i18n.t("banned"),
classes: "text-bg-danger",
shrink: false,
})}
</li> </li>
)} )}
{pv.person.deleted && ( {pv.person.deleted && (
<li className="list-inline-item badge text-bg-danger"> <li className="list-inline-item">
{I18NextService.i18n.t("deleted")} {getRoleLabelPill({
label: I18NextService.i18n.t("deleted"),
tooltip: I18NextService.i18n.t("deleted"),
classes: "text-bg-danger",
shrink: false,
})}
</li> </li>
)} )}
{pv.person.admin && ( {pv.person.admin && (
<li className="list-inline-item badge text-bg-light"> <li className="list-inline-item">
{I18NextService.i18n.t("admin")} {getRoleLabelPill({
label: I18NextService.i18n.t("admin"),
tooltip: I18NextService.i18n.t("admin"),
shrink: false,
})}
</li> </li>
)} )}
{pv.person.bot_account && ( {pv.person.bot_account && (
<li className="list-inline-item badge text-bg-light"> <li className="list-inline-item">
{I18NextService.i18n.t("bot_account").toLowerCase()} {getRoleLabelPill({
label: I18NextService.i18n
.t("bot_account")
.toLowerCase(),
tooltip: I18NextService.i18n.t("bot_account"),
shrink: false,
})}
</li> </li>
)} )}
</ul> </ul>
@ -692,6 +713,8 @@ export class Profile extends Component<
> >
{I18NextService.i18n.t("cancel")} {I18NextService.i18n.t("cancel")}
</button> </button>
</div>
<div className="mb-3 row">
<button <button
type="submit" type="submit"
className="btn btn-secondary" className="btn btn-secondary"

View file

@ -8,60 +8,54 @@ interface MetadataCardProps {
post: Post; post: Post;
} }
interface MetadataCardState { export class MetadataCard extends Component<MetadataCardProps> {
expanded: boolean;
}
export class MetadataCard extends Component<
MetadataCardProps,
MetadataCardState
> {
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
} }
render() { render() {
const post = this.props.post; const post = this.props.post;
return (
<> if (post.embed_title && post.url) {
{post.embed_title && post.url && ( return (
<div className="post-metadata-card card border-secondary mt-3 mb-2"> <div className="post-metadata-card card border-secondary mt-3 mb-2">
<div className="row"> <div className="row">
<div className="col-12"> <div className="col-12">
<div className="card-body"> <div className="card-body">
{post.name !== post.embed_title && ( {post.name !== post.embed_title && (
<> <>
<h5 className="card-title d-inline"> <h5 className="card-title d-inline">
<a className="text-body" href={post.url} rel={relTags}> <a className="text-body" href={post.url} rel={relTags}>
{post.embed_title} {post.embed_title}
</a> </a>
</h5> </h5>
<span className="d-inline-block ms-2 mb-2 small text-muted"> <span className="d-inline-block ms-2 mb-2 small text-muted">
<a <a
className="text-muted fst-italic" className="text-muted fst-italic"
href={post.url} href={post.url}
rel={relTags} rel={relTags}
> >
{new URL(post.url).hostname} {new URL(post.url).hostname}
<Icon icon="external-link" classes="ms-1" /> <Icon icon="external-link" classes="ms-1" />
</a> </a>
</span> </span>
</> </>
)} )}
{post.embed_description && ( {post.embed_description && (
<div <div
className="card-text small text-muted md-div" className="card-text small text-muted md-div"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: sanitizeHtml(post.embed_description), __html: sanitizeHtml(post.embed_description),
}} }}
/> />
)} )}
</div>
</div> </div>
</div> </div>
</div> </div>
)} </div>
</> );
); } else {
return <></>;
}
} }
} }

View file

@ -4,7 +4,6 @@ import {
myAuth, myAuth,
myAuthRequired, myAuthRequired,
} from "@utils/app"; } from "@utils/app";
import getUserInterfaceLangId from "@utils/app/user-interface-language";
import { import {
capitalizeFirstLetter, capitalizeFirstLetter,
debounce, debounce,
@ -188,6 +187,8 @@ function handleImageUpload(i: PostForm, event: any) {
imageLoading: false, imageLoading: false,
imageDeleteUrl: res.data.delete_url as string, imageDeleteUrl: res.data.delete_url as string,
}); });
} else if (res.data.msg === "too_large") {
toast(I18NextService.i18n.t("upload_too_large"), "danger");
} else { } else {
toast(JSON.stringify(res), "danger"); toast(JSON.stringify(res), "danger");
} }
@ -324,9 +325,10 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
} }
render() { render() {
const url = this.state.form.url; const firstLang = this.state.form.language_id;
const selectedLangs = firstLang ? Array.of(firstLang) : undefined;
const userInterfaceLangId = getUserInterfaceLangId(this.props.allLanguages); const url = this.state.form.url;
return ( return (
<form className="post-form" onSubmit={linkEvent(this, handlePostSubmit)}> <form className="post-form" onSubmit={linkEvent(this, handlePostSubmit)}>
@ -493,8 +495,8 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
</div> </div>
<LanguageSelect <LanguageSelect
allLanguages={this.props.allLanguages} allLanguages={this.props.allLanguages}
selectedLanguageIds={[userInterfaceLangId]}
siteLanguages={this.props.siteLanguages} siteLanguages={this.props.siteLanguages}
selectedLanguageIds={selectedLangs}
multiple={false} multiple={false}
onChange={this.handleLanguageChange} onChange={this.handleLanguageChange}
/> />

View file

@ -1,4 +1,4 @@
import { myAuthRequired } from "@utils/app"; import { getRoleLabelPill, myAuthRequired } 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 { import {
@ -49,7 +49,7 @@ import {
PurgeType, PurgeType,
VoteContentType, VoteContentType,
} from "../../interfaces"; } from "../../interfaces";
import { mdNoImages, mdToHtml, mdToHtmlInline } from "../../markdown"; import { mdToHtml, mdToHtmlInline } from "../../markdown";
import { I18NextService, UserService } from "../../services"; import { I18NextService, UserService } from "../../services";
import { setupTippy } from "../../tippy"; import { setupTippy } from "../../tippy";
import { Icon, PurgeWarning, Spinner } from "../common/icon"; import { Icon, PurgeWarning, Spinner } from "../common/icon";
@ -105,6 +105,9 @@ interface PostListingProps {
allLanguages: Language[]; allLanguages: Language[];
siteLanguages: number[]; siteLanguages: number[];
showCommunity?: boolean; showCommunity?: boolean;
/**
* Controls whether to show both the body *and* the metadata preview card
*/
showBody?: boolean; showBody?: boolean;
hideImage?: boolean; hideImage?: boolean;
enableDownvotes?: boolean; enableDownvotes?: boolean;
@ -183,7 +186,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
addModLoading: false, addModLoading: false,
addAdminLoading: false, addAdminLoading: false,
transferLoading: false, transferLoading: false,
imageExpanded: false,
}); });
} }
} }
@ -201,7 +203,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<> <>
{this.listing()} {this.listing()}
{this.state.imageExpanded && !this.props.hideImage && this.img} {this.state.imageExpanded && !this.props.hideImage && this.img}
{post.url && this.state.showBody && post.embed_title && ( {this.showBody && post.url && post.embed_title && (
<MetadataCard post={post} /> <MetadataCard post={post} />
)} )}
{this.showBody && this.body()} {this.showBody && this.body()}
@ -329,27 +331,33 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
if (!this.props.hideImage && url && isImage(url) && this.imageSrc) { if (!this.props.hideImage && url && isImage(url) && this.imageSrc) {
return ( return (
<a <button
href={this.imageSrc} type="button"
className="text-body d-inline-block position-relative mb-2" className="thumbnail rounded overflow-hidden d-inline-block position-relative p-0 border-0 bg-transparent"
data-tippy-content={I18NextService.i18n.t("expand_here")} data-tippy-content={I18NextService.i18n.t("expand_here")}
onClick={linkEvent(this, this.handleImageExpandClick)} onClick={linkEvent(this, this.handleImageExpandClick)}
aria-label={I18NextService.i18n.t("expand_here")} aria-label={I18NextService.i18n.t("expand_here")}
> >
{this.imgThumb(this.imageSrc)} {this.imgThumb(this.imageSrc)}
<Icon icon="image" classes="mini-overlay" /> <Icon
</a> icon="image"
classes="d-block text-white position-absolute end-0 top-0 mini-overlay text-opacity-75 text-opacity-100-hover"
/>
</button>
); );
} else if (!this.props.hideImage && url && thumbnail && this.imageSrc) { } else if (!this.props.hideImage && url && thumbnail && this.imageSrc) {
return ( return (
<a <a
className="text-body d-inline-block position-relative mb-2" className="thumbnail rounded overflow-hidden d-inline-block position-relative p-0 border-0"
href={url} href={url}
rel={relTags} rel={relTags}
title={url} title={url}
> >
{this.imgThumb(this.imageSrc)} {this.imgThumb(this.imageSrc)}
<Icon icon="external-link" classes="mini-overlay" /> <Icon
icon="external-link"
classes="d-block text-white position-absolute end-0 top-0 mini-overlay text-opacity-75 text-opacity-100-hover"
/>
</a> </a>
); );
} else if (url) { } else if (url) {
@ -395,24 +403,29 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
createdLine() { createdLine() {
const post_view = this.postView; const post_view = this.postView;
return ( return (
<span className="small"> <div className="small mb-1 mb-md-0">
<PersonListing person={post_view.creator} muted={true} /> <span className="me-1">
{this.creatorIsMod_ && ( <PersonListing person={post_view.creator} />
<span className="mx-1 badge text-bg-light"> </span>
{I18NextService.i18n.t("mod")} {this.creatorIsMod_ &&
</span> getRoleLabelPill({
)} label: I18NextService.i18n.t("mod"),
{this.creatorIsAdmin_ && ( tooltip: I18NextService.i18n.t("mod"),
<span className="mx-1 badge text-bg-light"> classes: "text-bg-primary",
{I18NextService.i18n.t("admin")} })}
</span> {this.creatorIsAdmin_ &&
)} getRoleLabelPill({
{post_view.creator.bot_account && ( label: I18NextService.i18n.t("admin"),
<span className="mx-1 badge text-bg-light"> tooltip: I18NextService.i18n.t("admin"),
{I18NextService.i18n.t("bot_account").toLowerCase()} classes: "text-bg-danger",
</span> })}
)} {post_view.creator.bot_account &&
getRoleLabelPill({
label: I18NextService.i18n.t("bot_account").toLowerCase(),
tooltip: I18NextService.i18n.t("bot_account"),
})}
{this.props.showCommunity && ( {this.props.showCommunity && (
<> <>
{" "} {" "}
@ -434,7 +447,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
published={post_view.post.published} published={post_view.post.published}
updated={post_view.post.updated} updated={post_view.post.updated}
/> />
</span> </div>
); );
} }
@ -483,6 +496,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
)} )}
</h5> </h5>
{/**
* If there is a URL, an embed title, and we were not told to show the
* body by the parent component, show the MetadataCard/body toggle.
*/}
{!this.props.showBody &&
post.url &&
post.embed_title &&
this.showPreviewButton()}
{post.removed && ( {post.removed && (
<small className="ms-2 badge text-bg-secondary"> <small className="ms-2 badge text-bg-secondary">
{I18NextService.i18n.t("removed")} {I18NextService.i18n.t("removed")}
@ -625,27 +647,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
); );
} }
showPreviewButton() {
const post_view = this.postView;
const body = post_view.post.body;
return (
<button
className="btn btn-sm btn-animate text-muted py-0"
data-tippy-content={body && mdNoImages.render(body)}
data-tippy-allowHtml={true}
onClick={linkEvent(this, this.handleShowBody)}
>
<Icon
icon="book-open"
classes={classNames("icon-inline me-1", {
"text-success": this.state.showBody,
})}
/>
</button>
);
}
postActions() { postActions() {
// Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button. // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
// Possible enhancement: Make each button a component. // Possible enhancement: Make each button a component.
@ -657,14 +658,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.saveButton} {this.saveButton}
{this.crossPostButton} {this.crossPostButton}
{/** {this.props.showBody && post_view.post.body && this.viewSourceButton}
* If there is a URL, or if the post has a body and we were told not to
* show the body, show the MetadataCard/body toggle.
*/}
{(post.url || (post.body && !this.props.showBody)) &&
this.showPreviewButton()}
{this.showBody && post_view.post.body && this.viewSourceButton}
<div className="dropdown"> <div className="dropdown">
<button <button
@ -709,6 +703,50 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{(this.canMod_ || this.canAdmin_) && ( {(this.canMod_ || this.canAdmin_) && (
<li>{this.modRemoveButton}</li> <li>{this.modRemoveButton}</li>
)} )}
{this.canMod_ && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{!this.creatorIsMod_ &&
(!post_view.creator_banned_from_community ? (
<li>{this.modBanFromCommunityButton}</li>
) : (
<li>{this.modUnbanFromCommunityButton}</li>
))}
{!post_view.creator_banned_from_community && (
<li>{this.addModToCommunityButton}</li>
)}
</>
)}
{(amCommunityCreator(post_view.creator.id, this.props.moderators) ||
this.canAdmin_) &&
this.creatorIsMod_ && <li>{this.transferCommunityButton}</li>}
{/* Admins can ban from all, and appoint other admins */}
{this.canAdmin_ && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{!this.creatorIsAdmin_ && (
<>
{!isBanned(post_view.creator) ? (
<li>{this.modBanButton}</li>
) : (
<li>{this.modUnbanButton}</li>
)}
<li>{this.purgePersonButton}</li>
<li>{this.purgePostButton}</li>
</>
)}
{!isBanned(post_view.creator) && post_view.creator.local && (
<li>{this.toggleAdminButton}</li>
)}
</>
)}
</ul> </ul>
</div> </div>
</> </>
@ -976,9 +1014,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modBanFromCommunityButton() { get modBanFromCommunityButton() {
return ( return (
<button <button
className="btn btn-link btn-animate text-muted py-0" className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleModBanFromCommunityShow)} onClick={linkEvent(this, this.handleModBanFromCommunityShow)}
aria-label={I18NextService.i18n.t("ban_from_community")}
> >
{I18NextService.i18n.t("ban_from_community")} {I18NextService.i18n.t("ban_from_community")}
</button> </button>
@ -988,9 +1025,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modUnbanFromCommunityButton() { get modUnbanFromCommunityButton() {
return ( return (
<button <button
className="btn btn-link btn-animate text-muted py-0" className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleModBanFromCommunitySubmit)} onClick={linkEvent(this, this.handleModBanFromCommunitySubmit)}
aria-label={I18NextService.i18n.t("unban")}
> >
{this.state.banLoading ? <Spinner /> : I18NextService.i18n.t("unban")} {this.state.banLoading ? <Spinner /> : I18NextService.i18n.t("unban")}
</button> </button>
@ -1000,20 +1036,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get addModToCommunityButton() { get addModToCommunityButton() {
return ( return (
<button <button
className="btn btn-link btn-animate text-muted py-0" className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleAddModToCommunity)} onClick={linkEvent(this, this.handleAddModToCommunity)}
aria-label={
this.creatorIsMod_
? I18NextService.i18n.t("remove_as_mod")
: I18NextService.i18n.t("appoint_as_mod")
}
> >
{this.state.addModLoading ? ( {this.state.addModLoading ? (
<Spinner /> <Spinner />
) : this.creatorIsMod_ ? ( ) : this.creatorIsMod_ ? (
I18NextService.i18n.t("remove_as_mod") capitalizeFirstLetter(I18NextService.i18n.t("remove_as_mod"))
) : ( ) : (
I18NextService.i18n.t("appoint_as_mod") capitalizeFirstLetter(I18NextService.i18n.t("appoint_as_mod"))
)} )}
</button> </button>
); );
@ -1022,11 +1053,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modBanButton() { get modBanButton() {
return ( return (
<button <button
className="btn btn-link btn-animate text-muted py-0" className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleModBanShow)} onClick={linkEvent(this, this.handleModBanShow)}
aria-label={I18NextService.i18n.t("ban_from_site")}
> >
{I18NextService.i18n.t("ban_from_site")} {capitalizeFirstLetter(I18NextService.i18n.t("ban_from_site"))}
</button> </button>
); );
} }
@ -1034,14 +1064,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modUnbanButton() { get modUnbanButton() {
return ( return (
<button <button
className="btn btn-link btn-animate text-muted py-0" className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleModBanSubmit)} onClick={linkEvent(this, this.handleModBanSubmit)}
aria-label={I18NextService.i18n.t("unban_from_site")}
> >
{this.state.banLoading ? ( {this.state.banLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
I18NextService.i18n.t("unban_from_site") capitalizeFirstLetter(I18NextService.i18n.t("unban_from_site"))
)} )}
</button> </button>
); );
@ -1050,11 +1079,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get purgePersonButton() { get purgePersonButton() {
return ( return (
<button <button
className="btn btn-link btn-animate text-muted py-0" className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handlePurgePersonShow)} onClick={linkEvent(this, this.handlePurgePersonShow)}
aria-label={I18NextService.i18n.t("purge_user")}
> >
{I18NextService.i18n.t("purge_user")} {capitalizeFirstLetter(I18NextService.i18n.t("purge_user"))}
</button> </button>
); );
} }
@ -1062,11 +1090,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get purgePostButton() { get purgePostButton() {
return ( return (
<button <button
className="btn btn-link btn-animate text-muted py-0" className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handlePurgePostShow)} onClick={linkEvent(this, this.handlePurgePostShow)}
aria-label={I18NextService.i18n.t("purge_post")}
> >
{I18NextService.i18n.t("purge_post")} {capitalizeFirstLetter(I18NextService.i18n.t("purge_post"))}
</button> </button>
); );
} }
@ -1074,20 +1101,31 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get toggleAdminButton() { get toggleAdminButton() {
return ( return (
<button <button
className="btn btn-link btn-animate text-muted py-0" className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleAddAdmin)} onClick={linkEvent(this, this.handleAddAdmin)}
> >
{this.state.addAdminLoading ? ( {this.state.addAdminLoading ? (
<Spinner /> <Spinner />
) : this.creatorIsAdmin_ ? ( ) : this.creatorIsAdmin_ ? (
I18NextService.i18n.t("remove_as_admin") capitalizeFirstLetter(I18NextService.i18n.t("remove_as_admin"))
) : ( ) : (
I18NextService.i18n.t("appoint_as_admin") capitalizeFirstLetter(I18NextService.i18n.t("appoint_as_admin"))
)} )}
</button> </button>
); );
} }
get transferCommunityButton() {
return (
<button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleShowConfirmTransferCommunity)}
>
{capitalizeFirstLetter(I18NextService.i18n.t("transfer_community"))}
</button>
);
}
get modRemoveButton() { get modRemoveButton() {
const removed = this.postView.post.removed; const removed = this.postView.post.removed;
return ( return (
@ -1102,102 +1140,17 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.state.removeLoading ? ( {this.state.removeLoading ? (
<Spinner /> <Spinner />
) : !removed ? ( ) : !removed ? (
I18NextService.i18n.t("remove") capitalizeFirstLetter(I18NextService.i18n.t("remove_post"))
) : ( ) : (
I18NextService.i18n.t("restore") <>
{capitalizeFirstLetter(I18NextService.i18n.t("restore"))}{" "}
{I18NextService.i18n.t("post")}
</>
)} )}
</button> </button>
); );
} }
/**
* Mod/Admin actions to be taken against the author.
*/
userActionsLine() {
// TODO: make nicer
const post_view = this.postView;
return (
this.state.showAdvanced && (
<div className="mt-3">
{this.canMod_ && (
<>
{!this.creatorIsMod_ &&
(!post_view.creator_banned_from_community
? this.modBanFromCommunityButton
: this.modUnbanFromCommunityButton)}
{!post_view.creator_banned_from_community &&
this.addModToCommunityButton}
</>
)}
{/* Community creators and admins can transfer community to another mod */}
{(amCommunityCreator(post_view.creator.id, this.props.moderators) ||
this.canAdmin_) &&
this.creatorIsMod_ &&
(!this.state.showConfirmTransferCommunity ? (
<button
className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(
this,
this.handleShowConfirmTransferCommunity
)}
aria-label={I18NextService.i18n.t("transfer_community")}
>
{I18NextService.i18n.t("transfer_community")}
</button>
) : (
<>
<button
className="d-inline-block me-1 btn btn-link btn-animate text-muted py-0"
aria-label={I18NextService.i18n.t("are_you_sure")}
>
{I18NextService.i18n.t("are_you_sure")}
</button>
<button
className="btn btn-link btn-animate text-muted py-0 d-inline-block me-1"
aria-label={I18NextService.i18n.t("yes")}
onClick={linkEvent(this, this.handleTransferCommunity)}
>
{this.state.transferLoading ? (
<Spinner />
) : (
I18NextService.i18n.t("yes")
)}
</button>
<button
className="btn btn-link btn-animate text-muted py-0 d-inline-block"
onClick={linkEvent(
this,
this.handleCancelShowConfirmTransferCommunity
)}
aria-label={I18NextService.i18n.t("no")}
>
{I18NextService.i18n.t("no")}
</button>
</>
))}
{/* Admins can ban from all, and appoint other admins */}
{this.canAdmin_ && (
<>
{!this.creatorIsAdmin_ && (
<>
{!isBanned(post_view.creator)
? this.modBanButton
: this.modUnbanButton}
{this.purgePersonButton}
{this.purgePostButton}
</>
)}
{!isBanned(post_view.creator) &&
post_view.creator.local &&
this.toggleAdminButton}
</>
)}
</div>
)
);
}
removeAndBanDialogs() { removeAndBanDialogs() {
const post = this.postView; const post = this.postView;
const purgeTypeText = const purgeTypeText =
@ -1225,11 +1178,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
value={this.state.removeReason} value={this.state.removeReason}
onInput={linkEvent(this, this.handleModRemoveReasonChange)} onInput={linkEvent(this, this.handleModRemoveReasonChange)}
/> />
<button <button type="submit" className="btn btn-secondary">
type="submit"
className="btn btn-secondary"
aria-label={I18NextService.i18n.t("remove_post")}
>
{this.state.removeLoading ? ( {this.state.removeLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
@ -1238,6 +1187,33 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</button> </button>
</form> </form>
)} )}
{this.state.showConfirmTransferCommunity && (
<>
<button className="d-inline-block me-1 btn btn-link btn-animate text-muted py-0">
{I18NextService.i18n.t("are_you_sure")}
</button>
<button
className="btn btn-link btn-animate text-muted py-0 d-inline-block me-1"
onClick={linkEvent(this, this.handleTransferCommunity)}
>
{this.state.transferLoading ? (
<Spinner />
) : (
I18NextService.i18n.t("yes")
)}
</button>
<button
className="btn btn-link btn-animate text-muted py-0 d-inline-block"
onClick={linkEvent(
this,
this.handleCancelShowConfirmTransferCommunity
)}
aria-label={I18NextService.i18n.t("no")}
>
{I18NextService.i18n.t("no")}
</button>
</>
)}
{this.state.showBanDialog && ( {this.state.showBanDialog && (
<form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}> <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
<div className="mb-3 row col-12"> <div className="mb-3 row col-12">
@ -1291,11 +1267,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{/* <input type="date" class="form-control me-2" placeholder={I18NextService.i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */} {/* <input type="date" class="form-control me-2" placeholder={I18NextService.i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
{/* </div> */} {/* </div> */}
<div className="mb-3 row"> <div className="mb-3 row">
<button <button type="submit" className="btn btn-secondary">
type="submit"
className="btn btn-secondary"
aria-label={I18NextService.i18n.t("ban")}
>
{this.state.banLoading ? ( {this.state.banLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
@ -1324,11 +1296,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
value={this.state.reportReason} value={this.state.reportReason}
onInput={linkEvent(this, this.handleReportReasonChange)} onInput={linkEvent(this, this.handleReportReasonChange)}
/> />
<button <button type="submit" className="btn btn-secondary">
type="submit"
className="btn btn-secondary"
aria-label={I18NextService.i18n.t("create_report")}
>
{this.state.reportLoading ? ( {this.state.reportLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
@ -1357,11 +1325,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.state.purgeLoading ? ( {this.state.purgeLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
<button <button type="submit" className="btn btn-secondary">
type="submit"
className="btn btn-secondary"
aria-label={purgeTypeText}
>
{this.state.purgeLoading ? <Spinner /> : { purgeTypeText }} {this.state.purgeLoading ? <Spinner /> : { purgeTypeText }}
</button> </button>
)} )}
@ -1388,15 +1352,18 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
); );
} }
showBodyPreview() { showPreviewButton() {
const { body, id } = this.postView.post; return (
<button
return !this.showBody && body ? ( type="button"
<Link className="text-body mt-2 d-block" to={`/post/${id}`}> className="btn btn-sm btn-link link-dark link-opacity-75 link-opacity-100-hover py-0 align-baseline"
<div className="md-div mb-1 preview-lines">{body}</div> onClick={linkEvent(this, this.handleShowBody)}
</Link> >
) : ( <Icon
<></> icon={!this.state.showBody ? "plus-square" : "minus-square"}
classes="icon-inline"
/>
</button>
); );
} }
@ -1412,11 +1379,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{/* If it has a thumbnail, do a right aligned thumbnail */} {/* If it has a thumbnail, do a right aligned thumbnail */}
{this.mobileThumbnail()} {this.mobileThumbnail()}
{/* Show a preview of the post body */}
{this.showBodyPreview()}
{this.commentsLine(true)} {this.commentsLine(true)}
{this.userActionsLine()}
{this.duplicatesLine()} {this.duplicatesLine()}
{this.removeAndBanDialogs()} {this.removeAndBanDialogs()}
</div> </div>
@ -1427,27 +1390,27 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<div className="d-none d-sm-block"> <div className="d-none d-sm-block">
<article className="row post-container"> <article className="row post-container">
{!this.props.viewOnly && ( {!this.props.viewOnly && (
<VoteButtons <div className="col flex-grow-0">
voteContentType={VoteContentType.Post} <VoteButtons
id={this.postView.post.id} voteContentType={VoteContentType.Post}
onVote={this.props.onPostVote} id={this.postView.post.id}
enableDownvotes={this.props.enableDownvotes} onVote={this.props.onPostVote}
counts={this.postView.counts} enableDownvotes={this.props.enableDownvotes}
my_vote={this.postView.my_vote} counts={this.postView.counts}
/> my_vote={this.postView.my_vote}
/>
</div>
)} )}
<div className="col-sm-2 pe-0 post-media"> <div className="col flex-grow-1">
<div className="">{this.thumbnail()}</div>
</div>
<div className="col-12 col-sm-9">
<div className="row"> <div className="row">
<div className="col-12"> <div className="col flex-grow-0 px-0">
<div className="">{this.thumbnail()}</div>
</div>
<div className="col flex-grow-1">
{this.postTitleLine()} {this.postTitleLine()}
{this.createdLine()} {this.createdLine()}
{this.showBodyPreview()}
{this.commentsLine()} {this.commentsLine()}
{this.duplicatesLine()} {this.duplicatesLine()}
{this.userActionsLine()}
{this.removeAndBanDialogs()} {this.removeAndBanDialogs()}
</div> </div>
</div> </div>

View file

@ -348,7 +348,7 @@ export class Post extends Component<any, PostState> {
const res = this.state.postRes.data; const res = this.state.postRes.data;
return ( return (
<div className="row"> <div className="row">
<main className="col-12 col-md-8 mb-3"> <main className="col-12 col-md-8 col-lg-9 mb-3">
<HtmlTags <HtmlTags
title={this.documentTitle} title={this.documentTitle}
path={this.context.router.route.match.url} path={this.context.router.route.match.url}
@ -416,7 +416,7 @@ export class Post extends Component<any, PostState> {
{this.state.commentViewType == CommentViewType.Flat && {this.state.commentViewType == CommentViewType.Flat &&
this.commentsFlat()} this.commentsFlat()}
</main> </main>
<aside className="d-none d-md-block col-md-4"> <aside className="d-none d-md-block col-md-4 col-lg-3">
{this.sidebar()} {this.sidebar()}
</aside> </aside>
</div> </div>

View file

@ -284,7 +284,6 @@ export class PrivateMessage extends Component<
<div className="row"> <div className="row">
<div className="col-sm-6"> <div className="col-sm-6">
<PrivateMessageForm <PrivateMessageForm
privateMessageView={message_view}
replyType={true} replyType={true}
recipient={otherPerson} recipient={otherPerson}
onCreate={this.props.onCreate} onCreate={this.props.onCreate}

View file

@ -332,9 +332,7 @@ export class Search extends Component<any, SearchState> {
} }
async componentDidMount() { async componentDidMount() {
if ( if (!this.state.isIsomorphic) {
!(this.state.isIsomorphic || this.props.history.location.state?.searched)
) {
const promises = [this.fetchCommunities()]; const promises = [this.fetchCommunities()];
if (this.state.searchText) { if (this.state.searchText) {
promises.push(this.search()); promises.push(this.search());
@ -432,7 +430,15 @@ export class Search extends Component<any, SearchState> {
q: query, q: query,
auth, auth,
}; };
resolveObjectResponse = await client.resolveObject(resolveObjectForm); resolveObjectResponse = await HttpService.silent_client.resolveObject(
resolveObjectForm
);
// If we return this object with a state of failed, the catch-all-handler will redirect
// to an error page, so we ignore it by covering up the error with the empty state.
if (resolveObjectResponse.state === "failed") {
resolveObjectResponse = { state: "empty" };
}
} }
} }
} }
@ -950,7 +956,7 @@ export class Search extends Component<any, SearchState> {
if (auth) { if (auth) {
this.setState({ resolveObjectRes: { state: "loading" } }); this.setState({ resolveObjectRes: { state: "loading" } });
this.setState({ this.setState({
resolveObjectRes: await HttpService.client.resolveObject({ resolveObjectRes: await HttpService.silent_client.resolveObject({
q, q,
auth, auth,
}), }),
@ -1097,10 +1103,6 @@ export class Search extends Component<any, SearchState> {
sort: sort ?? urlSort, sort: sort ?? urlSort,
}; };
this.props.history.push(`/search${getQueryString(queryParams)}`, { this.props.history.push(`/search${getQueryString(queryParams)}`);
searched: true,
});
await this.search();
} }
} }

View file

@ -1,5 +1,7 @@
export const favIconUrl = "/static/assets/icons/favicon.svg"; import { getStaticDir } from "@utils/env";
export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
export const favIconUrl = `${getStaticDir()}/assets/icons/favicon.svg`;
export const favIconPngUrl = `${getStaticDir()}/assets/icons/apple-touch-icon.png`;
export const repoUrl = "https://github.com/LemmyNet"; export const repoUrl = "https://github.com/LemmyNet";
export const joinLemmyUrl = "https://join-lemmy.org"; export const joinLemmyUrl = "https://join-lemmy.org";
@ -21,7 +23,7 @@ export const markdownFieldCharacterLimit = 50000;
export const maxUploadImages = 20; export const maxUploadImages = 20;
export const concurrentImageUpload = 4; export const concurrentImageUpload = 4;
export const updateUnreadCountsInterval = 30000; export const updateUnreadCountsInterval = 30000;
export const fetchLimit = 40; export const fetchLimit = 20;
export const relTags = "noopener nofollow"; export const relTags = "noopener nofollow";
export const emDash = "\u2014"; export const emDash = "\u2014";

View file

@ -1,9 +1,9 @@
import { getHttpBase } from "@utils/env"; import { getHttpBase } from "@utils/env";
import { LemmyHttp } from "lemmy-js-client"; import { LemmyHttp } from "lemmy-js-client";
import { toast } from "../../shared/toast"; import { toast } from "../toast";
import { I18NextService } from "./I18NextService"; import { I18NextService } from "./I18NextService";
type EmptyRequestState = { export type EmptyRequestState = {
state: "empty"; state: "empty";
}; };
@ -45,7 +45,7 @@ export type WrappedLemmyHttp = {
class WrappedLemmyHttpClient { class WrappedLemmyHttpClient {
#client: LemmyHttp; #client: LemmyHttp;
constructor(client: LemmyHttp) { constructor(client: LemmyHttp, silent = false) {
this.#client = client; this.#client = client;
for (const key of Object.getOwnPropertyNames( for (const key of Object.getOwnPropertyNames(
@ -61,8 +61,10 @@ class WrappedLemmyHttpClient {
state: !(res === undefined || res === null) ? "success" : "empty", state: !(res === undefined || res === null) ? "success" : "empty",
}; };
} catch (error) { } catch (error) {
console.error(`API error: ${error}`); if (!silent) {
toast(I18NextService.i18n.t(error), "danger"); console.error(`API error: ${error}`);
toast(I18NextService.i18n.t(error), "danger");
}
return { return {
state: "failed", state: "failed",
msg: error, msg: error,
@ -74,16 +76,23 @@ class WrappedLemmyHttpClient {
} }
} }
export function wrapClient(client: LemmyHttp) { export function wrapClient(client: LemmyHttp, silent = false) {
return new WrappedLemmyHttpClient(client) as unknown as WrappedLemmyHttp; // unfortunately, this verbose cast is necessary // unfortunately, this verbose cast is necessary
return new WrappedLemmyHttpClient(
client,
silent
) as unknown as WrappedLemmyHttp;
} }
export class HttpService { export class HttpService {
static #_instance: HttpService; static #_instance: HttpService;
#silent_client: WrappedLemmyHttp;
#client: WrappedLemmyHttp; #client: WrappedLemmyHttp;
private constructor() { private constructor() {
this.#client = wrapClient(new LemmyHttp(getHttpBase())); const lemmyHttp = new LemmyHttp(getHttpBase());
this.#client = wrapClient(lemmyHttp);
this.#silent_client = wrapClient(lemmyHttp, true);
} }
static get #Instance() { static get #Instance() {
@ -93,4 +102,8 @@ export class HttpService {
public static get client() { public static get client() {
return this.#Instance.#client; return this.#Instance.#client;
} }
public static get silent_client() {
return this.#Instance.#silent_client;
}
} }

View file

@ -2,7 +2,7 @@
import { isAuthPath } from "@utils/app"; import { isAuthPath } from "@utils/app";
import { isBrowser } from "@utils/browser"; import { isBrowser } from "@utils/browser";
import { isHttps } from "@utils/env"; import { isHttps } from "@utils/env";
import IsomorphicCookie from "isomorphic-cookie"; import * as cookie from "cookie";
import jwt_decode from "jwt-decode"; import jwt_decode from "jwt-decode";
import { LoginResponse, MyUserInfo } from "lemmy-js-client"; import { LoginResponse, MyUserInfo } from "lemmy-js-client";
import { toast } from "../toast"; import { toast } from "../toast";
@ -31,9 +31,15 @@ export class UserService {
public login(res: LoginResponse) { public login(res: LoginResponse) {
const expires = new Date(); const expires = new Date();
expires.setDate(expires.getDate() + 365); expires.setDate(expires.getDate() + 365);
if (res.jwt) { if (isBrowser() && res.jwt) {
toast(I18NextService.i18n.t("logged_in")); toast(I18NextService.i18n.t("logged_in"));
IsomorphicCookie.save("jwt", res.jwt, { expires, secure: isHttps() }); document.cookie = cookie.serialize("jwt", res.jwt, {
expires,
secure: isHttps(),
domain: location.hostname,
sameSite: true,
path: "/",
});
this.#setJwtInfo(); this.#setJwtInfo();
} }
} }
@ -41,8 +47,14 @@ export class UserService {
public logout() { public logout() {
this.jwtInfo = undefined; this.jwtInfo = undefined;
this.myUserInfo = undefined; this.myUserInfo = undefined;
IsomorphicCookie.remove("jwt"); // TODO is sometimes unreliable for some reason if (isBrowser()) {
document.cookie = "jwt=; Max-Age=0; path=/; domain=" + location.hostname; document.cookie = cookie.serialize("jwt", "", {
maxAge: 0,
path: "/",
domain: location.hostname,
sameSite: true,
});
}
if (isAuthPath(location.pathname)) { if (isAuthPath(location.pathname)) {
location.replace("/"); location.replace("/");
} else { } else {
@ -66,10 +78,11 @@ export class UserService {
} }
#setJwtInfo() { #setJwtInfo() {
const jwt: string | undefined = IsomorphicCookie.load("jwt"); if (isBrowser()) {
const { jwt } = cookie.parse(document.cookie);
if (jwt) { if (jwt) {
this.jwtInfo = { jwt, claims: jwt_decode(jwt) }; this.jwtInfo = { jwt, claims: jwt_decode(jwt) };
}
} }
} }

View file

@ -0,0 +1,21 @@
export default function getRoleLabelPill({
label,
tooltip,
classes,
shrink = true,
}: {
label: string;
tooltip: string;
classes?: string;
shrink?: boolean;
}) {
return (
<span
className={`badge me-1 ${classes ?? "text-bg-light"}`}
aria-label={tooltip}
data-tippy-content={tooltip}
>
{shrink ? label[0].toUpperCase() : label}
</span>
);
}

View file

@ -29,6 +29,7 @@ import getDataTypeString from "./get-data-type-string";
import getDepthFromComment from "./get-depth-from-comment"; import getDepthFromComment from "./get-depth-from-comment";
import getIdFromProps from "./get-id-from-props"; import getIdFromProps from "./get-id-from-props";
import getRecipientIdFromProps from "./get-recipient-id-from-props"; import getRecipientIdFromProps from "./get-recipient-id-from-props";
import getRoleLabelPill from "./get-role-label-pill";
import getUpdatedSearchId from "./get-updated-search-id"; import getUpdatedSearchId from "./get-updated-search-id";
import initializeSite from "./initialize-site"; import initializeSite from "./initialize-site";
import insertCommentIntoTree from "./insert-comment-into-tree"; import insertCommentIntoTree from "./insert-comment-into-tree";
@ -53,7 +54,6 @@ import showScores from "./show-scores";
import siteBannerCss from "./site-banner-css"; import siteBannerCss from "./site-banner-css";
import updateCommunityBlock from "./update-community-block"; import updateCommunityBlock from "./update-community-block";
import updatePersonBlock from "./update-person-block"; import updatePersonBlock from "./update-person-block";
import getUserInterfaceLangId from "./user-interface-language";
export { export {
buildCommentsTree, buildCommentsTree,
@ -87,8 +87,8 @@ export {
getDepthFromComment, getDepthFromComment,
getIdFromProps, getIdFromProps,
getRecipientIdFromProps, getRecipientIdFromProps,
getRoleLabelPill,
getUpdatedSearchId, getUpdatedSearchId,
getUserInterfaceLangId,
initializeSite, initializeSite,
insertCommentIntoTree, insertCommentIntoTree,
isAuthPath, isAuthPath,

View file

@ -1,5 +1,5 @@
export default function isAuthPath(pathname: string) { export default function isAuthPath(pathname: string) {
return /create_.*|inbox|settings|admin|reports|registration_applications/g.test( return /^\/create_.*|inbox|settings|admin|reports|registration_applications/g.test(
pathname pathname
); );
} }

View file

@ -1,18 +1,44 @@
import setDefaultOptions from "date-fns/setDefaultOptions"; import setDefaultOptions from "date-fns/setDefaultOptions";
import { I18NextService } from "../../services"; import { I18NextService } from "../../services";
const EN_US = "en-US";
export default async function () { export default async function () {
let lang = I18NextService.i18n.language; let lang = I18NextService.i18n.language;
if (lang === "en") { if (lang === "en") {
lang = "en-US"; lang = EN_US;
} }
const locale = ( // if lang and country are the same, then date-fns expects only the lang
await import( // eg: instead of "fr-FR", we should import just "fr"
/* webpackExclude: /\.js\.flow$/ */
`date-fns/locale/${lang}` if (lang.includes("-")) {
) const parts = lang.split("-");
).default; if (parts[0] === parts[1].toLowerCase()) {
lang = parts[0];
}
}
let locale;
try {
locale = (
await import(
/* webpackExclude: /\.js\.flow$/ */
`date-fns/locale/${lang}`
)
).default;
} catch (e) {
console.log(
`Could not load locale ${lang} from date-fns, falling back to ${EN_US}`
);
locale = (
await import(
/* webpackExclude: /\.js\.flow$/ */
`date-fns/locale/${EN_US}`
)
).default;
}
setDefaultOptions({ setDefaultOptions({
locale, locale,
}); });

View file

@ -1,18 +0,0 @@
import { Language } from "lemmy-js-client";
import { I18NextService } from "../../services/I18NextService";
export default function getUserInterfaceLangId(
allLanguages: Language[]
): number {
// Get the string of the browser- or user-defined language, like en-US
const i18nLang = I18NextService.i18n.language;
// Find the Language object with a code that matches the initial characters of
// this string
const userLang = allLanguages.find(lang => {
return i18nLang.indexOf(lang.code) === 0;
});
// Return the ID of that language object, or "0" for Undetermined
return userLang?.id || 0;
}

View file

@ -0,0 +1,5 @@
// Returns path to static directory, intended
// for cache-busting based on latest commit hash.
export default function getStaticDir() {
return `/static/${process.env.COMMIT_HASH}`;
}

View file

@ -6,6 +6,7 @@ import getHttpBaseExternal from "./get-http-base-external";
import getHttpBaseInternal from "./get-http-base-internal"; import getHttpBaseInternal from "./get-http-base-internal";
import getInternalHost from "./get-internal-host"; import getInternalHost from "./get-internal-host";
import getSecure from "./get-secure"; import getSecure from "./get-secure";
import getStaticDir from "./get-static-dir";
import httpExternalPath from "./http-external-path"; import httpExternalPath from "./http-external-path";
import isHttps from "./is-https"; import isHttps from "./is-https";
@ -18,6 +19,7 @@ export {
getHttpBaseInternal, getHttpBaseInternal,
getInternalHost, getInternalHost,
getSecure, getSecure,
getStaticDir,
httpExternalPath, httpExternalPath,
isHttps, isHttps,
}; };

View file

@ -2,11 +2,8 @@ import formatDistanceStrict from "date-fns/formatDistanceStrict";
import parseISO from "date-fns/parseISO"; import parseISO from "date-fns/parseISO";
export default function (dateString?: string) { export default function (dateString?: string) {
return formatDistanceStrict( const parsed = parseISO((dateString ?? Date.now().toString()) + "Z");
parseISO(dateString ?? Date.now().toString()), return formatDistanceStrict(parsed, new Date(), {
new Date(), addSuffix: true,
{ });
addSuffix: true,
}
);
} }

View file

@ -6,8 +6,7 @@ const CopyPlugin = require("copy-webpack-plugin");
const RunNodeWebpackPlugin = require("run-node-webpack-plugin"); const RunNodeWebpackPlugin = require("run-node-webpack-plugin");
const merge = require("lodash.merge"); const merge = require("lodash.merge");
const { ServiceWorkerPlugin } = require("service-worker-webpack"); const { ServiceWorkerPlugin } = require("service-worker-webpack");
const BundleAnalyzerPlugin =
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
const banner = ` const banner = `
hash:[contentHash], chunkhash:[chunkhash], name:[name], filebase:[base], query:[query], file:[file] hash:[contentHash], chunkhash:[chunkhash], name:[name], filebase:[base], query:[query], file:[file]
Source code: https://github.com/LemmyNet/lemmy-ui Source code: https://github.com/LemmyNet/lemmy-ui
@ -15,56 +14,63 @@ const banner = `
@license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL v3.0 @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL v3.0
`; `;
const base = { function getBase(env, mode) {
output: { return {
filename: "js/server.js", output: {
publicPath: "/", filename: "js/server.js",
hashFunction: "xxhash64", publicPath: "/",
}, hashFunction: "xxhash64",
resolve: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
alias: {
"@": path.resolve(__dirname, "src/"),
"@utils": path.resolve(__dirname, "src/shared/utils/"),
}, },
}, resolve: {
performance: { extensions: [".js", ".jsx", ".ts", ".tsx"],
hints: false, alias: {
}, "@": path.resolve(__dirname, "src/"),
module: { "@utils": path.resolve(__dirname, "src/shared/utils/"),
rules: [
{
test: /\.(scss|css)$/i,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
}, },
{ },
test: /\.(js|jsx|tsx|ts)$/, // All ts and tsx files will be process by performance: {
exclude: /node_modules/, // ignore node_modules hints: false,
loader: "babel-loader", },
}, module: {
// Due to some weird babel issue: https://github.com/webpack/webpack/issues/11467 rules: [
{ {
test: /\.m?js/, test: /\.(scss|css)$/i,
resolve: { use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
fullySpecified: false,
}, },
}, {
test: /\.(js|jsx|tsx|ts)$/, // All ts and tsx files will be process by
exclude: /node_modules/, // ignore node_modules
loader: "babel-loader",
},
// Due to some weird babel issue: https://github.com/webpack/webpack/issues/11467
{
test: /\.m?js/,
resolve: {
fullySpecified: false,
},
},
],
},
plugins: [
new webpack.DefinePlugin({
"process.env.COMMIT_HASH": `"${env.COMMIT_HASH}"`,
"process.env.NODE_ENV": `"${mode}"`,
}),
new MiniCssExtractPlugin({
filename: "styles/styles.css",
}),
new CopyPlugin({
patterns: [{ from: "./src/assets", to: "./assets" }],
}),
new webpack.BannerPlugin({
banner,
}),
], ],
}, };
plugins: [ }
new MiniCssExtractPlugin({
filename: "styles/styles.css",
}),
new CopyPlugin({
patterns: [{ from: "./src/assets", to: "./assets" }],
}),
new webpack.BannerPlugin({
banner,
}),
],
};
const createServerConfig = (_env, mode) => { const createServerConfig = (env, mode) => {
const base = getBase(env, mode);
const config = merge({}, base, { const config = merge({}, base, {
mode, mode,
entry: "./src/server/index.tsx", entry: "./src/server/index.tsx",
@ -91,23 +97,22 @@ const createServerConfig = (_env, mode) => {
return config; return config;
}; };
const createClientConfig = (_env, mode) => { const createClientConfig = (env, mode) => {
const base = getBase(env, mode);
const config = merge({}, base, { const config = merge({}, base, {
mode, mode,
entry: "./src/client/index.tsx", entry: "./src/client/index.tsx",
output: { output: {
filename: "js/client.js", filename: "js/client.js",
publicPath: `/static/${env.COMMIT_HASH}/`,
}, },
plugins: [ plugins: [
...base.plugins, ...base.plugins,
new ServiceWorkerPlugin({ new ServiceWorkerPlugin({
enableInDevelopment: mode !== "development", // this may seem counterintuitive, but it is correct enableInDevelopment: mode !== "development", // this may seem counterintuitive, but it is correct
workbox: { workbox: {
modifyURLPrefix: {
"/": "/static/",
},
cacheId: "lemmy", cacheId: "lemmy",
include: [/(assets|styles)\/.+\..+|client\.js$/g], include: [/(assets|styles|js)\/.+\..+$/g],
inlineWorkboxRuntime: true, inlineWorkboxRuntime: true,
runtimeCaching: [ runtimeCaching: [
{ {
@ -156,6 +161,8 @@ const createClientConfig = (_env, mode) => {
}); });
if (mode === "none") { if (mode === "none") {
const BundleAnalyzerPlugin =
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
config.plugins.push(new BundleAnalyzerPlugin()); config.plugins.push(new BundleAnalyzerPlugin());
} }

4481
yarn.lock

File diff suppressed because it is too large Load diff