Compare commits

..

3 commits

Author SHA1 Message Date
Dessalines
1e53c61aff
Merge branch 'main' into error-status-codes 2023-06-28 16:44:18 -04:00
SleeplessOne1917
61827c3d45
Merge branch 'main' into error-status-codes 2023-06-28 13:41:02 -04:00
SleeplessOne1917
0ea9b72242 Return appropriate error codes 2023-06-28 00:45:34 -04:00
30 changed files with 256 additions and 321 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "lemmy-ui", "name": "lemmy-ui",
"version": "0.18.1-rc.5", "version": "0.18.1-rc.1",
"description": "An isomorphic UI for lemmy", "description": "An isomorphic UI for lemmy",
"repository": "https://github.com/LemmyNet/lemmy-ui", "repository": "https://github.com/LemmyNet/lemmy-ui",
"license": "AGPL-3.0", "license": "AGPL-3.0",
@ -8,9 +8,9 @@
"scripts": { "scripts": {
"analyze": "webpack --mode=none", "analyze": "webpack --mode=none",
"prebuild:dev": "yarn clean && node generate_translations.js", "prebuild:dev": "yarn clean && node generate_translations.js",
"build:dev": "webpack --env COMMIT_HASH=$(git rev-parse --short HEAD) --mode=development", "build:dev": "webpack --mode=development",
"prebuild:prod": "yarn clean && node generate_translations.js", "prebuild:prod": "yarn clean && node generate_translations.js",
"build:prod": "webpack --env COMMIT_HASH=$(git rev-parse --short HEAD) --mode=production", "build:prod": "webpack --mode=production",
"clean": "yarn run rimraf dist", "clean": "yarn run rimraf dist",
"dev": "yarn build:dev --watch", "dev": "yarn build:dev --watch",
"lint": "yarn translations:generate && tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx \"src/**\" && prettier --check \"src/**/*.{ts,tsx,js,css,scss}\"", "lint": "yarn translations:generate && tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx \"src/**\" && prettier --check \"src/**/*.{ts,tsx,js,css,scss}\"",

View file

@ -198,9 +198,9 @@ blockquote {
.thumbnail { .thumbnail {
object-fit: cover; object-fit: cover;
aspect-ratio: 1/1; aspect-ratio: 4/3;
width: 5rem; width: 100%;
height: 5rem; max-height: 6rem;
} }
.thumbnail svg { .thumbnail svg {

View file

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

View file

@ -1,4 +1,4 @@
import type { NextFunction, Request, Response } from "express"; import type { NextFunction, Response } from "express";
import { UserService } from "../shared/services"; import { UserService } from "../shared/services";
export function setDefaultCsp({ export function setDefaultCsp({
@ -10,7 +10,7 @@ export function setDefaultCsp({
}) { }) {
res.setHeader( res.setHeader(
"Content-Security-Policy", "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:` `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 *`
); );
next(); next();
@ -18,33 +18,24 @@ export function setDefaultCsp({
// Set cache-control headers. If user is logged in, set `private` to prevent storing data in // 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 // 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 // all responses for 60 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). // 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 // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
export function setCacheControl( export function setCacheControl({
req: Request, res,
res: Response, next,
next: NextFunction }: {
) { res: Response;
next: NextFunction;
}) {
const user = UserService.Instance; const user = UserService.Instance;
let caching: string; let caching;
if (user.auth()) {
if ( caching = "private";
process.env.NODE_ENV === "production" &&
(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 { } else {
if (user.auth()) { caching = "public, max-age=60";
caching = "private";
} else {
caching = "public, max-age=5";
}
} }
res.setHeader("Cache-Control", caching); res.setHeader("Cache-Control", caching);
next(); next();

View file

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

View file

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

View file

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

View file

@ -443,10 +443,6 @@ export class MarkdownTextArea extends Component<
const textarea: any = document.getElementById(i.id); const textarea: any = document.getElementById(i.id);
autosize.update(textarea); autosize.update(textarea);
pictrsDeleteToast(image.name, res.data.delete_url as string); pictrsDeleteToast(image.name, res.data.delete_url as string);
} else if (res.data.msg === "too_large") {
toast(I18NextService.i18n.t("upload_too_large"), "danger");
i.setState({ imageUploadStatus: undefined });
throw JSON.stringify(res.data);
} else { } else {
throw JSON.stringify(res.data); throw JSON.stringify(res.data);
} }

View file

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

View file

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

View file

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

View file

@ -279,15 +279,13 @@ export class Home extends Component<any, HomeState> {
trendingCommunitiesRes, trendingCommunitiesRes,
commentsRes, commentsRes,
postsRes, postsRes,
tagline: getRandomFromList(this.state?.siteRes?.taglines ?? [])
?.content,
isIsomorphic: true, isIsomorphic: true,
}; };
HomeCacheService.postsRes = postsRes; HomeCacheService.postsRes = postsRes;
} }
this.state.tagline = getRandomFromList(
this.state?.siteRes?.taglines ?? []
)?.content;
} }
componentWillUnmount() { componentWillUnmount() {

View file

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

View file

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

View file

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

View file

@ -187,8 +187,6 @@ function handleImageUpload(i: PostForm, event: any) {
imageLoading: false, imageLoading: false,
imageDeleteUrl: res.data.delete_url as string, imageDeleteUrl: res.data.delete_url as string,
}); });
} else if (res.data.msg === "too_large") {
toast(I18NextService.i18n.t("upload_too_large"), "danger");
} else { } else {
toast(JSON.stringify(res), "danger"); toast(JSON.stringify(res), "danger");
} }

View file

@ -333,7 +333,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
return ( return (
<button <button
type="button" type="button"
className="thumbnail rounded overflow-hidden d-inline-block position-relative p-0 border-0" className="d-inline-block position-relative mb-2 p-0 border-0"
data-tippy-content={I18NextService.i18n.t("expand_here")} data-tippy-content={I18NextService.i18n.t("expand_here")}
onClick={linkEvent(this, this.handleImageExpandClick)} onClick={linkEvent(this, this.handleImageExpandClick)}
aria-label={I18NextService.i18n.t("expand_here")} aria-label={I18NextService.i18n.t("expand_here")}
@ -348,7 +348,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
} else if (!this.props.hideImage && url && thumbnail && this.imageSrc) { } else if (!this.props.hideImage && url && thumbnail && this.imageSrc) {
return ( return (
<a <a
className="thumbnail rounded overflow-hidden d-inline-block position-relative p-0 border-0" className="text-body d-inline-block position-relative mb-2"
href={url} href={url}
rel={relTags} rel={relTags}
title={url} title={url}
@ -403,9 +403,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
createdLine() { createdLine() {
const post_view = this.postView; const post_view = this.postView;
return ( return (
<div className="small mb-1 mb-md-0"> <div className="small">
<span className="me-1"> <span className="me-1">
<PersonListing person={post_view.creator} /> <PersonListing person={post_view.creator} />
</span> </span>
@ -703,50 +702,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{(this.canMod_ || this.canAdmin_) && ( {(this.canMod_ || this.canAdmin_) && (
<li>{this.modRemoveButton}</li> <li>{this.modRemoveButton}</li>
)} )}
{this.canMod_ && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{!this.creatorIsMod_ &&
(!post_view.creator_banned_from_community ? (
<li>{this.modBanFromCommunityButton}</li>
) : (
<li>{this.modUnbanFromCommunityButton}</li>
))}
{!post_view.creator_banned_from_community && (
<li>{this.addModToCommunityButton}</li>
)}
</>
)}
{(amCommunityCreator(post_view.creator.id, this.props.moderators) ||
this.canAdmin_) &&
this.creatorIsMod_ && <li>{this.transferCommunityButton}</li>}
{/* Admins can ban from all, and appoint other admins */}
{this.canAdmin_ && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{!this.creatorIsAdmin_ && (
<>
{!isBanned(post_view.creator) ? (
<li>{this.modBanButton}</li>
) : (
<li>{this.modUnbanButton}</li>
)}
<li>{this.purgePersonButton}</li>
<li>{this.purgePostButton}</li>
</>
)}
{!isBanned(post_view.creator) && post_view.creator.local && (
<li>{this.toggleAdminButton}</li>
)}
</>
)}
</ul> </ul>
</div> </div>
</> </>
@ -1014,8 +969,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modBanFromCommunityButton() { get modBanFromCommunityButton() {
return ( return (
<button <button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item" className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(this, this.handleModBanFromCommunityShow)} onClick={linkEvent(this, this.handleModBanFromCommunityShow)}
aria-label={I18NextService.i18n.t("ban_from_community")}
> >
{I18NextService.i18n.t("ban_from_community")} {I18NextService.i18n.t("ban_from_community")}
</button> </button>
@ -1025,8 +981,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modUnbanFromCommunityButton() { get modUnbanFromCommunityButton() {
return ( return (
<button <button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item" className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(this, this.handleModBanFromCommunitySubmit)} onClick={linkEvent(this, this.handleModBanFromCommunitySubmit)}
aria-label={I18NextService.i18n.t("unban")}
> >
{this.state.banLoading ? <Spinner /> : I18NextService.i18n.t("unban")} {this.state.banLoading ? <Spinner /> : I18NextService.i18n.t("unban")}
</button> </button>
@ -1036,15 +993,20 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get addModToCommunityButton() { get addModToCommunityButton() {
return ( return (
<button <button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item" className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(this, this.handleAddModToCommunity)} onClick={linkEvent(this, this.handleAddModToCommunity)}
aria-label={
this.creatorIsMod_
? I18NextService.i18n.t("remove_as_mod")
: I18NextService.i18n.t("appoint_as_mod")
}
> >
{this.state.addModLoading ? ( {this.state.addModLoading ? (
<Spinner /> <Spinner />
) : this.creatorIsMod_ ? ( ) : this.creatorIsMod_ ? (
capitalizeFirstLetter(I18NextService.i18n.t("remove_as_mod")) I18NextService.i18n.t("remove_as_mod")
) : ( ) : (
capitalizeFirstLetter(I18NextService.i18n.t("appoint_as_mod")) I18NextService.i18n.t("appoint_as_mod")
)} )}
</button> </button>
); );
@ -1053,10 +1015,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modBanButton() { get modBanButton() {
return ( return (
<button <button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item" className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(this, this.handleModBanShow)} onClick={linkEvent(this, this.handleModBanShow)}
aria-label={I18NextService.i18n.t("ban_from_site")}
> >
{capitalizeFirstLetter(I18NextService.i18n.t("ban_from_site"))} {I18NextService.i18n.t("ban_from_site")}
</button> </button>
); );
} }
@ -1064,13 +1027,14 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get modUnbanButton() { get modUnbanButton() {
return ( return (
<button <button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item" className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(this, this.handleModBanSubmit)} onClick={linkEvent(this, this.handleModBanSubmit)}
aria-label={I18NextService.i18n.t("unban_from_site")}
> >
{this.state.banLoading ? ( {this.state.banLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
capitalizeFirstLetter(I18NextService.i18n.t("unban_from_site")) I18NextService.i18n.t("unban_from_site")
)} )}
</button> </button>
); );
@ -1079,10 +1043,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get purgePersonButton() { get purgePersonButton() {
return ( return (
<button <button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item" className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(this, this.handlePurgePersonShow)} onClick={linkEvent(this, this.handlePurgePersonShow)}
aria-label={I18NextService.i18n.t("purge_user")}
> >
{capitalizeFirstLetter(I18NextService.i18n.t("purge_user"))} {I18NextService.i18n.t("purge_user")}
</button> </button>
); );
} }
@ -1090,10 +1055,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get purgePostButton() { get purgePostButton() {
return ( return (
<button <button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item" className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(this, this.handlePurgePostShow)} onClick={linkEvent(this, this.handlePurgePostShow)}
aria-label={I18NextService.i18n.t("purge_post")}
> >
{capitalizeFirstLetter(I18NextService.i18n.t("purge_post"))} {I18NextService.i18n.t("purge_post")}
</button> </button>
); );
} }
@ -1101,31 +1067,20 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get toggleAdminButton() { get toggleAdminButton() {
return ( return (
<button <button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item" className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(this, this.handleAddAdmin)} onClick={linkEvent(this, this.handleAddAdmin)}
> >
{this.state.addAdminLoading ? ( {this.state.addAdminLoading ? (
<Spinner /> <Spinner />
) : this.creatorIsAdmin_ ? ( ) : this.creatorIsAdmin_ ? (
capitalizeFirstLetter(I18NextService.i18n.t("remove_as_admin")) I18NextService.i18n.t("remove_as_admin")
) : ( ) : (
capitalizeFirstLetter(I18NextService.i18n.t("appoint_as_admin")) I18NextService.i18n.t("appoint_as_admin")
)} )}
</button> </button>
); );
} }
get transferCommunityButton() {
return (
<button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleShowConfirmTransferCommunity)}
>
{capitalizeFirstLetter(I18NextService.i18n.t("transfer_community"))}
</button>
);
}
get modRemoveButton() { get modRemoveButton() {
const removed = this.postView.post.removed; const removed = this.postView.post.removed;
return ( return (
@ -1140,17 +1095,102 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.state.removeLoading ? ( {this.state.removeLoading ? (
<Spinner /> <Spinner />
) : !removed ? ( ) : !removed ? (
capitalizeFirstLetter(I18NextService.i18n.t("remove_post")) I18NextService.i18n.t("remove")
) : ( ) : (
<> I18NextService.i18n.t("restore")
{capitalizeFirstLetter(I18NextService.i18n.t("restore"))}{" "}
{I18NextService.i18n.t("post")}
</>
)} )}
</button> </button>
); );
} }
/**
* Mod/Admin actions to be taken against the author.
*/
userActionsLine() {
// TODO: make nicer
const post_view = this.postView;
return (
this.state.showAdvanced && (
<div className="mt-3">
{this.canMod_ && (
<>
{!this.creatorIsMod_ &&
(!post_view.creator_banned_from_community
? this.modBanFromCommunityButton
: this.modUnbanFromCommunityButton)}
{!post_view.creator_banned_from_community &&
this.addModToCommunityButton}
</>
)}
{/* Community creators and admins can transfer community to another mod */}
{(amCommunityCreator(post_view.creator.id, this.props.moderators) ||
this.canAdmin_) &&
this.creatorIsMod_ &&
(!this.state.showConfirmTransferCommunity ? (
<button
className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(
this,
this.handleShowConfirmTransferCommunity
)}
aria-label={I18NextService.i18n.t("transfer_community")}
>
{I18NextService.i18n.t("transfer_community")}
</button>
) : (
<>
<button
className="d-inline-block me-1 btn btn-link btn-animate text-muted py-0"
aria-label={I18NextService.i18n.t("are_you_sure")}
>
{I18NextService.i18n.t("are_you_sure")}
</button>
<button
className="btn btn-link btn-animate text-muted py-0 d-inline-block me-1"
aria-label={I18NextService.i18n.t("yes")}
onClick={linkEvent(this, this.handleTransferCommunity)}
>
{this.state.transferLoading ? (
<Spinner />
) : (
I18NextService.i18n.t("yes")
)}
</button>
<button
className="btn btn-link btn-animate text-muted py-0 d-inline-block"
onClick={linkEvent(
this,
this.handleCancelShowConfirmTransferCommunity
)}
aria-label={I18NextService.i18n.t("no")}
>
{I18NextService.i18n.t("no")}
</button>
</>
))}
{/* Admins can ban from all, and appoint other admins */}
{this.canAdmin_ && (
<>
{!this.creatorIsAdmin_ && (
<>
{!isBanned(post_view.creator)
? this.modBanButton
: this.modUnbanButton}
{this.purgePersonButton}
{this.purgePostButton}
</>
)}
{!isBanned(post_view.creator) &&
post_view.creator.local &&
this.toggleAdminButton}
</>
)}
</div>
)
);
}
removeAndBanDialogs() { removeAndBanDialogs() {
const post = this.postView; const post = this.postView;
const purgeTypeText = const purgeTypeText =
@ -1178,7 +1218,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
value={this.state.removeReason} value={this.state.removeReason}
onInput={linkEvent(this, this.handleModRemoveReasonChange)} onInput={linkEvent(this, this.handleModRemoveReasonChange)}
/> />
<button type="submit" className="btn btn-secondary"> <button
type="submit"
className="btn btn-secondary"
aria-label={I18NextService.i18n.t("remove_post")}
>
{this.state.removeLoading ? ( {this.state.removeLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
@ -1187,33 +1231,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</button> </button>
</form> </form>
)} )}
{this.state.showConfirmTransferCommunity && (
<>
<button className="d-inline-block me-1 btn btn-link btn-animate text-muted py-0">
{I18NextService.i18n.t("are_you_sure")}
</button>
<button
className="btn btn-link btn-animate text-muted py-0 d-inline-block me-1"
onClick={linkEvent(this, this.handleTransferCommunity)}
>
{this.state.transferLoading ? (
<Spinner />
) : (
I18NextService.i18n.t("yes")
)}
</button>
<button
className="btn btn-link btn-animate text-muted py-0 d-inline-block"
onClick={linkEvent(
this,
this.handleCancelShowConfirmTransferCommunity
)}
aria-label={I18NextService.i18n.t("no")}
>
{I18NextService.i18n.t("no")}
</button>
</>
)}
{this.state.showBanDialog && ( {this.state.showBanDialog && (
<form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}> <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
<div className="mb-3 row col-12"> <div className="mb-3 row col-12">
@ -1267,7 +1284,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{/* <input type="date" class="form-control me-2" placeholder={I18NextService.i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */} {/* <input type="date" class="form-control me-2" placeholder={I18NextService.i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
{/* </div> */} {/* </div> */}
<div className="mb-3 row"> <div className="mb-3 row">
<button type="submit" className="btn btn-secondary"> <button
type="submit"
className="btn btn-secondary"
aria-label={I18NextService.i18n.t("ban")}
>
{this.state.banLoading ? ( {this.state.banLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
@ -1296,7 +1317,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
value={this.state.reportReason} value={this.state.reportReason}
onInput={linkEvent(this, this.handleReportReasonChange)} onInput={linkEvent(this, this.handleReportReasonChange)}
/> />
<button type="submit" className="btn btn-secondary"> <button
type="submit"
className="btn btn-secondary"
aria-label={I18NextService.i18n.t("create_report")}
>
{this.state.reportLoading ? ( {this.state.reportLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
@ -1325,7 +1350,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.state.purgeLoading ? ( {this.state.purgeLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
<button type="submit" className="btn btn-secondary"> <button
type="submit"
className="btn btn-secondary"
aria-label={purgeTypeText}
>
{this.state.purgeLoading ? <Spinner /> : { purgeTypeText }} {this.state.purgeLoading ? <Spinner /> : { purgeTypeText }}
</button> </button>
)} )}
@ -1380,6 +1409,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{this.mobileThumbnail()} {this.mobileThumbnail()}
{this.commentsLine(true)} {this.commentsLine(true)}
{this.userActionsLine()}
{this.duplicatesLine()} {this.duplicatesLine()}
{this.removeAndBanDialogs()} {this.removeAndBanDialogs()}
</div> </div>
@ -1403,14 +1433,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
)} )}
<div className="col flex-grow-1"> <div className="col flex-grow-1">
<div className="row"> <div className="row">
<div className="col flex-grow-0 px-0"> <div className="col-sm-3 col-lg-2 pe-0 post-media">
<div className="">{this.thumbnail()}</div> <div className="">{this.thumbnail()}</div>
</div> </div>
<div className="col flex-grow-1"> <div className="col-12 col-sm-9 col-lg-10">
{this.postTitleLine()} {this.postTitleLine()}
{this.createdLine()} {this.createdLine()}
{this.commentsLine()} {this.commentsLine()}
{this.duplicatesLine()} {this.duplicatesLine()}
{this.userActionsLine()}
{this.removeAndBanDialogs()} {this.removeAndBanDialogs()}
</div> </div>
</div> </div>

View file

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

View file

@ -1,7 +1,5 @@
import { getStaticDir } from "@utils/env"; export const favIconUrl = "/static/assets/icons/favicon.svg";
export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
export const favIconUrl = `${getStaticDir()}/assets/icons/favicon.svg`;
export const favIconPngUrl = `${getStaticDir()}/assets/icons/apple-touch-icon.png`;
export const repoUrl = "https://github.com/LemmyNet"; export const repoUrl = "https://github.com/LemmyNet";
export const joinLemmyUrl = "https://join-lemmy.org"; export const joinLemmyUrl = "https://join-lemmy.org";

View file

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

View file

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

View file

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

View file

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

View file

@ -14,63 +14,56 @@ const banner = `
@license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL v3.0 @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL v3.0
`; `;
function getBase(env, mode) { const base = {
return { output: {
output: { filename: "js/server.js",
filename: "js/server.js", publicPath: "/",
publicPath: "/", hashFunction: "xxhash64",
hashFunction: "xxhash64", },
resolve: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
alias: {
"@": path.resolve(__dirname, "src/"),
"@utils": path.resolve(__dirname, "src/shared/utils/"),
}, },
resolve: { },
extensions: [".js", ".jsx", ".ts", ".tsx"], performance: {
alias: { hints: false,
"@": path.resolve(__dirname, "src/"), },
"@utils": path.resolve(__dirname, "src/shared/utils/"), module: {
rules: [
{
test: /\.(scss|css)$/i,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
}, },
}, {
performance: { test: /\.(js|jsx|tsx|ts)$/, // All ts and tsx files will be process by
hints: false, exclude: /node_modules/, // ignore node_modules
}, loader: "babel-loader",
module: { },
rules: [ // Due to some weird babel issue: https://github.com/webpack/webpack/issues/11467
{ {
test: /\.(scss|css)$/i, test: /\.m?js/,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"], resolve: {
fullySpecified: false,
}, },
{ },
test: /\.(js|jsx|tsx|ts)$/, // All ts and tsx files will be process by
exclude: /node_modules/, // ignore node_modules
loader: "babel-loader",
},
// Due to some weird babel issue: https://github.com/webpack/webpack/issues/11467
{
test: /\.m?js/,
resolve: {
fullySpecified: false,
},
},
],
},
plugins: [
new webpack.DefinePlugin({
"process.env.COMMIT_HASH": `"${env.COMMIT_HASH}"`,
"process.env.NODE_ENV": `"${mode}"`,
}),
new MiniCssExtractPlugin({
filename: "styles/styles.css",
}),
new CopyPlugin({
patterns: [{ from: "./src/assets", to: "./assets" }],
}),
new webpack.BannerPlugin({
banner,
}),
], ],
}; },
} plugins: [
new MiniCssExtractPlugin({
filename: "styles/styles.css",
}),
new CopyPlugin({
patterns: [{ from: "./src/assets", to: "./assets" }],
}),
new webpack.BannerPlugin({
banner,
}),
],
};
const createServerConfig = (env, mode) => { const createServerConfig = (_env, mode) => {
const base = getBase(env, mode);
const config = merge({}, base, { const config = merge({}, base, {
mode, mode,
entry: "./src/server/index.tsx", entry: "./src/server/index.tsx",
@ -97,14 +90,12 @@ const createServerConfig = (env, mode) => {
return config; return config;
}; };
const createClientConfig = (env, mode) => { const createClientConfig = (_env, mode) => {
const base = getBase(env, mode);
const config = merge({}, base, { const config = merge({}, base, {
mode, mode,
entry: "./src/client/index.tsx", entry: "./src/client/index.tsx",
output: { output: {
filename: "js/client.js", filename: "js/client.js",
publicPath: `/static/${env.COMMIT_HASH}/`,
}, },
plugins: [ plugins: [
...base.plugins, ...base.plugins,
@ -112,10 +103,10 @@ const createClientConfig = (env, mode) => {
enableInDevelopment: mode !== "development", // this may seem counterintuitive, but it is correct enableInDevelopment: mode !== "development", // this may seem counterintuitive, but it is correct
workbox: { workbox: {
modifyURLPrefix: { modifyURLPrefix: {
"/": `/static/${env.COMMIT_HASH}/`, "/": "/static/",
}, },
cacheId: "lemmy", cacheId: "lemmy",
include: [/(assets|styles|js)\/.+\..+$/g], include: [/(assets|styles)\/.+\..+|client\.js$/g],
inlineWorkboxRuntime: true, inlineWorkboxRuntime: true,
runtimeCaching: [ runtimeCaching: [
{ {