Merge branch 'main' into remote-follow

This commit is contained in:
SleeplessOne1917 2023-07-21 15:01:44 -04:00
commit 89c366963e
34 changed files with 501 additions and 361 deletions

View file

@ -42,6 +42,9 @@ FROM node:alpine as runner
COPY --from=builder /usr/src/app/dist /app/dist
COPY --from=builder /usr/src/app/node_modules /app/node_modules
RUN chown -R node:node /app
USER node
EXPOSE 1234
WORKDIR /app
CMD node dist/js/server.js

@ -1 +1 @@
Subproject commit 713ceed9c7ef84deaa222e68361e670e0763cd83
Subproject commit a1a19aea1ad7d91195775a5ccea62ccc9076a2c7

View file

@ -1,6 +1,6 @@
{
"name": "lemmy-ui",
"version": "0.18.1",
"version": "0.18.2-rc.1",
"description": "An isomorphic UI for lemmy",
"repository": "https://github.com/LemmyNet/lemmy-ui",
"license": "AGPL-3.0",
@ -69,7 +69,6 @@
"jwt-decode": "^3.1.2",
"lemmy-js-client": "0.18.1",
"lodash.isequal": "^4.5.0",
"lodash.merge": "^4.6.2",
"markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.2",

View file

@ -258,5 +258,12 @@
<path d="M8.72046 10.6397L14.9999 7.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.70605 13.353L15 16.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="icon-eye" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</symbol>
<symbol id="icon-eye-slash" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -120,7 +120,7 @@ export default async (req: Request, res: Response) => {
const root = renderToString(wrapper);
res.send(await createSsrHtml(root, isoData));
res.send(await createSsrHtml(root, isoData, res.locals.cspNonce));
} catch (err) {
// If an error is caught here, the error page couldn't even be rendered
console.error(err);

View file

@ -15,5 +15,6 @@ export default async ({ res }: { res: Response }) => {
Disallow: /admin
Disallow: /password_change
Disallow: /search/
Disallow: /modlog
`);
};

View file

@ -29,7 +29,11 @@ server.use(
);
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"] &&
process.env["NODE_ENV"] !== "development"
) {
server.use(setDefaultCsp);
}

View file

@ -1,3 +1,4 @@
import * as crypto from "crypto";
import type { NextFunction, Request, Response } from "express";
import { hasJwtCookie } from "./utils/has-jwt-cookie";
@ -8,9 +9,20 @@ export function setDefaultCsp({
res: Response;
next: NextFunction;
}) {
res.locals.cspNonce = crypto.randomBytes(16).toString("hex");
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:`
`default-src 'self';
manifest-src *;
connect-src *;
img-src * data:;
script-src 'self' 'nonce-${res.locals.cspNonce}';
style-src 'self' 'unsafe-inline';
form-action 'self';
base-uri 'self';
frame-src *;
media-src * data:`.replace(/\s+/g, " ")
);
next();

View file

@ -4,7 +4,7 @@ import { renderToString } from "inferno-server";
import serialize from "serialize-javascript";
import sharp from "sharp";
import { favIconPngUrl, favIconUrl } from "../../shared/config";
import { ILemmyConfig, IsoDataOptionalSite } from "../../shared/interfaces";
import { IsoDataOptionalSite } from "../../shared/interfaces";
import { buildThemeList } from "./build-themes-list";
import { fetchIconPng } from "./fetch-icon-png";
@ -14,7 +14,8 @@ let appleTouchIcon: string | undefined = undefined;
export async function createSsrHtml(
root: string,
isoData: IsoDataOptionalSite
isoData: IsoDataOptionalSite,
cspNonce: string
) {
const site = isoData.site_res;
@ -22,6 +23,12 @@ export async function createSsrHtml(
(await buildThemeList())[0]
}.css" />`;
const customHtmlHeaderScriptTag = new RegExp("<script", "g");
const customHtmlHeaderWithNonce = customHtmlHeader.replace(
customHtmlHeaderScriptTag,
`<script nonce="${cspNonce}"`
);
if (!appleTouchIcon) {
appleTouchIcon = site?.site_view.site.icon
? `data:image/png;base64,${await sharp(
@ -45,28 +52,28 @@ export async function createSsrHtml(
process.env["LEMMY_UI_DEBUG"] === "true"
? renderToString(
<>
<script src="//cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
<script
nonce={cspNonce}
src="//cdn.jsdelivr.net/npm/eruda"
></script>
<script nonce={cspNonce}>eruda.init();</script>
</>
)
: "";
const helmet = Helmet.renderStatic();
const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST };
return `
<!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}>
<head>
<script>window.isoData = ${serialize(isoData)}</script>
<script>window.lemmyConfig = ${serialize(config)}</script>
<script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script>
<!-- A remote debugging utility for mobile -->
${erudaStr}
<!-- Custom injected script -->
${customHtmlHeader}
${customHtmlHeaderWithNonce}
${helmet.title.toString()}
${helmet.meta.toString()}

View file

@ -1,4 +1,3 @@
import { getHttpBaseExternal } from "@utils/env";
import { readFile } from "fs/promises";
import { GetSiteResponse } from "lemmy-js-client";
import path from "path";
@ -21,15 +20,13 @@ export default async function ({
local_site: { community_creation_admin_only },
},
}: GetSiteResponse) {
const url = getHttpBaseExternal();
const icon = site.icon ? await fetchIconPng(site.icon) : null;
return {
name: site.name,
description: site.description ?? "A link aggregator for the fediverse",
start_url: url,
scope: url,
start_url: "/",
scope: "/",
display: "standalone",
id: "/",
background_color: "#222222",

View file

@ -21,7 +21,10 @@ export class Theme extends Component<Props> {
/>
</Helmet>
);
} else if (this.props.defaultTheme != "browser") {
} else if (
this.props.defaultTheme != "browser" &&
this.props.defaultTheme != "browser-compact"
) {
return (
<Helmet>
<link
@ -31,6 +34,25 @@ export class Theme extends Component<Props> {
/>
</Helmet>
);
} else if (this.props.defaultTheme == "browser-compact") {
return (
<Helmet>
<link
rel="stylesheet"
type="text/css"
href="/css/themes/litely-compact.css"
id="default-light"
media="(prefers-color-scheme: light)"
/>
<link
rel="stylesheet"
type="text/css"
href="/css/themes/darkly-compact.css"
id="default-dark"
media="(prefers-color-scheme: no-preference), (prefers-color-scheme: dark)"
/>
</Helmet>
);
} else {
return (
<Helmet>

View file

@ -114,7 +114,7 @@ interface CommentNodeProps {
moderators?: CommunityModeratorView[];
admins?: PersonView[];
noBorder?: boolean;
noIndent?: boolean;
isTopLevel?: boolean;
viewOnly?: boolean;
locked?: boolean;
markable?: boolean;
@ -292,11 +292,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
mark: this.isCommentNew || this.commentView.comment.distinguished,
})}
>
<div
className={classNames({
"ms-2": !this.props.noIndent,
})}
>
<div className="ms-2">
<div className="d-flex flex-wrap align-items-center text-muted small">
<button
className="btn btn-sm btn-link text-muted me-2"
@ -1136,7 +1132,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
hideImages={this.props.hideImages}
isChild={!this.props.noIndent}
isChild={!this.props.isTopLevel}
depth={this.props.node.depth + 1}
finished={this.props.finished}
onCommentReplyRead={this.props.onCommentReplyRead}

View file

@ -35,7 +35,7 @@ interface CommentNodesProps {
admins?: PersonView[];
maxCommentsShown?: number;
noBorder?: boolean;
noIndent?: boolean;
isTopLevel?: boolean;
viewOnly?: boolean;
locked?: boolean;
markable?: boolean;
@ -86,7 +86,7 @@ export class CommentNodes extends Component<CommentNodesProps, any> {
this.props.nodes.length > 0 && (
<ul
className={classNames("comments", {
"ms-1": !!this.props.isChild,
"ms-1": this.props.depth && this.props.depth > 1,
"border-top border-light": !this.props.noBorder,
})}
style={
@ -100,7 +100,7 @@ export class CommentNodes extends Component<CommentNodesProps, any> {
key={node.comment_view.comment.id}
node={node}
noBorder={this.props.noBorder}
noIndent={this.props.noIndent}
isTopLevel={this.props.isTopLevel}
viewOnly={this.props.viewOnly}
locked={this.props.locked}
moderators={this.props.moderators}

View file

@ -8,6 +8,7 @@ import { I18NextService } from "../../services";
interface HtmlTagsProps {
title: string;
path: string;
canonicalPath?: string;
description?: string;
image?: string;
}
@ -16,6 +17,8 @@ interface HtmlTagsProps {
export class HtmlTags extends Component<HtmlTagsProps, any> {
render() {
const url = httpExternalPath(this.props.path);
const canonicalUrl =
this.props.canonicalPath ?? httpExternalPath(this.props.path);
const desc = this.props.description;
const image = this.props.image;
@ -30,6 +33,8 @@ export class HtmlTags extends Component<HtmlTagsProps, any> {
<meta key={u} property={u} content={url} />
))}
<link rel="canonical" href={canonicalUrl} />
{/* Open Graph / Facebook */}
<meta property="og:type" content="website" />

View file

@ -0,0 +1,157 @@
import { Options, passwordStrength } from "check-password-strength";
import classNames from "classnames";
import { NoOptionI18nKeys } from "i18next";
import { Component, FormEventHandler, linkEvent } from "inferno";
import { NavLink } from "inferno-router";
import { I18NextService } from "../../services";
import { Icon } from "./icon";
interface PasswordInputProps {
id: string;
value?: string;
onInput: FormEventHandler<HTMLInputElement>;
className?: string;
showStrength?: boolean;
label?: string | null;
showForgotLink?: boolean;
}
interface PasswordInputState {
show: boolean;
}
const passwordStrengthOptions: Options<string> = [
{
id: 0,
value: "very_weak",
minDiversity: 0,
minLength: 0,
},
{
id: 1,
value: "weak",
minDiversity: 2,
minLength: 10,
},
{
id: 2,
value: "medium",
minDiversity: 3,
minLength: 12,
},
{
id: 3,
value: "strong",
minDiversity: 4,
minLength: 14,
},
];
function handleToggleShow(i: PasswordInput) {
i.setState(prev => ({
...prev,
show: !prev.show,
}));
}
class PasswordInput extends Component<PasswordInputProps, PasswordInputState> {
state: PasswordInputState = {
show: false,
};
constructor(props: PasswordInputProps, context: any) {
super(props, context);
}
render() {
const {
props: {
id,
value,
onInput,
className,
showStrength,
label,
showForgotLink,
},
state: { show },
} = this;
return (
<>
<div className={classNames("row", className)}>
{label && (
<label className="col-sm-2 col-form-label" htmlFor={id}>
{label}
</label>
)}
<div className={`col-sm-${label ? 10 : 12}`}>
<div className="input-group">
<input
type={show ? "text" : "password"}
className="form-control"
aria-describedby={id}
autoComplete="on"
onInput={onInput}
value={value}
required
maxLength={60}
minLength={10}
/>
<button
className="btn btn-outline-dark"
type="button"
id={id}
onClick={linkEvent(this, handleToggleShow)}
aria-label={I18NextService.i18n.t(
`${show ? "show" : "hide"}_password`
)}
data-tippy-content={I18NextService.i18n.t(
`${show ? "show" : "hide"}_password`
)}
>
<Icon icon={`eye${show ? "-slash" : ""}`} inline />
</button>
</div>
{showStrength && value && (
<div className={this.passwordColorClass}>
{I18NextService.i18n.t(
this.passwordStrength as NoOptionI18nKeys
)}
</div>
)}
{showForgotLink && (
<NavLink
className="btn p-0 btn-link d-inline-block float-right text-muted small font-weight-bold pointer-events not-allowed"
to="/login_reset"
>
{I18NextService.i18n.t("forgot_password")}
</NavLink>
)}
</div>
</div>
</>
);
}
get passwordStrength(): string | undefined {
const password = this.props.value;
return password
? passwordStrength(password, passwordStrengthOptions).value
: undefined;
}
get passwordColorClass(): string {
const strength = this.passwordStrength;
if (strength && ["weak", "medium"].includes(strength)) {
return "text-warning";
} else if (strength == "strong") {
return "text-success";
} else {
return "text-danger";
}
}
}
export default PasswordInput;

View file

@ -20,6 +20,7 @@ import {
ListCommunities,
ListCommunitiesResponse,
ListingType,
SortType,
} from "lemmy-js-client";
import { InitialFetchRequest } from "../../interfaces";
import { FirstLoadService, I18NextService } from "../../services";
@ -28,6 +29,7 @@ import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import { ListingTypeSelect } from "../common/listing-type-select";
import { Paginator } from "../common/paginator";
import { SortSelect } from "../common/sort-select";
import { CommunityLink } from "./community-link";
const communityLimit = 50;
@ -45,6 +47,7 @@ interface CommunitiesState {
interface CommunitiesProps {
listingType: ListingType;
sort: SortType;
page: number;
}
@ -52,6 +55,10 @@ function getListingTypeFromQuery(listingType?: string): ListingType {
return listingType ? (listingType as ListingType) : "Local";
}
function getSortTypeFromQuery(type?: string): SortType {
return type ? (type as SortType) : "TopMonth";
}
export class Communities extends Component<any, CommunitiesState> {
private isoData = setIsoData<CommunitiesData>(this.context);
state: CommunitiesState = {
@ -64,6 +71,7 @@ export class Communities extends Component<any, CommunitiesState> {
constructor(props: any, context: any) {
super(props, context);
this.handlePageChange = this.handlePageChange.bind(this);
this.handleSortChange = this.handleSortChange.bind(this);
this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
// Only fetch the data if coming from another route
@ -99,13 +107,13 @@ export class Communities extends Component<any, CommunitiesState> {
</h5>
);
case "success": {
const { listingType, page } = this.getCommunitiesQueryParams();
const { listingType, sort, page } = this.getCommunitiesQueryParams();
return (
<div>
<h1 className="h4 mb-4">
{I18NextService.i18n.t("list_of_communities")}
</h1>
<div className="row g-2 justify-content-between">
<div className="row g-3 align-items-center mb-2">
<div className="col-auto">
<ListingTypeSelect
type_={listingType}
@ -114,6 +122,9 @@ export class Communities extends Component<any, CommunitiesState> {
onChange={this.handleListingTypeChange}
/>
</div>
<div className="col-auto me-auto">
<SortSelect sort={sort} onChange={this.handleSortChange} />
</div>
<div className="col-auto">{this.searchForm()}</div>
</div>
@ -224,10 +235,7 @@ export class Communities extends Component<any, CommunitiesState> {
searchForm() {
return (
<form
className="row mb-2"
onSubmit={linkEvent(this, this.handleSearchSubmit)}
>
<form className="row" onSubmit={linkEvent(this, this.handleSearchSubmit)}>
<div className="col-auto">
<input
type="text"
@ -252,12 +260,16 @@ export class Communities extends Component<any, CommunitiesState> {
);
}
async updateUrl({ listingType, page }: Partial<CommunitiesProps>) {
const { listingType: urlListingType, page: urlPage } =
this.getCommunitiesQueryParams();
async updateUrl({ listingType, sort, page }: Partial<CommunitiesProps>) {
const {
listingType: urlListingType,
sort: urlSort,
page: urlPage,
} = this.getCommunitiesQueryParams();
const queryParams: QueryParams<CommunitiesProps> = {
listingType: listingType ?? urlListingType,
sort: sort ?? urlSort,
page: (page ?? urlPage)?.toString(),
};
@ -270,6 +282,10 @@ export class Communities extends Component<any, CommunitiesState> {
this.updateUrl({ page });
}
handleSortChange(val: SortType) {
this.updateUrl({ sort: val, page: 1 });
}
handleListingTypeChange(val: ListingType) {
this.updateUrl({
listingType: val,
@ -290,7 +306,7 @@ export class Communities extends Component<any, CommunitiesState> {
}
static async fetchInitialData({
query: { listingType, page },
query: { listingType, sort, page },
client,
auth,
}: InitialFetchRequest<
@ -298,7 +314,7 @@ export class Communities extends Component<any, CommunitiesState> {
>): Promise<CommunitiesData> {
const listCommunitiesForm: ListCommunities = {
type_: getListingTypeFromQuery(listingType),
sort: "TopMonth",
sort: getSortTypeFromQuery(sort),
limit: communityLimit,
page: getPageFromString(page),
auth: auth,
@ -314,6 +330,7 @@ export class Communities extends Component<any, CommunitiesState> {
getCommunitiesQueryParams() {
return getQueryParams<CommunitiesProps>({
listingType: getListingTypeFromQuery,
sort: getSortTypeFromQuery,
page: getPageFromString,
});
}
@ -334,12 +351,12 @@ export class Communities extends Component<any, CommunitiesState> {
async refetch() {
this.setState({ listCommunitiesResponse: { state: "loading" } });
const { listingType, page } = this.getCommunitiesQueryParams();
const { listingType, sort, page } = this.getCommunitiesQueryParams();
this.setState({
listCommunitiesResponse: await HttpService.client.listCommunities({
type_: listingType,
sort: "TopMonth",
sort: sort,
limit: communityLimit,
page,
auth: myAuth(),

View file

@ -312,6 +312,7 @@ export class Community extends Component<
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
canonicalPath={res.community_view.community.actor_id}
description={res.community_view.community.description}
image={res.community_view.community.icon}
/>
@ -447,7 +448,7 @@ export class Community extends Component<
nodes={commentsToFlatNodes(this.state.commentsRes.data.comments)}
viewType={CommentViewType.Flat}
finished={this.state.finished}
noIndent
isTopLevel
showContext
enableDownvotes={enableDownvotes(site_res)}
moderators={communityRes.moderators}

View file

@ -636,7 +636,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
i.setState({ leaveModTeamLoading: true });
i.props.onLeaveModTeam({
community_id: i.props.community_view.community.id,
person_id: 92,
person_id: myId,
added: false,
auth: myAuthRequired(),
});

View file

@ -718,7 +718,7 @@ export class Home extends Component<any, HomeState> {
nodes={commentsToFlatNodes(comments)}
viewType={CommentViewType.Flat}
finished={this.state.finished}
noIndent
isTopLevel
showCommunity
showContext
enableDownvotes={enableDownvotes(siteRes)}

View file

@ -2,7 +2,6 @@ import { myAuth, setIsoData } from "@utils/app";
import { isBrowser } from "@utils/browser";
import { Location } from "history";
import { Component, linkEvent } from "inferno";
import { NavLink } from "inferno-router";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
import { I18NextService, UserService } from "../../services";
@ -10,6 +9,7 @@ import { HttpService, RequestState } from "../../services/HttpService";
import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
interface State {
loginRes: RequestState<LoginResponse>;
@ -163,28 +163,14 @@ export class Login extends Component<
/>
</div>
</div>
<div className="mb-3 row">
<label className="col-sm-2 col-form-label" htmlFor="login-password">
{I18NextService.i18n.t("password")}
</label>
<div className="col-sm-10">
<input
type="password"
<div className="mb-3">
<PasswordInput
id="login-password"
value={this.state.form.password}
onInput={linkEvent(this, handleLoginPasswordChange)}
className="form-control"
autoComplete="current-password"
required
maxLength={60}
onInput={linkEvent(this, this.handleLoginPasswordChange)}
label={I18NextService.i18n.t("password")}
showForgotLink
/>
<NavLink
className="btn p-0 btn-link d-inline-block float-right text-muted small font-weight-bold pointer-events not-allowed"
to="/login_reset"
>
{I18NextService.i18n.t("forgot_password")}
</NavLink>
</div>
</div>
{this.state.showTotp && (
<div className="mb-3 row">
@ -223,4 +209,67 @@ export class Login extends Component<
</div>
);
}
async handleLoginSubmit(i: Login, event: any) {
event.preventDefault();
const { password, totp_2fa_token, username_or_email } = i.state.form;
if (username_or_email && password) {
i.setState({ loginRes: { state: "loading" } });
const loginRes = await HttpService.client.login({
username_or_email,
password,
totp_2fa_token,
});
switch (loginRes.state) {
case "failed": {
if (loginRes.msg === "missing_totp_token") {
i.setState({ showTotp: true });
toast(I18NextService.i18n.t("enter_two_factor_code"), "info");
}
if (loginRes.msg === "incorrect_login") {
toast(I18NextService.i18n.t("incorrect_login"), "danger");
}
i.setState({ loginRes: { state: "failed", msg: loginRes.msg } });
break;
}
case "success": {
UserService.Instance.login({
res: loginRes.data,
});
const site = await HttpService.client.getSite({
auth: myAuth(),
});
if (site.state === "success") {
UserService.Instance.myUserInfo = site.data.my_user;
}
i.props.history.action === "PUSH"
? i.props.history.back()
: i.props.history.replace("/");
break;
}
}
}
}
handleLoginUsernameChange(i: Login, event: any) {
i.state.form.username_or_email = event.target.value.trim();
i.setState(i.state);
}
handleLoginTotpChange(i: Login, event: any) {
i.state.form.totp_2fa_token = event.target.value;
i.setState(i.state);
}
handleLoginPasswordChange(i: Login, event: any) {
i.state.form.password = event.target.value;
i.setState(i.state);
}
}

View file

@ -10,6 +10,7 @@ import {
import { I18NextService, UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
import { SiteForm } from "./site-form";
interface State {
@ -121,42 +122,22 @@ export class Setup extends Component<any, State> {
/>
</div>
</div>
<div className="mb-3 row">
<label className="col-sm-2 col-form-label" htmlFor="password">
{I18NextService.i18n.t("password")}
</label>
<div className="col-sm-10">
<input
type="password"
<div className="mb-3">
<PasswordInput
id="password"
value={this.state.form.password}
onInput={linkEvent(this, this.handleRegisterPasswordChange)}
className="form-control"
required
autoComplete="new-password"
minLength={10}
maxLength={60}
label={I18NextService.i18n.t("password")}
/>
</div>
</div>
<div className="mb-3 row">
<label className="col-sm-2 col-form-label" htmlFor="verify-password">
{I18NextService.i18n.t("verify_password")}
</label>
<div className="col-sm-10">
<input
type="password"
<div className="mb-3">
<PasswordInput
id="verify-password"
value={this.state.form.password_verify}
onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
className="form-control"
required
autoComplete="new-password"
minLength={10}
maxLength={60}
label={I18NextService.i18n.t("verify_password")}
/>
</div>
</div>
<div className="mb-3 row">
<div className="col-sm-10">
<button type="submit" className="btn btn-secondary">

View file

@ -1,8 +1,6 @@
import { myAuth, setIsoData } from "@utils/app";
import { isBrowser } from "@utils/browser";
import { validEmail } from "@utils/helpers";
import { Options, passwordStrength } from "check-password-strength";
import { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess";
import {
@ -20,33 +18,7 @@ import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea";
const passwordStrengthOptions: Options<string> = [
{
id: 0,
value: "very_weak",
minDiversity: 0,
minLength: 0,
},
{
id: 1,
value: "weak",
minDiversity: 2,
minLength: 10,
},
{
id: 2,
value: "medium",
minDiversity: 3,
minLength: 12,
},
{
id: 3,
value: "strong",
minDiversity: 4,
minLength: 14,
},
];
import PasswordInput from "../common/password-input";
interface State {
registerRes: RequestState<LoginResponse>;
@ -210,57 +182,26 @@ export class Signup extends Component<any, State> {
</div>
</div>
<div className="mb-3 row">
<label
className="col-sm-2 col-form-label"
htmlFor="register-password"
>
{I18NextService.i18n.t("password")}
</label>
<div className="col-sm-10">
<input
type="password"
<div className="mb-3">
<PasswordInput
id="register-password"
value={this.state.form.password}
autoComplete="new-password"
onInput={linkEvent(this, this.handleRegisterPasswordChange)}
minLength={10}
maxLength={60}
className="form-control"
required
showStrength
label={I18NextService.i18n.t("password")}
/>
{this.state.form.password && (
<div className={this.passwordColorClass}>
{I18NextService.i18n.t(
this.passwordStrength as NoOptionI18nKeys
)}
</div>
)}
</div>
</div>
<div className="mb-3 row">
<label
className="col-sm-2 col-form-label"
htmlFor="register-verify-password"
>
{I18NextService.i18n.t("verify_password")}
</label>
<div className="col-sm-10">
<input
type="password"
<div className="mb-3">
<PasswordInput
id="register-verify-password"
value={this.state.form.password_verify}
autoComplete="new-password"
onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
maxLength={60}
className="form-control"
required
label={I18NextService.i18n.t("verify_password")}
/>
</div>
</div>
{siteView.local_site.registration_mode == "RequireApplication" && (
{siteView.local_site.registration_mode === "RequireApplication" && (
<>
<div className="mb-3 row">
<div className="offset-sm-2 col-sm-10">
@ -411,25 +352,6 @@ export class Signup extends Component<any, State> {
);
}
get passwordStrength(): string | undefined {
const password = this.state.form.password;
return password
? passwordStrength(password, passwordStrengthOptions).value
: undefined;
}
get passwordColorClass(): string {
const strength = this.passwordStrength;
if (strength && ["weak", "medium"].includes(strength)) {
return "text-warning";
} else if (strength == "strong") {
return "text-success";
} else {
return "text-danger";
}
}
async handleRegisterSubmit(i: Signup, event: any) {
event.preventDefault();
const {

View file

@ -411,6 +411,9 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
<option value="browser">
{I18NextService.i18n.t("browser_default")}
</option>
<option value="browser-compact">
{I18NextService.i18n.t("browser_default_compact")}
</option>
{this.props.themeList?.map(theme => (
<option key={theme} value={theme}>
{theme}

View file

@ -312,6 +312,7 @@ function renderModlogType({ type_, view }: ModlogType) {
const {
mod_feature_post: { featured, is_featured_community },
post: { id, name },
community,
} = view as ModFeaturePostView;
return (
@ -320,7 +321,12 @@ function renderModlogType({ type_, view }: ModlogType) {
<span>
Post <Link to={`/post/${id}`}>{name}</Link>
</span>
<span>{is_featured_community ? " In Community" : " In Local"}</span>
<span>
{is_featured_community
? " in community "
: " in Local, from community "}
</span>
<CommunityLink community={community} />
</>
);
}
@ -532,7 +538,7 @@ function renderModlogType({ type_, view }: ModlogType) {
return (
<>
<span>Purged a Post from from </span>
<span>Purged a Post from </span>
<CommunityLink community={community} />
{reason && (
<span>

View file

@ -6,6 +6,7 @@ import { HttpService, I18NextService, UserService } from "../../services";
import { RequestState } from "../../services/HttpService";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
interface State {
passwordChangeRes: RequestState<LoginResponse>;
@ -60,38 +61,23 @@ export class PasswordChange extends Component<any, State> {
passwordChangeForm() {
return (
<form onSubmit={linkEvent(this, this.handlePasswordChangeSubmit)}>
<div className="mb-3 row">
<label className="col-sm-2 col-form-label" htmlFor="new-password">
{I18NextService.i18n.t("new_password")}
</label>
<div className="col-sm-10">
<input
<div className="mb-3">
<PasswordInput
id="new-password"
type="password"
value={this.state.form.password}
onInput={linkEvent(this, this.handlePasswordChange)}
className="form-control"
required
maxLength={60}
showStrength
label={I18NextService.i18n.t("new_password")}
/>
</div>
</div>
<div className="mb-3 row">
<label className="col-sm-2 col-form-label" htmlFor="verify-password">
{I18NextService.i18n.t("verify_password")}
</label>
<div className="col-sm-10">
<input
id="verify-password"
type="password"
<div className="mb-3">
<PasswordInput
id="password"
value={this.state.form.password_verify}
onInput={linkEvent(this, this.handleVerifyPasswordChange)}
className="form-control"
required
maxLength={60}
label={I18NextService.i18n.t("verify_password")}
/>
</div>
</div>
<div className="mb-3 row">
<div className="col-sm-10">
<button type="submit" className="btn btn-secondary">

View file

@ -252,7 +252,7 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
viewType={CommentViewType.Flat}
admins={this.props.admins}
finished={this.props.finished}
noIndent
isTopLevel
showCommunity
showContext
enableDownvotes={this.props.enableDownvotes}

View file

@ -324,6 +324,7 @@ export class Profile extends Component<
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
canonicalPath={personRes.person_view.person.actor_id}
description={personRes.person_view.person.bio}
image={personRes.person_view.person.avatar}
/>

View file

@ -40,6 +40,7 @@ import { ImageUploadForm } from "../common/image-upload-form";
import { LanguageSelect } from "../common/language-select";
import { ListingTypeSelect } from "../common/listing-type-select";
import { MarkdownTextArea } from "../common/markdown-textarea";
import PasswordInput from "../common/password-input";
import { SearchableSelect } from "../common/searchable-select";
import { SortSelect } from "../common/sort-select";
import Tabs from "../common/tabs";
@ -318,60 +319,31 @@ export class Settings extends Component<any, SettingsState> {
<>
<h2 className="h5">{I18NextService.i18n.t("change_password")}</h2>
<form onSubmit={linkEvent(this, this.handleChangePasswordSubmit)}>
<div className="mb-3 row">
<label className="col-sm-5 col-form-label" htmlFor="user-password">
{I18NextService.i18n.t("new_password")}
</label>
<div className="col-sm-7">
<input
type="password"
id="user-password"
className="form-control"
<div className="mb-3">
<PasswordInput
id="new-password"
value={this.state.changePasswordForm.new_password}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(this, this.handleNewPasswordChange)}
showStrength
label={I18NextService.i18n.t("new_password")}
/>
</div>
</div>
<div className="mb-3 row">
<label
className="col-sm-5 col-form-label"
htmlFor="user-verify-password"
>
{I18NextService.i18n.t("verify_password")}
</label>
<div className="col-sm-7">
<input
type="password"
id="user-verify-password"
className="form-control"
<div className="mb-3">
<PasswordInput
id="verify-new-password"
value={this.state.changePasswordForm.new_password_verify}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(this, this.handleNewPasswordVerifyChange)}
label={I18NextService.i18n.t("verify_password")}
/>
</div>
</div>
<div className="mb-3 row">
<label
className="col-sm-5 col-form-label"
htmlFor="user-old-password"
>
{I18NextService.i18n.t("old_password")}
</label>
<div className="col-sm-7">
<input
type="password"
<div className="mb-3">
<PasswordInput
id="user-old-password"
className="form-control"
value={this.state.changePasswordForm.old_password}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(this, this.handleOldPasswordChange)}
label={I18NextService.i18n.t("old_password")}
/>
</div>
</div>
<div className="input-group mb-3">
<button
type="submit"
@ -635,6 +607,9 @@ export class Settings extends Component<any, SettingsState> {
<option value="browser">
{I18NextService.i18n.t("browser_default")}
</option>
<option value="browser-compact">
{I18NextService.i18n.t("browser_default_compact")}
</option>
{this.state.themeList.map(theme => (
<option key={theme} value={theme}>
{theme}
@ -813,8 +788,12 @@ export class Settings extends Component<any, SettingsState> {
</button>
</div>
<hr />
<div className="input-group mb-3">
<form
className="mb-3"
onSubmit={linkEvent(this, this.handleDeleteAccount)}
>
<button
type="button"
className="btn d-block btn-danger"
onClick={linkEvent(
this,
@ -825,24 +804,26 @@ export class Settings extends Component<any, SettingsState> {
</button>
{this.state.deleteAccountShowConfirm && (
<>
<div className="my-2 alert alert-danger" role="alert">
<label
className="my-2 alert alert-danger d-block"
role="alert"
htmlFor="password-delete-account"
>
{I18NextService.i18n.t("delete_account_confirm")}
</div>
<input
type="password"
</label>
<PasswordInput
id="password-delete-account"
value={this.state.deleteAccountForm.password}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(
this,
this.handleDeleteAccountPasswordChange
)}
className="form-control my-2"
className="my-2"
/>
<button
type="submit"
className="btn btn-danger me-4"
disabled={!this.state.deleteAccountForm.password}
onClick={linkEvent(this, this.handleDeleteAccount)}
>
{this.state.deleteAccountRes.state === "loading" ? (
<Spinner />
@ -852,6 +833,7 @@ export class Settings extends Component<any, SettingsState> {
</button>
<button
className="btn btn-secondary"
type="button"
onClick={linkEvent(
this,
this.handleDeleteAccountShowConfirmToggle
@ -861,7 +843,7 @@ export class Settings extends Component<any, SettingsState> {
</button>
</>
)}
</div>
</form>
</form>
</>
);
@ -1222,7 +1204,8 @@ export class Settings extends Component<any, SettingsState> {
i.setState(s => ((s.deleteAccountForm.password = event.target.value), s));
}
async handleDeleteAccount(i: Settings) {
async handleDeleteAccount(i: Settings, event: Event) {
event.preventDefault();
const password = i.state.deleteAccountForm.password;
if (password) {
i.setState({ deleteAccountRes: { state: "loading" } });

View file

@ -310,16 +310,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
const url = post.url;
const thumbnail = post.thumbnail_url;
if (url && isImage(url)) {
if (url.includes("pictrs")) {
return url;
} else if (thumbnail) {
if (thumbnail) {
return thumbnail;
} else {
} else if (url && isImage(url)) {
return url;
}
} else if (thumbnail) {
return thumbnail;
} else {
return undefined;
}

View file

@ -353,6 +353,7 @@ export class Post extends Component<any, PostState> {
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
canonicalPath={res.post_view.post.ap_id}
image={this.imageTag}
description={res.post_view.post.body}
/>
@ -541,7 +542,7 @@ export class Post extends Component<any, PostState> {
nodes={commentsToFlatNodes(commentsRes.data.comments)}
viewType={this.state.commentViewType}
maxCommentsShown={this.state.maxCommentsShown}
noIndent
isTopLevel
locked={postRes.data.post_view.post.locked}
moderators={postRes.data.moderators}
admins={this.state.siteRes.admins}

View file

@ -466,6 +466,10 @@ export class Search extends Component<any, SearchState> {
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
canonicalPath={
this.context.router.route.match.url +
this.context.router.route.location.search
}
/>
<h1 className="h4 mb-4">{I18NextService.i18n.t("search")}</h1>
{this.selects}
@ -717,7 +721,7 @@ export class Search extends Component<any, SearchState> {
viewType={CommentViewType.Flat}
viewOnly
locked
noIndent
isTopLevel
enableDownvotes={enableDownvotes(this.state.siteRes)}
allLanguages={this.state.siteRes.all_languages}
siteLanguages={this.state.siteRes.discussion_languages}
@ -778,7 +782,7 @@ export class Search extends Component<any, SearchState> {
viewType={CommentViewType.Flat}
viewOnly
locked
noIndent
isTopLevel
enableDownvotes={enableDownvotes(siteRes)}
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}

View file

@ -18,14 +18,9 @@ export type IsoDataOptionalSite<T extends RouteData = any> = Partial<
> &
Pick<IsoData<T>, Exclude<keyof IsoData<T>, "site_res">>;
export interface ILemmyConfig {
wsHost?: string;
}
declare global {
interface Window {
isoData: IsoData;
lemmyConfig?: ILemmyConfig;
}
}

View file

@ -188,13 +188,16 @@ export function setupMarkdown() {
//Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
const item = tokens[idx] as any;
const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
const src: string = item.attrs[0][1];
const isCustomEmoji = customEmojisLookup.get(title) != undefined;
const customEmoji = customEmojisLookup.get(title);
const isCustomEmoji = customEmoji != undefined;
if (!isCustomEmoji) {
return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
}
const alt_text = item.content;
return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`;
return `<img class="icon icon-emoji" src="${
customEmoji!.custom_emoji.image_url
}" title="${customEmoji!.custom_emoji.shortcode}" alt="${
customEmoji!.custom_emoji.alt_text
}"/>`;
};
md.renderer.rules.table_open = function () {
return '<table class="table">';

View file

@ -1,10 +1,8 @@
const webpack = require("webpack");
const path = require("path");
const { resolve } = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const nodeExternals = require("webpack-node-externals");
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 banner = `
@ -14,18 +12,18 @@ const banner = `
@license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL v3.0
`;
function getBase(env, mode) {
return {
module.exports = (env, argv) => {
const mode = argv.mode;
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/"),
"@": resolve(__dirname, "src/"),
"@utils": resolve(__dirname, "src/shared/utils/"),
},
},
performance: {
@ -67,42 +65,24 @@ function getBase(env, mode) {
}),
],
};
}
const createServerConfig = (env, mode) => {
const base = getBase(env, mode);
const config = merge({}, base, {
mode,
const serverConfig = {
...base,
entry: "./src/server/index.tsx",
output: {
...base.output,
filename: "js/server.js",
publicPath: "/",
},
target: "node",
externals: [nodeExternals(), "inferno-helmet"],
});
};
if (mode === "development") {
// config.cache = {
// type: "filesystem",
// name: "server",
// };
config.plugins.push(
new RunNodeWebpackPlugin({
runOnlyInWatchMode: true,
})
);
}
return config;
};
const createClientConfig = (env, mode) => {
const base = getBase(env, mode);
const config = merge({}, base, {
mode,
const clientConfig = {
...base,
entry: "./src/client/index.tsx",
output: {
...base.output,
filename: "js/client.js",
publicPath: `/static/${env.COMMIT_HASH}/`,
},
@ -158,18 +138,22 @@ const createClientConfig = (env, mode) => {
},
}),
],
});
};
if (mode === "none") {
const BundleAnalyzerPlugin =
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
config.plugins.push(new BundleAnalyzerPlugin());
if (mode === "development") {
// serverConfig.cache = {
// type: "filesystem",
// name: "server",
// };
const RunNodeWebpackPlugin = require("run-node-webpack-plugin");
serverConfig.plugins.push(
new RunNodeWebpackPlugin({ runOnlyInWatchMode: true })
);
} else if (mode === "none") {
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");
serverConfig.plugins.push(new BundleAnalyzerPlugin());
}
return config;
return [serverConfig, clientConfig];
};
module.exports = (env, properties) => [
createServerConfig(env, properties.mode || "development"),
createClientConfig(env, properties.mode || "development"),
];