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/dist /app/dist
COPY --from=builder /usr/src/app/node_modules /app/node_modules COPY --from=builder /usr/src/app/node_modules /app/node_modules
RUN chown -R node:node /app
USER node
EXPOSE 1234 EXPOSE 1234
WORKDIR /app WORKDIR /app
CMD node dist/js/server.js CMD node dist/js/server.js

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

View file

@ -1,6 +1,6 @@
{ {
"name": "lemmy-ui", "name": "lemmy-ui",
"version": "0.18.1", "version": "0.18.2-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",
@ -69,7 +69,6 @@
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lemmy-js-client": "0.18.1", "lemmy-js-client": "0.18.1",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"lodash.merge": "^4.6.2",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0", "markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.2", "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.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"/> <path d="M8.70605 13.353L15 16.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol> </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> </defs>
</svg> </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); const root = renderToString(wrapper);
res.send(await createSsrHtml(root, isoData)); res.send(await createSsrHtml(root, isoData, res.locals.cspNonce));
} catch (err) { } catch (err) {
// If an error is caught here, the error page couldn't even be rendered // If an error is caught here, the error page couldn't even be rendered
console.error(err); console.error(err);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,7 +21,10 @@ export class Theme extends Component<Props> {
/> />
</Helmet> </Helmet>
); );
} else if (this.props.defaultTheme != "browser") { } else if (
this.props.defaultTheme != "browser" &&
this.props.defaultTheme != "browser-compact"
) {
return ( return (
<Helmet> <Helmet>
<link <link
@ -31,6 +34,25 @@ export class Theme extends Component<Props> {
/> />
</Helmet> </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 { } else {
return ( return (
<Helmet> <Helmet>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,6 @@ import { myAuth, setIsoData } from "@utils/app";
import { isBrowser } from "@utils/browser"; import { isBrowser } from "@utils/browser";
import { Location } from "history"; import { Location } from "history";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { NavLink } from "inferno-router";
import { RouteComponentProps } from "inferno-router/dist/Route"; import { RouteComponentProps } from "inferno-router/dist/Route";
import { GetSiteResponse, LoginResponse } from "lemmy-js-client"; import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
import { I18NextService, UserService } from "../../services"; import { I18NextService, UserService } from "../../services";
@ -10,6 +9,7 @@ import { HttpService, RequestState } from "../../services/HttpService";
import { toast } from "../../toast"; import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
interface State { interface State {
loginRes: RequestState<LoginResponse>; loginRes: RequestState<LoginResponse>;
@ -163,28 +163,14 @@ export class Login extends Component<
/> />
</div> </div>
</div> </div>
<div className="mb-3 row"> <div className="mb-3">
<label className="col-sm-2 col-form-label" htmlFor="login-password"> <PasswordInput
{I18NextService.i18n.t("password")}
</label>
<div className="col-sm-10">
<input
type="password"
id="login-password" id="login-password"
value={this.state.form.password} value={this.state.form.password}
onInput={linkEvent(this, handleLoginPasswordChange)} onInput={linkEvent(this, this.handleLoginPasswordChange)}
className="form-control" label={I18NextService.i18n.t("password")}
autoComplete="current-password" showForgotLink
required
maxLength={60}
/> />
<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> </div>
{this.state.showTotp && ( {this.state.showTotp && (
<div className="mb-3 row"> <div className="mb-3 row">
@ -223,4 +209,67 @@ export class Login extends Component<
</div> </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 { I18NextService, UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService"; import { HttpService, RequestState } from "../../services/HttpService";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
import { SiteForm } from "./site-form"; import { SiteForm } from "./site-form";
interface State { interface State {
@ -121,42 +122,22 @@ export class Setup extends Component<any, State> {
/> />
</div> </div>
</div> </div>
<div className="mb-3 row"> <div className="mb-3">
<label className="col-sm-2 col-form-label" htmlFor="password"> <PasswordInput
{I18NextService.i18n.t("password")}
</label>
<div className="col-sm-10">
<input
type="password"
id="password" id="password"
value={this.state.form.password} value={this.state.form.password}
onInput={linkEvent(this, this.handleRegisterPasswordChange)} onInput={linkEvent(this, this.handleRegisterPasswordChange)}
className="form-control" label={I18NextService.i18n.t("password")}
required
autoComplete="new-password"
minLength={10}
maxLength={60}
/> />
</div> </div>
</div> <div className="mb-3">
<div className="mb-3 row"> <PasswordInput
<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"
id="verify-password" id="verify-password"
value={this.state.form.password_verify} value={this.state.form.password_verify}
onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)} onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
className="form-control" label={I18NextService.i18n.t("verify_password")}
required
autoComplete="new-password"
minLength={10}
maxLength={60}
/> />
</div> </div>
</div>
<div className="mb-3 row"> <div className="mb-3 row">
<div className="col-sm-10"> <div className="col-sm-10">
<button type="submit" className="btn btn-secondary"> <button type="submit" className="btn btn-secondary">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -466,6 +466,10 @@ export class Search extends Component<any, SearchState> {
<HtmlTags <HtmlTags
title={this.documentTitle} title={this.documentTitle}
path={this.context.router.route.match.url} 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> <h1 className="h4 mb-4">{I18NextService.i18n.t("search")}</h1>
{this.selects} {this.selects}
@ -717,7 +721,7 @@ export class Search extends Component<any, SearchState> {
viewType={CommentViewType.Flat} viewType={CommentViewType.Flat}
viewOnly viewOnly
locked locked
noIndent isTopLevel
enableDownvotes={enableDownvotes(this.state.siteRes)} enableDownvotes={enableDownvotes(this.state.siteRes)}
allLanguages={this.state.siteRes.all_languages} allLanguages={this.state.siteRes.all_languages}
siteLanguages={this.state.siteRes.discussion_languages} siteLanguages={this.state.siteRes.discussion_languages}
@ -778,7 +782,7 @@ export class Search extends Component<any, SearchState> {
viewType={CommentViewType.Flat} viewType={CommentViewType.Flat}
viewOnly viewOnly
locked locked
noIndent isTopLevel
enableDownvotes={enableDownvotes(siteRes)} enableDownvotes={enableDownvotes(siteRes)}
allLanguages={siteRes.all_languages} allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_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">>; Pick<IsoData<T>, Exclude<keyof IsoData<T>, "site_res">>;
export interface ILemmyConfig {
wsHost?: string;
}
declare global { declare global {
interface Window { interface Window {
isoData: IsoData; 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. //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 item = tokens[idx] as any;
const title = item.attrs.length >= 3 ? item.attrs[2][1] : ""; const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
const src: string = item.attrs[0][1]; const customEmoji = customEmojisLookup.get(title);
const isCustomEmoji = customEmojisLookup.get(title) != undefined; const isCustomEmoji = customEmoji != undefined;
if (!isCustomEmoji) { if (!isCustomEmoji) {
return defaultRenderer?.(tokens, idx, options, env, self) ?? ""; return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
} }
const alt_text = item.content; return `<img class="icon icon-emoji" src="${
return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`; customEmoji!.custom_emoji.image_url
}" title="${customEmoji!.custom_emoji.shortcode}" alt="${
customEmoji!.custom_emoji.alt_text
}"/>`;
}; };
md.renderer.rules.table_open = function () { md.renderer.rules.table_open = function () {
return '<table class="table">'; return '<table class="table">';

View file

@ -1,10 +1,8 @@
const webpack = require("webpack"); const webpack = require("webpack");
const path = require("path"); const { resolve } = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const nodeExternals = require("webpack-node-externals"); const nodeExternals = require("webpack-node-externals");
const CopyPlugin = require("copy-webpack-plugin"); 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 { ServiceWorkerPlugin } = require("service-worker-webpack");
const banner = ` const banner = `
@ -14,18 +12,18 @@ 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) { module.exports = (env, argv) => {
return { const mode = argv.mode;
const base = {
output: { output: {
filename: "js/server.js",
publicPath: "/",
hashFunction: "xxhash64", hashFunction: "xxhash64",
}, },
resolve: { resolve: {
extensions: [".js", ".jsx", ".ts", ".tsx"], extensions: [".js", ".jsx", ".ts", ".tsx"],
alias: { alias: {
"@": path.resolve(__dirname, "src/"), "@": resolve(__dirname, "src/"),
"@utils": path.resolve(__dirname, "src/shared/utils/"), "@utils": resolve(__dirname, "src/shared/utils/"),
}, },
}, },
performance: { performance: {
@ -67,42 +65,24 @@ function getBase(env, mode) {
}), }),
], ],
}; };
}
const createServerConfig = (env, mode) => { const serverConfig = {
const base = getBase(env, mode); ...base,
const config = merge({}, base, {
mode,
entry: "./src/server/index.tsx", entry: "./src/server/index.tsx",
output: { output: {
...base.output,
filename: "js/server.js", filename: "js/server.js",
publicPath: "/",
}, },
target: "node", target: "node",
externals: [nodeExternals(), "inferno-helmet"], 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 clientConfig = {
const base = getBase(env, mode); ...base,
const config = merge({}, base, {
mode,
entry: "./src/client/index.tsx", entry: "./src/client/index.tsx",
output: { output: {
...base.output,
filename: "js/client.js", filename: "js/client.js",
publicPath: `/static/${env.COMMIT_HASH}/`, 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());
}
return config;
}; };
module.exports = (env, properties) => [ if (mode === "development") {
createServerConfig(env, properties.mode || "development"), // serverConfig.cache = {
createClientConfig(env, properties.mode || "development"), // 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 [serverConfig, clientConfig];
};