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
src/assets/css/themes/*.css
stats.json
dist

View file

@ -32,3 +32,14 @@ pipeline:
auto_tag: true
when:
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 yarn --production --prefer-offline
RUN yarn build:prod
RUN NODE_OPTIONS="--max-old-space-size=8192" yarn build:prod
# Prune the image
RUN node-prune /usr/src/app/node_modules

View file

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

View file

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

View file

@ -81,6 +81,7 @@
}
.vote-bar {
min-width: 5ch;
margin-top: -6.5px;
}
@ -198,9 +199,9 @@ blockquote {
.thumbnail {
object-fit: cover;
min-height: 60px;
max-height: 80px;
width: 100%;
aspect-ratio: 1/1;
width: 5rem;
height: 5rem;
}
.thumbnail svg {
@ -360,8 +361,9 @@ br.big {
}
.img-icon {
width: 2rem;
height: 2rem;
width: calc(var(--bs-body-line-height) * 1em);
height: calc(var(--bs-body-line-height) * 1em);
border-radius: 0.25em;
}
.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;
$min-contrast-ratio: 3;
$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-fluid,
.container-lg {
.container-xxl,
.container-xl,
.container-lg,
.container-md,
.container-sm {
--bs-gutter-x: 1.5rem;
--bs-gutter-y: 0;
width: 100%;
@ -736,11 +740,31 @@ progress {
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) {
.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;
}
}
@media (min-width: 1400px) {
.container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1320px;
}
}
:root {
--bs-breakpoint-xs: 0;
--bs-breakpoint-sm: 576px;
@ -3867,7 +3891,11 @@ textarea.form-control-lg {
}
.navbar > .container,
.navbar > .container-fluid,
.navbar > .container-lg {
.navbar > .container-sm,
.navbar > .container-md,
.navbar > .container-lg,
.navbar > .container-xl,
.navbar > .container-xxl {
display: flex;
flex-wrap: inherit;
align-items: center;

View file

@ -726,7 +726,11 @@ progress {
.container,
.container-fluid,
.container-lg {
.container-xxl,
.container-xl,
.container-lg,
.container-md,
.container-sm {
--bs-gutter-x: 1.5rem;
--bs-gutter-y: 0;
width: 100%;
@ -736,11 +740,31 @@ progress {
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) {
.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;
}
}
@media (min-width: 1400px) {
.container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1320px;
}
}
:root {
--bs-breakpoint-xs: 0;
--bs-breakpoint-sm: 576px;
@ -3867,7 +3891,11 @@ textarea.form-control-lg {
}
.navbar > .container,
.navbar > .container-fluid,
.navbar > .container-lg {
.navbar > .container-sm,
.navbar > .container-md,
.navbar > .container-lg,
.navbar > .container-xl,
.navbar > .container-xxl {
display: flex;
flex-wrap: inherit;
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-fluid,
.container-lg {
.container-xxl,
.container-xl,
.container-lg,
.container-md,
.container-sm {
--bs-gutter-x: 1.5rem;
--bs-gutter-y: 0;
width: 100%;
@ -735,11 +739,31 @@ progress {
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) {
.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;
}
}
@media (min-width: 1400px) {
.container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1320px;
}
}
:root {
--bs-breakpoint-xs: 0;
--bs-breakpoint-sm: 576px;
@ -3866,7 +3890,11 @@ textarea.form-control-lg {
}
.navbar > .container,
.navbar > .container-fluid,
.navbar > .container-lg {
.navbar > .container-sm,
.navbar > .container-md,
.navbar > .container-lg,
.navbar > .container-xl,
.navbar > .container-xxl {
display: flex;
flex-wrap: inherit;
align-items: center;

View file

@ -725,7 +725,11 @@ progress {
.container,
.container-fluid,
.container-lg {
.container-xxl,
.container-xl,
.container-lg,
.container-md,
.container-sm {
--bs-gutter-x: 1.5rem;
--bs-gutter-y: 0;
width: 100%;
@ -735,11 +739,31 @@ progress {
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) {
.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;
}
}
@media (min-width: 1400px) {
.container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {
max-width: 1320px;
}
}
:root {
--bs-breakpoint-xs: 0;
--bs-breakpoint-sm: 576px;
@ -3866,7 +3890,11 @@ textarea.form-control-lg {
}
.navbar > .container,
.navbar > .container-fluid,
.navbar > .container-lg {
.navbar > .container-sm,
.navbar > .container-md,
.navbar > .container-lg,
.navbar > .container-xl,
.navbar > .container-xxl {
display: flex;
flex-wrap: inherit;
align-items: center;

View file

@ -1,11 +1,11 @@
import { initializeSite, isAuthPath } from "@utils/app";
import { getHttpBaseInternal } from "@utils/env";
import { ErrorPageData } from "@utils/types";
import * as cookie from "cookie";
import fetch from "cross-fetch";
import type { Request, Response } from "express";
import { StaticRouter, matchPath } from "inferno-router";
import { renderToString } from "inferno-server";
import IsomorphicCookie from "isomorphic-cookie";
import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client";
import { App } from "../../shared/components/app/app";
import {
@ -25,11 +25,15 @@ import { setForwardedHeaders } from "../utils/set-forwarded-headers";
export default async (req: Request, res: Response) => {
try {
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 headers = setForwardedHeaders(req.headers);
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { fetchFunction: fetch, headers })
);
@ -43,6 +47,7 @@ export default async (req: Request, res: Response) => {
let routeData: RouteData = {};
let errorPageData: ErrorPageData | undefined = undefined;
let try_site = await client.getSite(getSiteForm);
if (try_site.state === "failed" && try_site.msg == "not_logged_in") {
console.error(
"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);
}
if (!activeRoute) {
res.status(404);
}
} else if (try_site.state === "failed") {
res.status(500);
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
if (error) {
console.error(error.msg);
if (error.msg === "instance_is_private") {
return res.redirect(`/signup`);
} else {
res.status(500);
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
console.error(err);
res.statusCode = 500;
return res.send(
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 { getStaticDir } from "@utils/env";
import express from "express";
import path from "path";
import process from "process";
import CatchAllHandler from "./handlers/catch-all-handler";
import ManifestHandler from "./handlers/manifest-handler";
import RobotsHandler from "./handlers/robots-handler";
import SecurityHandler from "./handlers/security-handler";
import ServiceWorkerHandler from "./handlers/service-worker-handler";
import ThemeHandler from "./handlers/theme-handler";
import ThemesListHandler from "./handlers/themes-list-handler";
import setDefaultCsp from "./middleware/set-default-csp";
import { setCacheControl, setDefaultCsp } from "./middleware";
const server = express();
@ -18,12 +20,20 @@ const [hostname, port] = process.env["LEMMY_UI_HOST"]
server.use(express.json());
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"]) {
server.use(setDefaultCsp);
}
server.get("/.well-known/security.txt", SecurityHandler);
server.get("/robots.txt", RobotsHandler);
server.get("/service-worker.js", ServiceWorkerHandler);
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-red",
"darkly-compact",
"darkly-pureblack",
"litely",
"litely-red",
"litely-compact",
"i386",
];
export async function buildThemeList(): Promise<ReadonlyArray<string>> {

View file

@ -1,3 +1,4 @@
import { getStaticDir } from "@utils/env";
import { Helmet } from "inferno-helmet";
import { renderToString } from "inferno-server";
import serialize from "serialize-javascript";
@ -23,7 +24,7 @@ export async function createSsrHtml(
if (!appleTouchIcon) {
appleTouchIcon = site?.site_view.site.icon
? `data:image/png;base64,${sharp(
? `data:image/png;base64,${await sharp(
await fetchIconPng(site.site_view.site.icon)
)
.resize(180, 180)
@ -87,7 +88,7 @@ export async function createSsrHtml(
<link rel="apple-touch-startup-image" href=${appleTouchIcon} />
<!-- 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 -->
${helmet.link.toString() || fallbackTheme}
@ -102,7 +103,7 @@ export async function createSsrHtml(
</noscript>
<div id='root'>${root}</div>
<script defer src='/static/js/client.js'></script>
<script defer src='${getStaticDir()}/js/client.js'></script>
</body>
</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 person = UserService.Instance.myUserInfo?.local_user_view.person;
return (
<nav
className="navbar navbar-expand-md navbar-light shadow-sm 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)}
<div className="shadow-sm">
<nav
className="navbar navbar-expand-md navbar-light p-0 px-3 container-lg"
id="navbar"
>
{siteView?.site.icon && showAvatars() && (
<PictrsImage src={siteView.site.icon} icon />
)}
{siteView?.site.name}
</NavLink>
{person && (
<ul className="navbar-nav d-flex flex-row ms-auto d-md-none">
<li id="navMessages" className="nav-item nav-item-icon">
<NavLink
to="/inbox"
className="p-1 nav-link border-0 nav-messages"
title={I18NextService.i18n.t("unread_messages", {
count: Number(this.state.unreadApplicationCountRes.state),
formattedCount: numToSI(this.unreadInboxCount),
})}
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
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() && (
<PictrsImage src={siteView.site.icon} icon />
)}
{siteView?.site.name}
</NavLink>
{person && (
<ul className="navbar-nav d-flex flex-row ms-auto d-md-none">
<li id="navMessages" className="nav-item nav-item-icon">
<NavLink
to="/reports"
className="p-1 nav-link border-0"
title={I18NextService.i18n.t("unread_reports", {
count: Number(this.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount),
to="/inbox"
className="p-1 nav-link border-0 nav-messages"
title={I18NextService.i18n.t("unread_messages", {
count: Number(this.state.unreadApplicationCountRes.state),
formattedCount: numToSI(this.unreadInboxCount),
})}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="shield" />
{this.unreadReportCount > 0 && (
<Icon icon="bell" />
{this.unreadInboxCount > 0 && (
<span className="mx-1 badge text-bg-light">
{numToSI(this.unreadReportCount)}
{numToSI(this.unreadInboxCount)}
</span>
)}
</NavLink>
</li>
)}
{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">
{this.moderatesSomething && (
<li className="nav-item nav-item-icon">
<NavLink
className="nav-link d-inline-flex align-items-center d-md-inline-block"
to="/inbox"
title={I18NextService.i18n.t("unread_messages", {
count: Number(this.unreadInboxCount),
formattedCount: numToSI(this.unreadInboxCount),
to="/reports"
className="p-1 nav-link border-0"
title={I18NextService.i18n.t("unread_reports", {
count: Number(this.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount),
})}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="bell" />
<span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("unread_messages", {
count: Number(this.unreadInboxCount),
formattedCount: numToSI(this.unreadInboxCount),
})}
</span>
{this.unreadInboxCount > 0 && (
<Icon icon="shield" />
{this.unreadReportCount > 0 && (
<span className="mx-1 badge text-bg-light">
{numToSI(this.unreadInboxCount)}
{numToSI(this.unreadReportCount)}
</span>
)}
</NavLink>
</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
className="nav-link d-inline-flex align-items-center d-md-inline-block"
to="/reports"
title={I18NextService.i18n.t("unread_reports", {
count: Number(this.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount),
to="/inbox"
title={I18NextService.i18n.t("unread_messages", {
count: Number(this.unreadInboxCount),
formattedCount: numToSI(this.unreadInboxCount),
})}
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">
{I18NextService.i18n.t("unread_reports", {
count: Number(this.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount),
{I18NextService.i18n.t("unread_messages", {
count: Number(this.unreadInboxCount),
formattedCount: numToSI(this.unreadInboxCount),
})}
</span>
{this.unreadReportCount > 0 && (
{this.unreadInboxCount > 0 && (
<span className="mx-1 badge text-bg-light">
{numToSI(this.unreadReportCount)}
{numToSI(this.unreadInboxCount)}
</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",
{
count: Number(this.unreadApplicationCount),
formattedCount: numToSI(this.unreadApplicationCount),
}
)}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="clipboard" />
<span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t(
{this.moderatesSomething && (
<li id="navModeration" className="nav-item">
<NavLink
className="nav-link d-inline-flex align-items-center d-md-inline-block"
to="/reports"
title={I18NextService.i18n.t("unread_reports", {
count: Number(this.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount),
})}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="shield" />
<span className="badge text-bg-light d-inline ms-1 d-md-none ms-md-0">
{I18NextService.i18n.t("unread_reports", {
count: Number(this.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount),
})}
</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",
{
count: Number(this.unreadApplicationCount),
@ -337,97 +327,111 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
),
}
)}
</span>
{this.unreadApplicationCount > 0 && (
<span className="mx-1 badge text-bg-light">
{numToSI(this.unreadApplicationCount)}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="clipboard" />
<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>
)}
{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>
</li>
)}
{person && (
<li id="dropdownUser" className="dropdown">
<button
type="button"
className="btn dropdown-toggle"
aria-expanded="false"
data-bs-toggle="dropdown"
<li className="nav-item">
<NavLink
to="/signup"
className="nav-link"
title={I18NextService.i18n.t("sign_up")}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
{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>
{I18NextService.i18n.t("sign_up")}
</NavLink>
</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>
</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>
</>
)}
</ul>
</div>
</nav>
</div>
);
}

View file

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

View file

@ -1,6 +1,7 @@
import {
colorList,
getCommentParentId,
getRoleLabelPill,
myAuth,
myAuthRequired,
showScores,
@ -308,32 +309,43 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
classes="icon-inline"
/>
</button>
<span className="me-2">
<PersonListing person={cv.creator} />
</span>
{cv.comment.distinguished && (
<Icon icon="shield" inline classes="text-danger me-2" />
)}
{this.isPostCreator && (
<div className="badge text-bg-light d-none d-sm-inline me-2">
{I18NextService.i18n.t("creator")}
</div>
)}
{isMod_ && (
<div className="badge text-bg-light d-none d-sm-inline me-2">
{I18NextService.i18n.t("mod")}
</div>
)}
{isAdmin_ && (
<div className="badge text-bg-light d-none d-sm-inline me-2">
{I18NextService.i18n.t("admin")}
</div>
)}
{cv.creator.bot_account && (
<div className="badge text-bg-light d-none d-sm-inline me-2">
{I18NextService.i18n.t("bot_account").toLowerCase()}
</div>
)}
{this.isPostCreator &&
getRoleLabelPill({
label: I18NextService.i18n.t("op").toUpperCase(),
tooltip: I18NextService.i18n.t("creator"),
classes: "text-bg-info",
shrink: false,
})}
{isMod_ &&
getRoleLabelPill({
label: I18NextService.i18n.t("mod"),
tooltip: I18NextService.i18n.t("mod"),
classes: "text-bg-primary",
})}
{isAdmin_ &&
getRoleLabelPill({
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 && (
<>
<span className="mx-1">{I18NextService.i18n.t("to")}</span>
@ -344,7 +356,9 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
</Link>
</>
)}
{this.linkBtn(true)}
{this.getLinkButton(true)}
{cv.comment.language_id !== 0 && (
<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">
{this.props.showContext && this.linkBtn()}
{this.props.showContext && this.getLinkButton()}
{this.props.markable && (
<button
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 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 { Component } from "inferno";
import { I18NextService } from "../../services";
@ -23,7 +24,9 @@ export class Icon extends Component<IconProps, any> {
})}
>
<use
xlinkHref={`/static/assets/symbols.svg#icon-${this.props.icon}`}
xlinkHref={`${getStaticDir()}/assets/symbols.svg#icon-${
this.props.icon
}`}
></use>
<div className="visually-hidden">
<title>{this.props.icon}</title>

View file

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

View file

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

View file

@ -159,13 +159,16 @@ export class MarkdownTextArea extends Component<
<div className="mb-3 row">
<div className="col-12">
<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("italic", this.handleInsertItalic)}
{this.getFormatButton("link", this.handleInsertLink)}
<EmojiPicker
onEmojiClick={e => this.handleEmoji(this, e)}
disabled={this.isDisabled}
></EmojiPicker>
<form className="btn btn-sm text-muted fw-bold">
<label
@ -191,9 +194,7 @@ export class MarkdownTextArea extends Component<
name="file"
className="d-none"
multiple
disabled={
!UserService.Instance.myUserInfo || this.isDisabled
}
disabled={!UserService.Instance.myUserInfo}
onChange={linkEvent(this, this.handleImageUpload)}
/>
</form>
@ -276,12 +277,8 @@ export class MarkdownTextArea extends Component<
<LanguageSelect
iconVersion
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={
languageId && this.props.siteLanguages.includes(languageId)
? [languageId]
: [0]
languageId ? Array.of(languageId) : undefined
}
siteLanguages={this.props.siteLanguages}
onChange={this.handleLanguageChange}
@ -355,7 +352,6 @@ export class MarkdownTextArea extends Component<
data-tippy-content={I18NextService.i18n.t(type)}
aria-label={I18NextService.i18n.t(type)}
onClick={linkEvent(this, handleClick)}
disabled={this.isDisabled}
>
<Icon icon={iconType} classes="icon-inline" />
</button>
@ -450,6 +446,10 @@ export class MarkdownTextArea extends Component<
const textarea: any = document.getElementById(i.id);
autosize.update(textarea);
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 {
throw JSON.stringify(res.data);
}
@ -476,7 +476,7 @@ export class MarkdownTextArea extends Component<
// Keybind handler
// Keybinds inspired by github comment area
handleKeyBinds(i: MarkdownTextArea, event: KeyboardEvent) {
if (event.ctrlKey) {
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case "k": {
i.handleInsertLink(i, event);
@ -705,18 +705,20 @@ export class MarkdownTextArea extends Component<
quoteInsert() {
const textarea: any = document.getElementById(this.id);
const selectedText = window.getSelection()?.toString();
const { content } = this.state;
let { content } = this.state;
if (selectedText) {
const quotedText =
selectedText
.split("\n")
.map(t => `> ${t}`)
.join("\n") + "\n\n";
if (!content) {
this.setState({ content: "" });
content = "";
} else {
this.setState({ content: `${content}\n` });
content = `${content}\n\n`;
}
this.setState({
content: `${content}${quotedText}`,
});

View file

@ -1,5 +1,5 @@
import { capitalizeFirstLetter, formatPastDate } from "@utils/helpers";
import format from "date-fns/format";
import { format } from "date-fns";
import parseISO from "date-fns/parseISO";
import { Component } from "inferno";
import { I18NextService } from "../../services";
@ -13,7 +13,8 @@ interface MomentTimeProps {
}
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> {

View file

@ -39,7 +39,7 @@ export class PictrsImage extends Component<PictrsImageProps, any> {
"img-expanded slight-radius":
!this.props.thumbnail && !this.props.icon,
"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":
this.props.iconOverlay,
"avatar-pushup": this.props.pushup,

View file

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

View file

@ -284,7 +284,9 @@ export class Communities extends Component<any, CommunitiesState> {
handleSearchSubmit(i: Communities, event: any) {
event.preventDefault();
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({

View file

@ -317,7 +317,10 @@ export class Community extends Component<
/>
<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)}
<div className="d-block d-md-none">
<button
@ -340,7 +343,7 @@ export class Community extends Component<
{this.listings(res)}
<Paginator page={page} onChange={this.handlePageChange} />
</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)}
</aside>
</div>

View file

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

View file

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

View file

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

View file

@ -205,9 +205,7 @@ export class Setup extends Component<any, State> {
const data = i.state.registerRes.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,
InfernoKeyboardEvent,
InfernoMouseEvent,
InfernoNode,
linkEvent,
} from "inferno";
import {
@ -13,6 +14,7 @@ import {
Instance,
ListingType,
} from "lemmy-js-client";
import deepEqual from "lodash.isequal";
import { I18NextService } from "../../services";
import { Icon, Spinner } from "../common/icon";
import { ImageUploadForm } from "../common/image-upload-form";
@ -55,6 +57,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
initSiteForm(): EditSite {
const site = this.props.siteRes.site_view.site;
const ls = this.props.siteRes.site_view.local_site;
return {
name: site.name,
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) {
const id = `create_site_${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) {
event.preventDefault();
if (this.state.editingRow == d.index) {
if (d.i.state.editingRow == d.index) {
d.i.setState({ editingRow: undefined });
} else {
d.i.setState({ editingRow: d.index });

View file

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

View file

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

View file

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

View file

@ -4,7 +4,6 @@ import {
myAuth,
myAuthRequired,
} from "@utils/app";
import getUserInterfaceLangId from "@utils/app/user-interface-language";
import {
capitalizeFirstLetter,
debounce,
@ -188,6 +187,8 @@ function handleImageUpload(i: PostForm, event: any) {
imageLoading: false,
imageDeleteUrl: res.data.delete_url as string,
});
} else if (res.data.msg === "too_large") {
toast(I18NextService.i18n.t("upload_too_large"), "danger");
} else {
toast(JSON.stringify(res), "danger");
}
@ -324,9 +325,10 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
}
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 (
<form className="post-form" onSubmit={linkEvent(this, handlePostSubmit)}>
@ -493,8 +495,8 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
</div>
<LanguageSelect
allLanguages={this.props.allLanguages}
selectedLanguageIds={[userInterfaceLangId]}
siteLanguages={this.props.siteLanguages}
selectedLanguageIds={selectedLangs}
multiple={false}
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 { getExternalHost, getHttpBase } from "@utils/env";
import {
@ -49,7 +49,7 @@ import {
PurgeType,
VoteContentType,
} from "../../interfaces";
import { mdNoImages, mdToHtml, mdToHtmlInline } from "../../markdown";
import { mdToHtml, mdToHtmlInline } from "../../markdown";
import { I18NextService, UserService } from "../../services";
import { setupTippy } from "../../tippy";
import { Icon, PurgeWarning, Spinner } from "../common/icon";
@ -105,6 +105,9 @@ interface PostListingProps {
allLanguages: Language[];
siteLanguages: number[];
showCommunity?: boolean;
/**
* Controls whether to show both the body *and* the metadata preview card
*/
showBody?: boolean;
hideImage?: boolean;
enableDownvotes?: boolean;
@ -183,7 +186,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
addModLoading: false,
addAdminLoading: false,
transferLoading: false,
imageExpanded: false,
});
}
}
@ -201,7 +203,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<>
{this.listing()}
{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} />
)}
{this.showBody && this.body()}
@ -329,27 +331,33 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
if (!this.props.hideImage && url && isImage(url) && this.imageSrc) {
return (
<a
href={this.imageSrc}
className="text-body d-inline-block position-relative mb-2"
<button
type="button"
className="thumbnail rounded overflow-hidden d-inline-block position-relative p-0 border-0 bg-transparent"
data-tippy-content={I18NextService.i18n.t("expand_here")}
onClick={linkEvent(this, this.handleImageExpandClick)}
aria-label={I18NextService.i18n.t("expand_here")}
>
{this.imgThumb(this.imageSrc)}
<Icon icon="image" classes="mini-overlay" />
</a>
<Icon
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) {
return (
<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}
rel={relTags}
title={url}
>
{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>
);
} else if (url) {
@ -395,24 +403,29 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
createdLine() {
const post_view = this.postView;
return (
<span className="small">
<PersonListing person={post_view.creator} muted={true} />
{this.creatorIsMod_ && (
<span className="mx-1 badge text-bg-light">
{I18NextService.i18n.t("mod")}
</span>
)}
{this.creatorIsAdmin_ && (
<span className="mx-1 badge text-bg-light">
{I18NextService.i18n.t("admin")}
</span>
)}
{post_view.creator.bot_account && (
<span className="mx-1 badge text-bg-light">
{I18NextService.i18n.t("bot_account").toLowerCase()}
</span>
)}
<div className="small mb-1 mb-md-0">
<span className="me-1">
<PersonListing person={post_view.creator} />
</span>
{this.creatorIsMod_ &&
getRoleLabelPill({
label: I18NextService.i18n.t("mod"),
tooltip: I18NextService.i18n.t("mod"),
classes: "text-bg-primary",
})}
{this.creatorIsAdmin_ &&
getRoleLabelPill({
label: I18NextService.i18n.t("admin"),
tooltip: I18NextService.i18n.t("admin"),
classes: "text-bg-danger",
})}
{post_view.creator.bot_account &&
getRoleLabelPill({
label: I18NextService.i18n.t("bot_account").toLowerCase(),
tooltip: I18NextService.i18n.t("bot_account"),
})}
{this.props.showCommunity && (
<>
{" "}
@ -434,7 +447,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
published={post_view.post.published}
updated={post_view.post.updated}
/>
</span>
</div>
);
}
@ -483,6 +496,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
)}
</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 && (
<small className="ms-2 badge text-bg-secondary">
{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() {
// Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
// Possible enhancement: Make each button a component.
@ -657,14 +658,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.saveButton}
{this.crossPostButton}
{/**
* 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}
{this.props.showBody && post_view.post.body && this.viewSourceButton}
<div className="dropdown">
<button
@ -709,6 +703,50 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{(this.canMod_ || this.canAdmin_) && (
<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>
</div>
</>
@ -976,9 +1014,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modBanFromCommunityButton() {
return (
<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)}
aria-label={I18NextService.i18n.t("ban_from_community")}
>
{I18NextService.i18n.t("ban_from_community")}
</button>
@ -988,9 +1025,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modUnbanFromCommunityButton() {
return (
<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)}
aria-label={I18NextService.i18n.t("unban")}
>
{this.state.banLoading ? <Spinner /> : I18NextService.i18n.t("unban")}
</button>
@ -1000,20 +1036,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get addModToCommunityButton() {
return (
<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)}
aria-label={
this.creatorIsMod_
? I18NextService.i18n.t("remove_as_mod")
: I18NextService.i18n.t("appoint_as_mod")
}
>
{this.state.addModLoading ? (
<Spinner />
) : 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>
);
@ -1022,11 +1053,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modBanButton() {
return (
<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)}
aria-label={I18NextService.i18n.t("ban_from_site")}
>
{I18NextService.i18n.t("ban_from_site")}
{capitalizeFirstLetter(I18NextService.i18n.t("ban_from_site"))}
</button>
);
}
@ -1034,14 +1064,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modUnbanButton() {
return (
<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)}
aria-label={I18NextService.i18n.t("unban_from_site")}
>
{this.state.banLoading ? (
<Spinner />
) : (
I18NextService.i18n.t("unban_from_site")
capitalizeFirstLetter(I18NextService.i18n.t("unban_from_site"))
)}
</button>
);
@ -1050,11 +1079,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get purgePersonButton() {
return (
<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)}
aria-label={I18NextService.i18n.t("purge_user")}
>
{I18NextService.i18n.t("purge_user")}
{capitalizeFirstLetter(I18NextService.i18n.t("purge_user"))}
</button>
);
}
@ -1062,11 +1090,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get purgePostButton() {
return (
<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)}
aria-label={I18NextService.i18n.t("purge_post")}
>
{I18NextService.i18n.t("purge_post")}
{capitalizeFirstLetter(I18NextService.i18n.t("purge_post"))}
</button>
);
}
@ -1074,20 +1101,31 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get toggleAdminButton() {
return (
<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)}
>
{this.state.addAdminLoading ? (
<Spinner />
) : 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>
);
}
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() {
const removed = this.postView.post.removed;
return (
@ -1102,102 +1140,17 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.state.removeLoading ? (
<Spinner />
) : !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>
);
}
/**
* 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() {
const post = this.postView;
const purgeTypeText =
@ -1225,11 +1178,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
value={this.state.removeReason}
onInput={linkEvent(this, this.handleModRemoveReasonChange)}
/>
<button
type="submit"
className="btn btn-secondary"
aria-label={I18NextService.i18n.t("remove_post")}
>
<button type="submit" className="btn btn-secondary">
{this.state.removeLoading ? (
<Spinner />
) : (
@ -1238,6 +1187,33 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</button>
</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 && (
<form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
<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)} /> */}
{/* </div> */}
<div className="mb-3 row">
<button
type="submit"
className="btn btn-secondary"
aria-label={I18NextService.i18n.t("ban")}
>
<button type="submit" className="btn btn-secondary">
{this.state.banLoading ? (
<Spinner />
) : (
@ -1324,11 +1296,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
value={this.state.reportReason}
onInput={linkEvent(this, this.handleReportReasonChange)}
/>
<button
type="submit"
className="btn btn-secondary"
aria-label={I18NextService.i18n.t("create_report")}
>
<button type="submit" className="btn btn-secondary">
{this.state.reportLoading ? (
<Spinner />
) : (
@ -1357,11 +1325,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.state.purgeLoading ? (
<Spinner />
) : (
<button
type="submit"
className="btn btn-secondary"
aria-label={purgeTypeText}
>
<button type="submit" className="btn btn-secondary">
{this.state.purgeLoading ? <Spinner /> : { purgeTypeText }}
</button>
)}
@ -1388,15 +1352,18 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
);
}
showBodyPreview() {
const { body, id } = this.postView.post;
return !this.showBody && body ? (
<Link className="text-body mt-2 d-block" to={`/post/${id}`}>
<div className="md-div mb-1 preview-lines">{body}</div>
</Link>
) : (
<></>
showPreviewButton() {
return (
<button
type="button"
className="btn btn-sm btn-link link-dark link-opacity-75 link-opacity-100-hover py-0 align-baseline"
onClick={linkEvent(this, this.handleShowBody)}
>
<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 */}
{this.mobileThumbnail()}
{/* Show a preview of the post body */}
{this.showBodyPreview()}
{this.commentsLine(true)}
{this.userActionsLine()}
{this.duplicatesLine()}
{this.removeAndBanDialogs()}
</div>
@ -1427,27 +1390,27 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<div className="d-none d-sm-block">
<article className="row post-container">
{!this.props.viewOnly && (
<VoteButtons
voteContentType={VoteContentType.Post}
id={this.postView.post.id}
onVote={this.props.onPostVote}
enableDownvotes={this.props.enableDownvotes}
counts={this.postView.counts}
my_vote={this.postView.my_vote}
/>
<div className="col flex-grow-0">
<VoteButtons
voteContentType={VoteContentType.Post}
id={this.postView.post.id}
onVote={this.props.onPostVote}
enableDownvotes={this.props.enableDownvotes}
counts={this.postView.counts}
my_vote={this.postView.my_vote}
/>
</div>
)}
<div className="col-sm-2 pe-0 post-media">
<div className="">{this.thumbnail()}</div>
</div>
<div className="col-12 col-sm-9">
<div className="col flex-grow-1">
<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.createdLine()}
{this.showBodyPreview()}
{this.commentsLine()}
{this.duplicatesLine()}
{this.userActionsLine()}
{this.removeAndBanDialogs()}
</div>
</div>

View file

@ -348,7 +348,7 @@ export class Post extends Component<any, PostState> {
const res = this.state.postRes.data;
return (
<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
title={this.documentTitle}
path={this.context.router.route.match.url}
@ -416,7 +416,7 @@ export class Post extends Component<any, PostState> {
{this.state.commentViewType == CommentViewType.Flat &&
this.commentsFlat()}
</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()}
</aside>
</div>

View file

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

View file

@ -332,9 +332,7 @@ export class Search extends Component<any, SearchState> {
}
async componentDidMount() {
if (
!(this.state.isIsomorphic || this.props.history.location.state?.searched)
) {
if (!this.state.isIsomorphic) {
const promises = [this.fetchCommunities()];
if (this.state.searchText) {
promises.push(this.search());
@ -432,7 +430,15 @@ export class Search extends Component<any, SearchState> {
q: query,
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) {
this.setState({ resolveObjectRes: { state: "loading" } });
this.setState({
resolveObjectRes: await HttpService.client.resolveObject({
resolveObjectRes: await HttpService.silent_client.resolveObject({
q,
auth,
}),
@ -1097,10 +1103,6 @@ export class Search extends Component<any, SearchState> {
sort: sort ?? urlSort,
};
this.props.history.push(`/search${getQueryString(queryParams)}`, {
searched: true,
});
await this.search();
this.props.history.push(`/search${getQueryString(queryParams)}`);
}
}

View file

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

View file

@ -1,9 +1,9 @@
import { getHttpBase } from "@utils/env";
import { LemmyHttp } from "lemmy-js-client";
import { toast } from "../../shared/toast";
import { toast } from "../toast";
import { I18NextService } from "./I18NextService";
type EmptyRequestState = {
export type EmptyRequestState = {
state: "empty";
};
@ -45,7 +45,7 @@ export type WrappedLemmyHttp = {
class WrappedLemmyHttpClient {
#client: LemmyHttp;
constructor(client: LemmyHttp) {
constructor(client: LemmyHttp, silent = false) {
this.#client = client;
for (const key of Object.getOwnPropertyNames(
@ -61,8 +61,10 @@ class WrappedLemmyHttpClient {
state: !(res === undefined || res === null) ? "success" : "empty",
};
} catch (error) {
console.error(`API error: ${error}`);
toast(I18NextService.i18n.t(error), "danger");
if (!silent) {
console.error(`API error: ${error}`);
toast(I18NextService.i18n.t(error), "danger");
}
return {
state: "failed",
msg: error,
@ -74,16 +76,23 @@ class WrappedLemmyHttpClient {
}
}
export function wrapClient(client: LemmyHttp) {
return new WrappedLemmyHttpClient(client) as unknown as WrappedLemmyHttp; // unfortunately, this verbose cast is necessary
export function wrapClient(client: LemmyHttp, silent = false) {
// unfortunately, this verbose cast is necessary
return new WrappedLemmyHttpClient(
client,
silent
) as unknown as WrappedLemmyHttp;
}
export class HttpService {
static #_instance: HttpService;
#silent_client: WrappedLemmyHttp;
#client: WrappedLemmyHttp;
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() {
@ -93,4 +102,8 @@ export class HttpService {
public static get 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 { isBrowser } from "@utils/browser";
import { isHttps } from "@utils/env";
import IsomorphicCookie from "isomorphic-cookie";
import * as cookie from "cookie";
import jwt_decode from "jwt-decode";
import { LoginResponse, MyUserInfo } from "lemmy-js-client";
import { toast } from "../toast";
@ -31,9 +31,15 @@ export class UserService {
public login(res: LoginResponse) {
const expires = new Date();
expires.setDate(expires.getDate() + 365);
if (res.jwt) {
if (isBrowser() && res.jwt) {
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();
}
}
@ -41,8 +47,14 @@ export class UserService {
public logout() {
this.jwtInfo = undefined;
this.myUserInfo = undefined;
IsomorphicCookie.remove("jwt"); // TODO is sometimes unreliable for some reason
document.cookie = "jwt=; Max-Age=0; path=/; domain=" + location.hostname;
if (isBrowser()) {
document.cookie = cookie.serialize("jwt", "", {
maxAge: 0,
path: "/",
domain: location.hostname,
sameSite: true,
});
}
if (isAuthPath(location.pathname)) {
location.replace("/");
} else {
@ -66,10 +78,11 @@ export class UserService {
}
#setJwtInfo() {
const jwt: string | undefined = IsomorphicCookie.load("jwt");
if (jwt) {
this.jwtInfo = { jwt, claims: jwt_decode(jwt) };
if (isBrowser()) {
const { jwt } = cookie.parse(document.cookie);
if (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 getIdFromProps from "./get-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 initializeSite from "./initialize-site";
import insertCommentIntoTree from "./insert-comment-into-tree";
@ -53,7 +54,6 @@ import showScores from "./show-scores";
import siteBannerCss from "./site-banner-css";
import updateCommunityBlock from "./update-community-block";
import updatePersonBlock from "./update-person-block";
import getUserInterfaceLangId from "./user-interface-language";
export {
buildCommentsTree,
@ -87,8 +87,8 @@ export {
getDepthFromComment,
getIdFromProps,
getRecipientIdFromProps,
getRoleLabelPill,
getUpdatedSearchId,
getUserInterfaceLangId,
initializeSite,
insertCommentIntoTree,
isAuthPath,

View file

@ -1,5 +1,5 @@
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
);
}

View file

@ -1,18 +1,44 @@
import setDefaultOptions from "date-fns/setDefaultOptions";
import { I18NextService } from "../../services";
const EN_US = "en-US";
export default async function () {
let lang = I18NextService.i18n.language;
if (lang === "en") {
lang = "en-US";
lang = EN_US;
}
const locale = (
await import(
/* webpackExclude: /\.js\.flow$/ */
`date-fns/locale/${lang}`
)
).default;
// if lang and country are the same, then date-fns expects only the lang
// eg: instead of "fr-FR", we should import just "fr"
if (lang.includes("-")) {
const parts = lang.split("-");
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({
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 getInternalHost from "./get-internal-host";
import getSecure from "./get-secure";
import getStaticDir from "./get-static-dir";
import httpExternalPath from "./http-external-path";
import isHttps from "./is-https";
@ -18,6 +19,7 @@ export {
getHttpBaseInternal,
getInternalHost,
getSecure,
getStaticDir,
httpExternalPath,
isHttps,
};

View file

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

View file

@ -6,8 +6,7 @@ const CopyPlugin = require("copy-webpack-plugin");
const RunNodeWebpackPlugin = require("run-node-webpack-plugin");
const merge = require("lodash.merge");
const { ServiceWorkerPlugin } = require("service-worker-webpack");
const BundleAnalyzerPlugin =
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
const banner = `
hash:[contentHash], chunkhash:[chunkhash], name:[name], filebase:[base], query:[query], file:[file]
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
`;
const base = {
output: {
filename: "js/server.js",
publicPath: "/",
hashFunction: "xxhash64",
},
resolve: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
alias: {
"@": path.resolve(__dirname, "src/"),
"@utils": path.resolve(__dirname, "src/shared/utils/"),
function getBase(env, mode) {
return {
output: {
filename: "js/server.js",
publicPath: "/",
hashFunction: "xxhash64",
},
},
performance: {
hints: false,
},
module: {
rules: [
{
test: /\.(scss|css)$/i,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
resolve: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
alias: {
"@": path.resolve(__dirname, "src/"),
"@utils": path.resolve(__dirname, "src/shared/utils/"),
},
{
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,
},
performance: {
hints: false,
},
module: {
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
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, {
mode,
entry: "./src/server/index.tsx",
@ -91,23 +97,22 @@ const createServerConfig = (_env, mode) => {
return config;
};
const createClientConfig = (_env, mode) => {
const createClientConfig = (env, mode) => {
const base = getBase(env, mode);
const config = merge({}, base, {
mode,
entry: "./src/client/index.tsx",
output: {
filename: "js/client.js",
publicPath: `/static/${env.COMMIT_HASH}/`,
},
plugins: [
...base.plugins,
new ServiceWorkerPlugin({
enableInDevelopment: mode !== "development", // this may seem counterintuitive, but it is correct
workbox: {
modifyURLPrefix: {
"/": "/static/",
},
cacheId: "lemmy",
include: [/(assets|styles)\/.+\..+|client\.js$/g],
include: [/(assets|styles|js)\/.+\..+$/g],
inlineWorkboxRuntime: true,
runtimeCaching: [
{
@ -156,6 +161,8 @@ const createClientConfig = (_env, mode) => {
});
if (mode === "none") {
const BundleAnalyzerPlugin =
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
config.plugins.push(new BundleAnalyzerPlugin());
}

4481
yarn.lock

File diff suppressed because it is too large Load diff