Merge branch 'main' into generate-theme-css

This commit is contained in:
SleeplessOne1917 2023-06-15 15:51:52 +00:00 committed by GitHub
commit 4c67ff255b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 9483 additions and 8348 deletions

View file

@ -18,6 +18,7 @@
"@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-empty-function": 0,
"arrow-body-style": 0,
"curly": 0,
"eol-last": 0,

View file

@ -9,6 +9,19 @@ body:
Found a bug? Please fill out the sections below. 👍
Thanks for taking the time to fill out this bug report!
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy)
- type: checkboxes
attributes:
label: Requirements
description: Before you create a bug report please do the following.
options:
- label: Is this a bug report? For questions or discussions use https://lemmy.ml/c/lemmy_support
required: true
- label: Did you check to see if this issue already exists?
required: true
- label: Is this only a single bug? Do not put multiple bugs in one issue.
required: true
- label: Is this a server side (not related to the UI) issue? Use the [Lemmy back end](https://github.com/LemmyNet/lemmy) repo.
required: true
- type: textarea
id: summary
attributes:
@ -45,3 +58,9 @@ body:
placeholder: ex. 0.17.4-rc.4
validations:
required: true
- type: input
id: lemmy-instance
attributes:
label: Lemmy Instance URL
description: Which Lemmy instance do you use? The address
placeholder: lemmy.ml, lemmy.world, etc

View file

@ -7,6 +7,19 @@ body:
value: |
Have a suggestion about Lemmy's UI?
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy)
- type: checkboxes
attributes:
label: Requirements
description: Before you create a bug report please do the following.
options:
- label: Is this a feature request? For questions or discussions use https://lemmy.ml/c/lemmy_support
required: true
- label: Did you check to see if this issue already exists?
required: true
- label: Is this only a feature request? Do not put multiple feature requests in one issue.
required: true
- label: Is this a server side (not related to the UI) issue? Use the [Lemmy back end](https://github.com/LemmyNet/lemmy) repo.
required: true
- type: textarea
id: problem
attributes:

View file

@ -1,6 +1,6 @@
pipeline:
fetch_git_submodules:
image: node:14-alpine
image: node:alpine
commands:
- apk add git
- git submodule init
@ -8,93 +8,27 @@ pipeline:
# - git fetch --tags
yarn:
image: node:14-alpine
image: node:alpine
commands:
- yarn
yarn_lint:
image: node:14-alpine
image: node:alpine
commands:
- yarn lint
yarn_build_dev:
image: node:14-alpine
image: node:alpine
commands:
- yarn build:dev
nightly_build:
image: plugins/docker
publish_release_docker:
image: woodpeckerci/plugin-docker-buildx
secrets: [docker_username, docker_password]
settings:
dockerfile: Dockerfile
repo: dessalines/lemmy-ui
username:
from_secret: docker_username
password:
from_secret: docker_password
tags:
- dev
when:
event:
- cron
publish_release_docker_image_amd:
image: plugins/docker
settings:
dockerfile: Dockerfile
repo: dessalines/lemmy-ui
platforms: linux/amd64
auto_tag: true
auto_tag_suffix: linux-amd64
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event: tag
platform: linux/arm64
publish_release_docker_image_arm:
image: plugins/docker
settings:
dockerfile: Dockerfile
repo: dessalines/lemmy-ui
auto_tag: true
auto_tag_suffix: linux-arm64
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event: tag
platform: linux/amd64
publish_release_docker_manifest:
image: plugins/manifest
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
target: "dessalines/lemmy-ui:${CI_COMMIT_TAG}"
template: "dessalines/lemmy-ui:${CI_COMMIT_TAG}-OS-ARCH"
platforms:
- linux/amd64
- linux/arm64
ignore_missing: true
when:
event: tag
publish_latest_release_docker_manifest:
image: plugins/manifest
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
target: "dessalines/lemmy-ui:latest"
template: "dessalines/lemmy-ui:${CI_COMMIT_TAG}-OS-ARCH"
platforms:
- linux/amd64
- linux/arm64
ignore_missing: true
when:
event: tag

View file

@ -1,4 +1,4 @@
# lemmy-ui
# Lemmy-UI
The official web app for [Lemmy](https://github.com/LemmyNet/lemmy), written in inferno.
@ -13,7 +13,6 @@ The following environment variables can be used to configure lemmy-ui:
| `LEMMY_UI_HOST` | `string` | `0.0.0.0:1234` | The IP / port that the lemmy-ui isomorphic node server is hosted at. |
| `LEMMY_UI_LEMMY_INTERNAL_HOST` | `string` | `0.0.0.0:8536` | The internal IP / port that lemmy is hosted at. Often `lemmy:8536` if using docker. |
| `LEMMY_UI_LEMMY_EXTERNAL_HOST` | `string` | `0.0.0.0:8536` | The external IP / port that lemmy is hosted at. Often `DOMAIN.TLD`. |
| `LEMMY_UI_LEMMY_WS_HOST` | `string` | `0.0.0.0:8536` | An alternate location for lemmy's websocket address. Not usually necessary. |
| `LEMMY_UI_HTTPS` | `bool` | `false` | Whether to use https. |
| `LEMMY_UI_EXTRA_THEMES_FOLDER` | `string` | `./extra_themes` | A location for additional lemmy css themes. |
| `LEMMY_UI_DEBUG` | `bool` | `false` | Loads the [Eruda](https://github.com/liriliri/eruda) debugging utility. |

View file

@ -4,6 +4,7 @@ set -e
new_tag="$1"
# Old deploy
# sudo docker build . --tag dessalines/lemmy-ui:$new_tag --platform=linux/amd64 --push
# sudo docker build . --tag dessalines/lemmy-ui:$new_tag --platform=linux/amd64
# sudo docker push dessalines/lemmy-ui:$new_tag

@ -1 +1 @@
Subproject commit ddf0d3a4dcfba5eddbcdb702db2470b52abb3815
Subproject commit f45ddff206adb52ab0ac7555bf14978edac5d2f2

View file

@ -1,6 +1,6 @@
{
"name": "lemmy-ui",
"version": "0.17.1",
"version": "0.18.0-rc.1",
"description": "An isomorphic UI for lemmy",
"repository": "https://github.com/LemmyNet/lemmy-ui",
"license": "AGPL-3.0",
@ -19,16 +19,9 @@
"themes:watch": "sass --watch src/assets/css/themes/:src/assets/css/themes"
},
"lint-staged": {
"*.{ts,tsx,js}": [
"prettier --write",
"eslint --fix"
],
"*.{css, scss}": [
"prettier --write"
],
"package.json": [
"sortpack"
]
"*.{ts,tsx,js}": ["prettier --write", "eslint --fix"],
"*.{css, scss}": ["prettier --write"],
"package.json": ["sortpack"]
},
"dependencies": {
"@babel/plugin-proposal-decorators": "^7.21.0",
@ -51,6 +44,7 @@
"emoji-mart": "^5.4.0",
"emoji-short-name": "^2.0.0",
"express": "~4.18.2",
"history": "^5.3.0",
"html-to-text": "^9.0.5",
"i18next": "^22.4.15",
"inferno": "^8.1.1",
@ -62,7 +56,7 @@
"inferno-server": "^8.1.1",
"isomorphic-cookie": "^1.2.4",
"jwt-decode": "^3.1.2",
"lemmy-js-client": "0.17.2-rc.17",
"lemmy-js-client": "0.17.2-rc.24",
"lodash": "^4.17.21",
"markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0",
@ -75,7 +69,6 @@
"moment": "^2.29.4",
"register-service-worker": "^1.7.2",
"run-node-webpack-plugin": "^1.3.0",
"rxjs": "^7.8.1",
"sanitize-html": "^2.10.0",
"sass": "^1.62.1",
"sass-loader": "^13.2.2",
@ -87,8 +80,7 @@
"tributejs": "^5.1.3",
"webpack": "5.82.1",
"webpack-cli": "^5.1.1",
"webpack-node-externals": "^3.0.0",
"websocket-ts": "^1.1.1"
"webpack-node-externals": "^3.0.0"
},
"devDependencies": {
"@babel/core": "^7.21.8",

View file

@ -75,6 +75,11 @@
font-size: 1.2rem;
}
.md-div pre {
white-space: pre;
overflow-x: auto;
}
.md-div table {
border-collapse: collapse;
width: 100%;
@ -213,6 +218,11 @@ blockquote {
overflow-y: auto;
}
.comments {
list-style: none;
padding: 0;
}
.thumbnail {
object-fit: cover;
min-height: 60px;

View file

@ -1,18 +1,19 @@
import { hydrate } from "inferno-hydrate";
import { BrowserRouter } from "inferno-router";
import { Router } from "inferno-router";
import { App } from "../shared/components/app/app";
import { initializeSite } from "../shared/utils";
import "bootstrap/js/dist/collapse";
import "bootstrap/js/dist/dropdown";
import { HistoryService } from "../shared/services/HistoryService";
const site = window.isoData.site_res;
initializeSite(site);
const wrapper = (
<BrowserRouter>
<Router history={HistoryService.history}>
<App />
</BrowserRouter>
</Router>
);
const root = document.getElementById("root");

View file

@ -6,19 +6,20 @@ import { Helmet } from "inferno-helmet";
import { matchPath, StaticRouter } from "inferno-router";
import { renderToString } from "inferno-server";
import IsomorphicCookie from "isomorphic-cookie";
import { GetSite, GetSiteResponse, LemmyHttp, Site } from "lemmy-js-client";
import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client";
import path from "path";
import process from "process";
import serialize from "serialize-javascript";
import sharp from "sharp";
import { App } from "../shared/components/app/app";
import { getHttpBase, getHttpBaseInternal } from "../shared/env";
import { getHttpBaseExternal, getHttpBaseInternal } from "../shared/env";
import {
ILemmyConfig,
InitialFetchRequest,
IsoDataOptionalSite,
} from "../shared/interfaces";
import { routes } from "../shared/routes";
import { RequestState, wrapClient } from "../shared/services/HttpService";
import {
ErrorPageData,
favIconPngUrl,
@ -64,7 +65,13 @@ Disallow: /search/
server.get("/service-worker.js", async (_req, res) => {
res.setHeader("Content-Type", "application/javascript");
res.sendFile(path.resolve("./dist/service-worker.js"));
res.sendFile(
path.resolve(
`./dist/service-worker${
process.env.NODE_ENV === "development" ? "-development" : ""
}.js`
)
);
});
server.get("/robots.txt", async (_req, res) => {
@ -121,7 +128,7 @@ server.get("/*", async (req, res) => {
const getSiteForm: GetSite = { auth };
const headers = setForwardedHeaders(req.headers);
const client = new LemmyHttp(getHttpBaseInternal(), headers);
const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers));
const { path, url, query } = req;
@ -129,11 +136,10 @@ server.get("/*", async (req, res) => {
// This bypasses errors, so that the client can hit the error on its own,
// in order to remove the jwt on the browser. Necessary for wrong jwts
let site: GetSiteResponse | undefined = undefined;
let routeData: any[] = [];
let errorPageData: ErrorPageData | undefined;
try {
let try_site: any = await client.getSite(getSiteForm);
if (try_site.error == "not_logged_in") {
const routeData: RequestState<any>[] = [];
let errorPageData: ErrorPageData | undefined = undefined;
let try_site = await client.getSite(getSiteForm);
if (try_site.state === "failed" && try_site.msg == "not_logged_in") {
console.error(
"Incorrect JWT token, skipping auth so frontend can remove jwt cookie"
);
@ -143,13 +149,17 @@ server.get("/*", async (req, res) => {
}
if (!auth && isAuthPath(path)) {
res.redirect("/login");
return;
return res.redirect("/login");
}
site = try_site;
if (try_site.state === "success") {
site = try_site.data;
initializeSite(site);
if (path !== "/setup" && !site.site_view.local_site.site_setup) {
return res.redirect("/setup");
}
if (site) {
const initialFetchReq: InitialFetchRequest = {
client,
@ -160,23 +170,25 @@ server.get("/*", async (req, res) => {
};
if (activeRoute?.fetchInitialData) {
routeData = await Promise.all([
routeData.push(
...(await Promise.all([
...activeRoute.fetchInitialData(initialFetchReq),
]);
]))
);
}
}
} catch (error) {
errorPageData = getErrorPageData(error, site);
} else if (try_site.state === "failed") {
errorPageData = getErrorPageData(new Error(try_site.msg), site);
}
// Redirect to the 404 if there's an API error
if (routeData[0] && routeData[0].error) {
const error = routeData[0].error;
if (routeData[0] && routeData[0].state === "failed") {
const error = routeData[0].msg;
console.error(error);
if (error === "instance_is_private") {
return res.redirect(`/signup`);
} else {
errorPageData = getErrorPageData(error, site);
errorPageData = getErrorPageData(new Error(error), site);
}
}
@ -234,7 +246,7 @@ process.on("SIGINT", () => {
process.exit(0);
});
const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512];
const iconSizes = [72, 96, 144, 192, 512];
const defaultLogoPathDirectory = path.join(
process.cwd(),
"dist",
@ -242,12 +254,15 @@ const defaultLogoPathDirectory = path.join(
"icons"
);
export async function generateManifestBase64(site: Site) {
const url = (
process.env.NODE_ENV === "development"
? "http://localhost:1236/"
: getHttpBase()
).replace(/\/$/g, "");
export async function generateManifestBase64({
my_user,
site_view: {
site,
local_site: { community_creation_admin_only },
},
}: GetSiteResponse) {
const url = getHttpBaseExternal();
const icon = site.icon ? await fetchIconPng(site.icon) : null;
const manifest = {
@ -281,15 +296,58 @@ export async function generateManifestBase64(site: Site) {
};
})
),
shortcuts: [
{
name: "Search",
short_name: "Search",
description: "Perform a search.",
url: "/search",
},
{
name: "Communities",
url: "/communities",
short_name: "Communities",
description: "Browse communities",
},
]
.concat(
my_user
? [
{
name: "Create Post",
url: "/create_post",
short_name: "Create Post",
description: "Create a post.",
},
]
: []
)
.concat(
my_user?.local_user_view.person.admin || !community_creation_admin_only
? [
{
name: "Create Community",
url: "/create_community",
short_name: "Create Community",
description: "Create a community",
},
]
: []
),
related_applications: [
{
platform: "f-droid",
url: "https://f-droid.org/packages/com.jerboa/",
id: "com.jerboa",
},
],
};
return Buffer.from(JSON.stringify(manifest)).toString("base64");
}
async function fetchIconPng(iconUrl: string) {
return await fetch(
iconUrl.replace(/https?:\/\/[^\/]+/g, getHttpBaseInternal())
)
return await fetch(iconUrl)
.then(res => res.blob())
.then(blob => blob.arrayBuffer());
}
@ -376,9 +434,9 @@ async function createSsrHtml(root: string, isoData: IsoDataOptionalSite) {
site &&
`<link
rel="manifest"
href={${`data:application/manifest+json;base64,${await generateManifestBase64(
site.site_view.site
)}`}}
href=${`data:application/manifest+json;base64,${await generateManifestBase64(
site
)}`}
/>`
}
<link rel="apple-touch-icon" href=${appleTouchIcon} />

View file

@ -1,35 +1,25 @@
import { Component, createRef, linkEvent } from "inferno";
import { NavLink } from "inferno-router";
import {
CommentResponse,
GetReportCount,
GetReportCountResponse,
GetSiteResponse,
GetUnreadCount,
GetUnreadCountResponse,
GetUnreadRegistrationApplicationCount,
GetUnreadRegistrationApplicationCountResponse,
PrivateMessageResponse,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services";
import { UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import {
amAdmin,
canCreateCommunity,
donateLemmyUrl,
isBrowser,
myAuth,
notifyComment,
notifyPrivateMessage,
numToSI,
poll,
showAvatars,
toast,
wsClient,
wsSubscribe,
updateUnreadCountsInterval,
} from "../../utils";
import { Icon } from "../common/icon";
import { PictrsImage } from "../common/pictrs-image";
@ -39,15 +29,17 @@ interface NavbarProps {
}
interface NavbarState {
unreadInboxCount: number;
unreadReportCount: number;
unreadApplicationCount: number;
unreadInboxCountRes: RequestState<GetUnreadCountResponse>;
unreadReportCountRes: RequestState<GetReportCountResponse>;
unreadApplicationCountRes: RequestState<GetUnreadRegistrationApplicationCountResponse>;
onSiteBanner?(url: string): any;
}
function handleCollapseClick(i: Navbar) {
if (i.collapseButtonRef.current?.ariaExpanded === "true") {
i.collapseButtonRef.current?.click();
}
}
function handleLogOut(i: Navbar) {
UserService.Instance.logout();
@ -55,77 +47,42 @@ function handleLogOut(i: Navbar) {
}
export class Navbar extends Component<NavbarProps, NavbarState> {
private wsSub: Subscription;
private userSub: Subscription;
private unreadInboxCountSub: Subscription;
private unreadReportCountSub: Subscription;
private unreadApplicationCountSub: Subscription;
state: NavbarState = {
unreadInboxCount: 0,
unreadReportCount: 0,
unreadApplicationCount: 0,
unreadInboxCountRes: { state: "empty" },
unreadReportCountRes: { state: "empty" },
unreadApplicationCountRes: { state: "empty" },
};
subscription: any;
collapseButtonRef = createRef<HTMLButtonElement>();
mobileMenuRef = createRef<HTMLDivElement>();
constructor(props: any, context: any) {
super(props, context);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
this.handleOutsideMenuClick = this.handleOutsideMenuClick.bind(this);
}
componentDidMount() {
async componentDidMount() {
// Subscribe to jwt changes
if (isBrowser()) {
// On the first load, check the unreads
const auth = myAuth(false);
if (auth && UserService.Instance.myUserInfo) {
this.requestNotificationPermission();
WebSocketService.Instance.send(
wsClient.userJoin({
auth,
})
);
this.fetchUnreads();
}
this.requestNotificationPermission();
// Subscribe to unread count changes
this.unreadInboxCountSub =
UserService.Instance.unreadInboxCountSub.subscribe(res => {
this.setState({ unreadInboxCount: res });
});
// Subscribe to unread report count changes
this.unreadReportCountSub =
UserService.Instance.unreadReportCountSub.subscribe(res => {
this.setState({ unreadReportCount: res });
});
// Subscribe to unread application count
this.unreadApplicationCountSub =
UserService.Instance.unreadApplicationCountSub.subscribe(res => {
this.setState({ unreadApplicationCount: res });
});
document.addEventListener("click", this.handleOutsideMenuClick);
document.addEventListener("mouseup", this.handleOutsideMenuClick);
}
}
componentWillUnmount() {
this.wsSub.unsubscribe();
this.userSub.unsubscribe();
this.unreadInboxCountSub.unsubscribe();
this.unreadReportCountSub.unsubscribe();
this.unreadApplicationCountSub.unsubscribe();
document.removeEventListener("click", this.handleOutsideMenuClick);
document.removeEventListener("mouseup", this.handleOutsideMenuClick);
}
render() {
return this.navbar();
}
// TODO class active corresponding to current page
render() {
navbar() {
const siteView = this.props.siteRes?.site_view;
const person = UserService.Instance.myUserInfo?.local_user_view.person;
return (
@ -148,15 +105,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
to="/inbox"
className="p-1 nav-link border-0"
title={i18n.t("unread_messages", {
count: Number(this.state.unreadInboxCount),
formattedCount: numToSI(this.state.unreadInboxCount),
count: Number(this.state.unreadApplicationCountRes.state),
formattedCount: numToSI(this.unreadInboxCount),
})}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="bell" />
{this.state.unreadInboxCount > 0 && (
{this.unreadInboxCount > 0 && (
<span className="mx-1 badge badge-light">
{numToSI(this.state.unreadInboxCount)}
{numToSI(this.unreadInboxCount)}
</span>
)}
</NavLink>
@ -167,15 +124,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
to="/reports"
className="p-1 nav-link border-0"
title={i18n.t("unread_reports", {
count: Number(this.state.unreadReportCount),
formattedCount: numToSI(this.state.unreadReportCount),
count: Number(this.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount),
})}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="shield" />
{this.state.unreadReportCount > 0 && (
{this.unreadReportCount > 0 && (
<span className="mx-1 badge badge-light">
{numToSI(this.state.unreadReportCount)}
{numToSI(this.unreadReportCount)}
</span>
)}
</NavLink>
@ -187,15 +144,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
to="/registration_applications"
className="p-1 nav-link border-0"
title={i18n.t("unread_registration_applications", {
count: Number(this.state.unreadApplicationCount),
formattedCount: numToSI(this.state.unreadApplicationCount),
count: Number(this.unreadApplicationCount),
formattedCount: numToSI(this.unreadApplicationCount),
})}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="clipboard" />
{this.state.unreadApplicationCount > 0 && (
{this.unreadApplicationCount > 0 && (
<span className="mx-1 badge badge-light">
{numToSI(this.state.unreadApplicationCount)}
{numToSI(this.unreadApplicationCount)}
</span>
)}
</NavLink>
@ -272,9 +229,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
</li>
</ul>
<ul className="navbar-nav">
{!this.context.router.history.location.pathname.match(
/^\/search/
) && (
<li className="nav-item">
<NavLink
to="/search"
@ -285,7 +239,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
<Icon icon="search" />
</NavLink>
</li>
)}
{amAdmin() && (
<li className="nav-item">
<NavLink
@ -305,15 +258,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
className="nav-link"
to="/inbox"
title={i18n.t("unread_messages", {
count: Number(this.state.unreadInboxCount),
formattedCount: numToSI(this.state.unreadInboxCount),
count: Number(this.unreadInboxCount),
formattedCount: numToSI(this.unreadInboxCount),
})}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="bell" />
{this.state.unreadInboxCount > 0 && (
<span className="ml-1 badge badge-light">
{numToSI(this.state.unreadInboxCount)}
{this.unreadInboxCount > 0 && (
<span className="mx-1 badge badge-light">
{numToSI(this.unreadInboxCount)}
</span>
)}
</NavLink>
@ -324,15 +277,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
className="nav-link"
to="/reports"
title={i18n.t("unread_reports", {
count: Number(this.state.unreadReportCount),
formattedCount: numToSI(this.state.unreadReportCount),
count: Number(this.unreadReportCount),
formattedCount: numToSI(this.unreadReportCount),
})}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="shield" />
{this.state.unreadReportCount > 0 && (
<span className="ml-1 badge badge-light">
{numToSI(this.state.unreadReportCount)}
{this.unreadReportCount > 0 && (
<span className="mx-1 badge badge-light">
{numToSI(this.unreadReportCount)}
</span>
)}
</NavLink>
@ -344,17 +297,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
to="/registration_applications"
className="nav-link"
title={i18n.t("unread_registration_applications", {
count: Number(this.state.unreadApplicationCount),
formattedCount: numToSI(
this.state.unreadApplicationCount
),
count: Number(this.unreadApplicationCount),
formattedCount: numToSI(this.unreadApplicationCount),
})}
onMouseUp={linkEvent(this, handleCollapseClick)}
>
<Icon icon="clipboard" />
{this.state.unreadApplicationCount > 0 && (
{this.unreadApplicationCount > 0 && (
<span className="mx-1 badge badge-light">
{numToSI(this.state.unreadApplicationCount)}
{numToSI(this.unreadApplicationCount)}
</span>
)}
</NavLink>
@ -457,124 +408,73 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
return amAdmin() || moderatesS;
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
if (msg.error == "not_logged_in") {
UserService.Instance.logout();
}
return;
} else if (msg.reconnect) {
console.log(i18n.t("websocket_reconnected"));
const auth = myAuth(false);
if (UserService.Instance.myUserInfo && auth) {
WebSocketService.Instance.send(
wsClient.userJoin({
auth,
})
);
this.fetchUnreads();
}
} else if (op == UserOperation.GetUnreadCount) {
const data = wsJsonToRes<GetUnreadCountResponse>(msg);
this.setState({
unreadInboxCount: data.replies + data.mentions + data.private_messages,
});
this.sendUnreadCount();
} else if (op == UserOperation.GetReportCount) {
const data = wsJsonToRes<GetReportCountResponse>(msg);
this.setState({
unreadReportCount:
data.post_reports +
data.comment_reports +
(data.private_message_reports ?? 0),
});
this.sendReportUnread();
} else if (op == UserOperation.GetUnreadRegistrationApplicationCount) {
const data =
wsJsonToRes<GetUnreadRegistrationApplicationCountResponse>(msg);
this.setState({ unreadApplicationCount: data.registration_applications });
this.sendApplicationUnread();
} else if (op == UserOperation.CreateComment) {
const data = wsJsonToRes<CommentResponse>(msg);
const mui = UserService.Instance.myUserInfo;
if (
mui &&
data.recipient_ids.includes(mui.local_user_view.local_user.id)
) {
this.setState({
unreadInboxCount: this.state.unreadInboxCount + 1,
});
this.sendUnreadCount();
notifyComment(data.comment_view, this.context.router);
}
} else if (op == UserOperation.CreatePrivateMessage) {
const data = wsJsonToRes<PrivateMessageResponse>(msg);
if (
data.private_message_view.recipient.id ==
UserService.Instance.myUserInfo?.local_user_view.person.id
) {
this.setState({
unreadInboxCount: this.state.unreadInboxCount + 1,
});
this.sendUnreadCount();
notifyPrivateMessage(data.private_message_view, this.context.router);
}
}
}
fetchUnreads() {
console.log("Fetching inbox unreads...");
poll(async () => {
if (window.document.visibilityState !== "hidden") {
const auth = myAuth();
if (auth) {
const unreadForm: GetUnreadCount = {
this.setState({
unreadInboxCountRes: await HttpService.client.getUnreadCount({
auth,
};
WebSocketService.Instance.send(wsClient.getUnreadCount(unreadForm));
}),
});
console.log("Fetching reports...");
const reportCountForm: GetReportCount = {
if (this.moderatesSomething) {
this.setState({
unreadReportCountRes: await HttpService.client.getReportCount({
auth,
};
WebSocketService.Instance.send(wsClient.getReportCount(reportCountForm));
}),
});
}
if (amAdmin()) {
console.log("Fetching applications...");
const applicationCountForm: GetUnreadRegistrationApplicationCount = {
this.setState({
unreadApplicationCountRes:
await HttpService.client.getUnreadRegistrationApplicationCount({
auth,
};
WebSocketService.Instance.send(
wsClient.getUnreadRegistrationApplicationCount(applicationCountForm)
);
}),
});
}
}
}
}, updateUnreadCountsInterval);
}
get unreadInboxCount(): number {
if (this.state.unreadInboxCountRes.state == "success") {
const data = this.state.unreadInboxCountRes.data;
return data.replies + data.mentions + data.private_messages;
} else {
return 0;
}
}
get unreadReportCount(): number {
if (this.state.unreadReportCountRes.state == "success") {
const data = this.state.unreadReportCountRes.data;
return (
data.post_reports +
data.comment_reports +
(data.private_message_reports ?? 0)
);
} else {
return 0;
}
}
get unreadApplicationCount(): number {
if (this.state.unreadApplicationCountRes.state == "success") {
const data = this.state.unreadApplicationCountRes.data;
return data.registration_applications;
} else {
return 0;
}
}
get currentLocation() {
return this.context.router.history.location.pathname;
}
sendUnreadCount() {
UserService.Instance.unreadInboxCountSub.next(this.state.unreadInboxCount);
}
sendReportUnread() {
UserService.Instance.unreadReportCountSub.next(
this.state.unreadReportCount
);
}
sendApplicationUnread() {
UserService.Instance.unreadApplicationCountSub.next(
this.state.unreadApplicationCount
);
}
requestNotificationPermission() {
if (UserService.Instance.myUserInfo) {
document.addEventListener("DOMContentLoaded", function () {

View file

@ -1,25 +1,11 @@
import { Component } from "inferno";
import { T } from "inferno-i18next-dess";
import { Link } from "inferno-router";
import {
CommentResponse,
CreateComment,
EditComment,
Language,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { CreateComment, EditComment, Language } from "lemmy-js-client";
import { i18n } from "../../i18next";
import { CommentNodeI } from "../../interfaces";
import { UserService, WebSocketService } from "../../services";
import {
capitalizeFirstLetter,
myAuth,
wsClient,
wsSubscribe,
} from "../../utils";
import { UserService } from "../../services";
import { capitalizeFirstLetter, myAuthRequired } from "../../utils";
import { Icon } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea";
@ -28,44 +14,21 @@ interface CommentFormProps {
* Can either be the parent, or the editable comment. The right side is a postId.
*/
node: CommentNodeI | number;
finished?: boolean;
edit?: boolean;
disabled?: boolean;
focus?: boolean;
onReplyCancel?(): any;
onReplyCancel?(): void;
allLanguages: Language[];
siteLanguages: number[];
onUpsertComment(form: EditComment | CreateComment): void;
}
interface CommentFormState {
buttonTitle: string;
finished: boolean;
formId?: string;
}
export class CommentForm extends Component<CommentFormProps, CommentFormState> {
private subscription?: Subscription;
state: CommentFormState = {
buttonTitle:
typeof this.props.node === "number"
? capitalizeFirstLetter(i18n.t("post"))
: this.props.edit
? capitalizeFirstLetter(i18n.t("save"))
: capitalizeFirstLetter(i18n.t("reply")),
finished: false,
};
export class CommentForm extends Component<CommentFormProps, any> {
constructor(props: any, context: any) {
super(props, context);
this.handleCommentSubmit = this.handleCommentSubmit.bind(this);
this.handleReplyCancel = this.handleReplyCancel.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
}
componentWillUnmount() {
this.subscription?.unsubscribe();
}
render() {
@ -82,13 +45,13 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
<MarkdownTextArea
initialContent={initialContent}
showLanguage
buttonTitle={this.state.buttonTitle}
finished={this.state.finished}
buttonTitle={this.buttonTitle}
finished={this.props.finished}
replyType={typeof this.props.node !== "number"}
focus={this.props.focus}
disabled={this.props.disabled}
onSubmit={this.handleCommentSubmit}
onReplyCancel={this.handleReplyCancel}
onReplyCancel={this.props.onReplyCancel}
placeholder={i18n.t("comment_here")}
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
@ -108,77 +71,46 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
);
}
handleCommentSubmit(msg: {
val: string;
formId: string;
languageId?: number;
}) {
const content = msg.val;
const language_id = msg.languageId;
const node = this.props.node;
get buttonTitle(): string {
return typeof this.props.node === "number"
? capitalizeFirstLetter(i18n.t("post"))
: this.props.edit
? capitalizeFirstLetter(i18n.t("save"))
: capitalizeFirstLetter(i18n.t("reply"));
}
this.setState({ formId: msg.formId });
const auth = myAuth();
if (auth) {
handleCommentSubmit(content: string, form_id: string, language_id?: number) {
const { node, onUpsertComment, edit } = this.props;
if (typeof node === "number") {
const postId = node;
const form: CreateComment = {
const post_id = node;
onUpsertComment({
content,
form_id: this.state.formId,
post_id: postId,
post_id,
language_id,
auth,
};
WebSocketService.Instance.send(wsClient.createComment(form));
form_id,
auth: myAuthRequired(),
});
} else {
if (this.props.edit) {
const form: EditComment = {
if (edit) {
const comment_id = node.comment_view.comment.id;
onUpsertComment({
content,
form_id: this.state.formId,
comment_id: node.comment_view.comment.id,
comment_id,
form_id,
language_id,
auth,
};
WebSocketService.Instance.send(wsClient.editComment(form));
auth: myAuthRequired(),
});
} else {
const form: CreateComment = {
const post_id = node.comment_view.post.id;
const parent_id = node.comment_view.comment.id;
this.props.onUpsertComment({
content,
form_id: this.state.formId,
post_id: node.comment_view.post.id,
parent_id: node.comment_view.comment.id,
parent_id,
post_id,
form_id,
language_id,
auth,
};
WebSocketService.Instance.send(wsClient.createComment(form));
}
}
}
}
handleReplyCancel() {
this.props.onReplyCancel?.();
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
// Only do the showing and hiding if logged in
if (UserService.Instance.myUserInfo) {
if (
op == UserOperation.CreateComment ||
op == UserOperation.EditComment
) {
const data = wsJsonToRes<CommentResponse>(msg);
// This only finishes this form, if the randomly generated form_id matches the one received
if (this.state.formId && this.state.formId == data.form_id) {
this.setState({ finished: true });
// Necessary because it broke tribute for some reason
this.setState({ finished: false });
}
auth: myAuthRequired(),
});
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,32 @@
import classNames from "classnames";
import { Component } from "inferno";
import { CommunityModeratorView, Language, PersonView } from "lemmy-js-client";
import {
AddAdmin,
AddModToCommunity,
BanFromCommunity,
BanPerson,
BlockPerson,
CommentId,
CommunityModeratorView,
CreateComment,
CreateCommentLike,
CreateCommentReport,
DeleteComment,
DistinguishComment,
EditComment,
GetComments,
Language,
MarkCommentReplyAsRead,
MarkPersonMentionAsRead,
PersonView,
PurgeComment,
PurgePerson,
RemoveComment,
SaveComment,
TransferCommunity,
} from "lemmy-js-client";
import { CommentNodeI, CommentViewType } from "../../interfaces";
import { colorList } from "../../utils";
import { CommentNode } from "./comment-node";
interface CommentNodesProps {
@ -20,6 +46,28 @@ interface CommentNodesProps {
allLanguages: Language[];
siteLanguages: number[];
hideImages?: boolean;
isChild?: boolean;
depth?: number;
finished: Map<CommentId, boolean | undefined>;
onSaveComment(form: SaveComment): void;
onCommentReplyRead(form: MarkCommentReplyAsRead): void;
onPersonMentionRead(form: MarkPersonMentionAsRead): void;
onCreateComment(form: EditComment | CreateComment): void;
onEditComment(form: EditComment | CreateComment): void;
onCommentVote(form: CreateCommentLike): void;
onBlockPerson(form: BlockPerson): void;
onDeleteComment(form: DeleteComment): void;
onRemoveComment(form: RemoveComment): void;
onDistinguishComment(form: DistinguishComment): void;
onAddModToCommunity(form: AddModToCommunity): void;
onAddAdmin(form: AddAdmin): void;
onBanPersonFromCommunity(form: BanFromCommunity): void;
onBanPerson(form: BanPerson): void;
onTransferCommunity(form: TransferCommunity): void;
onFetchChildren?(form: GetComments): void;
onCommentReport(form: CreateCommentReport): void;
onPurgePerson(form: PurgePerson): void;
onPurgeComment(form: PurgeComment): void;
}
export class CommentNodes extends Component<CommentNodesProps, any> {
@ -30,8 +78,19 @@ export class CommentNodes extends Component<CommentNodesProps, any> {
render() {
const maxComments = this.props.maxCommentsShown ?? this.props.nodes.length;
const borderColor = this.props.depth
? colorList[this.props.depth % colorList.length]
: colorList[0];
return (
<div className="comments">
this.props.nodes.length > 0 && (
<ul
className={classNames("comments", {
"ms-1": !!this.props.isChild,
"border-top border-light": !this.props.noBorder,
})}
style={`border-left: 2px solid ${borderColor} !important;`}
>
{this.props.nodes.slice(0, maxComments).map(node => (
<CommentNode
key={node.comment_view.comment.id}
@ -50,9 +109,30 @@ export class CommentNodes extends Component<CommentNodesProps, any> {
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
hideImages={this.props.hideImages}
onCommentReplyRead={this.props.onCommentReplyRead}
onPersonMentionRead={this.props.onPersonMentionRead}
finished={this.props.finished}
onCreateComment={this.props.onCreateComment}
onEditComment={this.props.onEditComment}
onCommentVote={this.props.onCommentVote}
onBlockPerson={this.props.onBlockPerson}
onSaveComment={this.props.onSaveComment}
onDeleteComment={this.props.onDeleteComment}
onRemoveComment={this.props.onRemoveComment}
onDistinguishComment={this.props.onDistinguishComment}
onAddModToCommunity={this.props.onAddModToCommunity}
onAddAdmin={this.props.onAddAdmin}
onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
onBanPerson={this.props.onBanPerson}
onTransferCommunity={this.props.onTransferCommunity}
onFetchChildren={this.props.onFetchChildren}
onCommentReport={this.props.onCommentReport}
onPurgePerson={this.props.onPurgePerson}
onPurgeComment={this.props.onPurgeComment}
/>
))}
</div>
</ul>
)
);
}
}

View file

@ -1,4 +1,4 @@
import { Component, linkEvent } from "inferno";
import { Component, InfernoNode, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess";
import {
CommentReportView,
@ -7,21 +7,39 @@ import {
} from "lemmy-js-client";
import { i18n } from "../../i18next";
import { CommentNodeI, CommentViewType } from "../../interfaces";
import { WebSocketService } from "../../services";
import { myAuth, wsClient } from "../../utils";
import { Icon } from "../common/icon";
import { myAuthRequired } from "../../utils";
import { Icon, Spinner } from "../common/icon";
import { PersonListing } from "../person/person-listing";
import { CommentNode } from "./comment-node";
interface CommentReportProps {
report: CommentReportView;
onResolveReport(form: ResolveCommentReport): void;
}
export class CommentReport extends Component<CommentReportProps, any> {
interface CommentReportState {
loading: boolean;
}
export class CommentReport extends Component<
CommentReportProps,
CommentReportState
> {
state: CommentReportState = {
loading: false,
};
constructor(props: any, context: any) {
super(props, context);
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & CommentReportProps>
): void {
if (this.props != nextProps) {
this.setState({ loading: false });
}
}
render() {
const r = this.props.report;
const comment = r.comment;
@ -62,6 +80,26 @@ export class CommentReport extends Component<CommentReportProps, any> {
allLanguages={[]}
siteLanguages={[]}
hideImages
// All of these are unused, since its viewonly
finished={new Map()}
onSaveComment={() => {}}
onBlockPerson={() => {}}
onDeleteComment={() => {}}
onRemoveComment={() => {}}
onCommentVote={() => {}}
onCommentReport={() => {}}
onDistinguishComment={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onPurgeComment={() => {}}
onPurgePerson={() => {}}
onCommentReplyRead={() => {}}
onPersonMentionRead={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onCreateComment={() => Promise.resolve({ state: "empty" })}
onEditComment={() => Promise.resolve({ state: "empty" })}
/>
<div>
{i18n.t("reporter")}: <PersonListing person={r.creator} />
@ -90,26 +128,27 @@ export class CommentReport extends Component<CommentReportProps, any> {
data-tippy-content={tippyContent}
aria-label={tippyContent}
>
{this.state.loading ? (
<Spinner />
) : (
<Icon
icon="check"
classes={`icon-inline ${
r.comment_report.resolved ? "text-success" : "text-danger"
}`}
/>
)}
</button>
</div>
);
}
handleResolveReport(i: CommentReport) {
const auth = myAuth();
if (auth) {
const form: ResolveCommentReport = {
i.setState({ loading: true });
i.props.onResolveReport({
report_id: i.props.report.comment_report.id,
resolved: !i.props.report.comment_report.resolved,
auth,
};
WebSocketService.Instance.send(wsClient.resolveCommentReport(form));
}
auth: myAuthRequired(),
});
}
}

View file

@ -1,7 +1,7 @@
import { Component, linkEvent } from "inferno";
import { i18n } from "../../i18next";
import { UserService } from "../../services";
import { randomStr, toast, uploadImage } from "../../utils";
import { HttpService, UserService } from "../../services";
import { randomStr, toast } from "../../utils";
import { Icon } from "./icon";
interface ImageUploadFormProps {
@ -73,26 +73,25 @@ export class ImageUploadForm extends Component<
handleImageUpload(i: ImageUploadForm, event: any) {
event.preventDefault();
const file = event.target.files[0];
const image = event.target.files[0] as File;
i.setState({ loading: true });
uploadImage(file)
.then(res => {
HttpService.client.uploadImage({ image }).then(res => {
console.log("pictrs upload:");
console.log(res);
if (res.msg === "ok") {
i.setState({ loading: false });
i.props.onUpload(res.url as string);
if (res.state === "success") {
if (res.data.msg === "ok") {
i.props.onUpload(res.data.url as string);
} else {
i.setState({ loading: false });
toast(JSON.stringify(res), "danger");
}
})
.catch(error => {
} else if (res.state === "failed") {
console.error(res.msg);
toast(res.msg, "danger");
}
i.setState({ loading: false });
console.error(error);
toast(error, "danger");
});
}

View file

@ -8,7 +8,7 @@ interface ListingTypeSelectProps {
type_: ListingType;
showLocal: boolean;
showSubscribed: boolean;
onChange?(val: ListingType): any;
onChange(val: ListingType): void;
}
interface ListingTypeSelectState {
@ -29,11 +29,11 @@ export class ListingTypeSelect extends Component<
super(props, context);
}
static getDerivedStateFromProps(props: any): ListingTypeSelectProps {
static getDerivedStateFromProps(
props: ListingTypeSelectProps
): ListingTypeSelectState {
return {
type_: props.type_,
showLocal: props.showLocal,
showSubscribed: props.showSubscribed,
};
}
@ -97,6 +97,6 @@ export class ListingTypeSelect extends Component<
}
handleTypeChange(i: ListingTypeSelect, event: any) {
i.props.onChange?.(event.target.value);
i.props.onChange(event.target.value);
}
}

View file

@ -3,7 +3,7 @@ import { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno";
import { Language } from "lemmy-js-client";
import { i18n } from "../../i18next";
import { UserService } from "../../services";
import { HttpService, UserService } from "../../services";
import {
concurrentImageUpload,
customEmojisLookup,
@ -19,7 +19,6 @@ import {
setupTippy,
setupTribute,
toast,
uploadImage,
} from "../../utils";
import { EmojiPicker } from "./emoji-picker";
import { Icon, Spinner } from "./icon";
@ -39,9 +38,9 @@ interface MarkdownTextAreaProps {
finished?: boolean;
showLanguage?: boolean;
hideNavigationWarnings?: boolean;
onContentChange?(val: string): any;
onReplyCancel?(): any;
onSubmit?(msg: { val?: string; formId: string; languageId?: number }): any;
onContentChange?(val: string): void;
onReplyCancel?(): void;
onSubmit?(content: string, formId: string, languageId?: number): void;
allLanguages: Language[]; // TODO should probably be nullable
siteLanguages: number[]; // TODO same
}
@ -55,8 +54,9 @@ interface MarkdownTextAreaState {
content?: string;
languageId?: number;
previewMode: boolean;
loading: boolean;
imageUploadStatus?: ImageUploadStatus;
loading: boolean;
submitted: boolean;
}
export class MarkdownTextArea extends Component<
@ -72,6 +72,7 @@ export class MarkdownTextArea extends Component<
languageId: this.props.initialLanguageId,
previewMode: false,
loading: false,
submitted: false,
};
constructor(props: any, context: any) {
@ -105,17 +106,14 @@ export class MarkdownTextArea extends Component<
}
}
componentDidUpdate() {
if (!this.props.hideNavigationWarnings && this.state.content) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = null;
}
}
componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
if (nextProps.finished) {
this.setState({ previewMode: false, loading: false, content: undefined });
this.setState({
previewMode: false,
imageUploadStatus: undefined,
loading: false,
content: undefined,
});
if (this.props.replyType) {
this.props.onReplyCancel?.();
}
@ -127,16 +125,23 @@ export class MarkdownTextArea extends Component<
}
}
componentWillUnmount() {
window.onbeforeunload = null;
}
render() {
const languageId = this.state.languageId;
// TODO add these prompts back in at some point
// <Prompt
// when={!this.props.hideNavigationWarnings && this.state.content}
// message={i18n.t("block_leaving")}
// />
return (
<form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
<NavigationPrompt when={!!this.state.content} />
<NavigationPrompt
when={
!this.props.hideNavigationWarnings &&
!!this.state.content &&
!this.state.submitted
}
/>
<div className="form-group row">
<div className={`col-sm-12`}>
<textarea
@ -390,29 +395,29 @@ export class MarkdownTextArea extends Component<
}
}
async uploadSingleImage(i: MarkdownTextArea, file: File) {
try {
const res = await uploadImage(file);
async uploadSingleImage(i: MarkdownTextArea, image: File) {
const res = await HttpService.client.uploadImage({ image });
console.log("pictrs upload:");
console.log(res);
if (res.msg === "ok") {
const imageMarkdown = `![](${res.url})`;
if (res.state === "success") {
if (res.data.msg === "ok") {
const imageMarkdown = `![](${res.data.url})`;
i.setState(({ content }) => ({
content: content ? `${content}\n${imageMarkdown}` : imageMarkdown,
}));
i.contentChange();
const textarea: any = document.getElementById(i.id);
autosize.update(textarea);
pictrsDeleteToast(file.name, res.delete_url as string);
pictrsDeleteToast(image.name, res.data.delete_url as string);
} else {
throw JSON.stringify(res);
throw JSON.stringify(res.data);
}
} catch (error) {
} else if (res.state === "failed") {
i.setState({ imageUploadStatus: undefined });
console.error(error);
toast(error, "danger");
console.error(res.msg);
toast(res.msg, "danger");
throw error;
throw res.msg;
}
}
@ -486,13 +491,10 @@ export class MarkdownTextArea extends Component<
handleSubmit(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.setState({ loading: true });
const msg = {
val: i.state.content,
formId: i.formId,
languageId: i.state.languageId,
};
i.props.onSubmit?.(msg);
if (i.state.content) {
i.setState({ loading: true, submitted: true });
i.props.onSubmit?.(i.state.content, i.formId, i.state.languageId);
}
}
handleReplyCancel(i: MarkdownTextArea) {

View file

@ -1,23 +1,26 @@
import { Component, linkEvent } from "inferno";
import { Component, InfernoNode, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess";
import {
ApproveRegistrationApplication,
RegistrationApplicationView,
} from "lemmy-js-client";
import { i18n } from "../../i18next";
import { WebSocketService } from "../../services";
import { mdToHtml, myAuth, wsClient } from "../../utils";
import { mdToHtml, myAuthRequired } from "../../utils";
import { PersonListing } from "../person/person-listing";
import { Spinner } from "./icon";
import { MarkdownTextArea } from "./markdown-textarea";
import { MomentTime } from "./moment-time";
interface RegistrationApplicationProps {
application: RegistrationApplicationView;
onApproveApplication(form: ApproveRegistrationApplication): void;
}
interface RegistrationApplicationState {
denyReason?: string;
denyExpanded: boolean;
approveLoading: boolean;
denyLoading: boolean;
}
export class RegistrationApplication extends Component<
@ -27,12 +30,27 @@ export class RegistrationApplication extends Component<
state: RegistrationApplicationState = {
denyReason: this.props.application.registration_application.deny_reason,
denyExpanded: false,
approveLoading: false,
denyLoading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.handleDenyReasonChange = this.handleDenyReasonChange.bind(this);
}
componentWillReceiveProps(
nextProps: Readonly<
{ children?: InfernoNode } & RegistrationApplicationProps
>
): void {
if (this.props != nextProps) {
this.setState({
denyExpanded: false,
approveLoading: false,
denyLoading: false,
});
}
}
render() {
const a = this.props.application;
@ -99,7 +117,7 @@ export class RegistrationApplication extends Component<
onClick={linkEvent(this, this.handleApprove)}
aria-label={i18n.t("approve")}
>
{i18n.t("approve")}
{this.state.approveLoading ? <Spinner /> : i18n.t("approve")}
</button>
)}
{(!ra.admin_id || (ra.admin_id && accepted)) && (
@ -108,7 +126,7 @@ export class RegistrationApplication extends Component<
onClick={linkEvent(this, this.handleDeny)}
aria-label={i18n.t("deny")}
>
{i18n.t("deny")}
{this.state.denyLoading ? <Spinner /> : i18n.t("deny")}
</button>
)}
</div>
@ -116,35 +134,23 @@ export class RegistrationApplication extends Component<
}
handleApprove(i: RegistrationApplication) {
const auth = myAuth();
if (auth) {
i.setState({ denyExpanded: false });
const form: ApproveRegistrationApplication = {
i.setState({ denyExpanded: false, approveLoading: true });
i.props.onApproveApplication({
id: i.props.application.registration_application.id,
approve: true,
auth,
};
WebSocketService.Instance.send(
wsClient.approveRegistrationApplication(form)
);
}
auth: myAuthRequired(),
});
}
handleDeny(i: RegistrationApplication) {
if (i.state.denyExpanded) {
i.setState({ denyExpanded: false });
const auth = myAuth();
if (auth) {
const form: ApproveRegistrationApplication = {
i.setState({ denyExpanded: false, denyLoading: true });
i.props.onApproveApplication({
id: i.props.application.registration_application.id,
approve: false,
deny_reason: i.state.denyReason,
auth,
};
WebSocketService.Instance.send(
wsClient.approveRegistrationApplication(form)
);
}
auth: myAuthRequired(),
});
} else {
i.setState({ denyExpanded: true });
}

View file

@ -38,12 +38,38 @@ function handleSearch(i: SearchableSelect, e: ChangeEvent<HTMLInputElement>) {
});
}
function focusSearch(i: SearchableSelect) {
if (i.toggleButtonRef.current?.ariaExpanded !== "true") {
i.searchInputRef.current?.focus();
if (i.props.onSearch) {
i.props.onSearch("");
}
i.setState({
searchText: "",
});
}
}
function handleChange({ option, i }: { option: Choice; i: SearchableSelect }) {
const { onChange, value } = i.props;
if (option.value !== value?.toString()) {
if (onChange) {
onChange(option);
}
i.setState({ searchText: "" });
}
}
export class SearchableSelect extends Component<
SearchableSelectProps,
SearchableSelectState
> {
private searchInputRef: RefObject<HTMLInputElement> = createRef();
private toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
searchInputRef: RefObject<HTMLInputElement> = createRef();
toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
private loadingEllipsesInterval?: NodeJS.Timer = undefined;
state: SearchableSelectState = {
@ -55,9 +81,6 @@ export class SearchableSelect extends Component<
constructor(props: SearchableSelectProps, context: any) {
super(props, context);
this.handleChange = this.handleChange.bind(this);
this.focusSearch = this.focusSearch.bind(this);
if (props.value) {
let selectedIndex = props.options.findIndex(
({ value }) => value === props.value?.toString()
@ -86,7 +109,8 @@ export class SearchableSelect extends Component<
className="custom-select text-start"
aria-haspopup="listbox"
data-bs-toggle="dropdown"
onClick={this.focusSearch}
onClick={linkEvent(this, focusSearch)}
ref={this.toggleButtonRef}
>
{loading
? `${i18n.t("loading")}${loadingEllipses}`
@ -127,7 +151,7 @@ export class SearchableSelect extends Component<
aria-disabled={option.disabled}
disabled={option.disabled}
aria-selected={selectedIndex === index}
onClick={() => this.handleChange(option)}
onClick={linkEvent({ i: this, option }, handleChange)}
type="button"
>
{option.label}
@ -138,20 +162,6 @@ export class SearchableSelect extends Component<
);
}
focusSearch() {
if (this.toggleButtonRef.current?.ariaExpanded !== "true") {
this.searchInputRef.current?.focus();
if (this.props.onSearch) {
this.props.onSearch("");
}
this.setState({
searchText: "",
});
}
}
static getDerivedStateFromProps({
value,
options,
@ -189,16 +199,4 @@ export class SearchableSelect extends Component<
clearInterval(this.loadingEllipsesInterval);
}
}
handleChange(option: Choice) {
const { onChange, value } = this.props;
if (option.value !== value?.toString()) {
if (onChange) {
onChange(option);
}
this.setState({ searchText: "" });
}
}
}

View file

@ -6,7 +6,7 @@ import { Icon } from "./icon";
interface SortSelectProps {
sort: SortType;
onChange?(val: SortType): any;
onChange(val: SortType): void;
hideHot?: boolean;
hideMostComments?: boolean;
}
@ -25,7 +25,7 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
super(props, context);
}
static getDerivedStateFromProps(props: any): SortSelectState {
static getDerivedStateFromProps(props: SortSelectProps): SortSelectState {
return {
sort: props.sort,
};
@ -85,6 +85,6 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
}
handleSortChange(i: SortSelect, event: any) {
i.props.onChange?.(event.target.value);
i.props.onChange(event.target.value);
}
}

View file

@ -1,32 +1,26 @@
import { Component, linkEvent } from "inferno";
import {
CommunityResponse,
FollowCommunity,
GetSiteResponse,
ListCommunities,
ListCommunitiesResponse,
ListingType,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { InitialFetchRequest } from "../../interfaces";
import { WebSocketService } from "../../services";
import { FirstLoadService } from "../../services/FirstLoadService";
import { HttpService, RequestState } from "../../services/HttpService";
import {
QueryParams,
editCommunity,
getPageFromString,
getQueryParams,
getQueryString,
isBrowser,
myAuth,
myAuthRequired,
numToSI,
setIsoData,
showLocal,
toast,
wsClient,
wsSubscribe,
} from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
@ -37,10 +31,10 @@ import { CommunityLink } from "./community-link";
const communityLimit = 50;
interface CommunitiesState {
listCommunitiesResponse?: ListCommunitiesResponse;
loading: boolean;
listCommunitiesResponse: RequestState<ListCommunitiesResponse>;
siteRes: GetSiteResponse;
searchText: string;
isIsomorphic: boolean;
}
interface CommunitiesProps {
@ -48,51 +42,17 @@ interface CommunitiesProps {
page: number;
}
function getCommunitiesQueryParams() {
return getQueryParams<CommunitiesProps>({
listingType: getListingTypeFromQuery,
page: getPageFromString,
});
}
function getListingTypeFromQuery(listingType?: string): ListingType {
return listingType ? (listingType as ListingType) : "Local";
}
function toggleSubscribe(community_id: number, follow: boolean) {
const auth = myAuth();
if (auth) {
const form: FollowCommunity = {
community_id,
follow,
auth,
};
WebSocketService.Instance.send(wsClient.followCommunity(form));
}
}
function refetch() {
const { listingType, page } = getCommunitiesQueryParams();
const listCommunitiesForm: ListCommunities = {
type_: listingType,
sort: "TopMonth",
limit: communityLimit,
page,
auth: myAuth(false),
};
WebSocketService.Instance.send(wsClient.listCommunities(listCommunitiesForm));
}
export class Communities extends Component<any, CommunitiesState> {
private subscription?: Subscription;
private isoData = setIsoData(this.context);
state: CommunitiesState = {
loading: true,
listCommunitiesResponse: { state: "empty" },
siteRes: this.isoData.site_res,
searchText: "",
isIsomorphic: false,
};
constructor(props: any, context: any) {
@ -100,25 +60,19 @@ export class Communities extends Component<any, CommunitiesState> {
this.handlePageChange = this.handlePageChange.bind(this);
this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
// Only fetch the data if coming from another route
if (this.isoData.path === this.context.router.route.match.url) {
const listRes = this.isoData.routeData[0] as ListCommunitiesResponse;
if (FirstLoadService.isFirstLoad) {
this.state = {
...this.state,
listCommunitiesResponse: listRes,
loading: false,
listCommunitiesResponse: this.isoData.routeData[0],
isIsomorphic: true,
};
} else {
refetch();
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
async componentDidMount() {
if (!this.state.isIsomorphic) {
await this.refetch();
}
}
@ -128,20 +82,17 @@ export class Communities extends Component<any, CommunitiesState> {
}`;
}
render() {
const { listingType, page } = getCommunitiesQueryParams();
renderListings() {
switch (this.state.listCommunitiesResponse.state) {
case "loading":
return (
<div className="container-lg">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
{this.state.loading ? (
<h5>
<Spinner large />
</h5>
) : (
);
case "success": {
const { listingType, page } = this.getCommunitiesQueryParams();
return (
<div>
<div className="row">
<div className="col-md-6">
@ -182,7 +133,8 @@ export class Communities extends Component<any, CommunitiesState> {
</tr>
</thead>
<tbody>
{this.state.listCommunitiesResponse?.communities.map(cv => (
{this.state.listCommunitiesResponse.data.communities.map(
cv => (
<tr key={cv.community.id}>
<td>
<CommunityLink community={cv.community} />
@ -204,8 +156,12 @@ export class Communities extends Component<any, CommunitiesState> {
<button
className="btn btn-link d-inline-block"
onClick={linkEvent(
cv.community.id,
this.handleUnsubscribe
{
i: this,
communityId: cv.community.id,
follow: false,
},
this.handleFollow
)}
>
{i18n.t("unsubscribe")}
@ -215,8 +171,12 @@ export class Communities extends Component<any, CommunitiesState> {
<button
className="btn btn-link d-inline-block"
onClick={linkEvent(
cv.community.id,
this.handleSubscribe
{
i: this,
communityId: cv.community.id,
follow: true,
},
this.handleFollow
)}
>
{i18n.t("subscribe")}
@ -229,13 +189,26 @@ export class Communities extends Component<any, CommunitiesState> {
)}
</td>
</tr>
))}
)
)}
</tbody>
</table>
</div>
<Paginator page={page} onChange={this.handlePageChange} />
</div>
)}
);
}
}
}
render() {
return (
<div className="container-lg">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
{this.renderListings()}
</div>
);
}
@ -266,9 +239,9 @@ export class Communities extends Component<any, CommunitiesState> {
);
}
updateUrl({ listingType, page }: Partial<CommunitiesProps>) {
async updateUrl({ listingType, page }: Partial<CommunitiesProps>) {
const { listingType: urlListingType, page: urlPage } =
getCommunitiesQueryParams();
this.getCommunitiesQueryParams();
const queryParams: QueryParams<CommunitiesProps> = {
listingType: listingType ?? urlListingType,
@ -277,7 +250,7 @@ export class Communities extends Component<any, CommunitiesState> {
this.props.history.push(`/communities${getQueryString(queryParams)}`);
refetch();
await this.refetch();
}
handlePageChange(page: number) {
@ -291,19 +264,12 @@ export class Communities extends Component<any, CommunitiesState> {
});
}
handleUnsubscribe(communityId: number) {
toggleSubscribe(communityId, false);
}
handleSubscribe(communityId: number) {
toggleSubscribe(communityId, true);
}
handleSearchChange(i: Communities, event: any) {
i.setState({ searchText: event.target.value });
}
handleSearchSubmit(i: Communities) {
handleSearchSubmit(i: Communities, event: any) {
event.preventDefault();
const searchParamEncoded = encodeURIComponent(i.state.searchText);
i.context.router.history.push(`/search?q=${searchParamEncoded}`);
}
@ -312,7 +278,9 @@ export class Communities extends Component<any, CommunitiesState> {
query: { listingType, page },
client,
auth,
}: InitialFetchRequest<QueryParams<CommunitiesProps>>): Promise<any>[] {
}: InitialFetchRequest<QueryParams<CommunitiesProps>>): Promise<
RequestState<any>
>[] {
const listCommunitiesForm: ListCommunities = {
type_: getListingTypeFromQuery(listingType),
sort: "TopMonth",
@ -324,33 +292,56 @@ export class Communities extends Component<any, CommunitiesState> {
return [client.listCommunities(listCommunitiesForm)];
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
} else if (op === UserOperation.ListCommunities) {
const data = wsJsonToRes<ListCommunitiesResponse>(msg);
this.setState({ listCommunitiesResponse: data, loading: false });
window.scrollTo(0, 0);
} else if (op === UserOperation.FollowCommunity) {
const {
community_view: {
community,
subscribed,
counts: { subscribers },
},
} = wsJsonToRes<CommunityResponse>(msg);
const res = this.state.listCommunitiesResponse;
const found = res?.communities.find(
({ community: { id } }) => id == community.id
);
getCommunitiesQueryParams() {
return getQueryParams<CommunitiesProps>({
listingType: getListingTypeFromQuery,
page: getPageFromString,
});
}
if (found) {
found.subscribed = subscribed;
found.counts.subscribers = subscribers;
this.setState(this.state);
}
}
async handleFollow(data: {
i: Communities;
communityId: number;
follow: boolean;
}) {
const res = await HttpService.client.followCommunity({
community_id: data.communityId,
follow: data.follow,
auth: myAuthRequired(),
});
data.i.findAndUpdateCommunity(res);
}
async refetch() {
this.setState({ listCommunitiesResponse: { state: "loading" } });
const { listingType, page } = this.getCommunitiesQueryParams();
this.setState({
listCommunitiesResponse: await HttpService.client.listCommunities({
type_: listingType,
sort: "TopMonth",
limit: communityLimit,
page,
auth: myAuth(),
}),
});
window.scrollTo(0, 0);
}
findAndUpdateCommunity(res: RequestState<CommunityResponse>) {
this.setState(s => {
if (
s.listCommunitiesResponse.state == "success" &&
res.state == "success"
) {
s.listCommunitiesResponse.data.communities = editCommunity(
res.data.community_view,
s.listCommunitiesResponse.data.communities
);
}
return s;
});
}
}

View file

@ -1,24 +1,12 @@
import { Component, linkEvent } from "inferno";
import {
CommunityResponse,
CommunityView,
CreateCommunity,
EditCommunity,
Language,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services";
import {
capitalizeFirstLetter,
myAuth,
randomStr,
wsClient,
wsSubscribe,
} from "../../utils";
import { capitalizeFirstLetter, myAuthRequired, randomStr } from "../../utils";
import { Icon, Spinner } from "../common/icon";
import { ImageUploadForm } from "../common/image-upload-form";
import { LanguageSelect } from "../common/language-select";
@ -31,9 +19,9 @@ interface CommunityFormProps {
siteLanguages: number[];
communityLanguages?: number[];
onCancel?(): any;
onCreate?(community: CommunityView): any;
onEdit?(community: CommunityView): any;
onUpsertCommunity(form: CreateCommunity | EditCommunity): void;
enableNsfw?: boolean;
loading?: boolean;
}
interface CommunityFormState {
@ -47,7 +35,7 @@ interface CommunityFormState {
posting_restricted_to_mods?: boolean;
discussion_languages?: number[];
};
loading: boolean;
submitted: boolean;
}
export class CommunityForm extends Component<
@ -55,11 +43,10 @@ export class CommunityForm extends Component<
CommunityFormState
> {
private id = `community-form-${randomStr()}`;
private subscription?: Subscription;
state: CommunityFormState = {
form: {},
loading: false,
submitted: false,
};
constructor(props: any, context: any) {
@ -77,12 +64,11 @@ export class CommunityForm extends Component<
this.handleDiscussionLanguageChange =
this.handleDiscussionLanguageChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
const cv = this.props.community_view;
if (cv) {
this.state = {
...this.state,
form: {
name: cv.community.name,
title: cv.community.title,
@ -93,43 +79,24 @@ export class CommunityForm extends Component<
posting_restricted_to_mods: cv.community.posting_restricted_to_mods,
discussion_languages: this.props.communityLanguages,
},
loading: false,
};
}
}
componentDidUpdate() {
if (
!this.state.loading &&
(this.state.form.name ||
this.state.form.title ||
this.state.form.description)
) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = null;
}
}
componentWillUnmount() {
this.subscription?.unsubscribe();
window.onbeforeunload = null;
}
render() {
return (
<>
<form onSubmit={linkEvent(this, this.handleCreateCommunitySubmit)}>
<NavigationPrompt
when={
!this.state.loading &&
!this.props.loading &&
!!(
this.state.form.name ||
this.state.form.title ||
this.state.form.description
)
) &&
!this.state.submitted
}
/>
<form onSubmit={linkEvent(this, this.handleCreateCommunitySubmit)}>
{!this.props.community_view && (
<div className="form-group row">
<label
@ -217,6 +184,7 @@ export class CommunityForm extends Component<
initialContent={this.state.form.description}
placeholder={i18n.t("description")}
onContentChange={this.handleCommunityDescriptionChange}
hideNavigationWarnings
allLanguages={[]}
siteLanguages={[]}
/>
@ -273,9 +241,9 @@ export class CommunityForm extends Component<
<button
type="submit"
className="btn btn-secondary mr-2"
disabled={this.state.loading}
disabled={this.props.loading}
>
{this.state.loading ? (
{this.props.loading ? (
<Spinner />
) : this.props.community_view ? (
capitalizeFirstLetter(i18n.t("save"))
@ -295,21 +263,19 @@ export class CommunityForm extends Component<
</div>
</div>
</form>
</>
);
}
handleCreateCommunitySubmit(i: CommunityForm, event: any) {
event.preventDefault();
i.setState({ loading: true });
i.setState({ submitted: true });
const cForm = i.state.form;
const auth = myAuth();
const auth = myAuthRequired();
const cv = i.props.community_view;
if (auth) {
if (cv) {
const form: EditCommunity = {
i.props.onUpsertCommunity({
community_id: cv.community.id,
title: cForm.title,
description: cForm.description,
@ -319,12 +285,10 @@ export class CommunityForm extends Component<
posting_restricted_to_mods: cForm.posting_restricted_to_mods,
discussion_languages: cForm.discussion_languages,
auth,
};
WebSocketService.Instance.send(wsClient.editCommunity(form));
});
} else {
if (cForm.title && cForm.name) {
const form: CreateCommunity = {
i.props.onUpsertCommunity({
name: cForm.name,
title: cForm.title,
description: cForm.description,
@ -334,22 +298,17 @@ export class CommunityForm extends Component<
posting_restricted_to_mods: cForm.posting_restricted_to_mods,
discussion_languages: cForm.discussion_languages,
auth,
};
WebSocketService.Instance.send(wsClient.createCommunity(form));
});
}
}
}
i.setState(i.state);
}
handleCommunityNameChange(i: CommunityForm, event: any) {
i.state.form.name = event.target.value;
i.setState(i.state);
i.setState(s => ((s.form.name = event.target.value), s));
}
handleCommunityTitleChange(i: CommunityForm, event: any) {
i.state.form.title = event.target.value;
i.setState(i.state);
i.setState(s => ((s.form.title = event.target.value), s));
}
handleCommunityDescriptionChange(val: string) {
@ -357,13 +316,13 @@ export class CommunityForm extends Component<
}
handleCommunityNsfwChange(i: CommunityForm, event: any) {
i.state.form.nsfw = event.target.checked;
i.setState(i.state);
i.setState(s => ((s.form.nsfw = event.target.checked), s));
}
handleCommunityPostingRestrictedToMods(i: CommunityForm, event: any) {
i.state.form.posting_restricted_to_mods = event.target.checked;
i.setState(i.state);
i.setState(
s => ((s.form.posting_restricted_to_mods = event.target.checked), s)
);
}
handleCancel(i: CommunityForm) {
@ -389,56 +348,4 @@ export class CommunityForm extends Component<
handleDiscussionLanguageChange(val: number[]) {
this.setState(s => ((s.form.discussion_languages = val), s));
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
// Errors handled by top level pages
// toast(i18n.t(msg.error), "danger");
this.setState({ loading: false });
return;
} else if (op == UserOperation.CreateCommunity) {
const data = wsJsonToRes<CommunityResponse>(msg);
this.props.onCreate?.(data.community_view);
// Update myUserInfo
const community = data.community_view.community;
const mui = UserService.Instance.myUserInfo;
if (mui) {
const person = mui.local_user_view.person;
mui.follows.push({
community,
follower: person,
});
mui.moderates.push({
community,
moderator: person,
});
}
} else if (op == UserOperation.EditCommunity) {
const data = wsJsonToRes<CommunityResponse>(msg);
this.setState({ loading: false });
this.props.onEdit?.(data.community_view);
const community = data.community_view.community;
const mui = UserService.Instance.myUserInfo;
if (mui) {
const followFound = mui.follows.findIndex(
f => f.community.id == community.id
);
if (followFound) {
mui.follows[followFound].community = community;
}
const moderatesFound = mui.moderates.findIndex(
f => f.community.id == community.id
);
if (moderatesFound) {
mui.moderates[moderatesFound].community = community;
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,12 @@
import { Component } from "inferno";
import { CommunityView, GetSiteResponse } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import {
enableNsfw,
isBrowser,
setIsoData,
toast,
wsSubscribe,
} from "../../utils";
CreateCommunity as CreateCommunityI,
GetSiteResponse,
} from "lemmy-js-client";
import { i18n } from "../../i18next";
import { HttpService } from "../../services/HttpService";
import { enableNsfw, setIsoData } from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import { CommunityForm } from "./community-form";
interface CreateCommunityState {
@ -20,7 +16,6 @@ interface CreateCommunityState {
export class CreateCommunity extends Component<any, CreateCommunityState> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: CreateCommunityState = {
siteRes: this.isoData.site_res,
loading: false,
@ -28,15 +23,6 @@ export class CreateCommunity extends Component<any, CreateCommunityState> {
constructor(props: any, context: any) {
super(props, context);
this.handleCommunityCreate = this.handleCommunityCreate.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
}
get documentTitle(): string {
@ -52,35 +38,33 @@ export class CreateCommunity extends Component<any, CreateCommunityState> {
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
{this.state.loading ? (
<h5>
<Spinner large />
</h5>
) : (
<div className="row">
<div className="col-12 col-lg-6 offset-lg-3 mb-4">
<h5>{i18n.t("create_community")}</h5>
<CommunityForm
onCreate={this.handleCommunityCreate}
onUpsertCommunity={this.handleCommunityCreate}
enableNsfw={enableNsfw(this.state.siteRes)}
allLanguages={this.state.siteRes.all_languages}
siteLanguages={this.state.siteRes.discussion_languages}
communityLanguages={this.state.siteRes.discussion_languages}
loading={this.state.loading}
/>
</div>
</div>
)}
</div>
);
}
handleCommunityCreate(cv: CommunityView) {
this.props.history.push(`/c/${cv.community.name}`);
}
async handleCommunityCreate(form: CreateCommunityI) {
this.setState({ loading: true });
parseMessage(msg: any) {
if (msg.error) {
toast(i18n.t(msg.error), "danger");
const res = await HttpService.client.createCommunity(form);
if (res.state === "success") {
const name = res.data.community_view.community.name;
this.props.history.replace(`/c/${name}`);
} else {
this.setState({ loading: false });
}
}
}

View file

@ -1,4 +1,4 @@
import { Component, linkEvent } from "inferno";
import { Component, InfernoNode, linkEvent } from "inferno";
import { Link } from "inferno-router";
import {
AddModToCommunity,
@ -6,6 +6,7 @@ import {
CommunityModeratorView,
CommunityView,
DeleteCommunity,
EditCommunity,
FollowCommunity,
Language,
PersonView,
@ -13,7 +14,7 @@ import {
RemoveCommunity,
} from "lemmy-js-client";
import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services";
import { UserService } from "../../services";
import {
amAdmin,
amMod,
@ -21,9 +22,8 @@ import {
getUnixTime,
hostname,
mdToHtml,
myAuth,
myAuthRequired,
numToSI,
wsClient,
} from "../../utils";
import { BannerIconHeader } from "../common/banner-icon-header";
import { Icon, PurgeWarning, Spinner } from "../common/icon";
@ -42,6 +42,13 @@ interface SidebarProps {
enableNsfw?: boolean;
showIcon?: boolean;
editable?: boolean;
onDeleteCommunity(form: DeleteCommunity): void;
onRemoveCommunity(form: RemoveCommunity): void;
onLeaveModTeam(form: AddModToCommunity): void;
onFollowCommunity(form: FollowCommunity): void;
onBlockCommunity(form: BlockCommunity): void;
onPurgeCommunity(form: PurgeCommunity): void;
onEditCommunity(form: EditCommunity): void;
}
interface SidebarState {
@ -51,8 +58,13 @@ interface SidebarState {
showRemoveDialog: boolean;
showPurgeDialog: boolean;
purgeReason?: string;
purgeLoading: boolean;
showConfirmLeaveModTeam: boolean;
deleteCommunityLoading: boolean;
removeCommunityLoading: boolean;
leaveModTeamLoading: boolean;
followCommunityLoading: boolean;
blockCommunityLoading: boolean;
purgeCommunityLoading: boolean;
}
export class Sidebar extends Component<SidebarProps, SidebarState> {
@ -60,16 +72,44 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
showEdit: false,
showRemoveDialog: false,
showPurgeDialog: false,
purgeLoading: false,
showConfirmLeaveModTeam: false,
deleteCommunityLoading: false,
removeCommunityLoading: false,
leaveModTeamLoading: false,
followCommunityLoading: false,
blockCommunityLoading: false,
purgeCommunityLoading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.handleEditCommunity = this.handleEditCommunity.bind(this);
this.handleEditCancel = this.handleEditCancel.bind(this);
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & SidebarProps>
): void {
if (this.props.moderators != nextProps.moderators) {
this.setState({
showConfirmLeaveModTeam: false,
});
}
if (this.props.community_view != nextProps.community_view) {
this.setState({
showEdit: false,
showPurgeDialog: false,
showRemoveDialog: false,
deleteCommunityLoading: false,
removeCommunityLoading: false,
leaveModTeamLoading: false,
followCommunityLoading: false,
blockCommunityLoading: false,
purgeCommunityLoading: false,
});
}
}
render() {
return (
<div>
@ -81,7 +121,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
communityLanguages={this.props.communityLanguages}
onEdit={this.handleEditCommunity}
onUpsertCommunity={this.props.onEditCommunity}
onCancel={this.handleEditCancel}
enableNsfw={this.props.enableNsfw}
/>
@ -138,18 +178,28 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
{subscribed === "Subscribed" && (
<button
className="btn btn-secondary btn-sm mr-2"
onClick={linkEvent(this, this.handleUnsubscribe)}
onClick={linkEvent(this, this.handleUnfollowCommunity)}
>
{this.state.followCommunityLoading ? (
<Spinner />
) : (
<>
<Icon icon="check" classes="icon-inline text-success mr-1" />
{i18n.t("joined")}
</>
)}
</button>
)}
{subscribed === "Pending" && (
<button
className="btn btn-warning mr-2"
onClick={linkEvent(this, this.handleUnsubscribe)}
onClick={linkEvent(this, this.handleUnfollowCommunity)}
>
{i18n.t("subscribe_pending")}
{this.state.followCommunityLoading ? (
<Spinner />
) : (
i18n.t("subscribe_pending")
)}
</button>
)}
{community.removed && (
@ -306,9 +356,13 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
{community_view.subscribed == "NotSubscribed" && (
<button
className="btn btn-secondary btn-block"
onClick={linkEvent(this, this.handleSubscribe)}
onClick={linkEvent(this, this.handleFollowCommunity)}
>
{i18n.t("subscribe")}
{this.state.followCommunityLoading ? (
<Spinner />
) : (
i18n.t("subscribe")
)}
</button>
)}
</div>
@ -325,16 +379,24 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
(blocked ? (
<button
className="btn btn-danger btn-block"
onClick={linkEvent(this, this.handleUnblock)}
onClick={linkEvent(this, this.handleBlockCommunity)}
>
{i18n.t("unblock_community")}
{this.state.blockCommunityLoading ? (
<Spinner />
) : (
i18n.t("unblock_community")
)}
</button>
) : (
<button
className="btn btn-danger btn-block"
onClick={linkEvent(this, this.handleBlock)}
onClick={linkEvent(this, this.handleBlockCommunity)}
>
{i18n.t("block_community")}
{this.state.blockCommunityLoading ? (
<Spinner />
) : (
i18n.t("block_community")
)}
</button>
))}
</div>
@ -388,7 +450,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
<li className="list-inline-item-action">
<button
className="btn btn-link text-muted d-inline-block"
onClick={linkEvent(this, this.handleLeaveModTeamClick)}
onClick={linkEvent(this, this.handleLeaveModTeam)}
>
{i18n.t("yes")}
</button>
@ -410,7 +472,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
<li className="list-inline-item-action">
<button
className="btn btn-link text-muted d-inline-block"
onClick={linkEvent(this, this.handleDeleteClick)}
onClick={linkEvent(this, this.handleDeleteCommunity)}
data-tippy-content={
!community_view.community.deleted
? i18n.t("delete")
@ -422,12 +484,16 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
: i18n.t("restore")
}
>
{this.state.deleteCommunityLoading ? (
<Spinner />
) : (
<Icon
icon="trash"
classes={`icon-inline ${
community_view.community.deleted && "text-danger"
}`}
/>
)}{" "}
</button>
</li>
)}
@ -445,9 +511,13 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
) : (
<button
className="btn btn-link text-muted d-inline-block"
onClick={linkEvent(this, this.handleModRemoveSubmit)}
onClick={linkEvent(this, this.handleRemoveCommunity)}
>
{i18n.t("restore")}
{this.state.removeCommunityLoading ? (
<Spinner />
) : (
i18n.t("restore")
)}
</button>
)}
<button
@ -461,7 +531,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
)}
</ul>
{this.state.showRemoveDialog && (
<form onSubmit={linkEvent(this, this.handleModRemoveSubmit)}>
<form onSubmit={linkEvent(this, this.handleRemoveCommunity)}>
<div className="form-group">
<label className="col-form-label" htmlFor="remove-reason">
{i18n.t("reason")}
@ -482,13 +552,17 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
{/* </div> */}
<div className="form-group">
<button type="submit" className="btn btn-secondary">
{i18n.t("remove_community")}
{this.state.removeCommunityLoading ? (
<Spinner />
) : (
i18n.t("remove_community")
)}
</button>
</div>
</form>
)}
{this.state.showPurgeDialog && (
<form onSubmit={linkEvent(this, this.handlePurgeSubmit)}>
<form onSubmit={linkEvent(this, this.handlePurgeCommunity)}>
<div className="form-group">
<PurgeWarning />
</div>
@ -506,7 +580,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
/>
</div>
<div className="form-group">
{this.state.purgeLoading ? (
{this.state.purgeCommunityLoading ? (
<Spinner />
) : (
<button
@ -528,93 +602,18 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
i.setState({ showEdit: true });
}
handleEditCommunity() {
this.setState({ showEdit: false });
}
handleEditCancel() {
this.setState({ showEdit: false });
}
handleDeleteClick(i: Sidebar, event: any) {
event.preventDefault();
const auth = myAuth();
if (auth) {
const deleteForm: DeleteCommunity = {
community_id: i.props.community_view.community.id,
deleted: !i.props.community_view.community.deleted,
auth,
};
WebSocketService.Instance.send(wsClient.deleteCommunity(deleteForm));
}
}
handleShowConfirmLeaveModTeamClick(i: Sidebar) {
i.setState({ showConfirmLeaveModTeam: true });
}
handleLeaveModTeamClick(i: Sidebar) {
const mui = UserService.Instance.myUserInfo;
const auth = myAuth();
if (auth && mui) {
const form: AddModToCommunity = {
person_id: mui.local_user_view.person.id,
community_id: i.props.community_view.community.id,
added: false,
auth,
};
WebSocketService.Instance.send(wsClient.addModToCommunity(form));
i.setState({ showConfirmLeaveModTeam: false });
}
}
handleCancelLeaveModTeamClick(i: Sidebar) {
i.setState({ showConfirmLeaveModTeam: false });
}
handleUnsubscribe(i: Sidebar, event: any) {
event.preventDefault();
const community_id = i.props.community_view.community.id;
const auth = myAuth();
if (auth) {
const form: FollowCommunity = {
community_id,
follow: false,
auth,
};
WebSocketService.Instance.send(wsClient.followCommunity(form));
}
// Update myUserInfo
const mui = UserService.Instance.myUserInfo;
if (mui) {
mui.follows = mui.follows.filter(i => i.community.id != community_id);
}
}
handleSubscribe(i: Sidebar, event: any) {
event.preventDefault();
const community_id = i.props.community_view.community.id;
const auth = myAuth();
if (auth) {
const form: FollowCommunity = {
community_id,
follow: true,
auth,
};
WebSocketService.Instance.send(wsClient.followCommunity(form));
}
// Update myUserInfo
const mui = UserService.Instance.myUserInfo;
if (mui) {
mui.follows.push({
community: i.props.community_view.community,
follower: mui.local_user_view.person,
});
}
}
get canPost(): boolean {
return (
!this.props.community_view.community.posting_restricted_to_mods ||
@ -635,23 +634,6 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
i.setState({ removeExpires: event.target.value });
}
handleModRemoveSubmit(i: Sidebar, event: any) {
event.preventDefault();
const auth = myAuth();
if (auth) {
const removeForm: RemoveCommunity = {
community_id: i.props.community_view.community.id,
removed: !i.props.community_view.community.removed,
reason: i.state.removeReason,
expires: getUnixTime(i.state.removeExpires),
auth,
};
WebSocketService.Instance.send(wsClient.removeCommunity(removeForm));
i.setState({ showRemoveDialog: false });
}
}
handlePurgeCommunityShow(i: Sidebar) {
i.setState({ showPurgeDialog: true, showRemoveDialog: false });
}
@ -660,48 +642,75 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
i.setState({ purgeReason: event.target.value });
}
handlePurgeSubmit(i: Sidebar, event: any) {
event.preventDefault();
// TODO Do we need two of these?
handleUnfollowCommunity(i: Sidebar) {
i.setState({ followCommunityLoading: true });
i.props.onFollowCommunity({
community_id: i.props.community_view.community.id,
follow: false,
auth: myAuthRequired(),
});
}
const auth = myAuth();
if (auth) {
const form: PurgeCommunity = {
handleFollowCommunity(i: Sidebar) {
i.setState({ followCommunityLoading: true });
i.props.onFollowCommunity({
community_id: i.props.community_view.community.id,
follow: true,
auth: myAuthRequired(),
});
}
handleBlockCommunity(i: Sidebar) {
i.setState({ blockCommunityLoading: true });
i.props.onBlockCommunity({
community_id: 0,
block: !i.props.community_view.blocked,
auth: myAuthRequired(),
});
}
handleLeaveModTeam(i: Sidebar) {
const myId = UserService.Instance.myUserInfo?.local_user_view.person.id;
if (myId) {
i.setState({ leaveModTeamLoading: true });
i.props.onLeaveModTeam({
community_id: i.props.community_view.community.id,
person_id: 92,
added: false,
auth: myAuthRequired(),
});
}
}
handleDeleteCommunity(i: Sidebar) {
i.setState({ deleteCommunityLoading: true });
i.props.onDeleteCommunity({
community_id: i.props.community_view.community.id,
deleted: !i.props.community_view.community.deleted,
auth: myAuthRequired(),
});
}
handleRemoveCommunity(i: Sidebar, event: any) {
event.preventDefault();
i.setState({ removeCommunityLoading: true });
i.props.onRemoveCommunity({
community_id: i.props.community_view.community.id,
removed: !i.props.community_view.community.removed,
reason: i.state.removeReason,
expires: getUnixTime(i.state.removeExpires), // TODO fix this
auth: myAuthRequired(),
});
}
handlePurgeCommunity(i: Sidebar, event: any) {
event.preventDefault();
i.setState({ purgeCommunityLoading: true });
i.props.onPurgeCommunity({
community_id: i.props.community_view.community.id,
reason: i.state.purgeReason,
auth,
};
WebSocketService.Instance.send(wsClient.purgeCommunity(form));
i.setState({ purgeLoading: true });
}
}
handleBlock(i: Sidebar, event: any) {
event.preventDefault();
const auth = myAuth();
if (auth) {
const blockCommunityForm: BlockCommunity = {
community_id: i.props.community_view.community.id,
block: true,
auth,
};
WebSocketService.Instance.send(
wsClient.blockCommunity(blockCommunityForm)
);
}
}
handleUnblock(i: Sidebar, event: any) {
event.preventDefault();
const auth = myAuth();
if (auth) {
const blockCommunityForm: BlockCommunity = {
community_id: i.props.community_view.community.id,
block: false,
auth,
};
WebSocketService.Instance.send(
wsClient.blockCommunity(blockCommunityForm)
);
}
auth: myAuthRequired(),
});
}
}

View file

@ -1,30 +1,27 @@
import autosize from "autosize";
import { Component, linkEvent } from "inferno";
import {
BannedPersonsResponse,
GetBannedPersons,
CreateCustomEmoji,
DeleteCustomEmoji,
EditCustomEmoji,
EditSite,
GetFederatedInstancesResponse,
GetSiteResponse,
PersonView,
SiteResponse,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { InitialFetchRequest } from "../../interfaces";
import { WebSocketService } from "../../services";
import { FirstLoadService } from "../../services/FirstLoadService";
import { HttpService, RequestState } from "../../services/HttpService";
import {
capitalizeFirstLetter,
isBrowser,
myAuth,
randomStr,
fetchThemeList,
myAuthRequired,
removeFromEmojiDataModel,
setIsoData,
showLocal,
toast,
wsClient,
wsSubscribe,
updateEmojiDataModel,
} from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
@ -37,76 +34,92 @@ import { TaglineForm } from "./tagline-form";
interface AdminSettingsState {
siteRes: GetSiteResponse;
instancesRes?: GetFederatedInstancesResponse;
banned: PersonView[];
loading: boolean;
leaveAdminTeamLoading: boolean;
currentTab: string;
instancesRes: RequestState<GetFederatedInstancesResponse>;
bannedRes: RequestState<BannedPersonsResponse>;
leaveAdminTeamRes: RequestState<GetSiteResponse>;
themeList: string[];
isIsomorphic: boolean;
}
export class AdminSettings extends Component<any, AdminSettingsState> {
private siteConfigTextAreaId = `site-config-${randomStr()}`;
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: AdminSettingsState = {
siteRes: this.isoData.site_res,
banned: [],
loading: true,
leaveAdminTeamLoading: false,
currentTab: "site",
bannedRes: { state: "empty" },
instancesRes: { state: "empty" },
leaveAdminTeamRes: { state: "empty" },
themeList: [],
isIsomorphic: false,
};
constructor(props: any, context: any) {
super(props, context);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
this.handleEditSite = this.handleEditSite.bind(this);
this.handleEditEmoji = this.handleEditEmoji.bind(this);
this.handleDeleteEmoji = this.handleDeleteEmoji.bind(this);
this.handleCreateEmoji = this.handleCreateEmoji.bind(this);
// Only fetch the data if coming from another route
if (this.isoData.path == this.context.router.route.match.url) {
if (FirstLoadService.isFirstLoad) {
const [bannedRes, instancesRes] = this.isoData.routeData;
this.state = {
...this.state,
banned: (this.isoData.routeData[0] as BannedPersonsResponse).banned,
instancesRes: this.isoData
.routeData[1] as GetFederatedInstancesResponse,
loading: false,
bannedRes,
instancesRes,
isIsomorphic: true,
};
} else {
const cAuth = myAuth();
if (cAuth) {
WebSocketService.Instance.send(
wsClient.getBannedPersons({
auth: cAuth,
})
);
WebSocketService.Instance.send(
wsClient.getFederatedInstances({ auth: cAuth })
);
}
}
}
static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
const promises: Promise<any>[] = [];
async fetchData() {
this.setState({
bannedRes: { state: "loading" },
instancesRes: { state: "loading" },
themeList: [],
});
const auth = myAuthRequired();
const [bannedRes, instancesRes, themeList] = await Promise.all([
HttpService.client.getBannedPersons({ auth }),
HttpService.client.getFederatedInstances({ auth }),
fetchThemeList(),
]);
this.setState({
bannedRes,
instancesRes,
themeList,
});
}
static fetchInitialData({
auth,
client,
}: InitialFetchRequest): Promise<any>[] {
const promises: Promise<RequestState<any>>[] = [];
const auth = req.auth;
if (auth) {
const bannedPersonsForm: GetBannedPersons = { auth };
promises.push(req.client.getBannedPersons(bannedPersonsForm));
promises.push(req.client.getFederatedInstances({ auth }));
promises.push(client.getBannedPersons({ auth }));
promises.push(client.getFederatedInstances({ auth }));
} else {
promises.push(
Promise.resolve({ state: "empty" }),
Promise.resolve({ state: "empty" })
);
}
return promises;
}
componentDidMount() {
if (isBrowser()) {
var textarea: any = document.getElementById(this.siteConfigTextAreaId);
autosize(textarea);
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
async componentDidMount() {
if (!this.state.isIsomorphic) {
await this.fetchData();
}
}
@ -117,17 +130,17 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
}
render() {
const federationData =
this.state.instancesRes.state === "success"
? this.state.instancesRes.data.federated_instances
: undefined;
return (
<div className="container-lg">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
{this.state.loading ? (
<h5>
<Spinner large />
</h5>
) : (
<Tabs
tabs={[
{
@ -137,9 +150,12 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
<div className="row">
<div className="col-12 col-md-6">
<SiteForm
siteRes={this.state.siteRes}
instancesRes={this.state.instancesRes}
showLocal={showLocal(this.isoData)}
allowedInstances={federationData?.allowed}
blockedInstances={federationData?.blocked}
onSaveSite={this.handleEditSite}
siteRes={this.state.siteRes}
themeList={this.state.themeList}
/>
</div>
<div className="col-12 col-md-6">
@ -154,13 +170,10 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
label: "Rate Limiting",
getNode: () => (
<RateLimitForm
localSiteRateLimit={
rateLimits={
this.state.siteRes.site_view.local_site_rate_limit
}
applicationQuestion={
this.state.siteRes.site_view.local_site
.application_question
}
onSaveSite={this.handleEditSite}
/>
),
},
@ -169,7 +182,10 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
label: i18n.t("taglines"),
getNode: () => (
<div className="row">
<TaglineForm siteRes={this.state.siteRes} />
<TaglineForm
taglines={this.state.siteRes.taglines}
onSaveSite={this.handleEditSite}
/>
</div>
),
},
@ -178,13 +194,16 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
label: i18n.t("emojis"),
getNode: () => (
<div className="row">
<EmojiForm />
<EmojiForm
onCreate={this.handleCreateEmoji}
onDelete={this.handleDeleteEmoji}
onEdit={this.handleEditEmoji}
/>
</div>
),
},
]}
/>
)}
</div>
);
}
@ -211,7 +230,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
onClick={linkEvent(this, this.handleLeaveAdminTeam)}
className="btn btn-danger mb-2"
>
{this.state.leaveAdminTeamLoading ? (
{this.state.leaveAdminTeamRes.state == "loading" ? (
<Spinner />
) : (
i18n.t("leave_admin_team")
@ -221,11 +240,20 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
}
bannedUsers() {
switch (this.state.bannedRes.state) {
case "loading":
return (
<h5>
<Spinner large />
</h5>
);
case "success": {
const bans = this.state.bannedRes.data.banned;
return (
<>
<h5>{i18n.t("banned_users")}</h5>
<ul className="list-unstyled">
{this.state.banned.map(banned => (
{bans.map(banned => (
<li key={banned.person.id} className="list-inline-item">
<PersonListing person={banned.person} />
</li>
@ -234,39 +262,61 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
</>
);
}
handleLeaveAdminTeam(i: AdminSettings) {
const auth = myAuth();
if (auth) {
i.setState({ leaveAdminTeamLoading: true });
WebSocketService.Instance.send(wsClient.leaveAdmin({ auth }));
}
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
this.context.router.history.push("/");
this.setState({ loading: false });
return;
} else if (op == UserOperation.EditSite) {
const data = wsJsonToRes<SiteResponse>(msg);
this.setState(s => ((s.siteRes.site_view = data.site_view), s));
async handleEditSite(form: EditSite) {
const editRes = await HttpService.client.editSite(form);
if (editRes.state === "success") {
this.setState(s => {
s.siteRes.site_view = editRes.data.site_view;
// TODO: Where to get taglines from?
s.siteRes.taglines = editRes.data.taglines;
return s;
});
toast(i18n.t("site_saved"));
} else if (op == UserOperation.GetBannedPersons) {
const data = wsJsonToRes<BannedPersonsResponse>(msg);
this.setState({ banned: data.banned, loading: false });
} else if (op == UserOperation.LeaveAdmin) {
const data = wsJsonToRes<GetSiteResponse>(msg);
this.setState(s => ((s.siteRes.site_view = data.site_view), s));
this.setState({ leaveAdminTeamLoading: false });
}
return editRes;
}
handleSwitchTab(i: { ctx: AdminSettings; tab: string }) {
i.ctx.setState({ currentTab: i.tab });
}
async handleLeaveAdminTeam(i: AdminSettings) {
i.setState({ leaveAdminTeamRes: { state: "loading" } });
this.setState({
leaveAdminTeamRes: await HttpService.client.leaveAdmin({
auth: myAuthRequired(),
}),
});
if (this.state.leaveAdminTeamRes.state === "success") {
toast(i18n.t("left_admin_team"));
this.context.router.history.push("/");
} else if (op == UserOperation.GetFederatedInstances) {
const data = wsJsonToRes<GetFederatedInstancesResponse>(msg);
this.setState({ instancesRes: data });
this.context.router.history.replace("/");
}
}
async handleEditEmoji(form: EditCustomEmoji) {
const res = await HttpService.client.editCustomEmoji(form);
if (res.state === "success") {
updateEmojiDataModel(res.data.custom_emoji);
}
}
async handleDeleteEmoji(form: DeleteCustomEmoji) {
const res = await HttpService.client.deleteCustomEmoji(form);
if (res.state === "success") {
removeFromEmojiDataModel(res.data.id);
}
}
async handleCreateEmoji(form: CreateCustomEmoji) {
const res = await HttpService.client.createCustomEmoji(form);
if (res.state === "success") {
updateEmojiDataModel(res.data.custom_emoji);
}
}
}

View file

@ -1,36 +1,30 @@
import { Component, linkEvent } from "inferno";
import {
CreateCustomEmoji,
CustomEmojiResponse,
DeleteCustomEmoji,
DeleteCustomEmojiResponse,
EditCustomEmoji,
GetSiteResponse,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { WebSocketService } from "../../services";
import { HttpService } from "../../services/HttpService";
import {
customEmojisLookup,
isBrowser,
myAuth,
myAuthRequired,
pictrsDeleteToast,
removeFromEmojiDataModel,
setIsoData,
toast,
updateEmojiDataModel,
uploadImage,
wsClient,
wsSubscribe,
} from "../../utils";
import { EmojiMart } from "../common/emoji-mart";
import { HtmlTags } from "../common/html-tags";
import { Icon } from "../common/icon";
import { Paginator } from "../common/paginator";
interface EmojiFormProps {
onEdit(form: EditCustomEmoji): void;
onCreate(form: CreateCustomEmoji): void;
onDelete(form: DeleteCustomEmoji): void;
}
interface EmojiFormState {
siteRes: GetSiteResponse;
customEmojis: CustomEmojiViewForm[];
@ -49,9 +43,8 @@ interface CustomEmojiViewForm {
page: number;
}
export class EmojiForm extends Component<any, EmojiFormState> {
export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
private isoData = setIsoData(this.context);
private subscription: Subscription | undefined;
private itemsPerPage = 15;
private emptyState: EmojiFormState = {
loading: false,
@ -75,20 +68,12 @@ export class EmojiForm extends Component<any, EmojiFormState> {
this.state = this.emptyState;
this.handlePageChange = this.handlePageChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.handleEmojiClick = this.handleEmojiClick.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
}
get documentTitle(): string {
return i18n.t("custom_emojis");
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
}
render() {
return (
<div className="col-12">
@ -232,7 +217,7 @@ export class EmojiForm extends Component<any, EmojiFormState> {
"btn btn-link btn-animate"
}
onClick={linkEvent(
{ form: this, cv: cv },
{ i: this, cv: cv },
this.handleEditEmojiClick
)}
data-tippy-content={i18n.t("save")}
@ -253,7 +238,7 @@ export class EmojiForm extends Component<any, EmojiFormState> {
<button
className="btn btn-link btn-animate text-muted"
onClick={linkEvent(
{ form: this, index: index, cv: cv },
{ i: this, index: index, cv: cv },
this.handleDeleteEmojiClick
)}
data-tippy-content={i18n.t("delete")}
@ -401,51 +386,47 @@ export class EmojiForm extends Component<any, EmojiFormState> {
props.form.setState({ customEmojis: custom_emojis });
}
handleDeleteEmojiClick(props: {
form: EmojiForm;
handleDeleteEmojiClick(d: {
i: EmojiForm;
index: number;
cv: CustomEmojiViewForm;
}) {
const pagedIndex =
(props.form.state.page - 1) * props.form.itemsPerPage + props.index;
if (props.cv.id != 0) {
const deleteForm: DeleteCustomEmoji = {
id: props.cv.id,
auth: myAuth() ?? "",
};
WebSocketService.Instance.send(wsClient.deleteCustomEmoji(deleteForm));
const pagedIndex = (d.i.state.page - 1) * d.i.itemsPerPage + d.index;
if (d.cv.id != 0) {
d.i.props.onDelete({
id: d.cv.id,
auth: myAuthRequired(),
});
} else {
const custom_emojis = [...props.form.state.customEmojis];
const custom_emojis = [...d.i.state.customEmojis];
custom_emojis.splice(Number(pagedIndex), 1);
props.form.setState({ customEmojis: custom_emojis });
d.i.setState({ customEmojis: custom_emojis });
}
}
handleEditEmojiClick(props: { form: EmojiForm; cv: CustomEmojiViewForm }) {
const keywords = props.cv.keywords
handleEditEmojiClick(d: { i: EmojiForm; cv: CustomEmojiViewForm }) {
const keywords = d.cv.keywords
.split(" ")
.filter(x => x.length > 0) as string[];
const uniqueKeywords = Array.from(new Set(keywords));
if (props.cv.id != 0) {
const editForm: EditCustomEmoji = {
id: props.cv.id,
category: props.cv.category,
image_url: props.cv.image_url,
alt_text: props.cv.alt_text,
if (d.cv.id != 0) {
d.i.props.onEdit({
id: d.cv.id,
category: d.cv.category,
image_url: d.cv.image_url,
alt_text: d.cv.alt_text,
keywords: uniqueKeywords,
auth: myAuth() ?? "",
};
WebSocketService.Instance.send(wsClient.editCustomEmoji(editForm));
auth: myAuthRequired(),
});
} else {
const createForm: CreateCustomEmoji = {
category: props.cv.category,
shortcode: props.cv.shortcode,
image_url: props.cv.image_url,
alt_text: props.cv.alt_text,
d.i.props.onCreate({
category: d.cv.category,
shortcode: d.cv.shortcode,
image_url: d.cv.image_url,
alt_text: d.cv.alt_text,
keywords: uniqueKeywords,
auth: myAuth() ?? "",
};
WebSocketService.Instance.send(wsClient.createCustomEmoji(createForm));
auth: myAuthRequired(),
});
}
}
@ -477,25 +458,25 @@ export class EmojiForm extends Component<any, EmojiFormState> {
file = event;
}
uploadImage(file)
.then(res => {
HttpService.client.uploadImage({ image: file }).then(res => {
console.log("pictrs upload:");
console.log(res);
if (res.msg === "ok") {
pictrsDeleteToast(file.name, res.delete_url as string);
if (res.state === "success") {
if (res.data.msg === "ok") {
pictrsDeleteToast(file.name, res.data.delete_url as string);
} else {
toast(JSON.stringify(res), "danger");
const hash = res.files?.at(0)?.file;
const url = `${res.url}/${hash}`;
const hash = res.data.files?.at(0)?.file;
const url = `${res.data.url}/${hash}`;
props.form.handleEmojiImageUrlChange(
{ form: props.form, index: props.index, overrideValue: url },
event
);
}
})
.catch(error => {
console.error(error);
toast(error, "danger");
} else if (res.state === "failed") {
console.error(res.msg);
toast(res.msg, "danger");
}
});
}
@ -506,51 +487,4 @@ export class EmojiForm extends Component<any, EmojiFormState> {
dynamicWidth: true,
};
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
this.context.router.history.push("/");
this.setState({ loading: false });
return;
} else if (op == UserOperation.CreateCustomEmoji) {
const data = wsJsonToRes<CustomEmojiResponse>(msg);
const custom_emoji_view = data.custom_emoji;
updateEmojiDataModel(custom_emoji_view);
const currentEmojis = this.state.customEmojis;
const newEmojiIndex = currentEmojis.findIndex(
x => x.shortcode == custom_emoji_view.custom_emoji.shortcode
);
currentEmojis[newEmojiIndex].id = custom_emoji_view.custom_emoji.id;
currentEmojis[newEmojiIndex].changed = false;
this.setState({ customEmojis: currentEmojis });
toast(i18n.t("saved_emoji"));
this.setState({ loading: false });
} else if (op == UserOperation.EditCustomEmoji) {
const data = wsJsonToRes<CustomEmojiResponse>(msg);
const custom_emoji_view = data.custom_emoji;
updateEmojiDataModel(data.custom_emoji);
const currentEmojis = this.state.customEmojis;
const newEmojiIndex = currentEmojis.findIndex(
x => x.shortcode == custom_emoji_view.custom_emoji.shortcode
);
currentEmojis[newEmojiIndex].changed = false;
this.setState({ customEmojis: currentEmojis });
toast(i18n.t("saved_emoji"));
this.setState({ loading: false });
} else if (op == UserOperation.DeleteCustomEmoji) {
const data = wsJsonToRes<DeleteCustomEmojiResponse>(msg);
if (data.success) {
removeFromEmojiDataModel(data.id);
const custom_emojis = [
...this.state.customEmojis.filter(x => x.id != data.id),
];
this.setState({ customEmojis: custom_emojis });
toast(i18n.t("deleted_emoji"));
}
this.setState({ loading: false });
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -3,106 +3,113 @@ import {
GetFederatedInstancesResponse,
GetSiteResponse,
Instance,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { InitialFetchRequest } from "../../interfaces";
import { WebSocketService } from "../../services";
import {
isBrowser,
relTags,
setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../../utils";
import { FirstLoadService } from "../../services/FirstLoadService";
import { HttpService, RequestState } from "../../services/HttpService";
import { relTags, setIsoData } from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
interface InstancesState {
instancesRes: RequestState<GetFederatedInstancesResponse>;
siteRes: GetSiteResponse;
instancesRes?: GetFederatedInstancesResponse;
loading: boolean;
isIsomorphic: boolean;
}
export class Instances extends Component<any, InstancesState> {
private isoData = setIsoData(this.context);
state: InstancesState = {
instancesRes: { state: "empty" },
siteRes: this.isoData.site_res,
loading: true,
isIsomorphic: false,
};
private subscription?: Subscription;
constructor(props: any, context: any) {
super(props, context);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
// Only fetch the data if coming from another route
if (this.isoData.path == this.context.router.route.match.url) {
if (FirstLoadService.isFirstLoad) {
this.state = {
...this.state,
instancesRes: this.isoData
.routeData[0] as GetFederatedInstancesResponse,
loading: false,
instancesRes: this.isoData.routeData[0],
isIsomorphic: true,
};
} else {
WebSocketService.Instance.send(wsClient.getFederatedInstances({}));
}
}
static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
const promises: Promise<any>[] = [];
async componentDidMount() {
if (!this.state.isIsomorphic) {
await this.fetchInstances();
}
}
promises.push(req.client.getFederatedInstances({}));
async fetchInstances() {
this.setState({
instancesRes: { state: "loading" },
});
return promises;
this.setState({
instancesRes: await HttpService.client.getFederatedInstances({}),
});
}
static fetchInitialData(
req: InitialFetchRequest
): Promise<RequestState<any>>[] {
return [req.client.getFederatedInstances({})];
}
get documentTitle(): string {
return `${i18n.t("instances")} - ${this.state.siteRes.site_view.site.name}`;
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
renderInstances() {
switch (this.state.instancesRes.state) {
case "loading":
return (
<h5>
<Spinner large />
</h5>
);
case "success": {
const instances = this.state.instancesRes.data.federated_instances;
return instances ? (
<div className="row">
<div className="col-md-6">
<h5>{i18n.t("linked_instances")}</h5>
{this.itemList(instances.linked)}
</div>
{instances.allowed && instances.allowed.length > 0 && (
<div className="col-md-6">
<h5>{i18n.t("allowed_instances")}</h5>
{this.itemList(instances.allowed)}
</div>
)}
{instances.blocked && instances.blocked.length > 0 && (
<div className="col-md-6">
<h5>{i18n.t("blocked_instances")}</h5>
{this.itemList(instances.blocked)}
</div>
)}
</div>
) : (
<></>
);
}
}
}
render() {
const federated_instances = this.state.instancesRes?.federated_instances;
return federated_instances ? (
return (
<div className="container-lg">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
<div className="row">
<div className="col-md-6">
<h5>{i18n.t("linked_instances")}</h5>
{this.itemList(federated_instances.linked)}
{this.renderInstances()}
</div>
{federated_instances.allowed &&
federated_instances.allowed.length > 0 && (
<div className="col-md-6">
<h5>{i18n.t("allowed_instances")}</h5>
{this.itemList(federated_instances.allowed)}
</div>
)}
{federated_instances.blocked &&
federated_instances.blocked.length > 0 && (
<div className="col-md-6">
<h5>{i18n.t("blocked_instances")}</h5>
{this.itemList(federated_instances.blocked)}
</div>
)}
</div>
</div>
) : (
<></>
);
}
@ -136,17 +143,4 @@ export class Instances extends Component<any, InstancesState> {
<div>{i18n.t("none_found")}</div>
);
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
this.context.router.history.push("/");
this.setState({ loading: false });
return;
} else if (op == UserOperation.GetFederatedInstances) {
const data = wsJsonToRes<GetFederatedInstancesResponse>(msg);
this.setState({ loading: false, instancesRes: data });
}
}
}

View file

@ -1,58 +1,35 @@
import { Component, linkEvent } from "inferno";
import {
GetSiteResponse,
Login as LoginI,
LoginResponse,
PasswordReset,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services";
import {
isBrowser,
setIsoData,
toast,
validEmail,
wsClient,
wsSubscribe,
} from "../../utils";
import { UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import { isBrowser, myAuth, setIsoData, toast, validEmail } from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
interface State {
loginRes: RequestState<LoginResponse>;
form: {
username_or_email?: string;
password?: string;
totp_2fa_token?: string;
};
loginLoading: boolean;
showTotp: boolean;
siteRes: GetSiteResponse;
}
export class Login extends Component<any, State> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: State = {
loginRes: { state: "empty" },
form: {},
loginLoading: false,
showTotp: false,
siteRes: this.isoData.site_res,
};
constructor(props: any, context: any) {
super(props, context);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
if (isBrowser()) {
WebSocketService.Instance.send(wsClient.getCaptcha({}));
}
}
componentDidMount() {
@ -62,12 +39,6 @@ export class Login extends Component<any, State> {
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
}
get documentTitle(): string {
return `${i18n.t("login")} - ${this.state.siteRes.site_view.site.name}`;
}
@ -169,7 +140,11 @@ export class Login extends Component<any, State> {
<div className="form-group row">
<div className="col-sm-10">
<button type="submit" className="btn btn-secondary">
{this.state.loginLoading ? <Spinner /> : i18n.t("login")}
{this.state.loginRes.state == "loading" ? (
<Spinner />
) : (
i18n.t("login")
)}
</button>
</div>
</div>
@ -178,25 +153,49 @@ export class Login extends Component<any, State> {
);
}
handleLoginSubmit(i: Login, event: any) {
async handleLoginSubmit(i: Login, event: any) {
event.preventDefault();
i.setState({ loginLoading: true });
const lForm = i.state.form;
const username_or_email = lForm.username_or_email;
const password = lForm.password;
const totp_2fa_token = lForm.totp_2fa_token;
const { password, totp_2fa_token, username_or_email } = i.state.form;
if (username_or_email && password) {
const form: LoginI = {
i.setState({ loginRes: { state: "loading" } });
const loginRes = await HttpService.client.login({
username_or_email,
password,
totp_2fa_token,
};
WebSocketService.Instance.send(wsClient.login(form));
});
switch (loginRes.state) {
case "failed": {
if (loginRes.msg === "missing_totp_token") {
i.setState({ showTotp: true });
toast(i18n.t("enter_two_factor_code"), "info");
}
i.setState({ loginRes: { state: "empty" } });
break;
}
case "success": {
UserService.Instance.login(loginRes.data);
const site = await HttpService.client.getSite({
auth: myAuth(),
});
if (site.state === "success") {
UserService.Instance.myUserInfo = site.data.my_user;
}
i.props.history.replace("/");
break;
}
}
}
}
handleLoginUsernameChange(i: Login, event: any) {
i.state.form.username_or_email = event.target.value;
i.state.form.username_or_email = event.target.value.trim();
i.setState(i.state);
}
@ -210,40 +209,13 @@ export class Login extends Component<any, State> {
i.setState(i.state);
}
handlePasswordReset(i: Login, event: any) {
async handlePasswordReset(i: Login, event: any) {
event.preventDefault();
const email = i.state.form.username_or_email;
if (email) {
const resetForm: PasswordReset = { email };
WebSocketService.Instance.send(wsClient.passwordReset(resetForm));
}
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
// If the error comes back that the token is missing, show the TOTP field
if (msg.error == "missing_totp_token") {
this.setState({ showTotp: true, loginLoading: false });
toast(i18n.t("enter_two_factor_code"));
return;
} else {
toast(i18n.t(msg.error), "danger");
this.setState({ form: {}, loginLoading: false });
return;
}
} else {
if (op == UserOperation.Login) {
const data = wsJsonToRes<LoginResponse>(msg);
UserService.Instance.login(data);
this.props.history.push("/");
location.reload();
} else if (op == UserOperation.PasswordReset) {
const res = await HttpService.client.passwordReset({ email });
if (res.state == "success") {
toast(i18n.t("reset_password_mail_sent"));
} else if (op == UserOperation.GetSite) {
const data = wsJsonToRes<GetSiteResponse>(msg);
this.setState({ siteRes: data });
}
}
}

View file

@ -1,8 +1,7 @@
import { Component, FormEventHandler, linkEvent } from "inferno";
import { EditSite, LocalSiteRateLimit } from "lemmy-js-client";
import { i18n } from "../../i18next";
import { WebSocketService } from "../../services";
import { capitalizeFirstLetter, myAuth, wsClient } from "../../utils";
import { capitalizeFirstLetter, myAuthRequired } from "../../utils";
import { Spinner } from "../common/icon";
import Tabs from "../common/tabs";
@ -23,8 +22,8 @@ interface RateLimitsProps {
}
interface RateLimitFormProps {
localSiteRateLimit: LocalSiteRateLimit;
applicationQuestion?: string;
rateLimits: LocalSiteRateLimit;
onSaveSite(form: EditSite): void;
}
interface RateLimitFormState {
@ -107,18 +106,19 @@ function handlePerSecondChange(
function submitRateLimitForm(i: RateLimitsForm, event: any) {
event.preventDefault();
const auth = myAuth() ?? "TODO";
const auth = myAuthRequired();
const form: EditSite = Object.entries(i.state.form).reduce(
(acc, [key, val]) => {
acc[`rate_limit_${key}`] = val;
return acc;
},
{ auth, application_question: i.props.applicationQuestion }
{
auth,
}
);
i.setState({ loading: true });
WebSocketService.Instance.send(wsClient.editSite(form));
i.props.onSaveSite(form);
}
export default class RateLimitsForm extends Component<
@ -127,43 +127,10 @@ export default class RateLimitsForm extends Component<
> {
state: RateLimitFormState = {
loading: false,
form: {},
form: this.props.rateLimits,
};
constructor(props: RateLimitFormProps, context) {
constructor(props: RateLimitFormProps, context: any) {
super(props, context);
const {
comment,
comment_per_second,
image,
image_per_second,
message,
message_per_second,
post,
post_per_second,
register,
register_per_second,
search,
search_per_second,
} = props.localSiteRateLimit;
this.state = {
...this.state,
form: {
comment,
comment_per_second,
image,
image_per_second,
message,
message_per_second,
post,
post_per_second,
register,
register_per_second,
search,
search_per_second,
},
};
}
render() {
@ -210,15 +177,4 @@ export default class RateLimitsForm extends Component<
</form>
);
}
componentDidUpdate({ localSiteRateLimit }: RateLimitFormProps) {
if (
this.state.loading &&
Object.entries(localSiteRateLimit).some(
([key, val]) => this.state.form[key] !== val
)
) {
this.setState({ loading: false });
}
}
}

View file

@ -1,18 +1,15 @@
import { Component, linkEvent } from "inferno";
import { Helmet } from "inferno-helmet";
import {
CreateSite,
GetSiteResponse,
LoginResponse,
Register,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { delay, retryWhen, take } from "rxjs/operators";
import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services";
import { setIsoData, toast, wsClient } from "../../utils";
import { UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import { fetchThemeList, setIsoData } from "../../utils";
import { Spinner } from "../common/icon";
import { SiteForm } from "./site-form";
@ -29,37 +26,32 @@ interface State {
answer?: string;
};
doneRegisteringUser: boolean;
userLoading: boolean;
registerRes: RequestState<LoginResponse>;
themeList: string[];
siteRes: GetSiteResponse;
}
export class Setup extends Component<any, State> {
private subscription: Subscription;
private isoData = setIsoData(this.context);
state: State = {
registerRes: { state: "empty" },
themeList: [],
form: {
show_nsfw: true,
},
doneRegisteringUser: !!UserService.Instance.myUserInfo,
userLoading: false,
siteRes: this.isoData.site_res,
};
constructor(props: any, context: any) {
super(props, context);
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log("complete")
);
this.handleCreateSite = this.handleCreateSite.bind(this);
}
componentWillUnmount() {
this.subscription.unsubscribe();
async componentDidMount() {
this.setState({ themeList: await fetchThemeList() });
}
get documentTitle(): string {
@ -76,7 +68,12 @@ export class Setup extends Component<any, State> {
{!this.state.doneRegisteringUser ? (
this.registerUser()
) : (
<SiteForm siteRes={this.state.siteRes} showLocal />
<SiteForm
showLocal
onSaveSite={this.handleCreateSite}
siteRes={this.state.siteRes}
themeList={this.state.themeList}
/>
)}
</div>
</div>
@ -161,7 +158,11 @@ export class Setup extends Component<any, State> {
<div className="form-group row">
<div className="col-sm-10">
<button type="submit" className="btn btn-secondary">
{this.state.userLoading ? <Spinner /> : i18n.t("sign_up")}
{this.state.registerRes.state == "loading" ? (
<Spinner />
) : (
i18n.t("sign_up")
)}
</button>
</div>
</div>
@ -169,29 +170,58 @@ export class Setup extends Component<any, State> {
);
}
handleRegisterSubmit(i: Setup, event: any) {
async handleRegisterSubmit(i: Setup, event: any) {
event.preventDefault();
i.setState({ userLoading: true });
event.preventDefault();
const cForm = i.state.form;
if (cForm.username && cForm.password && cForm.password_verify) {
i.setState({ registerRes: { state: "loading" } });
const {
username,
password_verify,
password,
email,
show_nsfw,
captcha_uuid,
captcha_answer,
honeypot,
answer,
} = i.state.form;
if (username && password && password_verify) {
const form: Register = {
username: cForm.username,
password: cForm.password,
password_verify: cForm.password_verify,
email: cForm.email,
show_nsfw: cForm.show_nsfw,
captcha_uuid: cForm.captcha_uuid,
captcha_answer: cForm.captcha_answer,
honeypot: cForm.honeypot,
answer: cForm.answer,
username,
password,
password_verify,
email,
show_nsfw,
captcha_uuid,
captcha_answer,
honeypot,
answer,
};
WebSocketService.Instance.send(wsClient.register(form));
i.setState({
registerRes: await HttpService.client.register(form),
});
if (i.state.registerRes.state == "success") {
const data = i.state.registerRes.data;
UserService.Instance.login(data);
if (UserService.Instance.jwtInfo) {
i.setState({ doneRegisteringUser: true });
}
}
}
}
async handleCreateSite(form: CreateSite) {
const createRes = await HttpService.client.createSite(form);
if (createRes.state === "success") {
this.props.history.replace("/");
location.reload();
}
}
handleRegisterUsernameChange(i: Setup, event: any) {
i.state.form.username = event.target.value;
i.state.form.username = event.target.value.trim();
i.setState(i.state);
}
@ -209,22 +239,4 @@ export class Setup extends Component<any, State> {
i.state.form.password_verify = event.target.value;
i.setState(i.state);
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
this.setState({ userLoading: false });
return;
} else if (op == UserOperation.Register) {
const data = wsJsonToRes<LoginResponse>(msg);
this.setState({ userLoading: false });
UserService.Instance.login(data);
if (UserService.Instance.jwtInfo) {
this.setState({ doneRegisteringUser: true });
}
} else if (op == UserOperation.CreateSite) {
window.location.href = "/";
}
}
}

View file

@ -7,24 +7,19 @@ import {
GetCaptchaResponse,
GetSiteResponse,
LoginResponse,
Register,
SiteView,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services";
import { UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import {
isBrowser,
joinLemmyUrl,
mdToHtml,
myAuth,
setIsoData,
toast,
validEmail,
wsClient,
wsSubscribe,
} from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon";
@ -58,6 +53,8 @@ const passwordStrengthOptions: Options<string> = [
];
interface State {
registerRes: RequestState<LoginResponse>;
captchaRes: RequestState<GetCaptchaResponse>;
form: {
username?: string;
email?: string;
@ -69,22 +66,20 @@ interface State {
honeypot?: string;
answer?: string;
};
registerLoading: boolean;
captcha?: GetCaptchaResponse;
captchaPlaying: boolean;
siteRes: GetSiteResponse;
}
export class Signup extends Component<any, State> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
private audio?: HTMLAudioElement;
state: State = {
registerRes: { state: "empty" },
captchaRes: { state: "empty" },
form: {
show_nsfw: false,
},
registerLoading: false,
captchaPlaying: false,
siteRes: this.isoData.site_res,
};
@ -93,19 +88,26 @@ export class Signup extends Component<any, State> {
super(props, context);
this.handleAnswerChange = this.handleAnswerChange.bind(this);
}
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
if (isBrowser()) {
WebSocketService.Instance.send(wsClient.getCaptcha({}));
async componentDidMount() {
if (this.state.siteRes.site_view.local_site.captcha_enabled) {
await this.fetchCaptcha();
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
async fetchCaptcha() {
this.setState({ captchaRes: { state: "loading" } });
this.setState({
captchaRes: await HttpService.client.getCaptcha({}),
});
this.setState(s => {
if (s.captchaRes.state == "success") {
s.form.captcha_uuid = s.captchaRes.data.ok?.uuid;
}
return s;
});
}
get documentTitle(): string {
@ -285,6 +287,7 @@ export class Signup extends Component<any, State> {
</label>
<div className="col-sm-10">
<MarkdownTextArea
initialContent=""
onContentChange={this.handleAnswerChange}
hideNavigationWarnings
allLanguages={[]}
@ -294,36 +297,7 @@ export class Signup extends Component<any, State> {
</div>
</>
)}
{this.state.captcha && (
<div className="form-group row">
<label className="col-sm-2" htmlFor="register-captcha">
<span className="mr-2">{i18n.t("enter_code")}</span>
<button
type="button"
className="btn btn-secondary"
onClick={linkEvent(this, this.handleRegenCaptcha)}
aria-label={i18n.t("captcha")}
>
<Icon icon="refresh-cw" classes="icon-refresh-cw" />
</button>
</label>
{this.showCaptcha()}
<div className="col-sm-6">
<input
type="text"
className="form-control"
id="register-captcha"
value={this.state.form.captcha_answer}
onInput={linkEvent(
this,
this.handleRegisterCaptchaAnswerChange
)}
required
/>
</div>
</div>
)}
{this.renderCaptcha()}
{siteView.local_site.enable_nsfw && (
<div className="form-group row">
<div className="col-sm-10">
@ -358,7 +332,7 @@ export class Signup extends Component<any, State> {
<div className="form-group row">
<div className="col-sm-10">
<button type="submit" className="btn btn-secondary">
{this.state.registerLoading ? (
{this.state.registerRes.state == "loading" ? (
<Spinner />
) : (
this.titleName(siteView)
@ -370,8 +344,47 @@ export class Signup extends Component<any, State> {
);
}
showCaptcha() {
const captchaRes = this.state.captcha?.ok;
renderCaptcha() {
switch (this.state.captchaRes.state) {
case "loading":
return <Spinner />;
case "success": {
const res = this.state.captchaRes.data;
return (
<div className="form-group row">
<label className="col-sm-2" htmlFor="register-captcha">
<span className="mr-2">{i18n.t("enter_code")}</span>
<button
type="button"
className="btn btn-secondary"
onClick={linkEvent(this, this.handleRegenCaptcha)}
aria-label={i18n.t("captcha")}
>
<Icon icon="refresh-cw" classes="icon-refresh-cw" />
</button>
</label>
{this.showCaptcha(res)}
<div className="col-sm-6">
<input
type="text"
className="form-control"
id="register-captcha"
value={this.state.form.captcha_answer}
onInput={linkEvent(
this,
this.handleRegisterCaptchaAnswerChange
)}
required
/>
</div>
</div>
);
}
}
}
showCaptcha(res: GetCaptchaResponse) {
const captchaRes = res?.ok;
return captchaRes ? (
<div className="col-sm-4">
<>
@ -419,28 +432,71 @@ export class Signup extends Component<any, State> {
}
}
handleRegisterSubmit(i: Signup, event: any) {
async handleRegisterSubmit(i: Signup, event: any) {
event.preventDefault();
i.setState({ registerLoading: true });
const cForm = i.state.form;
if (cForm.username && cForm.password && cForm.password_verify) {
const form: Register = {
username: cForm.username,
password: cForm.password,
password_verify: cForm.password_verify,
email: cForm.email,
show_nsfw: cForm.show_nsfw,
captcha_uuid: cForm.captcha_uuid,
captcha_answer: cForm.captcha_answer,
honeypot: cForm.honeypot,
answer: cForm.answer,
};
WebSocketService.Instance.send(wsClient.register(form));
const {
show_nsfw,
answer,
captcha_answer,
captcha_uuid,
email,
honeypot,
password,
password_verify,
username,
} = i.state.form;
if (username && password && password_verify) {
i.setState({ registerRes: { state: "loading" } });
const registerRes = await HttpService.client.register({
username,
password,
password_verify,
email,
show_nsfw,
captcha_uuid,
captcha_answer,
honeypot,
answer,
});
switch (registerRes.state) {
case "failed": {
toast(registerRes.msg, "danger");
i.setState({ registerRes: { state: "empty" } });
break;
}
case "success": {
const data = registerRes.data;
// Only log them in if a jwt was set
if (data.jwt) {
UserService.Instance.login(data);
const site = await HttpService.client.getSite({ auth: myAuth() });
if (site.state === "success") {
UserService.Instance.myUserInfo = site.data.my_user;
}
i.props.history.replace("/communities");
} else {
if (data.verify_email_sent) {
toast(i18n.t("verify_email_sent"));
}
if (data.registration_created) {
toast(i18n.t("registration_application_sent"));
}
i.props.history.push("/");
}
break;
}
}
}
}
handleRegisterUsernameChange(i: Signup, event: any) {
i.state.form.username = event.target.value;
i.state.form.username = event.target.value.trim();
i.setState(i.state);
}
@ -481,17 +537,18 @@ export class Signup extends Component<any, State> {
i.setState(i.state);
}
handleRegenCaptcha(i: Signup) {
async handleRegenCaptcha(i: Signup) {
i.audio = undefined;
i.setState({ captchaPlaying: false });
WebSocketService.Instance.send(wsClient.getCaptcha({}));
await i.fetchCaptcha();
}
handleCaptchaPlay(i: Signup) {
// This was a bad bug, it should only build the new audio on a new file.
// Replays would stop prematurely if this was rebuilt every time.
const captchaRes = i.state.captcha?.ok;
if (captchaRes) {
if (i.state.captchaRes.state == "success" && i.state.captchaRes.data.ok) {
const captchaRes = i.state.captchaRes.data.ok;
if (!i.audio) {
const base64 = `data:audio/wav;base64,${captchaRes.wav}`;
i.audio = new Audio(base64);
@ -512,45 +569,4 @@ export class Signup extends Component<any, State> {
captchaPngSrc(captcha: CaptchaResponse) {
return `data:image/png;base64,${captcha.png}`;
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
this.setState(s => ((s.form.captcha_answer = undefined), s));
// Refetch another captcha
// WebSocketService.Instance.send(wsClient.getCaptcha());
return;
} else {
if (op == UserOperation.Register) {
const data = wsJsonToRes<LoginResponse>(msg);
// Only log them in if a jwt was set
if (data.jwt) {
UserService.Instance.login(data);
this.props.history.push("/communities");
location.reload();
} else {
if (data.verify_email_sent) {
toast(i18n.t("verify_email_sent"));
}
if (data.registration_created) {
toast(i18n.t("registration_application_sent"));
}
this.props.history.push("/");
}
} else if (op == UserOperation.GetCaptcha) {
const data = wsJsonToRes<GetCaptchaResponse>(msg);
if (data.ok) {
this.setState({ captcha: data });
this.setState(s => ((s.form.captcha_uuid = data.ok?.uuid), s));
}
} else if (op == UserOperation.PasswordReset) {
toast(i18n.t("reset_password_mail_sent"));
} else if (op == UserOperation.GetSite) {
const data = wsJsonToRes<GetSiteResponse>(msg);
this.setState({ siteRes: data });
}
}
}
}

View file

@ -7,18 +7,12 @@ import {
import {
CreateSite,
EditSite,
GetFederatedInstancesResponse,
GetSiteResponse,
Instance,
ListingType,
} from "lemmy-js-client";
import { i18n } from "../../i18next";
import { WebSocketService } from "../../services";
import {
capitalizeFirstLetter,
fetchThemeList,
myAuth,
wsClient,
} from "../../utils";
import { capitalizeFirstLetter, myAuthRequired } from "../../utils";
import { Icon, Spinner } from "../common/icon";
import { ImageUploadForm } from "../common/image-upload-form";
import { LanguageSelect } from "../common/language-select";
@ -27,60 +21,41 @@ import { MarkdownTextArea } from "../common/markdown-textarea";
import NavigationPrompt from "../common/navigation-prompt";
interface SiteFormProps {
siteRes: GetSiteResponse;
instancesRes?: GetFederatedInstancesResponse;
blockedInstances?: Instance[];
allowedInstances?: Instance[];
showLocal?: boolean;
themeList?: string[];
onSaveSite(form: EditSite): void;
siteRes: GetSiteResponse;
}
interface SiteFormState {
siteForm: EditSite;
loading: boolean;
themeList?: string[];
instance_select: {
allowed_instances: string;
blocked_instances: string;
};
submitted: boolean;
}
type InstanceKey = "allowed_instances" | "blocked_instances";
export class SiteForm extends Component<SiteFormProps, SiteFormState> {
state: SiteFormState = {
siteForm: {
auth: "TODO",
},
siteForm: this.initSiteForm(),
loading: false,
instance_select: {
allowed_instances: "",
blocked_instances: "",
},
submitted: false,
};
constructor(props: any, context: any) {
super(props, context);
this.handleSiteSidebarChange = this.handleSiteSidebarChange.bind(this);
this.handleSiteLegalInfoChange = this.handleSiteLegalInfoChange.bind(this);
this.handleSiteApplicationQuestionChange =
this.handleSiteApplicationQuestionChange.bind(this);
this.handleIconUpload = this.handleIconUpload.bind(this);
this.handleIconRemove = this.handleIconRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
this.handleDefaultPostListingTypeChange =
this.handleDefaultPostListingTypeChange.bind(this);
this.handleDiscussionLanguageChange =
this.handleDiscussionLanguageChange.bind(this);
initSiteForm(): EditSite {
const site = this.props.siteRes.site_view.site;
const ls = this.props.siteRes.site_view.local_site;
this.state = {
...this.state,
siteForm: {
return {
name: site.name,
sidebar: site.sidebar,
description: site.description,
@ -107,21 +82,34 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
federation_worker_count: ls.federation_worker_count,
captcha_enabled: ls.captcha_enabled,
captcha_difficulty: ls.captcha_difficulty,
allowed_instances:
this.props.instancesRes?.federated_instances?.allowed.map(
i => i.domain
),
blocked_instances:
this.props.instancesRes?.federated_instances?.blocked.map(
i => i.domain
),
allowed_instances: this.props.allowedInstances?.map(i => i.domain),
blocked_instances: this.props.blockedInstances?.map(i => i.domain),
auth: "TODO",
},
};
}
async componentDidMount() {
this.setState({ themeList: await fetchThemeList() });
constructor(props: any, context: any) {
super(props, context);
this.handleSiteSidebarChange = this.handleSiteSidebarChange.bind(this);
this.handleSiteLegalInfoChange = this.handleSiteLegalInfoChange.bind(this);
this.handleSiteApplicationQuestionChange =
this.handleSiteApplicationQuestionChange.bind(this);
this.handleIconUpload = this.handleIconUpload.bind(this);
this.handleIconRemove = this.handleIconRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
this.handleDefaultPostListingTypeChange =
this.handleDefaultPostListingTypeChange.bind(this);
this.handleDiscussionLanguageChange =
this.handleDiscussionLanguageChange.bind(this);
this.handleAddInstance = this.handleAddInstance.bind(this);
this.handleInstanceEnterPress = this.handleInstanceEnterPress.bind(this);
this.handleInstanceTextChange = this.handleInstanceTextChange.bind(this);
}
// Necessary to stop the loading
@ -129,29 +117,10 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
this.setState({ loading: false });
}
componentDidUpdate() {
if (
!this.state.loading &&
!this.props.siteRes.site_view.local_site.site_setup &&
(this.state.siteForm.name ||
this.state.siteForm.sidebar ||
this.state.siteForm.application_question ||
this.state.siteForm.description)
) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = null;
}
}
componentWillUnmount() {
window.onbeforeunload = null;
}
render() {
const siteSetup = this.props.siteRes.site_view.local_site.site_setup;
return (
<>
<form onSubmit={linkEvent(this, this.handleSaveSiteSubmit)}>
<NavigationPrompt
when={
!this.state.loading &&
@ -161,10 +130,10 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
this.state.siteForm.sidebar ||
this.state.siteForm.application_question ||
this.state.siteForm.description
)
) &&
!this.state.submitted
}
/>
<form onSubmit={linkEvent(this, this.handleCreateSiteSubmit)}>
<h5>{`${
siteSetup
? capitalizeFirstLetter(i18n.t("save"))
@ -255,10 +224,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
id="create-site-downvotes"
type="checkbox"
checked={this.state.siteForm.enable_downvotes}
onChange={linkEvent(
this,
this.handleSiteEnableDownvotesChange
)}
onChange={linkEvent(this, this.handleSiteEnableDownvotesChange)}
/>
<label
className="form-check-label"
@ -299,10 +265,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
<select
id="create-site-registration-mode"
value={this.state.siteForm.registration_mode}
onChange={linkEvent(
this,
this.handleSiteRegistrationModeChange
)}
onChange={linkEvent(this, this.handleSiteRegistrationModeChange)}
className="custom-select w-auto"
>
<option value={"RequireApplication"}>
@ -429,7 +392,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
className="custom-select w-auto"
>
<option value="browser">{i18n.t("browser_default")}</option>
{this.state.themeList?.map(theme => (
{this.props.themeList?.map(theme => (
<option key={theme} value={theme}>
{theme}
</option>
@ -442,9 +405,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
<label className="col-sm-3">{i18n.t("listing_type")}</label>
<div className="col-sm-9">
<ListingTypeSelect
type_={
this.state.siteForm.default_post_listing_type ?? "Local"
}
type_={this.state.siteForm.default_post_listing_type ?? "Local"}
showLocal
showSubscribed={false}
onChange={this.handleDefaultPostListingTypeChange}
@ -661,7 +622,6 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
</div>
</div>
</form>
</>
);
}
@ -688,11 +648,15 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
type="button"
className="btn btn-sm bg-success ml-2"
onClick={linkEvent(key, this.handleAddInstance)}
style={"width: 2rem; height: 2rem;"}
tabIndex={
-1 /* Making this untabble because handling enter key in text input makes keyboard support for this button redundant */
}
>
<Icon icon="add" classes="icon-inline text-light m-auto" />
<Icon
icon="add"
classes="icon-inline text-light m-auto d-block position-static"
/>
</button>
</div>
{selectedInstances && selectedInstances.length > 0 && (
@ -708,13 +672,17 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
<button
id={instance}
type="button"
style={"width: 2rem; height: 2rem;"}
className="btn btn-sm bg-danger"
onClick={linkEvent(
{ key, instance },
this.handleRemoveInstance
)}
>
<Icon icon="x" classes="icon-inline text-light m-auto" />
<Icon
icon="x"
classes="icon-inline text-light m-auto d-block position-static"
/>
</button>
</li>
))}
@ -745,48 +713,69 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
}
}
handleCreateSiteSubmit(i: SiteForm, event: any) {
handleSaveSiteSubmit(i: SiteForm, event: any) {
event.preventDefault();
i.setState({ loading: true });
const auth = myAuth() ?? "TODO";
const auth = myAuthRequired();
i.setState(s => ((s.siteForm.auth = auth), s));
i.setState({ loading: true, submitted: true });
const stateSiteForm = i.state.siteForm;
let form: EditSite | CreateSite;
if (i.props.siteRes.site_view.local_site.site_setup) {
WebSocketService.Instance.send(wsClient.editSite(i.state.siteForm));
form = stateSiteForm;
} else {
const sForm = i.state.siteForm;
const form: CreateSite = {
name: sForm.name ?? "My site",
sidebar: sForm.sidebar,
description: sForm.description,
icon: sForm.icon,
banner: sForm.banner,
community_creation_admin_only: sForm.community_creation_admin_only,
enable_nsfw: sForm.enable_nsfw,
enable_downvotes: sForm.enable_downvotes,
application_question: sForm.application_question,
registration_mode: sForm.registration_mode,
require_email_verification: sForm.require_email_verification,
private_instance: sForm.private_instance,
default_theme: sForm.default_theme,
default_post_listing_type: sForm.default_post_listing_type,
application_email_admins: sForm.application_email_admins,
hide_modlog_mod_names: sForm.hide_modlog_mod_names,
legal_information: sForm.legal_information,
slur_filter_regex: sForm.slur_filter_regex,
actor_name_max_length: sForm.actor_name_max_length,
federation_enabled: sForm.federation_enabled,
federation_debug: sForm.federation_debug,
federation_worker_count: sForm.federation_worker_count,
captcha_enabled: sForm.captcha_enabled,
captcha_difficulty: sForm.captcha_difficulty,
allowed_instances: sForm.allowed_instances,
blocked_instances: sForm.blocked_instances,
discussion_languages: sForm.discussion_languages,
form = {
name: stateSiteForm.name ?? "My site",
sidebar: stateSiteForm.sidebar,
description: stateSiteForm.description,
icon: stateSiteForm.icon,
banner: stateSiteForm.banner,
community_creation_admin_only:
stateSiteForm.community_creation_admin_only,
enable_nsfw: stateSiteForm.enable_nsfw,
enable_downvotes: stateSiteForm.enable_downvotes,
application_question: stateSiteForm.application_question,
registration_mode: stateSiteForm.registration_mode,
require_email_verification: stateSiteForm.require_email_verification,
private_instance: stateSiteForm.private_instance,
default_theme: stateSiteForm.default_theme,
default_post_listing_type: stateSiteForm.default_post_listing_type,
application_email_admins: stateSiteForm.application_email_admins,
hide_modlog_mod_names: stateSiteForm.hide_modlog_mod_names,
legal_information: stateSiteForm.legal_information,
slur_filter_regex: stateSiteForm.slur_filter_regex,
actor_name_max_length: stateSiteForm.actor_name_max_length,
rate_limit_message: stateSiteForm.rate_limit_message,
rate_limit_message_per_second:
stateSiteForm.rate_limit_message_per_second,
rate_limit_comment: stateSiteForm.rate_limit_comment,
rate_limit_comment_per_second:
stateSiteForm.rate_limit_comment_per_second,
rate_limit_image: stateSiteForm.rate_limit_image,
rate_limit_image_per_second: stateSiteForm.rate_limit_image_per_second,
rate_limit_post: stateSiteForm.rate_limit_post,
rate_limit_post_per_second: stateSiteForm.rate_limit_post_per_second,
rate_limit_register: stateSiteForm.rate_limit_register,
rate_limit_register_per_second:
stateSiteForm.rate_limit_register_per_second,
rate_limit_search: stateSiteForm.rate_limit_search,
rate_limit_search_per_second:
stateSiteForm.rate_limit_search_per_second,
federation_enabled: stateSiteForm.federation_enabled,
federation_debug: stateSiteForm.federation_debug,
federation_worker_count: stateSiteForm.federation_worker_count,
captcha_enabled: stateSiteForm.captcha_enabled,
captcha_difficulty: stateSiteForm.captcha_difficulty,
allowed_instances: stateSiteForm.allowed_instances,
blocked_instances: stateSiteForm.blocked_instances,
discussion_languages: stateSiteForm.discussion_languages,
auth,
};
WebSocketService.Instance.send(wsClient.createSite(form));
}
i.setState(i.state);
i.props.onSaveSite(form);
}
handleAddInstance(key: InstanceKey) {

View file

@ -1,19 +1,18 @@
import { Component, InfernoMouseEvent, linkEvent } from "inferno";
import { EditSite, GetSiteResponse } from "lemmy-js-client";
import { EditSite, Tagline } from "lemmy-js-client";
import { i18n } from "../../i18next";
import { WebSocketService } from "../../services";
import { capitalizeFirstLetter, myAuth, wsClient } from "../../utils";
import { capitalizeFirstLetter, myAuthRequired } from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea";
interface TaglineFormProps {
siteRes: GetSiteResponse;
taglines: Array<Tagline>;
onSaveSite(form: EditSite): void;
}
interface TaglineFormState {
siteRes: GetSiteResponse;
siteForm: EditSite;
taglines: Array<string>;
loading: boolean;
editingRow?: number;
}
@ -21,12 +20,8 @@ interface TaglineFormState {
export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
state: TaglineFormState = {
loading: false,
siteRes: this.props.siteRes,
editingRow: undefined,
siteForm: {
taglines: this.props.siteRes.taglines?.map(x => x.content),
auth: "TODO",
},
taglines: this.props.taglines.map(x => x.content),
};
constructor(props: any, context: any) {
super(props, context);
@ -54,7 +49,7 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
<th style="width:121px"></th>
</thead>
<tbody>
{this.state.siteForm.taglines?.map((cv, index) => (
{this.state.taglines.map((cv, index) => (
<tr key={index}>
<td>
{this.state.editingRow == index && (
@ -64,8 +59,8 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
this.handleTaglineChange(this, index, s)
}
hideNavigationWarnings
allLanguages={this.state.siteRes.all_languages}
siteLanguages={this.state.siteRes.discussion_languages}
allLanguages={[]}
siteLanguages={[]}
/>
)}
{this.state.editingRow != index && <div>{cv}</div>}
@ -74,7 +69,7 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
<button
className="btn btn-link btn-animate text-muted"
onClick={linkEvent(
{ form: this, index: index },
{ i: this, index: index },
this.handleEditTaglineClick
)}
data-tippy-content={i18n.t("edit")}
@ -86,7 +81,7 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
<button
className="btn btn-link btn-animate text-muted"
onClick={linkEvent(
{ form: this, index: index },
{ i: this, index: index },
this.handleDeleteTaglineClick
)}
data-tippy-content={i18n.t("delete")}
@ -131,46 +126,38 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
}
handleTaglineChange(i: TaglineForm, index: number, val: string) {
const taglines = i.state.siteForm.taglines;
if (taglines) {
taglines[index] = val;
i.setState(i.state);
if (i.state.taglines) {
i.setState(prev => ({
...prev,
taglines: prev.taglines.map((tl, i) => (i === index ? val : tl)),
}));
}
}
handleDeleteTaglineClick(
props: { form: TaglineForm; index: number },
event: any
) {
handleDeleteTaglineClick(d: { i: TaglineForm; index: number }, event: any) {
event.preventDefault();
const taglines = props.form.state.siteForm.taglines;
if (taglines) {
taglines.splice(props.index, 1);
props.form.state.siteForm.taglines = undefined;
props.form.setState(props.form.state);
props.form.state.siteForm.taglines = taglines;
props.form.setState({ ...props.form.state, editingRow: undefined });
}
d.i.setState(prev => ({
...prev,
taglines: prev.taglines.filter((_, i) => i !== d.index),
editingRow: undefined,
}));
}
handleEditTaglineClick(
props: { form: TaglineForm; index: number },
event: any
) {
handleEditTaglineClick(d: { i: TaglineForm; index: number }, event: any) {
event.preventDefault();
if (this.state.editingRow == props.index) {
props.form.setState({ editingRow: undefined });
if (this.state.editingRow == d.index) {
d.i.setState({ editingRow: undefined });
} else {
props.form.setState({ editingRow: props.index });
d.i.setState({ editingRow: d.index });
}
}
handleSaveClick(i: TaglineForm) {
async handleSaveClick(i: TaglineForm) {
i.setState({ loading: true });
const auth = myAuth() ?? "TODO";
i.setState(s => ((s.siteForm.auth = auth), s));
WebSocketService.Instance.send(wsClient.editSite(i.state.siteForm));
i.setState({ ...i.state, editingRow: undefined });
i.props.onSaveSite({
taglines: i.state.taglines,
auth: myAuthRequired(),
});
}
handleAddTaglineClick(
@ -178,13 +165,12 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
event: InfernoMouseEvent<HTMLButtonElement>
) {
event.preventDefault();
if (!i.state.siteForm.taglines) {
i.state.siteForm.taglines = [];
}
i.state.siteForm.taglines.push("");
const newTaglines = [...i.state.taglines];
newTaglines.push("");
i.setState({
...i.state,
editingRow: i.state.siteForm.taglines.length - 1,
taglines: newTaglines,
editingRow: newTaglines.length - 1,
});
}
}

View file

@ -8,13 +8,11 @@ import {
AdminPurgeCommunityView,
AdminPurgePersonView,
AdminPurgePostView,
CommunityModeratorView,
GetCommunity,
GetCommunityResponse,
GetModlog,
GetModlogResponse,
GetPersonDetails,
GetPersonDetailsResponse,
ModAddCommunityView,
ModAddView,
ModBanFromCommunityView,
@ -27,15 +25,12 @@ import {
ModTransferCommunityView,
ModlogActionType,
Person,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import moment from "moment";
import { Subscription } from "rxjs";
import { i18n } from "../i18next";
import { InitialFetchRequest } from "../interfaces";
import { WebSocketService } from "../services";
import { FirstLoadService } from "../services/FirstLoadService";
import { HttpService, RequestState } from "../services/HttpService";
import {
Choice,
QueryParams,
@ -49,13 +44,9 @@ import {
getQueryParams,
getQueryString,
getUpdatedSearchId,
isBrowser,
myAuth,
personToChoice,
setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../utils";
import { HtmlTags } from "./common/html-tags";
import { Icon, Spinner } from "./common/icon";
@ -100,10 +91,8 @@ const getModlogQueryParams = () =>
});
interface ModlogState {
res?: GetModlogResponse;
communityMods?: CommunityModeratorView[];
communityName?: string;
loadingModlog: boolean;
res: RequestState<GetModlogResponse>;
communityRes: RequestState<GetCommunityResponse>;
loadingModSearch: boolean;
loadingUserSearch: boolean;
modSearchOptions: Choice[];
@ -629,7 +618,7 @@ async function createNewOptions({
if (text.length > 0) {
newOptions.push(
...(await fetchUsers(text)).users
...(await fetchUsers(text))
.slice(0, Number(fetchLimit))
.map<Choice>(personToChoice)
);
@ -643,10 +632,10 @@ export class Modlog extends Component<
ModlogState
> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: ModlogState = {
loadingModlog: true,
res: { state: "empty" },
communityRes: { state: "empty" },
loadingModSearch: false,
loadingUserSearch: false,
userSearchOptions: [],
@ -662,58 +651,35 @@ export class Modlog extends Component<
this.handleUserChange = this.handleUserChange.bind(this);
this.handleModChange = this.handleModChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
// Only fetch the data if coming from another route
if (this.isoData.path === this.context.router.route.match.url) {
if (FirstLoadService.isFirstLoad) {
const [res, communityRes, filteredModRes, filteredUserRes] =
this.isoData.routeData;
this.state = {
...this.state,
res: this.isoData.routeData[0] as GetModlogResponse,
res,
communityRes,
};
const communityRes: GetCommunityResponse | undefined =
this.isoData.routeData[1];
// Getting the moderators
if (filteredModRes.state === "success") {
this.state = {
...this.state,
communityMods: communityRes?.moderators,
};
const filteredModRes: GetPersonDetailsResponse | undefined =
this.isoData.routeData[2];
if (filteredModRes) {
this.state = {
...this.state,
modSearchOptions: [personToChoice(filteredModRes.person_view)],
modSearchOptions: [personToChoice(filteredModRes.data.person_view)],
};
}
const filteredUserRes: GetPersonDetailsResponse | undefined =
this.isoData.routeData[3];
if (filteredUserRes) {
if (filteredUserRes.state === "success") {
this.state = {
...this.state,
userSearchOptions: [personToChoice(filteredUserRes.person_view)],
userSearchOptions: [personToChoice(filteredUserRes.data.person_view)],
};
}
this.state = { ...this.state, loadingModlog: false };
} else {
this.refetch();
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
}
get combined() {
const res = this.state.res;
const combined = res ? buildCombined(res) : [];
const combined = res.state == "success" ? buildCombined(res.data) : [];
return (
<tbody>
@ -737,7 +703,10 @@ export class Modlog extends Component<
}
get amAdminOrMod(): boolean {
return amAdmin() || amMod(this.state.communityMods);
const amMod_ =
this.state.communityRes.state == "success" &&
amMod(this.state.communityRes.data.moderators);
return amAdmin() || amMod_;
}
modOrAdminText(person?: Person): string {
@ -755,14 +724,12 @@ export class Modlog extends Component<
render() {
const {
communityName,
loadingModlog,
loadingModSearch,
loadingUserSearch,
userSearchOptions,
modSearchOptions,
} = this.state;
const { actionType, page, modId, userId } = getModlogQueryParams();
const { actionType, modId, userId } = getModlogQueryParams();
return (
<div className="container-lg">
@ -785,14 +752,17 @@ export class Modlog extends Component<
#<strong>#</strong>#
</T>
</div>
{this.state.communityRes.state === "success" && (
<h5>
{communityName && (
<Link className="text-body" to={`/c/${communityName}`}>
/c/{communityName}{" "}
<Link
className="text-body"
to={`/c/${this.state.communityRes.data.community_view.community.name}`}
>
/c/{this.state.communityRes.data.community_view.community.name}{" "}
</Link>
)}
<span>{i18n.t("modlog")}</span>
</h5>
)}
<div className="form-row">
<select
value={actionType}
@ -841,12 +811,24 @@ export class Modlog extends Component<
/>
)}
</div>
<div className="table-responsive">
{loadingModlog ? (
{this.renderModlogTable()}
</div>
</div>
);
}
renderModlogTable() {
switch (this.state.res.state) {
case "loading":
return (
<h5>
<Spinner large />
</h5>
) : (
);
case "success": {
const page = getModlogQueryParams().page;
return (
<div className="table-responsive">
<table id="modlog_table" className="table table-sm table-hover">
<thead className="pointer">
<tr>
@ -857,13 +839,12 @@ export class Modlog extends Component<
</thead>
{this.combined}
</table>
)}
<Paginator page={page} onChange={this.handlePageChange} />
</div>
</div>
</div>
);
}
}
}
handleFilterActionChange(i: Modlog, event: any) {
i.updateUrl({
@ -918,7 +899,7 @@ export class Modlog extends Component<
});
});
updateUrl({ actionType, modId, page, userId }: Partial<ModlogProps>) {
async updateUrl({ actionType, modId, page, userId }: Partial<ModlogProps>) {
const {
page: urlPage,
actionType: urlActionType,
@ -941,21 +922,18 @@ export class Modlog extends Component<
)}`
);
this.setState({
loadingModlog: true,
res: undefined,
});
this.refetch();
await this.refetch();
}
refetch() {
const auth = myAuth(false);
async refetch() {
const auth = myAuth();
const { actionType, page, modId, userId } = getModlogQueryParams();
const { communityId: urlCommunityId } = this.props.match.params;
const communityId = getIdFromString(urlCommunityId);
const modlogForm: GetModlog = {
this.setState({ res: { state: "loading" } });
this.setState({
res: await HttpService.client.getModlog({
community_id: communityId,
page,
limit: fetchLimit,
@ -966,17 +944,17 @@ export class Modlog extends Component<
? modId ?? undefined
: undefined,
auth,
};
WebSocketService.Instance.send(wsClient.getModlog(modlogForm));
}),
});
if (communityId) {
const communityForm: GetCommunity = {
this.setState({ communityRes: { state: "loading" } });
this.setState({
communityRes: await HttpService.client.getCommunity({
id: communityId,
auth,
};
WebSocketService.Instance.send(wsClient.getCommunity(communityForm));
}),
});
}
}
@ -986,9 +964,11 @@ export class Modlog extends Component<
query: { modId: urlModId, page, userId: urlUserId, actionType },
auth,
site,
}: InitialFetchRequest<QueryParams<ModlogProps>>): Promise<any>[] {
}: InitialFetchRequest<QueryParams<ModlogProps>>): Promise<
RequestState<any>
>[] {
const pathSplit = path.split("/");
const promises: Promise<any>[] = [];
const promises: Promise<RequestState<any>>[] = [];
const communityId = getIdFromString(pathSplit[2]);
const modId = !site.site_view.local_site.hide_modlog_mod_names
? getIdFromString(urlModId)
@ -1014,7 +994,7 @@ export class Modlog extends Component<
};
promises.push(client.getCommunity(communityForm));
} else {
promises.push(Promise.resolve());
promises.push(Promise.resolve({ state: "empty" }));
}
if (modId) {
@ -1025,7 +1005,7 @@ export class Modlog extends Component<
promises.push(client.getPersonDetails(getPersonForm));
} else {
promises.push(Promise.resolve());
promises.push(Promise.resolve({ state: "empty" }));
}
if (userId) {
@ -1036,43 +1016,9 @@ export class Modlog extends Component<
promises.push(client.getPersonDetails(getPersonForm));
} else {
promises.push(Promise.resolve());
promises.push(Promise.resolve({ state: "empty" }));
}
return promises;
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
} else {
switch (op) {
case UserOperation.GetModlog: {
const res = wsJsonToRes<GetModlogResponse>(msg);
window.scrollTo(0, 0);
this.setState({ res, loadingModlog: false });
break;
}
case UserOperation.GetCommunity: {
const {
moderators,
community_view: {
community: { name },
},
} = wsJsonToRes<GetCommunityResponse>(msg);
this.setState({
communityMods: moderators,
communityName: name,
});
break;
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,59 +1,35 @@
import { Component, linkEvent } from "inferno";
import {
GetSiteResponse,
LoginResponse,
PasswordChangeAfterReset,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services";
import {
capitalizeFirstLetter,
isBrowser,
setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../../utils";
import { HttpService, UserService } from "../../services";
import { RequestState } from "../../services/HttpService";
import { capitalizeFirstLetter, myAuth, setIsoData } from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
interface State {
passwordChangeRes: RequestState<LoginResponse>;
form: {
token: string;
password?: string;
password_verify?: string;
};
loading: boolean;
siteRes: GetSiteResponse;
}
export class PasswordChange extends Component<any, State> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: State = {
passwordChangeRes: { state: "empty" },
siteRes: this.isoData.site_res,
form: {
token: this.props.match.params.token,
},
loading: false,
siteRes: this.isoData.site_res,
};
constructor(props: any, context: any) {
super(props, context);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
}
get documentTitle(): string {
@ -117,7 +93,7 @@ export class PasswordChange extends Component<any, State> {
<div className="form-group row">
<div className="col-sm-10">
<button type="submit" className="btn btn-secondary">
{this.state.loading ? (
{this.state.passwordChangeRes.state == "loading" ? (
<Spinner />
) : (
capitalizeFirstLetter(i18n.t("save"))
@ -139,36 +115,33 @@ export class PasswordChange extends Component<any, State> {
i.setState(i.state);
}
handlePasswordChangeSubmit(i: PasswordChange, event: any) {
async handlePasswordChangeSubmit(i: PasswordChange, event: any) {
event.preventDefault();
i.setState({ loading: true });
i.setState({ passwordChangeRes: { state: "loading" } });
const password = i.state.form.password;
const password_verify = i.state.form.password_verify;
if (password && password_verify) {
const form: PasswordChangeAfterReset = {
i.setState({
passwordChangeRes: await HttpService.client.passwordChangeAfterReset({
token: i.state.form.token,
password,
password_verify,
};
}),
});
WebSocketService.Instance.send(wsClient.passwordChange(form));
}
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
this.setState({ loading: false });
return;
} else if (op == UserOperation.PasswordChangeAfterReset) {
const data = wsJsonToRes<LoginResponse>(msg);
if (i.state.passwordChangeRes.state === "success") {
const data = i.state.passwordChangeRes.data;
UserService.Instance.login(data);
this.props.history.push("/");
location.reload();
const site = await HttpService.client.getSite({ auth: myAuth() });
if (site.state === "success") {
UserService.Instance.myUserInfo = site.data.my_user;
}
this.props.history.replace("/");
}
}
}
}

View file

@ -1,11 +1,40 @@
import { Component } from "inferno";
import {
AddAdmin,
AddModToCommunity,
BanFromCommunity,
BanPerson,
BlockPerson,
CommentId,
CommentView,
CreateComment,
CreateCommentLike,
CreateCommentReport,
CreatePostLike,
CreatePostReport,
DeleteComment,
DeletePost,
DistinguishComment,
EditComment,
EditPost,
FeaturePost,
GetComments,
GetPersonDetailsResponse,
Language,
LockPost,
MarkCommentReplyAsRead,
MarkPersonMentionAsRead,
PersonView,
PostView,
PurgeComment,
PurgePerson,
PurgePost,
RemoveComment,
RemovePost,
SaveComment,
SavePost,
SortType,
TransferCommunity,
} from "lemmy-js-client";
import { CommentViewType, PersonDetailsView } from "../../interfaces";
import { commentsToFlatNodes, setupTippy } from "../../utils";
@ -15,6 +44,7 @@ import { PostListing } from "../post/post-listing";
interface PersonDetailsProps {
personRes: GetPersonDetailsResponse;
finished: Map<CommentId, boolean | undefined>;
admins: PersonView[];
allLanguages: Language[];
siteLanguages: number[];
@ -25,6 +55,34 @@ interface PersonDetailsProps {
enableNsfw: boolean;
view: PersonDetailsView;
onPageChange(page: number): number | any;
onSaveComment(form: SaveComment): void;
onCommentReplyRead(form: MarkCommentReplyAsRead): void;
onPersonMentionRead(form: MarkPersonMentionAsRead): void;
onCreateComment(form: CreateComment): void;
onEditComment(form: EditComment): void;
onCommentVote(form: CreateCommentLike): void;
onBlockPerson(form: BlockPerson): void;
onDeleteComment(form: DeleteComment): void;
onRemoveComment(form: RemoveComment): void;
onDistinguishComment(form: DistinguishComment): void;
onAddModToCommunity(form: AddModToCommunity): void;
onAddAdmin(form: AddAdmin): void;
onBanPersonFromCommunity(form: BanFromCommunity): void;
onBanPerson(form: BanPerson): void;
onTransferCommunity(form: TransferCommunity): void;
onFetchChildren?(form: GetComments): void;
onCommentReport(form: CreateCommentReport): void;
onPurgePerson(form: PurgePerson): void;
onPurgeComment(form: PurgeComment): void;
onPostEdit(form: EditPost): void;
onPostVote(form: CreatePostLike): void;
onPostReport(form: CreatePostReport): void;
onLockPost(form: LockPost): void;
onDeletePost(form: DeletePost): void;
onRemovePost(form: RemovePost): void;
onSavePost(form: SavePost): void;
onFeaturePost(form: FeaturePost): void;
onPurgePost(form: PurgePost): void;
}
enum ItemEnum {
@ -93,6 +151,7 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
key={i.id}
nodes={[{ comment_view: c, children: [], depth: 0 }]}
viewType={CommentViewType.Flat}
finished={this.props.finished}
admins={this.props.admins}
noBorder
noIndent
@ -101,6 +160,25 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
enableDownvotes={this.props.enableDownvotes}
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
onCommentReplyRead={this.props.onCommentReplyRead}
onPersonMentionRead={this.props.onPersonMentionRead}
onCreateComment={this.props.onCreateComment}
onEditComment={this.props.onEditComment}
onCommentVote={this.props.onCommentVote}
onBlockPerson={this.props.onBlockPerson}
onSaveComment={this.props.onSaveComment}
onDeleteComment={this.props.onDeleteComment}
onRemoveComment={this.props.onRemoveComment}
onDistinguishComment={this.props.onDistinguishComment}
onAddModToCommunity={this.props.onAddModToCommunity}
onAddAdmin={this.props.onAddAdmin}
onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
onBanPerson={this.props.onBanPerson}
onTransferCommunity={this.props.onTransferCommunity}
onFetchChildren={this.props.onFetchChildren}
onCommentReport={this.props.onCommentReport}
onPurgePerson={this.props.onPurgePerson}
onPurgeComment={this.props.onPurgeComment}
/>
);
}
@ -116,6 +194,22 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
enableNsfw={this.props.enableNsfw}
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
onPostEdit={this.props.onPostEdit}
onPostVote={this.props.onPostVote}
onPostReport={this.props.onPostReport}
onBlockPerson={this.props.onBlockPerson}
onLockPost={this.props.onLockPost}
onDeletePost={this.props.onDeletePost}
onRemovePost={this.props.onRemovePost}
onSavePost={this.props.onSavePost}
onFeaturePost={this.props.onFeaturePost}
onPurgePerson={this.props.onPurgePerson}
onPurgePost={this.props.onPurgePost}
onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
onBanPerson={this.props.onBanPerson}
onAddModToCommunity={this.props.onAddModToCommunity}
onAddAdmin={this.props.onAddAdmin}
onTransferCommunity={this.props.onTransferCommunity}
/>
);
}
@ -167,12 +261,32 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
nodes={commentsToFlatNodes(this.props.personRes.comments)}
viewType={CommentViewType.Flat}
admins={this.props.admins}
finished={this.props.finished}
noIndent
showCommunity
showContext
enableDownvotes={this.props.enableDownvotes}
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
onCommentReplyRead={this.props.onCommentReplyRead}
onPersonMentionRead={this.props.onPersonMentionRead}
onCreateComment={this.props.onCreateComment}
onEditComment={this.props.onEditComment}
onCommentVote={this.props.onCommentVote}
onBlockPerson={this.props.onBlockPerson}
onSaveComment={this.props.onSaveComment}
onDeleteComment={this.props.onDeleteComment}
onRemoveComment={this.props.onRemoveComment}
onDistinguishComment={this.props.onDistinguishComment}
onAddModToCommunity={this.props.onAddModToCommunity}
onAddAdmin={this.props.onAddAdmin}
onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
onBanPerson={this.props.onBanPerson}
onTransferCommunity={this.props.onTransferCommunity}
onFetchChildren={this.props.onFetchChildren}
onCommentReport={this.props.onCommentReport}
onPurgePerson={this.props.onPurgePerson}
onPurgeComment={this.props.onPurgeComment}
/>
</div>
);
@ -191,6 +305,22 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
enableNsfw={this.props.enableNsfw}
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
onPostEdit={this.props.onPostEdit}
onPostVote={this.props.onPostVote}
onPostReport={this.props.onPostReport}
onBlockPerson={this.props.onBlockPerson}
onLockPost={this.props.onLockPost}
onDeletePost={this.props.onDeletePost}
onRemovePost={this.props.onRemovePost}
onSavePost={this.props.onSavePost}
onFeaturePost={this.props.onFeaturePost}
onPurgePerson={this.props.onPurgePerson}
onPurgePost={this.props.onPurgePost}
onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
onBanPerson={this.props.onBanPerson}
onAddModToCommunity={this.props.onAddModToCommunity}
onAddAdmin={this.props.onAddAdmin}
onTransferCommunity={this.props.onTransferCommunity}
/>
<hr className="my-3" />
</>

View file

@ -4,41 +4,66 @@ import { Component, linkEvent } from "inferno";
import { Link } from "inferno-router";
import { RouteComponentProps } from "inferno-router/dist/Route";
import {
AddAdminResponse,
AddAdmin,
AddModToCommunity,
BanFromCommunity,
BanFromCommunityResponse,
BanPerson,
BanPersonResponse,
BlockPerson,
BlockPersonResponse,
CommentId,
CommentReplyResponse,
CommentResponse,
Community,
CommunityModeratorView,
CreateComment,
CreateCommentLike,
CreateCommentReport,
CreatePostLike,
CreatePostReport,
DeleteComment,
DeletePost,
DistinguishComment,
EditComment,
EditPost,
FeaturePost,
GetPersonDetails,
GetPersonDetailsResponse,
GetSiteResponse,
LockPost,
MarkCommentReplyAsRead,
MarkPersonMentionAsRead,
PersonView,
PostResponse,
PurgeComment,
PurgeItemResponse,
PurgePerson,
PurgePost,
RemoveComment,
RemovePost,
SaveComment,
SavePost,
SortType,
UserOperation,
wsJsonToRes,
wsUserOp,
TransferCommunity,
} from "lemmy-js-client";
import moment from "moment";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { InitialFetchRequest, PersonDetailsView } from "../../interfaces";
import { UserService, WebSocketService } from "../../services";
import { UserService } from "../../services";
import { FirstLoadService } from "../../services/FirstLoadService";
import { HttpService, RequestState } from "../../services/HttpService";
import {
QueryParams,
canMod,
capitalizeFirstLetter,
createCommentLikeRes,
createPostLikeFindRes,
editCommentRes,
editPostFindRes,
editComment,
editPost,
editWith,
enableDownvotes,
enableNsfw,
fetchLimit,
futureDaysToUnixTime,
getCommentParentId,
getPageFromString,
getQueryParams,
getQueryString,
@ -46,17 +71,15 @@ import {
isBanned,
mdToHtml,
myAuth,
myAuthRequired,
numToSI,
relTags,
restoreScrollPosition,
saveCommentRes,
saveScrollPosition,
setIsoData,
setupTippy,
toast,
updatePersonBlock,
wsClient,
wsSubscribe,
} from "../../utils";
import { BannerIconHeader } from "../common/banner-icon-header";
import { HtmlTags } from "../common/html-tags";
@ -68,14 +91,15 @@ import { PersonDetails } from "./person-details";
import { PersonListing } from "./person-listing";
interface ProfileState {
personRes?: GetPersonDetailsResponse;
loading: boolean;
personRes: RequestState<GetPersonDetailsResponse>;
personBlocked: boolean;
banReason?: string;
banExpireDays?: number;
showBanDialog: boolean;
removeData: boolean;
siteRes: GetSiteResponse;
finished: Map<CommentId, boolean | undefined>;
isIsomorphic: boolean;
}
interface ProfileProps {
@ -102,26 +126,6 @@ function getViewFromProps(view?: string): PersonDetailsView {
: PersonDetailsView.Overview;
}
function toggleBlockPerson(recipientId: number, block: boolean) {
const auth = myAuth();
if (auth) {
const blockUserForm: BlockPerson = {
person_id: recipientId,
block,
auth,
};
WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
}
}
const handleUnblockPerson = (personId: number) =>
toggleBlockPerson(personId, false);
const handleBlockPerson = (personId: number) =>
toggleBlockPerson(personId, true);
const getCommunitiesListing = (
translationKey: NoOptionI18nKeys,
communityViews?: { community: Community }[]
@ -153,13 +157,14 @@ export class Profile extends Component<
ProfileState
> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: ProfileState = {
loading: true,
personRes: { state: "empty" },
personBlocked: false,
siteRes: this.isoData.site_res,
showBanDialog: false,
removeData: false,
finished: new Map(),
isIsomorphic: false,
};
constructor(props: RouteComponentProps<{ username: string }>, context: any) {
@ -168,51 +173,95 @@ export class Profile extends Component<
this.handleSortChange = this.handleSortChange.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
this.handleBlockPerson = this.handleBlockPerson.bind(this);
this.handleUnblockPerson = this.handleUnblockPerson.bind(this);
this.handleCreateComment = this.handleCreateComment.bind(this);
this.handleEditComment = this.handleEditComment.bind(this);
this.handleSaveComment = this.handleSaveComment.bind(this);
this.handleBlockPersonAlt = this.handleBlockPersonAlt.bind(this);
this.handleDeleteComment = this.handleDeleteComment.bind(this);
this.handleRemoveComment = this.handleRemoveComment.bind(this);
this.handleCommentVote = this.handleCommentVote.bind(this);
this.handleAddModToCommunity = this.handleAddModToCommunity.bind(this);
this.handleAddAdmin = this.handleAddAdmin.bind(this);
this.handlePurgePerson = this.handlePurgePerson.bind(this);
this.handlePurgeComment = this.handlePurgeComment.bind(this);
this.handleCommentReport = this.handleCommentReport.bind(this);
this.handleDistinguishComment = this.handleDistinguishComment.bind(this);
this.handleTransferCommunity = this.handleTransferCommunity.bind(this);
this.handleCommentReplyRead = this.handleCommentReplyRead.bind(this);
this.handlePersonMentionRead = this.handlePersonMentionRead.bind(this);
this.handleBanFromCommunity = this.handleBanFromCommunity.bind(this);
this.handleBanPerson = this.handleBanPerson.bind(this);
this.handlePostVote = this.handlePostVote.bind(this);
this.handlePostEdit = this.handlePostEdit.bind(this);
this.handlePostReport = this.handlePostReport.bind(this);
this.handleLockPost = this.handleLockPost.bind(this);
this.handleDeletePost = this.handleDeletePost.bind(this);
this.handleRemovePost = this.handleRemovePost.bind(this);
this.handleSavePost = this.handleSavePost.bind(this);
this.handlePurgePost = this.handlePurgePost.bind(this);
this.handleFeaturePost = this.handleFeaturePost.bind(this);
// Only fetch the data if coming from another route
if (this.isoData.path === this.context.router.route.match.url) {
if (FirstLoadService.isFirstLoad) {
this.state = {
...this.state,
personRes: this.isoData.routeData[0] as GetPersonDetailsResponse,
loading: false,
personRes: this.isoData.routeData[0],
isIsomorphic: true,
};
} else {
this.fetchUserData();
}
}
fetchUserData() {
async componentDidMount() {
if (!this.state.isIsomorphic) {
await this.fetchUserData();
}
setupTippy();
}
componentWillUnmount() {
saveScrollPosition(this.context);
}
async fetchUserData() {
const { page, sort, view } = getProfileQueryParams();
const form: GetPersonDetails = {
this.setState({ personRes: { state: "empty" } });
this.setState({
personRes: await HttpService.client.getPersonDetails({
username: this.props.match.params.username,
sort,
saved_only: view === PersonDetailsView.Saved,
page,
limit: fetchLimit,
auth: myAuth(false),
};
WebSocketService.Instance.send(wsClient.getPersonDetails(form));
auth: myAuth(),
}),
});
restoreScrollPosition(this.context);
this.setPersonBlock();
}
get amCurrentUser() {
if (this.state.personRes.state === "success") {
return (
UserService.Instance.myUserInfo?.local_user_view.person.id ===
this.state.personRes?.person_view.person.id
this.state.personRes.data.person_view.person.id
);
} else {
return false;
}
}
setPersonBlock() {
const mui = UserService.Instance.myUserInfo;
const res = this.state.personRes;
if (mui && res) {
if (mui && res.state === "success") {
this.setState({
personBlocked: mui.person_blocks.some(
({ target: { id } }) => id === res.person_view.person.id
({ target: { id } }) => id === res.data.person_view.person.id
),
});
}
@ -223,7 +272,9 @@ export class Profile extends Component<
path,
query: { page, sort, view: urlView },
auth,
}: InitialFetchRequest<QueryParams<ProfileProps>>): Promise<any>[] {
}: InitialFetchRequest<QueryParams<ProfileProps>>): Promise<
RequestState<any>
>[] {
const pathSplit = path.split("/");
const username = pathSplit[2];
@ -241,35 +292,28 @@ export class Profile extends Component<
return [client.getPersonDetails(form)];
}
componentDidMount() {
this.setPersonBlock();
setupTippy();
}
componentWillUnmount() {
this.subscription?.unsubscribe();
saveScrollPosition(this.context);
}
get documentTitle(): string {
const siteName = this.state.siteRes.site_view.site.name;
const res = this.state.personRes;
return res
? `@${res.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`
: "";
return res.state == "success"
? `@${res.data.person_view.person.name} - ${siteName}`
: siteName;
}
render() {
const { personRes, loading, siteRes } = this.state;
const { page, sort, view } = getProfileQueryParams();
renderPersonRes() {
switch (this.state.personRes.state) {
case "loading":
return (
<div className="container-lg">
{loading ? (
<h5>
<Spinner large />
</h5>
) : (
personRes && (
);
case "success": {
const siteRes = this.state.siteRes;
const personRes = this.state.personRes.data;
const { page, sort, view } = getProfileQueryParams();
return (
<div className="row">
<div className="col-12 col-md-8">
<HtmlTags
@ -279,7 +323,7 @@ export class Profile extends Component<
image={personRes.person_view.person.avatar}
/>
{this.userInfo}
{this.userInfo(personRes.person_view)}
<hr />
@ -291,12 +335,41 @@ export class Profile extends Component<
sort={sort}
page={page}
limit={fetchLimit}
finished={this.state.finished}
enableDownvotes={enableDownvotes(siteRes)}
enableNsfw={enableNsfw(siteRes)}
view={view}
onPageChange={this.handlePageChange}
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
// TODO all the forms here
onSaveComment={this.handleSaveComment}
onBlockPerson={this.handleBlockPersonAlt}
onDeleteComment={this.handleDeleteComment}
onRemoveComment={this.handleRemoveComment}
onCommentVote={this.handleCommentVote}
onCommentReport={this.handleCommentReport}
onDistinguishComment={this.handleDistinguishComment}
onAddModToCommunity={this.handleAddModToCommunity}
onAddAdmin={this.handleAddAdmin}
onTransferCommunity={this.handleTransferCommunity}
onPurgeComment={this.handlePurgeComment}
onPurgePerson={this.handlePurgePerson}
onCommentReplyRead={this.handleCommentReplyRead}
onPersonMentionRead={this.handlePersonMentionRead}
onBanPersonFromCommunity={this.handleBanFromCommunity}
onBanPerson={this.handleBanPerson}
onCreateComment={this.handleCreateComment}
onEditComment={this.handleEditComment}
onPostEdit={this.handlePostEdit}
onPostVote={this.handlePostVote}
onPostReport={this.handlePostReport}
onLockPost={this.handleLockPost}
onDeletePost={this.handleDeletePost}
onRemovePost={this.handleRemovePost}
onSavePost={this.handleSavePost}
onPurgePost={this.handlePurgePost}
onFeaturePost={this.handleFeaturePost}
/>
</div>
@ -305,11 +378,14 @@ export class Profile extends Component<
{this.amCurrentUser && <Follows />}
</div>
</div>
)
)}
</div>
);
}
}
}
render() {
return <div className="container-lg">{this.renderPersonRes()}</div>;
}
get viewRadios() {
return (
@ -366,8 +442,7 @@ export class Profile extends Component<
);
}
get userInfo() {
const pv = this.state.personRes?.person_view;
userInfo(pv: PersonView) {
const {
personBlocked,
siteRes: { admins },
@ -422,7 +497,7 @@ export class Profile extends Component<
)}
</ul>
</div>
{this.banDialog}
{this.banDialog(pv)}
<div className="flex-grow-1 unselectable pointer mx-2"></div>
{!this.amCurrentUser && UserService.Instance.myUserInfo && (
<>
@ -448,7 +523,10 @@ export class Profile extends Component<
className={
"d-flex align-self-start btn btn-secondary mr-2"
}
onClick={linkEvent(pv.person.id, handleUnblockPerson)}
onClick={linkEvent(
pv.person.id,
this.handleUnblockPerson
)}
>
{i18n.t("unblock_user")}
</button>
@ -457,7 +535,10 @@ export class Profile extends Component<
className={
"d-flex align-self-start btn btn-secondary mr-2"
}
onClick={linkEvent(pv.person.id, handleBlockPerson)}
onClick={linkEvent(
pv.person.id,
this.handleBlockPerson
)}
>
{i18n.t("block_user")}
</button>
@ -544,14 +625,11 @@ export class Profile extends Component<
);
}
get banDialog() {
const pv = this.state.personRes?.person_view;
banDialog(pv: PersonView) {
const { showBanDialog } = this.state;
return (
pv && (
<>
{showBanDialog && (
showBanDialog && (
<form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
<div className="form-group row col-12">
<label className="col-form-label" htmlFor="profile-ban-reason">
@ -618,13 +696,11 @@ export class Profile extends Component<
</button>
</div>
</form>
)}
</>
)
);
}
updateUrl({ page, sort, view }: Partial<ProfileProps>) {
async updateUrl({ page, sort, view }: Partial<ProfileProps>) {
const {
page: urlPage,
sort: urlSort,
@ -640,9 +716,7 @@ export class Profile extends Component<
const { username } = this.props.match.params;
this.props.history.push(`/u/${username}${getQueryString(queryParams)}`);
this.setState({ loading: true });
this.fetchUserData();
await this.fetchUserData();
}
handlePageChange(page: number) {
@ -676,19 +750,18 @@ export class Profile extends Component<
i.setState({ removeData: event.target.checked });
}
handleModBanSubmitCancel(i: Profile, event?: any) {
event.preventDefault();
handleModBanSubmitCancel(i: Profile) {
i.setState({ showBanDialog: false });
}
handleModBanSubmit(i: Profile, event?: any) {
if (event) event.preventDefault();
const { personRes, removeData, banReason, banExpireDays } = i.state;
async handleModBanSubmit(i: Profile, event: any) {
event.preventDefault();
const { removeData, banReason, banExpireDays } = i.state;
const person = personRes?.person_view.person;
const auth = myAuth();
const personRes = i.state.personRes;
if (person && auth) {
if (personRes.state == "success") {
const person = personRes.data.person_view.person;
const ban = !person.banned;
// If its an unban, restore all their data
@ -696,154 +769,281 @@ export class Profile extends Component<
i.setState({ removeData: false });
}
const form: BanPerson = {
const res = await HttpService.client.banPerson({
person_id: person.id,
ban,
remove_data: removeData,
reason: banReason,
expires: futureDaysToUnixTime(banExpireDays),
auth,
};
WebSocketService.Instance.send(wsClient.banPerson(form));
auth: myAuthRequired(),
});
// TODO
this.updateBan(res);
i.setState({ showBanDialog: false });
}
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
if (msg.error === "couldnt_find_that_username_or_email") {
this.context.router.history.push("/");
async toggleBlockPerson(recipientId: number, block: boolean) {
const res = await HttpService.client.blockPerson({
person_id: recipientId,
block,
auth: myAuthRequired(),
});
if (res.state == "success") {
updatePersonBlock(res.data);
}
} else if (msg.reconnect) {
this.fetchUserData();
} else {
switch (op) {
case UserOperation.GetPersonDetails: {
// Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
// and set the parent state if it is not set or differs
// TODO this might need to get abstracted
const data = wsJsonToRes<GetPersonDetailsResponse>(msg);
this.setState({ personRes: data, loading: false });
this.setPersonBlock();
restoreScrollPosition(this.context);
break;
}
case UserOperation.AddAdmin: {
const { admins } = wsJsonToRes<AddAdminResponse>(msg);
this.setState(s => ((s.siteRes.admins = admins), s));
break;
handleUnblockPerson(personId: number) {
this.toggleBlockPerson(personId, false);
}
case UserOperation.CreateCommentLike: {
const { comment_view } = wsJsonToRes<CommentResponse>(msg);
createCommentLikeRes(comment_view, this.state.personRes?.comments);
this.setState(this.state);
break;
handleBlockPerson(personId: number) {
this.toggleBlockPerson(personId, true);
}
case UserOperation.EditComment:
case UserOperation.DeleteComment:
case UserOperation.RemoveComment: {
const { comment_view } = wsJsonToRes<CommentResponse>(msg);
editCommentRes(comment_view, this.state.personRes?.comments);
this.setState(this.state);
break;
async handleAddModToCommunity(form: AddModToCommunity) {
// TODO not sure what to do here
await HttpService.client.addModToCommunity(form);
}
case UserOperation.CreateComment: {
const {
comment_view: {
creator: { id },
},
} = wsJsonToRes<CommentResponse>(msg);
const mui = UserService.Instance.myUserInfo;
if (id === mui?.local_user_view.person.id) {
toast(i18n.t("reply_sent"));
async handlePurgePerson(form: PurgePerson) {
const purgePersonRes = await HttpService.client.purgePerson(form);
this.purgeItem(purgePersonRes);
}
break;
async handlePurgeComment(form: PurgeComment) {
const purgeCommentRes = await HttpService.client.purgeComment(form);
this.purgeItem(purgeCommentRes);
}
case UserOperation.SaveComment: {
const { comment_view } = wsJsonToRes<CommentResponse>(msg);
saveCommentRes(comment_view, this.state.personRes?.comments);
this.setState(this.state);
break;
async handlePurgePost(form: PurgePost) {
const purgeRes = await HttpService.client.purgePost(form);
this.purgeItem(purgeRes);
}
case UserOperation.EditPost:
case UserOperation.DeletePost:
case UserOperation.RemovePost:
case UserOperation.LockPost:
case UserOperation.FeaturePost:
case UserOperation.SavePost: {
const { post_view } = wsJsonToRes<PostResponse>(msg);
editPostFindRes(post_view, this.state.personRes?.posts);
this.setState(this.state);
break;
async handleBlockPersonAlt(form: BlockPerson) {
const blockPersonRes = await HttpService.client.blockPerson(form);
if (blockPersonRes.state === "success") {
updatePersonBlock(blockPersonRes.data);
}
}
case UserOperation.CreatePostLike: {
const { post_view } = wsJsonToRes<PostResponse>(msg);
createPostLikeFindRes(post_view, this.state.personRes?.posts);
this.setState(this.state);
async handleCreateComment(form: CreateComment) {
const createCommentRes = await HttpService.client.createComment(form);
this.createAndUpdateComments(createCommentRes);
break;
return createCommentRes;
}
case UserOperation.BanPerson: {
const data = wsJsonToRes<BanPersonResponse>(msg);
const res = this.state.personRes;
res?.comments
.filter(c => c.creator.id === data.person_view.person.id)
.forEach(c => (c.creator.banned = data.banned));
res?.posts
.filter(c => c.creator.id === data.person_view.person.id)
.forEach(c => (c.creator.banned = data.banned));
const pv = res?.person_view;
async handleEditComment(form: EditComment) {
const editCommentRes = await HttpService.client.editComment(form);
this.findAndUpdateComment(editCommentRes);
if (pv?.person.id === data.person_view.person.id) {
pv.person.banned = data.banned;
}
this.setState(this.state);
break;
return editCommentRes;
}
case UserOperation.BlockPerson: {
const data = wsJsonToRes<BlockPersonResponse>(msg);
updatePersonBlock(data);
this.setPersonBlock();
break;
async handleDeleteComment(form: DeleteComment) {
const deleteCommentRes = await HttpService.client.deleteComment(form);
this.findAndUpdateComment(deleteCommentRes);
}
case UserOperation.PurgePerson:
case UserOperation.PurgePost:
case UserOperation.PurgeComment:
case UserOperation.PurgeCommunity: {
const { success } = wsJsonToRes<PurgeItemResponse>(msg);
async handleDeletePost(form: DeletePost) {
const deleteRes = await HttpService.client.deletePost(form);
this.findAndUpdatePost(deleteRes);
}
if (success) {
async handleRemovePost(form: RemovePost) {
const removeRes = await HttpService.client.removePost(form);
this.findAndUpdatePost(removeRes);
}
async handleRemoveComment(form: RemoveComment) {
const removeCommentRes = await HttpService.client.removeComment(form);
this.findAndUpdateComment(removeCommentRes);
}
async handleSaveComment(form: SaveComment) {
const saveCommentRes = await HttpService.client.saveComment(form);
this.findAndUpdateComment(saveCommentRes);
}
async handleSavePost(form: SavePost) {
const saveRes = await HttpService.client.savePost(form);
this.findAndUpdatePost(saveRes);
}
async handleFeaturePost(form: FeaturePost) {
const featureRes = await HttpService.client.featurePost(form);
this.findAndUpdatePost(featureRes);
}
async handleCommentVote(form: CreateCommentLike) {
const voteRes = await HttpService.client.likeComment(form);
this.findAndUpdateComment(voteRes);
}
async handlePostVote(form: CreatePostLike) {
const voteRes = await HttpService.client.likePost(form);
this.findAndUpdatePost(voteRes);
}
async handlePostEdit(form: EditPost) {
const res = await HttpService.client.editPost(form);
this.findAndUpdatePost(res);
}
async handleCommentReport(form: CreateCommentReport) {
const reportRes = await HttpService.client.createCommentReport(form);
if (reportRes.state === "success") {
toast(i18n.t("report_created"));
}
}
async handlePostReport(form: CreatePostReport) {
const reportRes = await HttpService.client.createPostReport(form);
if (reportRes.state === "success") {
toast(i18n.t("report_created"));
}
}
async handleLockPost(form: LockPost) {
const lockRes = await HttpService.client.lockPost(form);
this.findAndUpdatePost(lockRes);
}
async handleDistinguishComment(form: DistinguishComment) {
const distinguishRes = await HttpService.client.distinguishComment(form);
this.findAndUpdateComment(distinguishRes);
}
async handleAddAdmin(form: AddAdmin) {
const addAdminRes = await HttpService.client.addAdmin(form);
if (addAdminRes.state == "success") {
this.setState(s => ((s.siteRes.admins = addAdminRes.data.admins), s));
}
}
async handleTransferCommunity(form: TransferCommunity) {
await HttpService.client.transferCommunity(form);
toast(i18n.t("transfer_community"));
}
async handleCommentReplyRead(form: MarkCommentReplyAsRead) {
const readRes = await HttpService.client.markCommentReplyAsRead(form);
this.findAndUpdateCommentReply(readRes);
}
async handlePersonMentionRead(form: MarkPersonMentionAsRead) {
// TODO not sure what to do here. Maybe it is actually optional, because post doesn't need it.
await HttpService.client.markPersonMentionAsRead(form);
}
async handleBanFromCommunity(form: BanFromCommunity) {
const banRes = await HttpService.client.banFromCommunity(form);
this.updateBanFromCommunity(banRes);
}
async handleBanPerson(form: BanPerson) {
const banRes = await HttpService.client.banPerson(form);
this.updateBan(banRes);
}
updateBanFromCommunity(banRes: RequestState<BanFromCommunityResponse>) {
// Maybe not necessary
if (banRes.state === "success") {
this.setState(s => {
if (s.personRes.state == "success") {
s.personRes.data.posts
.filter(c => c.creator.id === banRes.data.person_view.person.id)
.forEach(
c => (c.creator_banned_from_community = banRes.data.banned)
);
s.personRes.data.comments
.filter(c => c.creator.id === banRes.data.person_view.person.id)
.forEach(
c => (c.creator_banned_from_community = banRes.data.banned)
);
}
return s;
});
}
}
updateBan(banRes: RequestState<BanPersonResponse>) {
// Maybe not necessary
if (banRes.state == "success") {
this.setState(s => {
if (s.personRes.state == "success") {
s.personRes.data.posts
.filter(c => c.creator.id == banRes.data.person_view.person.id)
.forEach(c => (c.creator.banned = banRes.data.banned));
s.personRes.data.comments
.filter(c => c.creator.id == banRes.data.person_view.person.id)
.forEach(c => (c.creator.banned = banRes.data.banned));
}
return s;
});
}
}
purgeItem(purgeRes: RequestState<PurgeItemResponse>) {
if (purgeRes.state == "success") {
toast(i18n.t("purge_success"));
this.context.router.history.push(`/`);
}
}
}
}
findAndUpdateComment(res: RequestState<CommentResponse>) {
this.setState(s => {
if (s.personRes.state == "success" && res.state == "success") {
s.personRes.data.comments = editComment(
res.data.comment_view,
s.personRes.data.comments
);
s.finished.set(res.data.comment_view.comment.id, true);
}
return s;
});
}
createAndUpdateComments(res: RequestState<CommentResponse>) {
this.setState(s => {
if (s.personRes.state == "success" && res.state == "success") {
s.personRes.data.comments.unshift(res.data.comment_view);
// Set finished for the parent
s.finished.set(
getCommentParentId(res.data.comment_view.comment) ?? 0,
true
);
}
return s;
});
}
findAndUpdateCommentReply(res: RequestState<CommentReplyResponse>) {
this.setState(s => {
if (s.personRes.state == "success" && res.state == "success") {
s.personRes.data.comments = editWith(
res.data.comment_reply_view,
s.personRes.data.comments
);
}
return s;
});
}
findAndUpdatePost(res: RequestState<PostResponse>) {
this.setState(s => {
if (s.personRes.state == "success" && res.state == "success") {
s.personRes.data.posts = editPost(
res.data.post_view,
s.personRes.data.posts
);
}
return s;
});
}
}

View file

@ -1,27 +1,22 @@
import { Component, linkEvent } from "inferno";
import {
ApproveRegistrationApplication,
GetSiteResponse,
ListRegistrationApplications,
ListRegistrationApplicationsResponse,
RegistrationApplicationResponse,
UserOperation,
wsJsonToRes,
wsUserOp,
RegistrationApplicationView,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { InitialFetchRequest } from "../../interfaces";
import { UserService, WebSocketService } from "../../services";
import { UserService } from "../../services";
import { FirstLoadService } from "../../services/FirstLoadService";
import { HttpService, RequestState } from "../../services/HttpService";
import {
editRegistrationApplication,
fetchLimit,
isBrowser,
myAuth,
myAuthRequired,
setIsoData,
setupTippy,
toast,
updateRegistrationApplicationRes,
wsClient,
wsSubscribe,
} from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
@ -34,11 +29,11 @@ enum UnreadOrAll {
}
interface RegistrationApplicationsState {
listRegistrationApplicationsResponse?: ListRegistrationApplicationsResponse;
appsRes: RequestState<ListRegistrationApplicationsResponse>;
siteRes: GetSiteResponse;
unreadOrAll: UnreadOrAll;
page: number;
loading: boolean;
isIsomorphic: boolean;
}
export class RegistrationApplications extends Component<
@ -46,45 +41,37 @@ export class RegistrationApplications extends Component<
RegistrationApplicationsState
> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: RegistrationApplicationsState = {
appsRes: { state: "empty" },
siteRes: this.isoData.site_res,
unreadOrAll: UnreadOrAll.Unread,
page: 1,
loading: true,
isIsomorphic: false,
};
constructor(props: any, context: any) {
super(props, context);
this.handlePageChange = this.handlePageChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
this.handleApproveApplication = this.handleApproveApplication.bind(this);
// Only fetch the data if coming from another route
if (this.isoData.path == this.context.router.route.match.url) {
if (FirstLoadService.isFirstLoad) {
this.state = {
...this.state,
listRegistrationApplicationsResponse: this.isoData
.routeData[0] as ListRegistrationApplicationsResponse,
loading: false,
appsRes: this.isoData.routeData[0],
isIsomorphic: true,
};
} else {
this.refetch();
}
}
componentDidMount() {
async componentDidMount() {
if (!this.state.isIsomorphic) {
await this.refetch();
}
setupTippy();
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
}
get documentTitle(): string {
const mui = UserService.Instance.myUserInfo;
return mui
@ -94,14 +81,17 @@ export class RegistrationApplications extends Component<
: "";
}
render() {
renderApps() {
switch (this.state.appsRes.state) {
case "loading":
return (
<div className="container-lg">
{this.state.loading ? (
<h5>
<Spinner large />
</h5>
) : (
);
case "success": {
const apps = this.state.appsRes.data.registration_applications;
return (
<div className="row">
<div className="col-12">
<HtmlTags
@ -110,17 +100,21 @@ export class RegistrationApplications extends Component<
/>
<h5 className="mb-2">{i18n.t("registration_applications")}</h5>
{this.selects()}
{this.applicationList()}
{this.applicationList(apps)}
<Paginator
page={this.state.page}
onChange={this.handlePageChange}
/>
</div>
</div>
)}
</div>
);
}
}
}
render() {
return <div className="container-lg">{this.renderApps()}</div>;
}
unreadOrAllRadios() {
return (
@ -163,22 +157,20 @@ export class RegistrationApplications extends Component<
);
}
applicationList() {
const res = this.state.listRegistrationApplicationsResponse;
applicationList(apps: RegistrationApplicationView[]) {
return (
res && (
<div>
{res.registration_applications.map(ra => (
{apps.map(ra => (
<>
<hr />
<RegistrationApplication
key={ra.registration_application.id}
application={ra}
onApproveApplication={this.handleApproveApplication}
/>
</>
))}
</div>
)
);
}
@ -192,10 +184,12 @@ export class RegistrationApplications extends Component<
this.refetch();
}
static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
const promises: Promise<any>[] = [];
static fetchInitialData({
auth,
client,
}: InitialFetchRequest): Promise<any>[] {
const promises: Promise<RequestState<any>>[] = [];
const auth = req.auth;
if (auth) {
const form: ListRegistrationApplications = {
unread_only: true,
@ -203,54 +197,41 @@ export class RegistrationApplications extends Component<
limit: fetchLimit,
auth,
};
promises.push(req.client.listRegistrationApplications(form));
promises.push(client.listRegistrationApplications(form));
} else {
promises.push(Promise.resolve({ state: "empty" }));
}
return promises;
}
refetch() {
async refetch() {
const unread_only = this.state.unreadOrAll == UnreadOrAll.Unread;
const auth = myAuth();
if (auth) {
const form: ListRegistrationApplications = {
this.setState({
appsRes: { state: "loading" },
});
this.setState({
appsRes: await HttpService.client.listRegistrationApplications({
unread_only: unread_only,
page: this.state.page,
limit: fetchLimit,
auth,
};
WebSocketService.Instance.send(
wsClient.listRegistrationApplications(form)
);
}
auth: myAuthRequired(),
}),
});
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
return;
} else if (msg.reconnect) {
this.refetch();
} else if (op == UserOperation.ListRegistrationApplications) {
const data = wsJsonToRes<ListRegistrationApplicationsResponse>(msg);
this.setState({
listRegistrationApplicationsResponse: data,
loading: false,
});
window.scrollTo(0, 0);
} else if (op == UserOperation.ApproveRegistrationApplication) {
const data = wsJsonToRes<RegistrationApplicationResponse>(msg);
updateRegistrationApplicationRes(
data.registration_application,
this.state.listRegistrationApplicationsResponse
?.registration_applications
async handleApproveApplication(form: ApproveRegistrationApplication) {
const approveRes = await HttpService.client.approveRegistrationApplication(
form
);
const uacs = UserService.Instance.unreadApplicationCountSub;
// Minor bug, where if the application switches from deny to approve, the count will still go down
uacs.next(uacs.getValue() - 1);
this.setState(this.state);
}
this.setState(s => {
if (s.appsRes.state == "success" && approveRes.state == "success") {
s.appsRes.data.registration_applications = editRegistrationApplication(
approveRes.data.registration_application,
s.appsRes.data.registration_applications
);
}
return s;
});
}
}

View file

@ -13,27 +13,23 @@ import {
PostReportView,
PrivateMessageReportResponse,
PrivateMessageReportView,
UserOperation,
wsJsonToRes,
wsUserOp,
ResolveCommentReport,
ResolvePostReport,
ResolvePrivateMessageReport,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { InitialFetchRequest } from "../../interfaces";
import { UserService, WebSocketService } from "../../services";
import { HttpService, UserService } from "../../services";
import { FirstLoadService } from "../../services/FirstLoadService";
import { RequestState } from "../../services/HttpService";
import {
amAdmin,
editCommentReport,
editPostReport,
editPrivateMessageReport,
fetchLimit,
isBrowser,
myAuth,
myAuthRequired,
setIsoData,
setupTippy,
toast,
updateCommentReportRes,
updatePostReportRes,
updatePrivateMessageReportRes,
wsClient,
wsSubscribe,
} from "../../utils";
import { CommentReport } from "../comment/comment-report";
import { HtmlTags } from "../common/html-tags";
@ -68,66 +64,62 @@ type ItemType = {
};
interface ReportsState {
listCommentReportsResponse?: ListCommentReportsResponse;
listPostReportsResponse?: ListPostReportsResponse;
listPrivateMessageReportsResponse?: ListPrivateMessageReportsResponse;
commentReportsRes: RequestState<ListCommentReportsResponse>;
postReportsRes: RequestState<ListPostReportsResponse>;
messageReportsRes: RequestState<ListPrivateMessageReportsResponse>;
unreadOrAll: UnreadOrAll;
messageType: MessageType;
combined: ItemType[];
siteRes: GetSiteResponse;
page: number;
loading: boolean;
isIsomorphic: boolean;
}
export class Reports extends Component<any, ReportsState> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: ReportsState = {
commentReportsRes: { state: "empty" },
postReportsRes: { state: "empty" },
messageReportsRes: { state: "empty" },
unreadOrAll: UnreadOrAll.Unread,
messageType: MessageType.All,
combined: [],
page: 1,
siteRes: this.isoData.site_res,
loading: true,
isIsomorphic: false,
};
constructor(props: any, context: any) {
super(props, context);
this.handlePageChange = this.handlePageChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
this.handleResolveCommentReport =
this.handleResolveCommentReport.bind(this);
this.handleResolvePostReport = this.handleResolvePostReport.bind(this);
this.handleResolvePrivateMessageReport =
this.handleResolvePrivateMessageReport.bind(this);
// Only fetch the data if coming from another route
if (this.isoData.path == this.context.router.route.match.url) {
if (FirstLoadService.isFirstLoad) {
const [commentReportsRes, postReportsRes, messageReportsRes] =
this.isoData.routeData;
this.state = {
...this.state,
listCommentReportsResponse: this.isoData
.routeData[0] as ListCommentReportsResponse,
listPostReportsResponse: this.isoData
.routeData[1] as ListPostReportsResponse,
commentReportsRes,
postReportsRes,
isIsomorphic: true,
};
if (amAdmin()) {
this.state = {
...this.state,
listPrivateMessageReportsResponse: this.isoData
.routeData[2] as ListPrivateMessageReportsResponse,
messageReportsRes,
};
}
this.state = {
...this.state,
combined: this.buildCombined(),
loading: false,
};
} else {
this.refetch();
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
async componentDidMount() {
if (!this.state.isIsomorphic) {
await this.refetch();
}
}
@ -143,11 +135,6 @@ export class Reports extends Component<any, ReportsState> {
render() {
return (
<div className="container-lg">
{this.state.loading ? (
<h5>
<Spinner large />
</h5>
) : (
<div className="row">
<div className="col-12">
<HtmlTags
@ -156,24 +143,38 @@ export class Reports extends Component<any, ReportsState> {
/>
<h5 className="mb-2">{i18n.t("reports")}</h5>
{this.selects()}
{this.state.messageType == MessageType.All && this.all()}
{this.state.messageType == MessageType.CommentReport &&
this.commentReports()}
{this.state.messageType == MessageType.PostReport &&
this.postReports()}
{this.state.messageType == MessageType.PrivateMessageReport &&
this.privateMessageReports()}
{this.section}
<Paginator
page={this.state.page}
onChange={this.handlePageChange}
/>
</div>
</div>
)}
</div>
);
}
get section() {
switch (this.state.messageType) {
case MessageType.All: {
return this.all();
}
case MessageType.CommentReport: {
return this.commentReports();
}
case MessageType.PostReport: {
return this.postReports();
}
case MessageType.PrivateMessageReport: {
return this.privateMessageReports();
}
default: {
return null;
}
}
}
unreadOrAllRadios() {
return (
<div className="btn-group btn-group-toggle flex-wrap mb-2">
@ -309,23 +310,25 @@ export class Reports extends Component<any, ReportsState> {
};
}
buildCombined(): ItemType[] {
// let comments: ItemType[] = this.state.listCommentReportsResponse
// .map(r => r.comment_reports)
// .unwrapOr([])
// .map(r => this.commentReportToItemType(r));
get buildCombined(): ItemType[] {
const commentRes = this.state.commentReportsRes;
const comments =
this.state.listCommentReportsResponse?.comment_reports.map(
this.commentReportToItemType
) ?? [];
commentRes.state == "success"
? commentRes.data.comment_reports.map(this.commentReportToItemType)
: [];
const postRes = this.state.postReportsRes;
const posts =
this.state.listPostReportsResponse?.post_reports.map(
this.postReportToItemType
) ?? [];
postRes.state == "success"
? postRes.data.post_reports.map(this.postReportToItemType)
: [];
const pmRes = this.state.messageReportsRes;
const privateMessages =
this.state.listPrivateMessageReportsResponse?.private_message_reports.map(
pmRes.state == "success"
? pmRes.data.private_message_reports.map(
this.privateMessageReportToItemType
) ?? [];
)
: [];
return [...comments, ...posts, ...privateMessages].sort((a, b) =>
b.published.localeCompare(a.published)
@ -336,15 +339,26 @@ export class Reports extends Component<any, ReportsState> {
switch (i.type_) {
case MessageEnum.CommentReport:
return (
<CommentReport key={i.id} report={i.view as CommentReportView} />
<CommentReport
key={i.id}
report={i.view as CommentReportView}
onResolveReport={this.handleResolveCommentReport}
/>
);
case MessageEnum.PostReport:
return <PostReport key={i.id} report={i.view as PostReportView} />;
return (
<PostReport
key={i.id}
report={i.view as PostReportView}
onResolveReport={this.handleResolvePostReport}
/>
);
case MessageEnum.PrivateMessageReport:
return (
<PrivateMessageReport
key={i.id}
report={i.view as PrivateMessageReportView}
onResolveReport={this.handleResolvePrivateMessageReport}
/>
);
default:
@ -355,7 +369,7 @@ export class Reports extends Component<any, ReportsState> {
all() {
return (
<div>
{this.state.combined.map(i => (
{this.buildCombined.map(i => (
<>
<hr />
{this.renderItemType(i)}
@ -366,42 +380,75 @@ export class Reports extends Component<any, ReportsState> {
}
commentReports() {
const reports = this.state.listCommentReportsResponse?.comment_reports;
const res = this.state.commentReportsRes;
switch (res.state) {
case "loading":
return (
<h5>
<Spinner large />
</h5>
);
case "success": {
const reports = res.data.comment_reports;
return (
reports && (
<div>
{reports.map(cr => (
<>
<hr />
<CommentReport key={cr.comment_report.id} report={cr} />
<CommentReport
key={cr.comment_report.id}
report={cr}
onResolveReport={this.handleResolveCommentReport}
/>
</>
))}
</div>
)
);
}
}
}
postReports() {
const reports = this.state.listPostReportsResponse?.post_reports;
const res = this.state.postReportsRes;
switch (res.state) {
case "loading":
return (
<h5>
<Spinner large />
</h5>
);
case "success": {
const reports = res.data.post_reports;
return (
reports && (
<div>
{reports.map(pr => (
<>
<hr />
<PostReport key={pr.post_report.id} report={pr} />
<PostReport
key={pr.post_report.id}
report={pr}
onResolveReport={this.handleResolvePostReport}
/>
</>
))}
</div>
)
);
}
}
}
privateMessageReports() {
const reports =
this.state.listPrivateMessageReportsResponse?.private_message_reports;
const res = this.state.messageReportsRes;
switch (res.state) {
case "loading":
return (
<h5>
<Spinner large />
</h5>
);
case "success": {
const reports = res.data.private_message_reports;
return (
reports && (
<div>
{reports.map(pmr => (
<>
@ -409,36 +456,40 @@ export class Reports extends Component<any, ReportsState> {
<PrivateMessageReport
key={pmr.private_message_report.id}
report={pmr}
onResolveReport={this.handleResolvePrivateMessageReport}
/>
</>
))}
</div>
)
);
}
}
}
handlePageChange(page: number) {
async handlePageChange(page: number) {
this.setState({ page });
this.refetch();
await this.refetch();
}
handleUnreadOrAllChange(i: Reports, event: any) {
async handleUnreadOrAllChange(i: Reports, event: any) {
i.setState({ unreadOrAll: Number(event.target.value), page: 1 });
i.refetch();
await i.refetch();
}
handleMessageTypeChange(i: Reports, event: any) {
async handleMessageTypeChange(i: Reports, event: any) {
i.setState({ messageType: Number(event.target.value), page: 1 });
i.refetch();
await i.refetch();
}
static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
const promises: Promise<any>[] = [];
static fetchInitialData({
auth,
client,
}: InitialFetchRequest): Promise<any>[] {
const promises: Promise<RequestState<any>>[] = [];
const unresolved_only = true;
const page = 1;
const limit = fetchLimit;
const auth = req.auth;
if (auth) {
const commentReportsForm: ListCommentReports = {
@ -447,7 +498,7 @@ export class Reports extends Component<any, ReportsState> {
limit,
auth,
};
promises.push(req.client.listCommentReports(commentReportsForm));
promises.push(client.listCommentReports(commentReportsForm));
const postReportsForm: ListPostReports = {
unresolved_only,
@ -455,7 +506,7 @@ export class Reports extends Component<any, ReportsState> {
limit,
auth,
};
promises.push(req.client.listPostReports(postReportsForm));
promises.push(client.listPostReports(postReportsForm));
if (amAdmin()) {
const privateMessageReportsForm: ListPrivateMessageReports = {
@ -465,120 +516,109 @@ export class Reports extends Component<any, ReportsState> {
auth,
};
promises.push(
req.client.listPrivateMessageReports(privateMessageReportsForm)
client.listPrivateMessageReports(privateMessageReportsForm)
);
} else {
promises.push(Promise.resolve({ state: "empty" }));
}
} else {
promises.push(
Promise.resolve({ state: "empty" }),
Promise.resolve({ state: "empty" }),
Promise.resolve({ state: "empty" })
);
}
return promises;
}
refetch() {
async refetch() {
const unresolved_only = this.state.unreadOrAll == UnreadOrAll.Unread;
const page = this.state.page;
const limit = fetchLimit;
const auth = myAuth();
if (auth) {
const commentReportsForm: ListCommentReports = {
unresolved_only,
page,
limit,
auth,
};
WebSocketService.Instance.send(
wsClient.listCommentReports(commentReportsForm)
);
const auth = myAuthRequired();
const postReportsForm: ListPostReports = {
this.setState({
commentReportsRes: { state: "loading" },
postReportsRes: { state: "loading" },
messageReportsRes: { state: "loading" },
});
const form:
| ListCommentReports
| ListPostReports
| ListPrivateMessageReports = {
unresolved_only,
page,
limit,
auth,
};
WebSocketService.Instance.send(wsClient.listPostReports(postReportsForm));
this.setState({
commentReportsRes: await HttpService.client.listCommentReports(form),
postReportsRes: await HttpService.client.listPostReports(form),
});
if (amAdmin()) {
const privateMessageReportsForm: ListPrivateMessageReports = {
unresolved_only,
page,
limit,
auth,
};
WebSocketService.Instance.send(
wsClient.listPrivateMessageReports(privateMessageReportsForm)
);
}
this.setState({
messageReportsRes: await HttpService.client.listPrivateMessageReports(
form
),
});
}
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
return;
} else if (msg.reconnect) {
this.refetch();
} else if (op == UserOperation.ListCommentReports) {
const data = wsJsonToRes<ListCommentReportsResponse>(msg);
this.setState({ listCommentReportsResponse: data });
this.setState({ combined: this.buildCombined(), loading: false });
// this.sendUnreadCount();
window.scrollTo(0, 0);
setupTippy();
} else if (op == UserOperation.ListPostReports) {
const data = wsJsonToRes<ListPostReportsResponse>(msg);
this.setState({ listPostReportsResponse: data });
this.setState({ combined: this.buildCombined(), loading: false });
// this.sendUnreadCount();
window.scrollTo(0, 0);
setupTippy();
} else if (op == UserOperation.ListPrivateMessageReports) {
const data = wsJsonToRes<ListPrivateMessageReportsResponse>(msg);
this.setState({ listPrivateMessageReportsResponse: data });
this.setState({ combined: this.buildCombined(), loading: false });
// this.sendUnreadCount();
window.scrollTo(0, 0);
setupTippy();
} else if (op == UserOperation.ResolvePostReport) {
const data = wsJsonToRes<PostReportResponse>(msg);
updatePostReportRes(
data.post_report_view,
this.state.listPostReportsResponse?.post_reports
async handleResolveCommentReport(form: ResolveCommentReport) {
const res = await HttpService.client.resolveCommentReport(form);
this.findAndUpdateCommentReport(res);
}
async handleResolvePostReport(form: ResolvePostReport) {
const res = await HttpService.client.resolvePostReport(form);
this.findAndUpdatePostReport(res);
}
async handleResolvePrivateMessageReport(form: ResolvePrivateMessageReport) {
const res = await HttpService.client.resolvePrivateMessageReport(form);
this.findAndUpdatePrivateMessageReport(res);
}
findAndUpdateCommentReport(res: RequestState<CommentReportResponse>) {
this.setState(s => {
if (s.commentReportsRes.state == "success" && res.state == "success") {
s.commentReportsRes.data.comment_reports = editCommentReport(
res.data.comment_report_view,
s.commentReportsRes.data.comment_reports
);
const urcs = UserService.Instance.unreadReportCountSub;
if (data.post_report_view.post_report.resolved) {
urcs.next(urcs.getValue() - 1);
} else {
urcs.next(urcs.getValue() + 1);
}
this.setState(this.state);
} else if (op == UserOperation.ResolveCommentReport) {
const data = wsJsonToRes<CommentReportResponse>(msg);
updateCommentReportRes(
data.comment_report_view,
this.state.listCommentReportsResponse?.comment_reports
return s;
});
}
findAndUpdatePostReport(res: RequestState<PostReportResponse>) {
this.setState(s => {
if (s.postReportsRes.state == "success" && res.state == "success") {
s.postReportsRes.data.post_reports = editPostReport(
res.data.post_report_view,
s.postReportsRes.data.post_reports
);
const urcs = UserService.Instance.unreadReportCountSub;
if (data.comment_report_view.comment_report.resolved) {
urcs.next(urcs.getValue() - 1);
} else {
urcs.next(urcs.getValue() + 1);
}
this.setState(this.state);
} else if (op == UserOperation.ResolvePrivateMessageReport) {
const data = wsJsonToRes<PrivateMessageReportResponse>(msg);
updatePrivateMessageReportRes(
data.private_message_report_view,
this.state.listPrivateMessageReportsResponse?.private_message_reports
return s;
});
}
findAndUpdatePrivateMessageReport(
res: RequestState<PrivateMessageReportResponse>
) {
this.setState(s => {
if (s.messageReportsRes.state == "success" && res.state == "success") {
s.messageReportsRes.data.private_message_reports =
editPrivateMessageReport(
res.data.private_message_report_view,
s.messageReportsRes.data.private_message_reports
);
const urcs = UserService.Instance.unreadReportCountSub;
if (data.private_message_report_view.private_message_report.resolved) {
urcs.next(urcs.getValue() - 1);
} else {
urcs.next(urcs.getValue() + 1);
}
this.setState(this.state);
}
return s;
});
}
}

View file

@ -1,26 +1,19 @@
import { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno";
import {
BlockCommunity,
BlockCommunityResponse,
BlockPerson,
BlockPersonResponse,
ChangePassword,
CommunityBlockView,
DeleteAccount,
DeleteAccountResponse,
GetSiteResponse,
ListingType,
LoginResponse,
PersonBlockView,
SaveUserSettings,
SortType,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n, languages } from "../../i18next";
import { UserService, WebSocketService } from "../../services";
import { UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import {
Choice,
capitalizeFirstLetter,
@ -34,6 +27,7 @@ import {
fetchUsers,
getLanguages,
myAuth,
myAuthRequired,
personToChoice,
relTags,
setIsoData,
@ -43,8 +37,6 @@ import {
toast,
updateCommunityBlock,
updatePersonBlock,
wsClient,
wsSubscribe,
} from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon";
@ -59,6 +51,9 @@ import { CommunityLink } from "../community/community-link";
import { PersonListing } from "./person-listing";
interface SettingsState {
saveRes: RequestState<LoginResponse>;
changePasswordRes: RequestState<LoginResponse>;
deleteAccountRes: RequestState<DeleteAccountResponse>;
// TODO redo these forms
saveUserSettingsForm: {
show_nsfw?: boolean;
@ -94,9 +89,6 @@ interface SettingsState {
communityBlocks: CommunityBlockView[];
currentTab: string;
themeList: string[];
saveUserSettingsLoading: boolean;
changePasswordLoading: boolean;
deleteAccountLoading: boolean;
deleteAccountShowConfirm: boolean;
siteRes: GetSiteResponse;
searchCommunityLoading: boolean;
@ -143,13 +135,12 @@ const Filter = ({
export class Settings extends Component<any, SettingsState> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: SettingsState = {
saveRes: { state: "empty" },
deleteAccountRes: { state: "empty" },
changePasswordRes: { state: "empty" },
saveUserSettingsForm: {},
changePasswordForm: {},
saveUserSettingsLoading: false,
changePasswordLoading: false,
deleteAccountLoading: false,
deleteAccountShowConfirm: false,
deleteAccountForm: {},
personBlocks: [],
@ -180,8 +171,8 @@ export class Settings extends Component<any, SettingsState> {
this.userSettings = this.userSettings.bind(this);
this.blockCards = this.blockCards.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
this.handleBlockPerson = this.handleBlockPerson.bind(this);
this.handleBlockCommunity = this.handleBlockCommunity.bind(this);
const mui = UserService.Instance.myUserInfo;
if (mui) {
@ -245,10 +236,6 @@ export class Settings extends Component<any, SettingsState> {
this.setState({ themeList: await fetchThemeList() });
}
componentWillUnmount() {
this.subscription?.unsubscribe();
}
get documentTitle(): string {
return i18n.t("settings");
}
@ -375,7 +362,7 @@ export class Settings extends Component<any, SettingsState> {
</div>
<div className="form-group">
<button type="submit" className="btn btn-block btn-secondary mr-4">
{this.state.changePasswordLoading ? (
{this.state.changePasswordRes.state === "loading" ? (
<Spinner />
) : (
capitalizeFirstLetter(i18n.t("save"))
@ -791,7 +778,7 @@ export class Settings extends Component<any, SettingsState> {
{this.totpSection()}
<div className="form-group">
<button type="submit" className="btn btn-block btn-secondary mr-4">
{this.state.saveUserSettingsLoading ? (
{this.state.saveRes.state === "loading" ? (
<Spinner />
) : (
capitalizeFirstLetter(i18n.t("save"))
@ -830,7 +817,7 @@ export class Settings extends Component<any, SettingsState> {
disabled={!this.state.deleteAccountForm.password}
onClick={linkEvent(this, this.handleDeleteAccount)}
>
{this.state.deleteAccountLoading ? (
{this.state.deleteAccountRes.state === "loading" ? (
<Spinner />
) : (
capitalizeFirstLetter(i18n.t("delete"))
@ -911,9 +898,7 @@ export class Settings extends Component<any, SettingsState> {
const searchPersonOptions: Choice[] = [];
if (text.length > 0) {
searchPersonOptions.push(
...(await fetchUsers(text)).users.map(personToChoice)
);
searchPersonOptions.push(...(await fetchUsers(text)).map(personToChoice));
}
this.setState({
@ -929,7 +914,7 @@ export class Settings extends Component<any, SettingsState> {
if (text.length > 0) {
searchCommunityOptions.push(
...(await fetchCommunities(text)).communities.map(communityToChoice)
...(await fetchCommunities(text)).map(communityToChoice)
);
}
@ -939,100 +924,107 @@ export class Settings extends Component<any, SettingsState> {
});
});
handleBlockPerson({ value }: Choice) {
const auth = myAuth();
if (auth && value !== "0") {
const blockUserForm: BlockPerson = {
async handleBlockPerson({ value }: Choice) {
if (value !== "0") {
const res = await HttpService.client.blockPerson({
person_id: Number(value),
block: true,
auth,
};
WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
auth: myAuthRequired(),
});
this.personBlock(res);
}
}
handleUnblockPerson(i: { ctx: Settings; recipientId: number }) {
const auth = myAuth();
if (auth) {
const blockUserForm: BlockPerson = {
person_id: i.recipientId,
async handleUnblockPerson({
ctx,
recipientId,
}: {
ctx: Settings;
recipientId: number;
}) {
const res = await HttpService.client.blockPerson({
person_id: recipientId,
block: false,
auth,
};
WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
}
auth: myAuthRequired(),
});
ctx.personBlock(res);
}
handleBlockCommunity({ value }: Choice) {
const auth = myAuth();
if (auth && value !== "0") {
const blockCommunityForm: BlockCommunity = {
async handleBlockCommunity({ value }: Choice) {
if (value !== "0") {
const res = await HttpService.client.blockCommunity({
community_id: Number(value),
block: true,
auth,
};
WebSocketService.Instance.send(
wsClient.blockCommunity(blockCommunityForm)
);
auth: myAuthRequired(),
});
this.communityBlock(res);
}
}
handleUnblockCommunity(i: { ctx: Settings; communityId: number }) {
async handleUnblockCommunity(i: { ctx: Settings; communityId: number }) {
const auth = myAuth();
if (auth) {
const blockCommunityForm: BlockCommunity = {
const res = await HttpService.client.blockCommunity({
community_id: i.communityId,
block: false,
auth,
};
WebSocketService.Instance.send(
wsClient.blockCommunity(blockCommunityForm)
);
auth: myAuthRequired(),
});
i.ctx.communityBlock(res);
}
}
handleShowNsfwChange(i: Settings, event: any) {
i.state.saveUserSettingsForm.show_nsfw = event.target.checked;
i.setState(i.state);
i.setState(
s => ((s.saveUserSettingsForm.show_nsfw = event.target.checked), s)
);
}
handleShowAvatarsChange(i: Settings, event: any) {
i.state.saveUserSettingsForm.show_avatars = event.target.checked;
const mui = UserService.Instance.myUserInfo;
if (mui) {
mui.local_user_view.local_user.show_avatars = event.target.checked;
}
i.setState(i.state);
i.setState(
s => ((s.saveUserSettingsForm.show_avatars = event.target.checked), s)
);
}
handleBotAccount(i: Settings, event: any) {
i.state.saveUserSettingsForm.bot_account = event.target.checked;
i.setState(i.state);
i.setState(
s => ((s.saveUserSettingsForm.bot_account = event.target.checked), s)
);
}
handleShowBotAccounts(i: Settings, event: any) {
i.state.saveUserSettingsForm.show_bot_accounts = event.target.checked;
i.setState(i.state);
i.setState(
s => (
(s.saveUserSettingsForm.show_bot_accounts = event.target.checked), s
)
);
}
handleReadPosts(i: Settings, event: any) {
i.state.saveUserSettingsForm.show_read_posts = event.target.checked;
i.setState(i.state);
i.setState(
s => ((s.saveUserSettingsForm.show_read_posts = event.target.checked), s)
);
}
handleShowNewPostNotifs(i: Settings, event: any) {
i.state.saveUserSettingsForm.show_new_post_notifs = event.target.checked;
i.setState(i.state);
i.setState(
s => (
(s.saveUserSettingsForm.show_new_post_notifs = event.target.checked), s
)
);
}
handleShowScoresChange(i: Settings, event: any) {
i.state.saveUserSettingsForm.show_scores = event.target.checked;
const mui = UserService.Instance.myUserInfo;
if (mui) {
mui.local_user_view.local_user.show_scores = event.target.checked;
}
i.setState(i.state);
i.setState(
s => ((s.saveUserSettingsForm.show_scores = event.target.checked), s)
);
}
handleGenerateTotp(i: Settings, event: any) {
@ -1041,35 +1033,37 @@ export class Settings extends Component<any, SettingsState> {
if (checked) {
toast(i18n.t("two_factor_setup_instructions"));
}
i.state.saveUserSettingsForm.generate_totp_2fa = checked;
i.setState(i.state);
i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
}
handleRemoveTotp(i: Settings, event: any) {
// Coerce true to undefined here, so it won't generate it.
const checked: boolean | undefined = !event.target.checked && undefined;
i.state.saveUserSettingsForm.generate_totp_2fa = checked;
i.setState(i.state);
i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
}
handleSendNotificationsToEmailChange(i: Settings, event: any) {
i.state.saveUserSettingsForm.send_notifications_to_email =
event.target.checked;
i.setState(i.state);
i.setState(
s => (
(s.saveUserSettingsForm.send_notifications_to_email =
event.target.checked),
s
)
);
}
handleThemeChange(i: Settings, event: any) {
i.state.saveUserSettingsForm.theme = event.target.value;
i.setState(s => ((s.saveUserSettingsForm.theme = event.target.value), s));
setTheme(event.target.value, true);
i.setState(i.state);
}
handleInterfaceLangChange(i: Settings, event: any) {
i.state.saveUserSettingsForm.interface_language = event.target.value;
i.setState(
s => ((s.saveUserSettingsForm.interface_language = event.target.value), s)
);
i18n.changeLanguage(
getLanguages(i.state.saveUserSettingsForm.interface_language).at(0)
);
i.setState(i.state);
}
handleDiscussionLanguageChange(val: number[]) {
@ -1089,8 +1083,7 @@ export class Settings extends Component<any, SettingsState> {
}
handleEmailChange(i: Settings, event: any) {
i.state.saveUserSettingsForm.email = event.target.value;
i.setState(i.state);
i.setState(s => ((s.saveUserSettingsForm.email = event.target.value), s));
}
handleBioChange(val: string) {
@ -1114,90 +1107,100 @@ export class Settings extends Component<any, SettingsState> {
}
handleDisplayNameChange(i: Settings, event: any) {
i.state.saveUserSettingsForm.display_name = event.target.value;
i.setState(i.state);
i.setState(
s => ((s.saveUserSettingsForm.display_name = event.target.value), s)
);
}
handleMatrixUserIdChange(i: Settings, event: any) {
i.state.saveUserSettingsForm.matrix_user_id = event.target.value;
i.setState(i.state);
i.setState(
s => ((s.saveUserSettingsForm.matrix_user_id = event.target.value), s)
);
}
handleNewPasswordChange(i: Settings, event: any) {
i.state.changePasswordForm.new_password = event.target.value;
if (i.state.changePasswordForm.new_password == "") {
i.state.changePasswordForm.new_password = undefined;
}
i.setState(i.state);
const newPass: string | undefined =
event.target.value == "" ? undefined : event.target.value;
i.setState(s => ((s.changePasswordForm.new_password = newPass), s));
}
handleNewPasswordVerifyChange(i: Settings, event: any) {
i.state.changePasswordForm.new_password_verify = event.target.value;
if (i.state.changePasswordForm.new_password_verify == "") {
i.state.changePasswordForm.new_password_verify = undefined;
}
i.setState(i.state);
const newPassVerify: string | undefined =
event.target.value == "" ? undefined : event.target.value;
i.setState(
s => ((s.changePasswordForm.new_password_verify = newPassVerify), s)
);
}
handleOldPasswordChange(i: Settings, event: any) {
i.state.changePasswordForm.old_password = event.target.value;
if (i.state.changePasswordForm.old_password == "") {
i.state.changePasswordForm.old_password = undefined;
}
i.setState(i.state);
const oldPass: string | undefined =
event.target.value == "" ? undefined : event.target.value;
i.setState(s => ((s.changePasswordForm.old_password = oldPass), s));
}
handleSaveSettingsSubmit(i: Settings, event: any) {
async handleSaveSettingsSubmit(i: Settings, event: any) {
event.preventDefault();
i.setState({ saveUserSettingsLoading: true });
const auth = myAuth();
if (auth) {
const form: SaveUserSettings = { ...i.state.saveUserSettingsForm, auth };
WebSocketService.Instance.send(wsClient.saveUserSettings(form));
}
i.setState({ saveRes: { state: "loading" } });
const saveRes = await HttpService.client.saveUserSettings({
...i.state.saveUserSettingsForm,
auth: myAuthRequired(),
});
if (saveRes.state === "success") {
UserService.Instance.login(saveRes.data);
location.reload();
toast(i18n.t("saved"));
window.scrollTo(0, 0);
}
handleChangePasswordSubmit(i: Settings, event: any) {
i.setState({ saveRes });
}
async handleChangePasswordSubmit(i: Settings, event: any) {
event.preventDefault();
i.setState({ changePasswordLoading: true });
const auth = myAuth();
const pForm = i.state.changePasswordForm;
const new_password = pForm.new_password;
const new_password_verify = pForm.new_password_verify;
const old_password = pForm.old_password;
if (auth && new_password && old_password && new_password_verify) {
const form: ChangePassword = {
const { new_password, new_password_verify, old_password } =
i.state.changePasswordForm;
if (new_password && old_password && new_password_verify) {
i.setState({ changePasswordRes: { state: "loading" } });
const changePasswordRes = await HttpService.client.changePassword({
new_password,
new_password_verify,
old_password,
auth,
};
auth: myAuthRequired(),
});
if (changePasswordRes.state === "success") {
UserService.Instance.login(changePasswordRes.data);
window.scrollTo(0, 0);
toast(i18n.t("password_changed"));
}
WebSocketService.Instance.send(wsClient.changePassword(form));
i.setState({ changePasswordRes });
}
}
handleDeleteAccountShowConfirmToggle(i: Settings, event: any) {
event.preventDefault();
handleDeleteAccountShowConfirmToggle(i: Settings) {
i.setState({ deleteAccountShowConfirm: !i.state.deleteAccountShowConfirm });
}
handleDeleteAccountPasswordChange(i: Settings, event: any) {
i.state.deleteAccountForm.password = event.target.value;
i.setState(i.state);
i.setState(s => ((s.deleteAccountForm.password = event.target.value), s));
}
handleDeleteAccount(i: Settings, event: any) {
event.preventDefault();
i.setState({ deleteAccountLoading: true });
const auth = myAuth();
async handleDeleteAccount(i: Settings) {
const password = i.state.deleteAccountForm.password;
if (auth && password) {
const form: DeleteAccount = {
if (password) {
i.setState({ deleteAccountRes: { state: "loading" } });
const deleteAccountRes = await HttpService.client.deleteAccount({
password,
auth,
};
WebSocketService.Instance.send(wsClient.deleteAccount(form));
auth: myAuthRequired(),
});
if (deleteAccountRes.state === "success") {
UserService.Instance.logout();
this.context.router.history.replace("/");
}
i.setState({ deleteAccountRes });
}
}
@ -1205,44 +1208,19 @@ export class Settings extends Component<any, SettingsState> {
i.ctx.setState({ currentTab: i.tab });
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
this.setState({
saveUserSettingsLoading: false,
changePasswordLoading: false,
deleteAccountLoading: false,
});
toast(i18n.t(msg.error), "danger");
return;
} else if (op == UserOperation.SaveUserSettings) {
this.setState({ saveUserSettingsLoading: false });
toast(i18n.t("saved"));
window.scrollTo(0, 0);
} else if (op == UserOperation.ChangePassword) {
const data = wsJsonToRes<LoginResponse>(msg);
UserService.Instance.login(data);
this.setState({ changePasswordLoading: false });
window.scrollTo(0, 0);
toast(i18n.t("password_changed"));
} else if (op == UserOperation.DeleteAccount) {
this.setState({
deleteAccountLoading: false,
deleteAccountShowConfirm: false,
});
UserService.Instance.logout();
window.location.href = "/";
} else if (op == UserOperation.BlockPerson) {
const data = wsJsonToRes<BlockPersonResponse>(msg);
updatePersonBlock(data);
personBlock(res: RequestState<BlockPersonResponse>) {
if (res.state === "success") {
updatePersonBlock(res.data);
const mui = UserService.Instance.myUserInfo;
if (mui) {
this.setState({ personBlocks: mui.person_blocks });
}
} else if (op == UserOperation.BlockCommunity) {
const data = wsJsonToRes<BlockCommunityResponse>(msg);
updateCommunityBlock(data);
}
}
communityBlock(res: RequestState<BlockCommunityResponse>) {
if (res.state === "success") {
updateCommunityBlock(res.data);
const mui = UserService.Instance.myUserInfo;
if (mui) {
this.setState({ communityBlocks: mui.community_blocks });

View file

@ -1,56 +1,47 @@
import { Component } from "inferno";
import {
GetSiteResponse,
UserOperation,
VerifyEmail as VerifyEmailForm,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { GetSiteResponse, VerifyEmailResponse } from "lemmy-js-client";
import { i18n } from "../../i18next";
import { WebSocketService } from "../../services";
import {
isBrowser,
setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../../utils";
import { HttpService, RequestState } from "../../services/HttpService";
import { setIsoData, toast } from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
interface State {
verifyEmailForm: VerifyEmailForm;
verifyRes: RequestState<VerifyEmailResponse>;
siteRes: GetSiteResponse;
}
export class VerifyEmail extends Component<any, State> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: State = {
verifyEmailForm: {
token: this.props.match.params.token,
},
verifyRes: { state: "empty" },
siteRes: this.isoData.site_res,
};
constructor(props: any, context: any) {
super(props, context);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
}
componentDidMount() {
WebSocketService.Instance.send(
wsClient.verifyEmail(this.state.verifyEmailForm)
);
async verify() {
this.setState({
verifyRes: { state: "loading" },
});
this.setState({
verifyRes: await HttpService.client.verifyEmail({
token: this.props.match.params.token,
}),
});
if (this.state.verifyRes.state == "success") {
toast(i18n.t("email_verified"));
this.props.history.push("/login");
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
async componentDidMount() {
await this.verify();
}
get documentTitle(): string {
@ -69,26 +60,14 @@ export class VerifyEmail extends Component<any, State> {
<div className="row">
<div className="col-12 col-lg-6 offset-lg-3 mb-4">
<h5>{i18n.t("verify_email")}</h5>
{this.state.verifyRes.state == "loading" && (
<h5>
<Spinner large />
</h5>
)}
</div>
</div>
</div>
);
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
this.setState(this.state);
this.props.history.push("/");
return;
} else if (op == UserOperation.VerifyEmail) {
const data = wsJsonToRes(msg);
if (data) {
toast(i18n.t("email_verified"));
this.props.history.push("/login");
}
}
}
}

View file

@ -1,18 +1,19 @@
import { Component } from "inferno";
import { RouteComponentProps } from "inferno-router/dist/Route";
import {
CreatePost as CreatePostI,
GetCommunity,
GetCommunityResponse,
GetSiteResponse,
PostView,
UserOperation,
wsJsonToRes,
wsUserOp,
ListCommunitiesResponse,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { InitialFetchRequest, PostFormParams } from "../../interfaces";
import { WebSocketService } from "../../services";
import { FirstLoadService } from "../../services/FirstLoadService";
import {
HttpService,
RequestState,
WrappedLemmyHttp,
} from "../../services/HttpService";
import {
Choice,
QueryParams,
@ -20,12 +21,8 @@ import {
enableNsfw,
getIdFromString,
getQueryParams,
isBrowser,
myAuth,
setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
@ -41,10 +38,16 @@ function getCreatePostQueryParams() {
});
}
function fetchCommunitiesForOptions(client: WrappedLemmyHttp) {
return client.listCommunities({ limit: 30, sort: "TopMonth", type_: "All" });
}
interface CreatePostState {
siteRes: GetSiteResponse;
loading: boolean;
selectedCommunityChoice?: Choice;
initialCommunitiesRes: RequestState<ListCommunitiesResponse>;
isIsomorphic: boolean;
}
export class CreatePost extends Component<
@ -52,10 +55,11 @@ export class CreatePost extends Component<
CreatePostState
> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: CreatePostState = {
siteRes: this.isoData.site_res,
loading: true,
initialCommunitiesRes: { state: "empty" },
isIsomorphic: false,
};
constructor(props: RouteComponentProps<Record<string, never>>, context: any) {
@ -65,19 +69,14 @@ export class CreatePost extends Component<
this.handleSelectedCommunityChange =
this.handleSelectedCommunityChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
// Only fetch the data if coming from another route
if (this.isoData.path === this.context.router.route.match.url) {
const communityRes = this.isoData.routeData[0] as
| GetCommunityResponse
| undefined;
if (FirstLoadService.isFirstLoad) {
const [communityRes, listCommunitiesRes] = this.isoData.routeData;
if (communityRes) {
if (communityRes?.state === "success") {
const communityChoice: Choice = {
label: communityRes.community_view.community.title,
value: communityRes.community_view.community.id.toString(),
label: communityRes.data.community_view.community.title,
value: communityRes.data.community_view.community.id.toString(),
};
this.state = {
@ -89,31 +88,50 @@ export class CreatePost extends Component<
this.state = {
...this.state,
loading: false,
initialCommunitiesRes: listCommunitiesRes,
isIsomorphic: true,
};
} else {
this.fetchCommunity();
}
}
fetchCommunity() {
async fetchCommunity() {
const { communityId } = getCreatePostQueryParams();
const auth = myAuth(false);
const auth = myAuth();
if (communityId) {
const form: GetCommunity = {
const res = await HttpService.client.getCommunity({
id: communityId,
auth,
};
WebSocketService.Instance.send(wsClient.getCommunity(form));
});
if (res.state === "success") {
this.setState({
selectedCommunityChoice: {
label: res.data.community_view.community.name,
value: res.data.community_view.community.id.toString(),
},
loading: false,
});
}
}
}
componentDidMount(): void {
async componentDidMount() {
// TODO test this
if (!this.state.isIsomorphic) {
const { communityId } = getCreatePostQueryParams();
if (communityId?.toString() !== this.state.selectedCommunityChoice?.value) {
this.fetchCommunity();
const initialCommunitiesRes = await fetchCommunitiesForOptions(
HttpService.client
);
this.setState({
initialCommunitiesRes,
});
if (
communityId?.toString() !== this.state.selectedCommunityChoice?.value
) {
await this.fetchCommunity();
} else if (!communityId) {
this.setState({
selectedCommunityChoice: undefined,
@ -121,11 +139,6 @@ export class CreatePost extends Component<
});
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
}
get documentTitle(): string {
@ -164,6 +177,11 @@ export class CreatePost extends Component<
siteLanguages={this.state.siteRes.discussion_languages}
selectedCommunityChoice={selectedCommunityChoice}
onSelectCommunity={this.handleSelectedCommunityChange}
initialCommunities={
this.state.initialCommunitiesRes.state === "success"
? this.state.initialCommunitiesRes.data.communities
: []
}
/>
</div>
</div>
@ -172,7 +190,7 @@ export class CreatePost extends Component<
);
}
updateUrl({ communityId }: Partial<CreatePostProps>) {
async updateUrl({ communityId }: Partial<CreatePostProps>) {
const { communityId: urlCommunityId } = getCreatePostQueryParams();
const locationState = this.props.history.location.state as
@ -191,7 +209,7 @@ export class CreatePost extends Component<
history.replaceState(locationState, "", url);
this.fetchCommunity();
await this.fetchCommunity();
}
handleSelectedCommunityChange(choice: Choice) {
@ -200,16 +218,23 @@ export class CreatePost extends Component<
});
}
handlePostCreate(post_view: PostView) {
this.props.history.replace(`/post/${post_view.post.id}`);
async handlePostCreate(form: CreatePostI) {
const res = await HttpService.client.createPost(form);
if (res.state === "success") {
const postId = res.data.post_view.post.id;
this.props.history.replace(`/post/${postId}`);
}
}
static fetchInitialData({
client,
query: { communityId },
auth,
}: InitialFetchRequest<QueryParams<CreatePostProps>>): Promise<any>[] {
const promises: Promise<any>[] = [];
}: InitialFetchRequest<QueryParams<CreatePostProps>>): Promise<
RequestState<any>
>[] {
const promises: Promise<RequestState<any>>[] = [];
if (communityId) {
const form: GetCommunity = {
@ -219,31 +244,11 @@ export class CreatePost extends Component<
promises.push(client.getCommunity(form));
} else {
promises.push(Promise.resolve());
promises.push(Promise.resolve({ state: "empty" }));
}
promises.push(fetchCommunitiesForOptions(client));
return promises;
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
return;
}
if (op === UserOperation.GetCommunity) {
const {
community_view: {
community: { title, id },
},
} = wsJsonToRes<GetCommunityResponse>(msg);
this.setState({
selectedCommunityChoice: { label: title, value: id.toString() },
loading: false,
});
}
}
}

View file

@ -1,21 +1,18 @@
import autosize from "autosize";
import { Component, linkEvent } from "inferno";
import { Component, InfernoNode, linkEvent } from "inferno";
import {
CommunityView,
CreatePost,
EditPost,
GetSiteMetadataResponse,
Language,
PostResponse,
PostView,
Search,
SearchResponse,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { PostFormParams } from "../../interfaces";
import { UserService, WebSocketService } from "../../services";
import { UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import {
Choice,
archiveTodayUrl,
@ -24,21 +21,17 @@ import {
debounce,
fetchCommunities,
getIdFromString,
getSiteMetadata,
ghostArchiveUrl,
isImage,
myAuth,
pictrsDeleteToast,
myAuthRequired,
relTags,
setupTippy,
toast,
trendingFetchLimit,
uploadImage,
validTitle,
validURL,
webArchiveUrl,
wsClient,
wsSubscribe,
} from "../../utils";
import { Icon, Spinner } from "../common/icon";
import { LanguageSelect } from "../common/language-select";
@ -51,16 +44,18 @@ const MAX_POST_TITLE_LENGTH = 200;
interface PostFormProps {
post_view?: PostView; // If a post is given, that means this is an edit
crossPosts?: PostView[];
allLanguages: Language[];
siteLanguages: number[];
params?: PostFormParams;
onCancel?(): any;
onCreate?(post: PostView): any;
onEdit?(post: PostView): any;
onCancel?(): void;
onCreate?(form: CreatePost): void;
onEdit?(form: EditPost): void;
enableNsfw?: boolean;
enableDownvotes?: boolean;
selectedCommunityChoice?: Choice;
onSelectCommunity?: (choice: Choice) => void;
initialCommunities?: CommunityView[];
}
interface PostFormState {
@ -73,25 +68,29 @@ interface PostFormState {
community_id?: number;
honeypot?: string;
};
suggestedTitle?: string;
suggestedPosts?: PostView[];
crossPosts?: PostView[];
loading: boolean;
suggestedPostsRes: RequestState<SearchResponse>;
metadataRes: RequestState<GetSiteMetadataResponse>;
imageLoading: boolean;
imageDeleteUrl: string;
communitySearchLoading: boolean;
communitySearchOptions: Choice[];
previewMode: boolean;
submitted: boolean;
}
export class PostForm extends Component<PostFormProps, PostFormState> {
private subscription?: Subscription;
state: PostFormState = {
suggestedPostsRes: { state: "empty" },
metadataRes: { state: "empty" },
form: {},
loading: false,
imageLoading: false,
imageDeleteUrl: "",
communitySearchLoading: false,
previewMode: false,
communitySearchOptions: [],
submitted: false,
};
constructor(props: PostFormProps, context: any) {
@ -102,39 +101,52 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.handleLanguageChange = this.handleLanguageChange.bind(this);
this.handleCommunitySelect = this.handleCommunitySelect.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
const { post_view, selectedCommunityChoice, params } = this.props;
// Means its an edit
const pv = this.props.post_view;
if (pv) {
if (post_view) {
this.state = {
...this.state,
form: {
body: pv.post.body,
name: pv.post.name,
community_id: pv.community.id,
url: pv.post.url,
nsfw: pv.post.nsfw,
language_id: pv.post.language_id,
body: post_view.post.body,
name: post_view.post.name,
community_id: post_view.community.id,
url: post_view.post.url,
nsfw: post_view.post.nsfw,
language_id: post_view.post.language_id,
},
};
}
const selectedCommunityChoice = this.props.selectedCommunityChoice;
if (selectedCommunityChoice) {
} else if (selectedCommunityChoice) {
this.state = {
...this.state,
form: {
...this.state.form,
community_id: getIdFromString(selectedCommunityChoice.value),
},
communitySearchOptions: [selectedCommunityChoice],
communitySearchOptions: [selectedCommunityChoice]
.concat(
this.props.initialCommunities?.map(
({ community: { id, title } }) => ({
label: title,
value: id.toString(),
})
) ?? []
)
.filter(option => option.value !== selectedCommunityChoice.value),
};
} else {
this.state = {
...this.state,
communitySearchOptions:
this.props.initialCommunities?.map(
({ community: { id, title } }) => ({
label: title,
value: id.toString(),
})
) ?? [],
};
}
const params = this.props.params;
if (params) {
this.state = {
...this.state,
@ -155,54 +167,42 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
}
}
componentDidUpdate() {
if (
!this.state.loading &&
(this.state.form.name || this.state.form.url || this.state.form.body)
) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = null;
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & PostFormProps>
): void {
if (this.props != nextProps) {
this.setState(
s => (
(s.form.community_id = getIdFromString(
nextProps.selectedCommunityChoice?.value
)),
s
)
);
}
}
componentWillUnmount() {
this.subscription?.unsubscribe();
/* this.choices && this.choices.destroy(); */
window.onbeforeunload = null;
}
static getDerivedStateFromProps(
{ selectedCommunityChoice }: PostFormProps,
{ form, ...restState }: PostFormState
) {
return {
...restState,
form: {
...form,
community_id: getIdFromString(selectedCommunityChoice?.value),
},
};
}
render() {
const firstLang = this.state.form.language_id;
const selectedLangs = firstLang ? Array.of(firstLang) : undefined;
const url = this.state.form.url;
// TODO
// const promptCheck =
// !!this.state.form.name || !!this.state.form.url || !!this.state.form.body;
// <Prompt when={promptCheck} message={i18n.t("block_leaving")} />
return (
<div>
<form onSubmit={linkEvent(this, this.handlePostSubmit)}>
<NavigationPrompt
when={
!this.state.loading &&
!!(
this.state.form.name ||
this.state.form.url ||
this.state.form.body
)
) && !this.state.submitted
}
/>
<form onSubmit={linkEvent(this, this.handlePostSubmit)}>
<div className="form-group row">
<label className="col-sm-2 col-form-label" htmlFor="post-url">
{i18n.t("url")}
@ -216,16 +216,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
onInput={linkEvent(this, this.handlePostUrlChange)}
onPaste={linkEvent(this, this.handleImageUploadPaste)}
/>
{this.state.suggestedTitle && (
<div
className="mt-1 text-muted small font-weight-bold pointer"
role="button"
onClick={linkEvent(this, this.copySuggestedTitle)}
>
{i18n.t("copy_suggested_title", { title: "" })}{" "}
{this.state.suggestedTitle}
</div>
)}
{this.renderSuggestedTitleCopy()}
<form>
<label
htmlFor="file-upload"
@ -279,18 +270,47 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
{url && isImage(url) && (
<img src={url} className="img-fluid" alt="" />
)}
{this.state.crossPosts && this.state.crossPosts.length > 0 && (
{this.state.imageDeleteUrl && (
<button
className="btn btn-danger btn-sm mt-2"
onClick={linkEvent(this, this.handleImageDelete)}
aria-label={i18n.t("delete")}
data-tippy-content={i18n.t("delete")}
>
<Icon icon="x" classes="icon-inline mr-1" />
{capitalizeFirstLetter(i18n.t("delete"))}
</button>
)}
{this.props.crossPosts && this.props.crossPosts.length > 0 && (
<>
<div className="my-1 text-muted small font-weight-bold">
{i18n.t("cross_posts")}
</div>
<PostListings
showCommunity
posts={this.state.crossPosts}
posts={this.props.crossPosts}
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
viewOnly
// All of these are unused, since its view only
onPostEdit={() => {}}
onPostVote={() => {}}
onPostReport={() => {}}
onBlockPerson={() => {}}
onLockPost={() => {}}
onDeletePost={() => {}}
onRemovePost={() => {}}
onSavePost={() => {}}
onFeaturePost={() => {}}
onPurgePerson={() => {}}
onPurgePost={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
/>
</>
)}
@ -318,22 +338,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
{i18n.t("invalid_post_title")}
</div>
)}
{this.state.suggestedPosts &&
this.state.suggestedPosts.length > 0 && (
<>
<div className="my-1 text-muted small font-weight-bold">
{i18n.t("related_posts")}
</div>
<PostListings
showCommunity
posts={this.state.suggestedPosts}
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
/>
</>
)}
{this.renderSuggestedPosts()}
</div>
</div>
@ -345,15 +350,13 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
onContentChange={this.handlePostBodyChange}
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
hideNavigationWarnings
/>
</div>
</div>
{!this.props.post_view && (
<div className="form-group row">
<label
className="col-sm-2 col-form-label"
htmlFor="post-community"
>
<label className="col-sm-2 col-form-label" htmlFor="post-community">
{i18n.t("community")}
</label>
<div className="col-sm-10">
@ -436,26 +439,95 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
</div>
</div>
</form>
</div>
);
}
renderSuggestedTitleCopy() {
switch (this.state.metadataRes.state) {
case "loading":
return <Spinner />;
case "success": {
const suggestedTitle = this.state.metadataRes.data.metadata.title;
return (
suggestedTitle && (
<div
className="mt-1 text-muted small font-weight-bold pointer"
role="button"
onClick={linkEvent(
{ i: this, suggestedTitle },
this.copySuggestedTitle
)}
>
{i18n.t("copy_suggested_title", { title: "" })} {suggestedTitle}
</div>
)
);
}
}
}
renderSuggestedPosts() {
switch (this.state.suggestedPostsRes.state) {
case "loading":
return <Spinner />;
case "success": {
const suggestedPosts = this.state.suggestedPostsRes.data.posts;
return (
suggestedPosts &&
suggestedPosts.length > 0 && (
<>
<div className="my-1 text-muted small font-weight-bold">
{i18n.t("related_posts")}
</div>
<PostListings
showCommunity
posts={suggestedPosts}
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
viewOnly
// All of these are unused, since its view only
onPostEdit={() => {}}
onPostVote={() => {}}
onPostReport={() => {}}
onBlockPerson={() => {}}
onLockPost={() => {}}
onDeletePost={() => {}}
onRemovePost={() => {}}
onSavePost={() => {}}
onFeaturePost={() => {}}
onPurgePerson={() => {}}
onPurgePost={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
/>
</>
)
);
}
}
}
handlePostSubmit(i: PostForm, event: any) {
event.preventDefault();
i.setState({ loading: true });
// Coerce empty url string to undefined
if ((i.state.form.url ?? "blank") === "") {
if ((i.state.form.url ?? "") === "") {
i.setState(s => ((s.form.url = undefined), s));
}
i.setState({ loading: true, submitted: true });
const auth = myAuthRequired();
const pForm = i.state.form;
const pv = i.props.post_view;
const auth = myAuth();
if (auth) {
if (pv) {
const form: EditPost = {
i.props.onEdit?.({
name: pForm.name,
url: pForm.url,
body: pForm.body,
@ -463,11 +535,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
post_id: pv.post.id,
language_id: pForm.language_id,
auth,
};
WebSocketService.Instance.send(wsClient.editPost(form));
} else {
if (pForm.name && pForm.community_id) {
const form: CreatePost = {
});
} else if (pForm.name && pForm.community_id) {
i.props.onCreate?.({
name: pForm.name,
community_id: pForm.community_id,
url: pForm.url,
@ -476,20 +546,17 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
language_id: pForm.language_id,
honeypot: pForm.honeypot,
auth,
};
WebSocketService.Instance.send(wsClient.createPost(form));
}
}
});
}
}
copySuggestedTitle(i: PostForm) {
const sTitle = i.state.suggestedTitle;
copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
const sTitle = d.suggestedTitle;
if (sTitle) {
i.setState(
d.i.setState(
s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
);
i.setState({ suggestedTitle: undefined });
d.i.setState({ suggestedPostsRes: { state: "empty" } });
setTimeout(() => {
const textarea: any = document.getElementById("post-title");
autosize.update(textarea);
@ -498,31 +565,25 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
}
handlePostUrlChange(i: PostForm, event: any) {
i.setState(s => ((s.form.url = event.target.value), s));
const url = event.target.value;
i.setState({
form: {
url,
},
imageDeleteUrl: "",
});
i.fetchPageTitle();
}
fetchPageTitle() {
async fetchPageTitle() {
const url = this.state.form.url;
if (url && validURL(url)) {
const form: Search = {
q: url,
type_: "Url",
sort: "TopAll",
listing_type: "All",
page: 1,
limit: trendingFetchLimit,
auth: myAuth(false),
};
WebSocketService.Instance.send(wsClient.search(form));
// Fetch the page title
getSiteMetadata(url).then(d => {
this.setState({ suggestedTitle: d.metadata.title });
this.setState({ metadataRes: { state: "loading" } });
this.setState({
metadataRes: await HttpService.client.getSiteMetadata({ url }),
});
} else {
this.setState({ suggestedTitle: undefined, crossPosts: undefined });
}
}
@ -531,10 +592,12 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
i.fetchSimilarPosts();
}
fetchSimilarPosts() {
async fetchSimilarPosts() {
const q = this.state.form.name;
if (q && q !== "") {
const form: Search = {
this.setState({ suggestedPostsRes: { state: "loading" } });
this.setState({
suggestedPostsRes: await HttpService.client.search({
q,
type_: "Posts",
sort: "TopAll",
@ -542,12 +605,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
community_id: this.state.form.community_id,
page: 1,
limit: trendingFetchLimit,
auth: myAuth(false),
};
WebSocketService.Instance.send(wsClient.search(form));
} else {
this.setState({ suggestedPosts: undefined });
auth: myAuth(),
}),
});
}
}
@ -598,23 +658,38 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
i.setState({ imageLoading: true });
uploadImage(file)
.then(res => {
HttpService.client.uploadImage({ image: file }).then(res => {
console.log("pictrs upload:");
console.log(res);
if (res.msg === "ok") {
i.state.form.url = res.url;
i.setState({ imageLoading: false });
pictrsDeleteToast(file.name, res.delete_url as string);
if (res.state === "success") {
if (res.data.msg === "ok") {
i.state.form.url = res.data.url;
i.setState({
imageLoading: false,
imageDeleteUrl: res.data.delete_url as string,
});
} else {
i.setState({ imageLoading: false });
toast(JSON.stringify(res), "danger");
}
})
.catch(error => {
} else if (res.state === "failed") {
console.error(res.msg);
toast(res.msg, "danger");
i.setState({ imageLoading: false });
console.error(error);
toast(error, "danger");
}
});
}
handleImageDelete(i: PostForm) {
const { imageDeleteUrl } = i.state;
fetch(imageDeleteUrl);
i.setState({
imageDeleteUrl: "",
imageLoading: false,
form: {
url: "",
},
});
}
@ -629,9 +704,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
}
if (text.length > 0) {
newOptions.push(
...(await fetchCommunities(text)).communities.map(communityToChoice)
);
newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
this.setState({
communitySearchOptions: newOptions,
@ -648,35 +721,4 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.props.onSelectCommunity(choice);
}
}
parseMessage(msg: any) {
const mui = UserService.Instance.myUserInfo;
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
// Errors handled by top level pages
// toast(i18n.t(msg.error), "danger");
this.setState({ loading: false });
return;
} else if (op == UserOperation.CreatePost) {
const data = wsJsonToRes<PostResponse>(msg);
if (data.post_view.creator.id == mui?.local_user_view.person.id) {
this.props.onCreate?.(data.post_view);
}
} else if (op == UserOperation.EditPost) {
const data = wsJsonToRes<PostResponse>(msg);
if (data.post_view.creator.id == mui?.local_user_view.person.id) {
this.setState({ loading: false });
this.props.onEdit?.(data.post_view);
}
} else if (op == UserOperation.Search) {
const data = wsJsonToRes<SearchResponse>(msg);
if (data.type_ == "Posts") {
this.setState({ suggestedPosts: data.posts });
} else if (data.type_ == "Url") {
this.setState({ crossPosts: data.posts });
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,26 @@
import { Component } from "inferno";
import { T } from "inferno-i18next-dess";
import { Link } from "inferno-router";
import { Language, PostView } from "lemmy-js-client";
import {
AddAdmin,
AddModToCommunity,
BanFromCommunity,
BanPerson,
BlockPerson,
CreatePostLike,
CreatePostReport,
DeletePost,
EditPost,
FeaturePost,
Language,
LockPost,
PostView,
PurgePerson,
PurgePost,
RemovePost,
SavePost,
TransferCommunity,
} from "lemmy-js-client";
import { i18n } from "../../i18next";
import { PostListing } from "./post-listing";
@ -13,6 +32,23 @@ interface PostListingsProps {
removeDuplicates?: boolean;
enableDownvotes?: boolean;
enableNsfw?: boolean;
viewOnly?: boolean;
onPostEdit(form: EditPost): void;
onPostVote(form: CreatePostLike): void;
onPostReport(form: CreatePostReport): void;
onBlockPerson(form: BlockPerson): void;
onLockPost(form: LockPost): void;
onDeletePost(form: DeletePost): void;
onRemovePost(form: RemovePost): void;
onSavePost(form: SavePost): void;
onFeaturePost(form: FeaturePost): void;
onPurgePerson(form: PurgePerson): void;
onPurgePost(form: PurgePost): void;
onBanPersonFromCommunity(form: BanFromCommunity): void;
onBanPerson(form: BanPerson): void;
onAddModToCommunity(form: AddModToCommunity): void;
onAddAdmin(form: AddAdmin): void;
onTransferCommunity(form: TransferCommunity): void;
}
export class PostListings extends Component<PostListingsProps, any> {
@ -36,12 +72,29 @@ export class PostListings extends Component<PostListingsProps, any> {
<>
<PostListing
post_view={post_view}
duplicates={this.duplicatesMap.get(post_view.post.id)}
crossPosts={this.duplicatesMap.get(post_view.post.id)}
showCommunity={this.props.showCommunity}
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
viewOnly={this.props.viewOnly}
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
onPostEdit={this.props.onPostEdit}
onPostVote={this.props.onPostVote}
onPostReport={this.props.onPostReport}
onBlockPerson={this.props.onBlockPerson}
onLockPost={this.props.onLockPost}
onDeletePost={this.props.onDeletePost}
onRemovePost={this.props.onRemovePost}
onSavePost={this.props.onSavePost}
onFeaturePost={this.props.onFeaturePost}
onPurgePerson={this.props.onPurgePerson}
onPurgePost={this.props.onPurgePost}
onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
onBanPerson={this.props.onBanPerson}
onAddModToCommunity={this.props.onAddModToCommunity}
onAddAdmin={this.props.onAddAdmin}
onTransferCommunity={this.props.onTransferCommunity}
/>
<hr className="my-3" />
</>
@ -62,7 +115,7 @@ export class PostListings extends Component<PostListingsProps, any> {
removeDuplicates(): PostView[] {
// Must use a spread to clone the props, because splice will fail below otherwise.
const posts = [...this.props.posts];
const posts = [...this.props.posts].filter(empty => empty);
// A map from post url to list of posts (dupes)
const urlMap = new Map<string, PostView[]>();

View file

@ -1,22 +1,38 @@
import { Component, linkEvent } from "inferno";
import { Component, InfernoNode, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess";
import { PostReportView, PostView, ResolvePostReport } from "lemmy-js-client";
import { i18n } from "../../i18next";
import { WebSocketService } from "../../services";
import { myAuth, wsClient } from "../../utils";
import { Icon } from "../common/icon";
import { myAuthRequired } from "../../utils";
import { Icon, Spinner } from "../common/icon";
import { PersonListing } from "../person/person-listing";
import { PostListing } from "./post-listing";
interface PostReportProps {
report: PostReportView;
onResolveReport(form: ResolvePostReport): void;
}
export class PostReport extends Component<PostReportProps, any> {
interface PostReportState {
loading: boolean;
}
export class PostReport extends Component<PostReportProps, PostReportState> {
state: PostReportState = {
loading: false,
};
constructor(props: any, context: any) {
super(props, context);
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & PostReportProps>
): void {
if (this.props != nextProps) {
this.setState({ loading: false });
}
}
render() {
const r = this.props.report;
const resolver = r.resolver;
@ -54,6 +70,23 @@ export class PostReport extends Component<PostReportProps, any> {
allLanguages={[]}
siteLanguages={[]}
hideImage
// All of these are unused, since its view only
onPostEdit={() => {}}
onPostVote={() => {}}
onPostReport={() => {}}
onBlockPerson={() => {}}
onLockPost={() => {}}
onDeletePost={() => {}}
onRemovePost={() => {}}
onSavePost={() => {}}
onFeaturePost={() => {}}
onPurgePerson={() => {}}
onPurgePost={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
/>
<div>
{i18n.t("reporter")}: <PersonListing person={r.creator} />
@ -82,26 +115,27 @@ export class PostReport extends Component<PostReportProps, any> {
data-tippy-content={tippyContent}
aria-label={tippyContent}
>
{this.state.loading ? (
<Spinner />
) : (
<Icon
icon="check"
classes={`icon-inline ${
r.post_report.resolved ? "text-success" : "text-danger"
}`}
/>
)}
</button>
</div>
);
}
handleResolveReport(i: PostReport) {
const auth = myAuth();
if (auth) {
const form: ResolvePostReport = {
i.setState({ loading: true });
i.props.onResolveReport({
report_id: i.props.report.post_report.id,
resolved: !i.props.report.post_report.resolved,
auth,
};
WebSocketService.Instance.send(wsClient.resolvePostReport(form));
}
auth: myAuthRequired(),
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,19 @@
import { Component } from "inferno";
import {
CreatePrivateMessage as CreatePrivateMessageI,
GetPersonDetails,
GetPersonDetailsResponse,
GetSiteResponse,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { InitialFetchRequest } from "../../interfaces";
import { WebSocketService } from "../../services";
import { FirstLoadService } from "../../services/FirstLoadService";
import { HttpService, RequestState } from "../../services/HttpService";
import {
getRecipientIdFromProps,
isBrowser,
myAuth,
setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../../utils";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
@ -26,9 +21,9 @@ import { PrivateMessageForm } from "./private-message-form";
interface CreatePrivateMessageState {
siteRes: GetSiteResponse;
recipientDetailsRes?: GetPersonDetailsResponse;
recipient_id: number;
loading: boolean;
recipientRes: RequestState<GetPersonDetailsResponse>;
recipientId: number;
isIsomorphic: boolean;
}
export class CreatePrivateMessage extends Component<
@ -36,11 +31,11 @@ export class CreatePrivateMessage extends Component<
CreatePrivateMessageState
> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: CreatePrivateMessageState = {
siteRes: this.isoData.site_res,
recipient_id: getRecipientIdFromProps(this.props),
loading: true,
recipientRes: { state: "empty" },
recipientId: getRecipientIdFromProps(this.props),
isIsomorphic: false,
};
constructor(props: any, context: any) {
@ -48,33 +43,40 @@ export class CreatePrivateMessage extends Component<
this.handlePrivateMessageCreate =
this.handlePrivateMessageCreate.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
// Only fetch the data if coming from another route
if (this.isoData.path == this.context.router.route.match.url) {
if (FirstLoadService.isFirstLoad) {
this.state = {
...this.state,
recipientDetailsRes: this.isoData
.routeData[0] as GetPersonDetailsResponse,
loading: false,
recipientRes: this.isoData.routeData[0],
isIsomorphic: true,
};
} else {
this.fetchPersonDetails();
}
}
fetchPersonDetails() {
const form: GetPersonDetails = {
person_id: this.state.recipient_id,
async componentDidMount() {
if (!this.state.isIsomorphic) {
await this.fetchPersonDetails();
}
}
async fetchPersonDetails() {
this.setState({
recipientRes: { state: "loading" },
});
this.setState({
recipientRes: await HttpService.client.getPersonDetails({
person_id: this.state.recipientId,
sort: "New",
saved_only: false,
auth: myAuth(false),
};
WebSocketService.Instance.send(wsClient.getPersonDetails(form));
auth: myAuth(),
}),
});
}
static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
static fetchInitialData(
req: InitialFetchRequest
): Promise<RequestState<any>>[] {
const person_id = Number(req.path.split("/").pop());
const form: GetPersonDetails = {
person_id,
@ -86,30 +88,25 @@ export class CreatePrivateMessage extends Component<
}
get documentTitle(): string {
const name_ = this.state.recipientDetailsRes?.person_view.person.name;
return name_ ? `${i18n.t("create_private_message")} - ${name_}` : "";
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
if (this.state.recipientRes.state == "success") {
const name_ = this.state.recipientRes.data.person_view.person.name;
return `${i18n.t("create_private_message")} - ${name_}`;
} else {
return "";
}
}
render() {
const res = this.state.recipientDetailsRes;
renderRecipientRes() {
switch (this.state.recipientRes.state) {
case "loading":
return (
<div className="container-lg">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
{this.state.loading ? (
<h5>
<Spinner large />
</h5>
) : (
res && (
);
case "success": {
const res = this.state.recipientRes.data;
return (
<div className="row">
<div className="col-12 col-lg-6 offset-lg-3 mb-4">
<h5>{i18n.t("create_private_message")}</h5>
@ -119,29 +116,31 @@ export class CreatePrivateMessage extends Component<
/>
</div>
</div>
)
)}
);
}
}
}
render() {
return (
<div className="container-lg">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
{this.renderRecipientRes()}
</div>
);
}
handlePrivateMessageCreate() {
async handlePrivateMessageCreate(form: CreatePrivateMessageI) {
const res = await HttpService.client.createPrivateMessage(form);
if (res.state == "success") {
toast(i18n.t("message_sent"));
// Navigate to the front
this.context.router.history.push("/");
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
this.setState({ loading: false });
return;
} else if (op == UserOperation.GetPersonDetails) {
const data = wsJsonToRes<GetPersonDetailsResponse>(msg);
this.setState({ recipientDetailsRes: data, loading: false });
}
}
}

View file

@ -1,27 +1,17 @@
import { Component, linkEvent } from "inferno";
import { Component, InfernoNode, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess";
import {
CreatePrivateMessage,
EditPrivateMessage,
Person,
PrivateMessageResponse,
PrivateMessageView,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { WebSocketService } from "../../services";
import {
capitalizeFirstLetter,
isBrowser,
myAuth,
myAuthRequired,
relTags,
setupTippy,
toast,
wsClient,
wsSubscribe,
} from "../../utils";
import { Icon, Spinner } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea";
@ -32,8 +22,8 @@ interface PrivateMessageFormProps {
recipient: Person;
privateMessageView?: PrivateMessageView; // If a pm is given, that means this is an edit
onCancel?(): any;
onCreate?(message: PrivateMessageView): any;
onEdit?(message: PrivateMessageView): any;
onCreate?(form: CreatePrivateMessage): void;
onEdit?(form: EditPrivateMessage): void;
}
interface PrivateMessageFormState {
@ -41,58 +31,54 @@ interface PrivateMessageFormState {
loading: boolean;
previewMode: boolean;
showDisclaimer: boolean;
submitted: boolean;
}
export class PrivateMessageForm extends Component<
PrivateMessageFormProps,
PrivateMessageFormState
> {
private subscription?: Subscription;
state: PrivateMessageFormState = {
loading: false,
previewMode: false,
showDisclaimer: false,
content: this.props.privateMessageView
? this.props.privateMessageView.private_message.content
: undefined,
submitted: false,
};
constructor(props: any, context: any) {
super(props, context);
this.handleContentChange = this.handleContentChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
// Its an edit
if (this.props.privateMessageView) {
this.state.content =
this.props.privateMessageView.private_message.content;
}
}
componentDidMount() {
setupTippy();
}
componentDidUpdate() {
if (!this.state.loading && this.state.content) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = null;
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
window.onbeforeunload = null;
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & PrivateMessageFormProps>
): void {
if (this.props != nextProps) {
this.setState({ loading: false, content: undefined, previewMode: false });
}
}
// TODO
// <Prompt
// when={!this.state.loading && this.state.content}
// message={i18n.t("block_leaving")}
// />
render() {
return (
<div>
<NavigationPrompt when={!this.state.loading && !!this.state.content} />
<form onSubmit={linkEvent(this, this.handlePrivateMessageSubmit)}>
<NavigationPrompt
when={
!this.state.loading && !!this.state.content && !this.state.submitted
}
/>
{!this.props.privateMessageView && (
<div className="form-group row">
<label className="col-sm-2 col-form-label">
@ -122,6 +108,7 @@ export class PrivateMessageForm extends Component<
onContentChange={this.handleContentChange}
allLanguages={[]}
siteLanguages={[]}
hideNavigationWarnings
/>
</div>
</div>
@ -174,32 +161,27 @@ export class PrivateMessageForm extends Component<
</div>
</div>
</form>
</div>
);
}
handlePrivateMessageSubmit(i: PrivateMessageForm, event: any) {
event.preventDefault();
i.setState({ loading: true, submitted: true });
const pm = i.props.privateMessageView;
const auth = myAuth();
const content = i.state.content;
if (auth && content) {
const auth = myAuthRequired();
const content = i.state.content ?? "";
if (pm) {
const form: EditPrivateMessage = {
i.props.onEdit?.({
private_message_id: pm.private_message.id,
content,
auth,
};
WebSocketService.Instance.send(wsClient.editPrivateMessage(form));
});
} else {
const form: CreatePrivateMessage = {
i.props.onCreate?.({
content,
recipient_id: i.props.recipient.id,
auth,
};
WebSocketService.Instance.send(wsClient.createPrivateMessage(form));
}
i.setState({ loading: true });
});
}
}
@ -219,25 +201,4 @@ export class PrivateMessageForm extends Component<
handleShowDisclaimer(i: PrivateMessageForm) {
i.setState({ showDisclaimer: !i.state.showDisclaimer });
}
parseMessage(msg: any) {
const op = wsUserOp(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
this.setState({ loading: false });
return;
} else if (
op == UserOperation.EditPrivateMessage ||
op == UserOperation.DeletePrivateMessage ||
op == UserOperation.MarkPrivateMessageAsRead
) {
const data = wsJsonToRes<PrivateMessageResponse>(msg);
this.setState({ loading: false });
this.props.onEdit?.(data.private_message_view);
} else if (op == UserOperation.CreatePrivateMessage) {
const data = wsJsonToRes<PrivateMessageResponse>(msg);
this.props.onCreate?.(data.private_message_view);
}
}
}

View file

@ -1,24 +1,40 @@
import { Component, linkEvent } from "inferno";
import { Component, InfernoNode, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess";
import {
PrivateMessageReportView,
ResolvePrivateMessageReport,
} from "lemmy-js-client";
import { i18n } from "../../i18next";
import { WebSocketService } from "../../services";
import { mdToHtml, myAuth, wsClient } from "../../utils";
import { Icon } from "../common/icon";
import { mdToHtml, myAuthRequired } from "../../utils";
import { Icon, Spinner } from "../common/icon";
import { PersonListing } from "../person/person-listing";
interface Props {
report: PrivateMessageReportView;
onResolveReport(form: ResolvePrivateMessageReport): void;
}
export class PrivateMessageReport extends Component<Props, any> {
interface State {
loading: boolean;
}
export class PrivateMessageReport extends Component<Props, State> {
state: State = {
loading: false,
};
constructor(props: any, context: any) {
super(props, context);
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & Props>
): void {
if (this.props != nextProps) {
this.setState({ loading: false });
}
}
render() {
const r = this.props.report;
const pmr = r.private_message_report;
@ -66,29 +82,28 @@ export class PrivateMessageReport extends Component<Props, any> {
data-tippy-content={tippyContent}
aria-label={tippyContent}
>
{this.state.loading ? (
<Spinner />
) : (
<Icon
icon="check"
classes={`icon-inline ${
pmr.resolved ? "text-success" : "text-danger"
}`}
/>
)}
</button>
</div>
);
}
handleResolveReport(i: PrivateMessageReport) {
i.setState({ loading: true });
const pmr = i.props.report.private_message_report;
const auth = myAuth();
if (auth) {
const form: ResolvePrivateMessageReport = {
i.props.onResolveReport({
report_id: pmr.id,
resolved: !pmr.resolved,
auth,
};
WebSocketService.Instance.send(
wsClient.resolvePrivateMessageReport(form)
);
}
auth: myAuthRequired(),
});
}
}

View file

@ -1,15 +1,17 @@
import { Component, linkEvent } from "inferno";
import { Component, InfernoNode, linkEvent } from "inferno";
import {
CreatePrivateMessage,
CreatePrivateMessageReport,
DeletePrivateMessage,
EditPrivateMessage,
MarkPrivateMessageAsRead,
Person,
PrivateMessageView,
} from "lemmy-js-client";
import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services";
import { mdToHtml, myAuth, toast, wsClient } from "../../utils";
import { Icon } from "../common/icon";
import { UserService } from "../../services";
import { mdToHtml, myAuthRequired } from "../../utils";
import { Icon, Spinner } from "../common/icon";
import { MomentTime } from "../common/moment-time";
import { PersonListing } from "../person/person-listing";
import { PrivateMessageForm } from "./private-message-form";
@ -21,10 +23,18 @@ interface PrivateMessageState {
viewSource: boolean;
showReportDialog: boolean;
reportReason?: string;
deleteLoading: boolean;
readLoading: boolean;
reportLoading: boolean;
}
interface PrivateMessageProps {
private_message_view: PrivateMessageView;
onDelete(form: DeletePrivateMessage): void;
onMarkRead(form: MarkPrivateMessageAsRead): void;
onReport(form: CreatePrivateMessageReport): void;
onCreate(form: CreatePrivateMessage): void;
onEdit(form: EditPrivateMessage): void;
}
export class PrivateMessage extends Component<
@ -37,15 +47,14 @@ export class PrivateMessage extends Component<
collapsed: false,
viewSource: false,
showReportDialog: false,
deleteLoading: false,
readLoading: false,
reportLoading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.handleReplyCancel = this.handleReplyCancel.bind(this);
this.handlePrivateMessageCreate =
this.handlePrivateMessageCreate.bind(this);
this.handlePrivateMessageEdit = this.handlePrivateMessageEdit.bind(this);
}
get mine(): boolean {
@ -55,6 +64,23 @@ export class PrivateMessage extends Component<
);
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & PrivateMessageProps>
): void {
if (this.props != nextProps) {
this.setState({
showReply: false,
showEdit: false,
collapsed: false,
viewSource: false,
showReportDialog: false,
deleteLoading: false,
readLoading: false,
reportLoading: false,
});
}
}
render() {
const message_view = this.props.private_message_view;
const otherPerson: Person = this.mine
@ -98,8 +124,7 @@ export class PrivateMessage extends Component<
<PrivateMessageForm
recipient={otherPerson}
privateMessageView={message_view}
onEdit={this.handlePrivateMessageEdit}
onCreate={this.handlePrivateMessageCreate}
onEdit={this.props.onEdit}
onCancel={this.handleReplyCancel}
/>
)}
@ -131,12 +156,17 @@ export class PrivateMessage extends Component<
: i18n.t("mark_as_read")
}
>
{this.state.readLoading ? (
<Spinner />
) : (
<Icon
icon="check"
classes={`icon-inline ${
message_view.private_message.read && "text-success"
message_view.private_message.read &&
"text-success"
}`}
/>
)}
</button>
</li>
<li className="list-inline-item">{this.reportButton}</li>
@ -179,6 +209,9 @@ export class PrivateMessage extends Component<
: i18n.t("restore")
}
>
{this.state.deleteLoading ? (
<Spinner />
) : (
<Icon
icon="trash"
classes={`icon-inline ${
@ -186,6 +219,7 @@ export class PrivateMessage extends Component<
"text-danger"
}`}
/>
)}
</button>
</li>
</>
@ -231,14 +265,14 @@ export class PrivateMessage extends Component<
className="btn btn-secondary"
aria-label={i18n.t("create_report")}
>
{i18n.t("create_report")}
{this.state.reportLoading ? <Spinner /> : i18n.t("create_report")}
</button>
</form>
)}
{this.state.showReply && (
<PrivateMessageForm
recipient={otherPerson}
onCreate={this.handlePrivateMessageCreate}
onCreate={this.props.onCreate}
/>
)}
{/* A collapsed clearfix */}
@ -275,15 +309,12 @@ export class PrivateMessage extends Component<
}
handleDeleteClick(i: PrivateMessage) {
const auth = myAuth();
if (auth) {
const form: DeletePrivateMessage = {
i.setState({ deleteLoading: true });
i.props.onDelete({
private_message_id: i.props.private_message_view.private_message.id,
deleted: !i.props.private_message_view.private_message.deleted,
auth,
};
WebSocketService.Instance.send(wsClient.deletePrivateMessage(form));
}
auth: myAuthRequired(),
});
}
handleReplyCancel() {
@ -291,15 +322,12 @@ export class PrivateMessage extends Component<
}
handleMarkRead(i: PrivateMessage) {
const auth = myAuth();
if (auth) {
const form: MarkPrivateMessageAsRead = {
i.setState({ readLoading: true });
i.props.onMarkRead({
private_message_id: i.props.private_message_view.private_message.id,
read: !i.props.private_message_view.private_message.read,
auth,
};
WebSocketService.Instance.send(wsClient.markPrivateMessageAsRead(form));
}
auth: myAuthRequired(),
});
}
handleMessageCollapse(i: PrivateMessage) {
@ -320,31 +348,11 @@ export class PrivateMessage extends Component<
handleReportSubmit(i: PrivateMessage, event: any) {
event.preventDefault();
const auth = myAuth();
const reason = i.state.reportReason;
if (auth && reason) {
const form: CreatePrivateMessageReport = {
i.setState({ reportLoading: true });
i.props.onReport({
private_message_id: i.props.private_message_view.private_message.id,
reason,
auth,
};
WebSocketService.Instance.send(wsClient.createPrivateMessageReport(form));
i.setState({ showReportDialog: false });
}
}
handlePrivateMessageEdit() {
this.setState({ showEdit: false });
}
handlePrivateMessageCreate(message: PrivateMessageView) {
if (
message.creator.id ==
UserService.Instance.myUserInfo?.local_user_view.person.id
) {
this.setState({ showReply: false });
toast(i18n.t("message_sent"));
}
reason: i.state.reportReason ?? "",
auth: myAuthRequired(),
});
}
}

View file

@ -1,7 +1,6 @@
import type { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno";
import {
CommentResponse,
CommentView,
CommunityView,
GetCommunity,
@ -13,7 +12,6 @@ import {
ListCommunitiesResponse,
ListingType,
PersonView,
PostResponse,
PostView,
ResolveObject,
ResolveObjectResponse,
@ -21,22 +19,17 @@ import {
SearchResponse,
SearchType,
SortType,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../i18next";
import { CommentViewType, InitialFetchRequest } from "../interfaces";
import { WebSocketService } from "../services";
import { FirstLoadService } from "../services/FirstLoadService";
import { HttpService, RequestState } from "../services/HttpService";
import {
Choice,
QueryParams,
capitalizeFirstLetter,
commentsToFlatNodes,
communityToChoice,
createCommentLikeRes,
createPostLikeFindRes,
debounce,
enableDownvotes,
enableNsfw,
@ -55,9 +48,6 @@ import {
saveScrollPosition,
setIsoData,
showLocal,
toast,
wsClient,
wsSubscribe,
} from "../utils";
import { CommentNodes } from "./comment/comment-nodes";
import { HtmlTags } from "./common/html-tags";
@ -83,17 +73,18 @@ interface SearchProps {
type FilterType = "creator" | "community";
interface SearchState {
searchResponse?: SearchResponse;
communities: CommunityView[];
creatorDetails?: GetPersonDetailsResponse;
searchLoading: boolean;
searchCommunitiesLoading: boolean;
searchCreatorLoading: boolean;
searchRes: RequestState<SearchResponse>;
resolveObjectRes: RequestState<ResolveObjectResponse>;
creatorDetailsRes: RequestState<GetPersonDetailsResponse>;
communitiesRes: RequestState<ListCommunitiesResponse>;
communityRes: RequestState<GetCommunityResponse>;
siteRes: GetSiteResponse;
searchText?: string;
resolveObjectResponse?: ResolveObjectResponse;
communitySearchOptions: Choice[];
creatorSearchOptions: Choice[];
searchCreatorLoading: boolean;
searchCommunitiesLoading: boolean;
isIsomorphic: boolean;
}
interface Combined {
@ -238,15 +229,18 @@ function getListing(
export class Search extends Component<any, SearchState> {
private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: SearchState = {
searchLoading: false,
resolveObjectRes: { state: "empty" },
creatorDetailsRes: { state: "empty" },
communitiesRes: { state: "empty" },
communityRes: { state: "empty" },
siteRes: this.isoData.site_res,
communities: [],
searchCommunitiesLoading: false,
searchCreatorLoading: false,
creatorSearchOptions: [],
communitySearchOptions: [],
searchRes: { state: "empty" },
searchCreatorLoading: false,
searchCommunitiesLoading: false,
isIsomorphic: false,
};
constructor(props: any, context: any) {
@ -259,9 +253,6 @@ export class Search extends Component<any, SearchState> {
this.handleCommunityFilterChange.bind(this);
this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
const { q } = getSearchQueryParams();
this.state = {
@ -270,71 +261,70 @@ export class Search extends Component<any, SearchState> {
};
// Only fetch the data if coming from another route
if (this.isoData.path === this.context.router.route.match.url) {
const communityRes = this.isoData.routeData[0] as
| GetCommunityResponse
| undefined;
const communitiesRes = this.isoData.routeData[1] as
| ListCommunitiesResponse
| undefined;
// This can be single or multiple communities given
if (communitiesRes) {
if (FirstLoadService.isFirstLoad) {
const [
communityRes,
communitiesRes,
creatorDetailsRes,
searchRes,
resolveObjectRes,
] = this.isoData.routeData;
this.state = {
...this.state,
communities: communitiesRes.communities,
communitiesRes,
communityRes,
creatorDetailsRes,
creatorSearchOptions:
creatorDetailsRes.state == "success"
? [personToChoice(creatorDetailsRes.data.person_view)]
: [],
isIsomorphic: true,
};
}
if (communityRes) {
if (communityRes.state === "success") {
this.state = {
...this.state,
communities: [communityRes.community_view],
communitySearchOptions: [
communityToChoice(communityRes.community_view),
communityToChoice(communityRes.data.community_view),
],
};
}
const creatorRes = this.isoData.routeData[2] as GetPersonDetailsResponse;
if (q) {
this.state = {
...this.state,
creatorDetails: creatorRes,
creatorSearchOptions: creatorRes
? [personToChoice(creatorRes.person_view)]
: [],
searchRes,
resolveObjectRes,
};
if (q !== "") {
this.state = {
...this.state,
searchResponse: this.isoData.routeData[3] as SearchResponse,
resolveObjectResponse: this.isoData
.routeData[4] as ResolveObjectResponse,
searchLoading: false,
};
} else {
this.search();
}
} else {
const listCommunitiesForm: ListCommunities = {
}
}
async componentDidMount() {
if (!this.state.isIsomorphic) {
const promises = [this.fetchCommunities()];
if (this.state.searchText) {
promises.push(this.search());
}
await Promise.all(promises);
}
}
async fetchCommunities() {
this.setState({ communitiesRes: { state: "loading" } });
this.setState({
communitiesRes: await HttpService.client.listCommunities({
type_: defaultListingType,
sort: defaultSortType,
limit: fetchLimit,
auth: myAuth(false),
};
WebSocketService.Instance.send(
wsClient.listCommunities(listCommunitiesForm)
);
if (q) {
this.search();
}
}
auth: myAuth(),
}),
});
}
componentWillUnmount() {
this.subscription?.unsubscribe();
saveScrollPosition(this.context);
}
@ -342,8 +332,10 @@ export class Search extends Component<any, SearchState> {
client,
auth,
query: { communityId, creatorId, q, type, sort, listingType, page },
}: InitialFetchRequest<QueryParams<SearchProps>>): Promise<any>[] {
const promises: Promise<any>[] = [];
}: InitialFetchRequest<QueryParams<SearchProps>>): Promise<
RequestState<any>
>[] {
const promises: Promise<RequestState<any>>[] = [];
const community_id = getIdFromString(communityId);
if (community_id) {
@ -352,7 +344,7 @@ export class Search extends Component<any, SearchState> {
auth,
};
promises.push(client.getCommunity(getCommunityForm));
promises.push(Promise.resolve());
promises.push(Promise.resolve({ state: "empty" }));
} else {
const listCommunitiesForm: ListCommunities = {
type_: defaultListingType,
@ -360,7 +352,7 @@ export class Search extends Component<any, SearchState> {
limit: fetchLimit,
auth,
};
promises.push(Promise.resolve());
promises.push(Promise.resolve({ state: "empty" }));
promises.push(client.listCommunities(listCommunitiesForm));
}
@ -372,7 +364,7 @@ export class Search extends Component<any, SearchState> {
};
promises.push(client.getPersonDetails(getCreatorForm));
} else {
promises.push(Promise.resolve());
promises.push(Promise.resolve({ state: "empty" }));
}
const query = getSearchQueryFromQuery(q);
@ -400,8 +392,8 @@ export class Search extends Component<any, SearchState> {
promises.push(client.resolveObject(resolveObjectForm));
}
} else {
promises.push(Promise.resolve());
promises.push(Promise.resolve());
promises.push(Promise.resolve({ state: "empty" }));
promises.push(Promise.resolve({ state: "empty" }));
}
}
@ -427,7 +419,8 @@ export class Search extends Component<any, SearchState> {
{this.selects}
{this.searchForm}
{this.displayResults(type)}
{this.resultsCount === 0 && !this.state.searchLoading && (
{this.resultsCount === 0 &&
this.state.searchRes.state === "success" && (
<span>{i18n.t("no_results")}</span>
)}
<Paginator page={page} onChange={this.handlePageChange} />
@ -470,7 +463,7 @@ export class Search extends Component<any, SearchState> {
minLength={1}
/>
<button type="submit" className="btn btn-secondary mr-2 mb-2">
{this.state.searchLoading ? (
{this.state.searchRes.state == "loading" ? (
<Spinner />
) : (
<span>{i18n.t("search")}</span>
@ -488,8 +481,13 @@ export class Search extends Component<any, SearchState> {
creatorSearchOptions,
searchCommunitiesLoading,
searchCreatorLoading,
communitiesRes,
} = this.state;
const hasCommunities =
communitiesRes.state == "success" &&
communitiesRes.data.communities.length > 0;
return (
<div className="mb-2">
<select
@ -524,14 +522,14 @@ export class Search extends Component<any, SearchState> {
/>
</span>
<div className="form-row">
{this.state.communities.length > 0 && (
{hasCommunities && (
<Filter
filterType="community"
onChange={this.handleCommunityFilterChange}
onSearch={this.handleCommunitySearch}
options={communitySearchOptions}
loading={searchCommunitiesLoading}
value={communityId}
loading={searchCommunitiesLoading}
/>
)}
<Filter
@ -539,8 +537,8 @@ export class Search extends Component<any, SearchState> {
onChange={this.handleCreatorFilterChange}
onSearch={this.handleCreatorSearch}
options={creatorSearchOptions}
loading={searchCreatorLoading}
value={creatorId}
loading={searchCreatorLoading}
/>
</div>
</div>
@ -549,11 +547,14 @@ export class Search extends Component<any, SearchState> {
buildCombined(): Combined[] {
const combined: Combined[] = [];
const { resolveObjectResponse, searchResponse } = this.state;
const {
resolveObjectRes: resolveObjectResponse,
searchRes: searchResponse,
} = this.state;
// Push the possible resolve / federated objects first
if (resolveObjectResponse) {
const { comment, post, community, person } = resolveObjectResponse;
if (resolveObjectResponse.state == "success") {
const { comment, post, community, person } = resolveObjectResponse.data;
if (comment) {
combined.push(commentViewToCombined(comment));
@ -570,8 +571,8 @@ export class Search extends Component<any, SearchState> {
}
// Push the search results
if (searchResponse) {
const { comments, posts, communities, users } = searchResponse;
if (searchResponse.state === "success") {
const { comments, posts, communities, users } = searchResponse.data;
combined.push(
...[
@ -622,6 +623,23 @@ export class Search extends Component<any, SearchState> {
allLanguages={this.state.siteRes.all_languages}
siteLanguages={this.state.siteRes.discussion_languages}
viewOnly
// All of these are unused, since its view only
onPostEdit={() => {}}
onPostVote={() => {}}
onPostReport={() => {}}
onBlockPerson={() => {}}
onLockPost={() => {}}
onDeletePost={() => {}}
onRemovePost={() => {}}
onSavePost={() => {}}
onFeaturePost={() => {}}
onPurgePerson={() => {}}
onPurgePost={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
/>
)}
{i.type_ === "comments" && (
@ -641,6 +659,26 @@ export class Search extends Component<any, SearchState> {
enableDownvotes={enableDownvotes(this.state.siteRes)}
allLanguages={this.state.siteRes.all_languages}
siteLanguages={this.state.siteRes.discussion_languages}
// All of these are unused, since its viewonly
finished={new Map()}
onSaveComment={() => {}}
onBlockPerson={() => {}}
onDeleteComment={() => {}}
onRemoveComment={() => {}}
onCommentVote={() => {}}
onCommentReport={() => {}}
onDistinguishComment={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onPurgeComment={() => {}}
onPurgePerson={() => {}}
onCommentReplyRead={() => {}}
onPersonMentionRead={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onCreateComment={() => Promise.resolve({ state: "empty" })}
onEditComment={() => Promise.resolve({ state: "empty" })}
/>
)}
{i.type_ === "communities" && (
@ -657,11 +695,19 @@ export class Search extends Component<any, SearchState> {
}
get comments() {
const { searchResponse, resolveObjectResponse, siteRes } = this.state;
const comments = searchResponse?.comments ?? [];
const {
searchRes: searchResponse,
resolveObjectRes: resolveObjectResponse,
siteRes,
} = this.state;
const comments =
searchResponse.state === "success" ? searchResponse.data.comments : [];
if (resolveObjectResponse?.comment) {
comments.unshift(resolveObjectResponse?.comment);
if (
resolveObjectResponse.state === "success" &&
resolveObjectResponse.data.comment
) {
comments.unshift(resolveObjectResponse.data.comment);
}
return (
@ -674,16 +720,44 @@ export class Search extends Component<any, SearchState> {
enableDownvotes={enableDownvotes(siteRes)}
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
// All of these are unused, since its viewonly
finished={new Map()}
onSaveComment={() => {}}
onBlockPerson={() => {}}
onDeleteComment={() => {}}
onRemoveComment={() => {}}
onCommentVote={() => {}}
onCommentReport={() => {}}
onDistinguishComment={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onPurgeComment={() => {}}
onPurgePerson={() => {}}
onCommentReplyRead={() => {}}
onPersonMentionRead={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onCreateComment={() => Promise.resolve({ state: "empty" })}
onEditComment={() => Promise.resolve({ state: "empty" })}
/>
);
}
get posts() {
const { searchResponse, resolveObjectResponse, siteRes } = this.state;
const posts = searchResponse?.posts ?? [];
const {
searchRes: searchResponse,
resolveObjectRes: resolveObjectResponse,
siteRes,
} = this.state;
const posts =
searchResponse.state === "success" ? searchResponse.data.posts : [];
if (resolveObjectResponse?.post) {
posts.unshift(resolveObjectResponse.post);
if (
resolveObjectResponse.state === "success" &&
resolveObjectResponse.data.post
) {
posts.unshift(resolveObjectResponse.data.post);
}
return (
@ -699,6 +773,23 @@ export class Search extends Component<any, SearchState> {
allLanguages={siteRes.all_languages}
siteLanguages={siteRes.discussion_languages}
viewOnly
// All of these are unused, since its view only
onPostEdit={() => {}}
onPostVote={() => {}}
onPostReport={() => {}}
onBlockPerson={() => {}}
onLockPost={() => {}}
onDeletePost={() => {}}
onRemovePost={() => {}}
onSavePost={() => {}}
onFeaturePost={() => {}}
onPurgePerson={() => {}}
onPurgePost={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
/>
</div>
</div>
@ -708,11 +799,18 @@ export class Search extends Component<any, SearchState> {
}
get communities() {
const { searchResponse, resolveObjectResponse } = this.state;
const communities = searchResponse?.communities ?? [];
const {
searchRes: searchResponse,
resolveObjectRes: resolveObjectResponse,
} = this.state;
const communities =
searchResponse.state === "success" ? searchResponse.data.communities : [];
if (resolveObjectResponse?.community) {
communities.unshift(resolveObjectResponse.community);
if (
resolveObjectResponse.state === "success" &&
resolveObjectResponse.data.community
) {
communities.unshift(resolveObjectResponse.data.community);
}
return (
@ -727,11 +825,18 @@ export class Search extends Component<any, SearchState> {
}
get users() {
const { searchResponse, resolveObjectResponse } = this.state;
const users = searchResponse?.users ?? [];
const {
searchRes: searchResponse,
resolveObjectRes: resolveObjectResponse,
} = this.state;
const users =
searchResponse.state === "success" ? searchResponse.data.users : [];
if (resolveObjectResponse?.person) {
users.unshift(resolveObjectResponse.person);
if (
resolveObjectResponse.state === "success" &&
resolveObjectResponse.data.person
) {
users.unshift(resolveObjectResponse.data.person);
}
return (
@ -746,20 +851,22 @@ export class Search extends Component<any, SearchState> {
}
get resultsCount(): number {
const { searchResponse: r, resolveObjectResponse: resolveRes } = this.state;
const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
const searchCount = r
? r.posts.length +
r.comments.length +
r.communities.length +
r.users.length
const searchCount =
r.state === "success"
? r.data.posts.length +
r.data.comments.length +
r.data.communities.length +
r.data.users.length
: 0;
const resObjCount = resolveRes
? resolveRes.post ||
resolveRes.person ||
resolveRes.community ||
resolveRes.comment
const resObjCount =
resolveRes.state === "success"
? resolveRes.data.post ||
resolveRes.data.person ||
resolveRes.data.community ||
resolveRes.data.comment
? 1
: 0
: 0;
@ -767,14 +874,16 @@ export class Search extends Component<any, SearchState> {
return resObjCount + searchCount;
}
search() {
const auth = myAuth(false);
async search() {
const auth = myAuth();
const { searchText: q } = this.state;
const { communityId, creatorId, type, sort, listingType, page } =
getSearchQueryParams();
if (q && q !== "") {
const form: SearchForm = {
if (q) {
this.setState({ searchRes: { state: "loading" } });
this.setState({
searchRes: await HttpService.client.search({
q,
community_id: communityId ?? undefined,
creator_id: creatorId ?? undefined,
@ -784,37 +893,30 @@ export class Search extends Component<any, SearchState> {
page,
limit: fetchLimit,
auth,
};
}),
});
window.scrollTo(0, 0);
restoreScrollPosition(this.context);
if (auth) {
const resolveObjectForm: ResolveObject = {
this.setState({ resolveObjectRes: { state: "loading" } });
this.setState({
resolveObjectRes: await HttpService.client.resolveObject({
q,
auth,
};
WebSocketService.Instance.send(
wsClient.resolveObject(resolveObjectForm)
);
}
this.setState({
searchResponse: undefined,
resolveObjectResponse: undefined,
searchLoading: true,
}),
});
WebSocketService.Instance.send(wsClient.search(form));
}
}
}
handleCreatorSearch = debounce(async (text: string) => {
const { creatorId } = getSearchQueryParams();
const { creatorSearchOptions } = this.state;
this.setState({
searchCreatorLoading: true,
});
const newOptions: Choice[] = [];
this.setState({ searchCreatorLoading: true });
const selectedChoice = creatorSearchOptions.find(
choice => getIdFromString(choice.value) === creatorId
);
@ -824,7 +926,7 @@ export class Search extends Component<any, SearchState> {
}
if (text.length > 0) {
newOptions.push(...(await fetchUsers(text)).users.map(personToChoice));
newOptions.push(...(await fetchUsers(text)).map(personToChoice));
}
this.setState({
@ -851,9 +953,7 @@ export class Search extends Component<any, SearchState> {
}
if (text.length > 0) {
newOptions.push(
...(await fetchCommunities(text)).communities.map(communityToChoice)
);
newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
}
this.setState({
@ -913,7 +1013,7 @@ export class Search extends Component<any, SearchState> {
i.setState({ searchText: event.target.value });
}
updateUrl({
async updateUrl({
q,
type,
listingType,
@ -950,71 +1050,6 @@ export class Search extends Component<any, SearchState> {
this.props.history.push(`/search${getQueryString(queryParams)}`);
this.search();
}
parseMessage(msg: any) {
console.log(msg);
const op = wsUserOp(msg);
if (msg.error) {
if (msg.error === "couldnt_find_object") {
this.setState({
resolveObjectResponse: {},
});
this.checkFinishedLoading();
} else {
toast(i18n.t(msg.error), "danger");
}
} else {
switch (op) {
case UserOperation.Search: {
const searchResponse = wsJsonToRes<SearchResponse>(msg);
this.setState({ searchResponse });
window.scrollTo(0, 0);
this.checkFinishedLoading();
restoreScrollPosition(this.context);
break;
}
case UserOperation.CreateCommentLike: {
const { comment_view } = wsJsonToRes<CommentResponse>(msg);
createCommentLikeRes(
comment_view,
this.state.searchResponse?.comments
);
break;
}
case UserOperation.CreatePostLike: {
const { post_view } = wsJsonToRes<PostResponse>(msg);
createPostLikeFindRes(post_view, this.state.searchResponse?.posts);
break;
}
case UserOperation.ListCommunities: {
const { communities } = wsJsonToRes<ListCommunitiesResponse>(msg);
this.setState({ communities });
break;
}
case UserOperation.ResolveObject: {
const resolveObjectResponse = wsJsonToRes<ResolveObjectResponse>(msg);
this.setState({ resolveObjectResponse });
this.checkFinishedLoading();
break;
}
}
}
}
checkFinishedLoading() {
if (this.state.searchResponse || this.state.resolveObjectResponse) {
this.setState({ searchLoading: false });
}
await this.search();
}
}

View file

@ -34,12 +34,6 @@ function getHost() {
return isBrowser() ? getExternalHost() : getInternalHost();
}
function getWsHost() {
return isBrowser()
? window.lemmyConfig?.wsHost ?? getHost()
: process.env.LEMMY_UI_LEMMY_WS_HOST ?? getExternalHost();
}
function getBaseLocal(s = "") {
return `http${s}://${getHost()}`;
}
@ -47,18 +41,20 @@ function getBaseLocal(s = "") {
export function getHttpBaseInternal() {
return getBaseLocal(); // Don't use secure here
}
export function getHttpBaseExternal() {
return `http${getSecure()}://${getExternalHost()}`;
}
export function getHttpBase() {
return getBaseLocal(getSecure());
}
export function getWsUri() {
return `ws${getSecure()}://${getWsHost()}/api/v3/ws`;
}
export function isHttps() {
return getSecure() === "s";
}
console.log(`httpbase: ${getHttpBase()}`);
console.log(`wsUri: ${getWsUri()}`);
console.log(`isHttps: ${isHttps()}`);
// This is for html tags, don't include port

View file

@ -1,5 +1,6 @@
import { CommentView, GetSiteResponse, LemmyHttp } from "lemmy-js-client";
import { CommentView, GetSiteResponse } from "lemmy-js-client";
import type { ParsedQs } from "qs";
import { RequestState, WrappedLemmyHttp } from "./services/HttpService";
import { ErrorPageData } from "./utils";
/**
@ -7,7 +8,7 @@ import { ErrorPageData } from "./utils";
*/
export interface IsoData {
path: string;
routeData: any[];
routeData: RequestState<any>[];
site_res: GetSiteResponse;
errorPageData?: ErrorPageData;
}
@ -28,7 +29,7 @@ declare global {
export interface InitialFetchRequest<T extends ParsedQs = ParsedQs> {
auth?: string;
client: LemmyHttp;
client: WrappedLemmyHttp;
path: string;
query: T;
site: GetSiteResponse;
@ -69,6 +70,11 @@ export enum PurgeType {
Comment,
}
export enum VoteType {
Upvote,
Downvote,
}
export interface CommentNodeI {
comment_view: CommentView;
children: Array<CommentNodeI>;

View file

@ -22,10 +22,11 @@ import { Post } from "./components/post/post";
import { CreatePrivateMessage } from "./components/private_message/create-private-message";
import { Search } from "./components/search";
import { InitialFetchRequest } from "./interfaces";
import { RequestState } from "./services/HttpService";
interface IRoutePropsWithFetch extends IRouteProps {
// TODO Make sure this one is good.
fetchInitialData?(req: InitialFetchRequest): Promise<any>[];
fetchInitialData?(req: InitialFetchRequest): Promise<RequestState<any>>[];
}
export const routes: IRoutePropsWithFetch[] = [

View file

@ -0,0 +1,25 @@
export class FirstLoadService {
#isFirstLoad: boolean;
static #instance: FirstLoadService;
private constructor() {
this.#isFirstLoad = true;
}
get isFirstLoad() {
const isFirst = this.#isFirstLoad;
if (isFirst) {
this.#isFirstLoad = false;
}
return isFirst;
}
static get #Instance() {
return this.#instance ?? (this.#instance = new this());
}
static get isFirstLoad() {
return this.#Instance.isFirstLoad;
}
}

View file

@ -0,0 +1,18 @@
import { History, createBrowserHistory } from "history";
export class HistoryService {
static #_instance: HistoryService;
#history: History;
private constructor() {
this.#history = createBrowserHistory();
}
static get #Instance() {
return this.#_instance ?? (this.#_instance = new this());
}
public static get history() {
return this.#Instance.#history;
}
}

View file

@ -0,0 +1,96 @@
import { LemmyHttp } from "lemmy-js-client";
import { getHttpBase } from "../../shared/env";
import { i18n } from "../../shared/i18next";
import { toast } from "../../shared/utils";
type EmptyRequestState = {
state: "empty";
};
type LoadingRequestState = {
state: "loading";
};
type FailedRequestState = {
state: "failed";
msg: string;
};
type SuccessRequestState<T> = {
state: "success";
data: T;
};
/**
* Shows the state of an API request.
*
* Can be empty, loading, failed, or success
*/
export type RequestState<T> =
| EmptyRequestState
| LoadingRequestState
| FailedRequestState
| SuccessRequestState<T>;
export type WrappedLemmyHttp = {
[K in keyof LemmyHttp]: LemmyHttp[K] extends (...args: any[]) => any
? ReturnType<LemmyHttp[K]> extends Promise<infer U>
? (...args: Parameters<LemmyHttp[K]>) => Promise<RequestState<U>>
: (
...args: Parameters<LemmyHttp[K]>
) => Promise<RequestState<LemmyHttp[K]>>
: LemmyHttp[K];
};
class WrappedLemmyHttpClient {
#client: LemmyHttp;
constructor(client: LemmyHttp) {
this.#client = client;
for (const key of Object.getOwnPropertyNames(
Object.getPrototypeOf(this.#client)
)) {
if (key !== "constructor") {
WrappedLemmyHttpClient.prototype[key] = async (...args) => {
try {
const res = await this.#client[key](...args);
return {
data: res,
state: "success",
};
} catch (error) {
console.error(`API error: ${error}`);
toast(i18n.t(error), "danger");
return {
state: "failed",
msg: error,
};
}
};
}
}
}
}
export function wrapClient(client: LemmyHttp) {
return new WrappedLemmyHttpClient(client) as unknown as WrappedLemmyHttp; // unfortunately, this verbose cast is necessary
}
export class HttpService {
static #_instance: HttpService;
#client: WrappedLemmyHttp;
private constructor() {
this.#client = wrapClient(new LemmyHttp(getHttpBase()));
}
static get #Instance() {
return this.#_instance ?? (this.#_instance = new this());
}
public static get client() {
return this.#Instance.#client;
}
}

View file

@ -2,7 +2,6 @@
import IsomorphicCookie from "isomorphic-cookie";
import jwt_decode from "jwt-decode";
import { LoginResponse, MyUserInfo } from "lemmy-js-client";
import { BehaviorSubject } from "rxjs";
import { isHttps } from "../env";
import { i18n } from "../i18next";
import { isAuthPath, isBrowser, toast } from "../utils";
@ -19,18 +18,12 @@ interface JwtInfo {
}
export class UserService {
private static _instance: UserService;
static #instance: UserService;
public myUserInfo?: MyUserInfo;
public jwtInfo?: JwtInfo;
public unreadInboxCountSub: BehaviorSubject<number> =
new BehaviorSubject<number>(0);
public unreadReportCountSub: BehaviorSubject<number> =
new BehaviorSubject<number>(0);
public unreadApplicationCountSub: BehaviorSubject<number> =
new BehaviorSubject<number>(0);
private constructor() {
this.setJwtInfo();
this.#setJwtInfo();
}
public login(res: LoginResponse) {
@ -39,7 +32,7 @@ export class UserService {
if (res.jwt) {
toast(i18n.t("logged_in"));
IsomorphicCookie.save("jwt", res.jwt, { expires, secure: isHttps() });
this.setJwtInfo();
this.#setJwtInfo();
}
}
@ -55,7 +48,7 @@ export class UserService {
}
}
public auth(throwErr = true): string | undefined {
public auth(throwErr = false): string | undefined {
const jwt = this.jwtInfo?.jwt;
if (jwt) {
return jwt;
@ -70,7 +63,7 @@ export class UserService {
}
}
private setJwtInfo() {
#setJwtInfo() {
const jwt: string | undefined = IsomorphicCookie.load("jwt");
if (jwt) {
@ -79,6 +72,6 @@ export class UserService {
}
public static get Instance() {
return this._instance || (this._instance = new this());
return this.#instance || (this.#instance = new this());
}
}

View file

@ -1,68 +0,0 @@
import { Observable } from "rxjs";
import { share } from "rxjs/operators";
import {
ExponentialBackoff,
LRUBuffer,
Websocket as WS,
WebsocketBuilder,
} from "websocket-ts";
import { getWsUri } from "../env";
import { isBrowser } from "../utils";
export class WebSocketService {
private static _instance: WebSocketService;
private ws: WS;
public subject: Observable<any>;
private constructor() {
let firstConnect = true;
this.subject = new Observable((obs: any) => {
this.ws = new WebsocketBuilder(getWsUri())
.onMessage((_i, e) => {
try {
obs.next(JSON.parse(e.data.toString()));
} catch (err) {
console.error(err);
}
})
.onOpen(() => {
console.log(`Connected to ${getWsUri()}`);
if (!firstConnect) {
const res = {
reconnect: true,
};
obs.next(res);
}
firstConnect = false;
})
.onRetry(() => {
console.log("Retrying websocket connection...");
})
.onClose(() => {
console.error("Websocket closed.");
})
.withBackoff(new ExponentialBackoff(100, 7))
.withBuffer(new LRUBuffer(1000))
.build();
}).pipe(share());
if (isBrowser()) {
window.onunload = () => {
this.ws.close();
// Clears out scroll positions.
sessionStorage.clear();
};
}
}
public send(data: string) {
this.ws.send(data);
}
public static get Instance() {
return this._instance || (this._instance = new this());
}
}

View file

@ -1,2 +1,2 @@
export { HttpService } from "./HttpService";
export { UserService } from "./UserService";
export { WebSocketService } from "./WebSocketService";

View file

@ -3,7 +3,9 @@ import emojiShortName from "emoji-short-name";
import {
BlockCommunityResponse,
BlockPersonResponse,
CommentAggregates,
Comment as CommentI,
CommentReplyView,
CommentReportView,
CommentSortType,
CommentView,
@ -14,9 +16,9 @@ import {
GetSiteResponse,
Language,
LemmyHttp,
LemmyWebsocket,
MyUserInfo,
Person,
PersonMentionView,
PersonView,
PostReportView,
PostView,
@ -24,8 +26,8 @@ import {
PrivateMessageView,
RegistrationApplicationView,
Search,
SearchType,
SortType,
UploadImageResponse,
} from "lemmy-js-client";
import { default as MarkdownIt } from "markdown-it";
import markdown_it_container from "markdown-it-container";
@ -37,22 +39,18 @@ import markdown_it_sup from "markdown-it-sup";
import Renderer from "markdown-it/lib/renderer";
import Token from "markdown-it/lib/token";
import moment from "moment";
import { Subscription } from "rxjs";
import { delay, retryWhen, take } from "rxjs/operators";
import tippy from "tippy.js";
import Toastify from "toastify-js";
import { getHttpBase } from "./env";
import { i18n, languages } from "./i18next";
import { CommentNodeI, DataType, IsoData } from "./interfaces";
import { UserService, WebSocketService } from "./services";
import { CommentNodeI, DataType, IsoData, VoteType } from "./interfaces";
import { HttpService, UserService } from "./services";
let Tribute: any;
if (isBrowser()) {
Tribute = require("tributejs");
}
export const wsClient = new LemmyWebsocket();
export const favIconUrl = "/static/assets/icons/favicon.svg";
export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
// TODO
@ -77,6 +75,7 @@ export const commentTreeMaxDepth = 8;
export const markdownFieldCharacterLimit = 50000;
export const maxUploadImages = 20;
export const concurrentImageUpload = 4;
export const updateUnreadCountsInterval = 30000;
export const relTags = "noopener nofollow";
@ -206,12 +205,10 @@ export function hotRank(score: number, timeStr: string): number {
}
export function mdToHtml(text: string) {
// restore '>' character to fix quotes
return { __html: md.render(text) };
}
export function mdToHtmlNoImages(text: string) {
// restore '>' character to fix quotes
return { __html: mdNoImages.render(text) };
}
@ -561,86 +558,6 @@ export function pictrsDeleteToast(filename: string, deleteUrl: string) {
}
}
interface NotifyInfo {
name: string;
icon?: string;
link: string;
body?: string;
}
export function messageToastify(info: NotifyInfo, router: any) {
if (isBrowser()) {
const htmlBody = info.body ? md.render(info.body) : "";
const backgroundColor = `var(--light)`;
const toast = Toastify({
text: `${htmlBody}<br />${info.name}`,
avatar: info.icon,
backgroundColor: backgroundColor,
className: "text-dark",
close: true,
gravity: "top",
position: "right",
duration: 5000,
escapeMarkup: false,
onClick: () => {
if (toast) {
toast.hideToast();
router.history.push(info.link);
}
},
});
toast.showToast();
}
}
export function notifyPost(post_view: PostView, router: any) {
const info: NotifyInfo = {
name: post_view.community.name,
icon: post_view.community.icon,
link: `/post/${post_view.post.id}`,
body: post_view.post.name,
};
notify(info, router);
}
export function notifyComment(comment_view: CommentView, router: any) {
const info: NotifyInfo = {
name: comment_view.creator.name,
icon: comment_view.creator.avatar,
link: `/comment/${comment_view.comment.id}`,
body: comment_view.comment.content,
};
notify(info, router);
}
export function notifyPrivateMessage(pmv: PrivateMessageView, router: any) {
const info: NotifyInfo = {
name: pmv.creator.name,
icon: pmv.creator.avatar,
link: `/inbox`,
body: pmv.private_message.content,
};
notify(info, router);
}
function notify(info: NotifyInfo, router: any) {
messageToastify(info, router);
if (Notification.permission !== "granted") Notification.requestPermission();
else {
var notification = new Notification(info.name, {
...{ body: info.body },
...(info.icon && { icon: info.icon }),
});
notification.onclick = (ev: Event): any => {
ev.preventDefault();
router.history.push(info.link);
};
}
}
export function setupTribute() {
return new Tribute({
noMatchTemplate: function () {
@ -877,15 +794,12 @@ interface PersonTribute {
}
async function personSearch(text: string): Promise<PersonTribute[]> {
const users = (await fetchUsers(text)).users;
const persons: PersonTribute[] = users.map(pv => {
const tribute: PersonTribute = {
const usersResponse = await fetchUsers(text);
return usersResponse.map(pv => ({
key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`,
view: pv,
};
return tribute;
});
return persons;
}));
}
interface CommunityTribute {
@ -894,15 +808,12 @@ interface CommunityTribute {
}
async function communitySearch(text: string): Promise<CommunityTribute[]> {
const comms = (await fetchCommunities(text)).communities;
const communities: CommunityTribute[] = comms.map(cv => {
const tribute: CommunityTribute = {
const communitiesResponse = await fetchCommunities(text);
return communitiesResponse.map(cv => ({
key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`,
view: cv,
};
return tribute;
});
return communities;
}));
}
export function getRecipientIdFromProps(props: any): number {
@ -921,42 +832,128 @@ export function getCommentIdFromProps(props: any): number | undefined {
return id ? Number(id) : undefined;
}
export function editCommentRes(data: CommentView, comments?: CommentView[]) {
const found = comments?.find(c => c.comment.id == data.comment.id);
if (found) {
found.comment.content = data.comment.content;
found.comment.distinguished = data.comment.distinguished;
found.comment.updated = data.comment.updated;
found.comment.removed = data.comment.removed;
found.comment.deleted = data.comment.deleted;
found.counts.upvotes = data.counts.upvotes;
found.counts.downvotes = data.counts.downvotes;
found.counts.score = data.counts.score;
}
type ImmutableListKey =
| "comment"
| "comment_reply"
| "person_mention"
| "community"
| "private_message"
| "post"
| "post_report"
| "comment_report"
| "private_message_report"
| "registration_application";
function editListImmutable<
T extends { [key in F]: { id: number } },
F extends ImmutableListKey
>(fieldName: F, data: T, list: T[]): T[] {
return [
...list.map(c => (c[fieldName].id === data[fieldName].id ? data : c)),
];
}
export function saveCommentRes(data: CommentView, comments?: CommentView[]) {
const found = comments?.find(c => c.comment.id == data.comment.id);
if (found) {
found.saved = data.saved;
export function editComment(
data: CommentView,
comments: CommentView[]
): CommentView[] {
return editListImmutable("comment", data, comments);
}
export function editCommentReply(
data: CommentReplyView,
replies: CommentReplyView[]
): CommentReplyView[] {
return editListImmutable("comment_reply", data, replies);
}
interface WithComment {
comment: CommentI;
counts: CommentAggregates;
my_vote?: number;
saved: boolean;
}
export function editMention(
data: PersonMentionView,
comments: PersonMentionView[]
): PersonMentionView[] {
return editListImmutable("person_mention", data, comments);
}
export function editCommunity(
data: CommunityView,
communities: CommunityView[]
): CommunityView[] {
return editListImmutable("community", data, communities);
}
export function editPrivateMessage(
data: PrivateMessageView,
messages: PrivateMessageView[]
): PrivateMessageView[] {
return editListImmutable("private_message", data, messages);
}
export function editPost(data: PostView, posts: PostView[]): PostView[] {
return editListImmutable("post", data, posts);
}
export function editPostReport(
data: PostReportView,
reports: PostReportView[]
) {
return editListImmutable("post_report", data, reports);
}
export function editCommentReport(
data: CommentReportView,
reports: CommentReportView[]
): CommentReportView[] {
return editListImmutable("comment_report", data, reports);
}
export function editPrivateMessageReport(
data: PrivateMessageReportView,
reports: PrivateMessageReportView[]
): PrivateMessageReportView[] {
return editListImmutable("private_message_report", data, reports);
}
export function editRegistrationApplication(
data: RegistrationApplicationView,
apps: RegistrationApplicationView[]
): RegistrationApplicationView[] {
return editListImmutable("registration_application", data, apps);
}
export function editWith<D extends WithComment, L extends WithComment>(
{ comment, counts, saved, my_vote }: D,
list: L[]
) {
return [
...list.map(c =>
c.comment.id === comment.id
? { ...c, comment, counts, saved, my_vote }
: c
),
];
}
export function updatePersonBlock(
data: BlockPersonResponse,
myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
) {
const mui = myUserInfo;
if (mui) {
if (myUserInfo) {
if (data.blocked) {
mui.person_blocks.push({
person: mui.local_user_view.person,
myUserInfo.person_blocks.push({
person: myUserInfo.local_user_view.person,
target: data.person_view.person,
});
toast(`${i18n.t("blocked")} ${data.person_view.person.name}`);
} else {
mui.person_blocks = mui.person_blocks.filter(
i => i.target.id != data.person_view.person.id
myUserInfo.person_blocks = myUserInfo.person_blocks.filter(
i => i.target.id !== data.person_view.person.id
);
toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`);
}
@ -967,127 +964,22 @@ export function updateCommunityBlock(
data: BlockCommunityResponse,
myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
) {
const mui = myUserInfo;
if (mui) {
if (myUserInfo) {
if (data.blocked) {
mui.community_blocks.push({
person: mui.local_user_view.person,
myUserInfo.community_blocks.push({
person: myUserInfo.local_user_view.person,
community: data.community_view.community,
});
toast(`${i18n.t("blocked")} ${data.community_view.community.name}`);
} else {
mui.community_blocks = mui.community_blocks.filter(
i => i.community.id != data.community_view.community.id
myUserInfo.community_blocks = myUserInfo.community_blocks.filter(
i => i.community.id !== data.community_view.community.id
);
toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`);
}
}
}
export function createCommentLikeRes(
data: CommentView,
comments?: CommentView[]
) {
const found = comments?.find(c => c.comment.id === data.comment.id);
if (found) {
found.counts.score = data.counts.score;
found.counts.upvotes = data.counts.upvotes;
found.counts.downvotes = data.counts.downvotes;
if (data.my_vote !== null) {
found.my_vote = data.my_vote;
}
}
}
export function createPostLikeFindRes(data: PostView, posts?: PostView[]) {
const found = posts?.find(p => p.post.id == data.post.id);
if (found) {
createPostLikeRes(data, found);
}
}
export function createPostLikeRes(data: PostView, post_view?: PostView) {
if (post_view) {
post_view.counts.score = data.counts.score;
post_view.counts.upvotes = data.counts.upvotes;
post_view.counts.downvotes = data.counts.downvotes;
if (data.my_vote !== null) {
post_view.my_vote = data.my_vote;
}
}
}
export function editPostFindRes(data: PostView, posts?: PostView[]) {
const found = posts?.find(p => p.post.id == data.post.id);
if (found) {
editPostRes(data, found);
}
}
export function editPostRes(data: PostView, post: PostView) {
if (post) {
post.post.url = data.post.url;
post.post.name = data.post.name;
post.post.nsfw = data.post.nsfw;
post.post.deleted = data.post.deleted;
post.post.removed = data.post.removed;
post.post.featured_community = data.post.featured_community;
post.post.featured_local = data.post.featured_local;
post.post.body = data.post.body;
post.post.locked = data.post.locked;
post.saved = data.saved;
}
}
// TODO possible to make these generic?
export function updatePostReportRes(
data: PostReportView,
reports?: PostReportView[]
) {
const found = reports?.find(p => p.post_report.id == data.post_report.id);
if (found) {
found.post_report = data.post_report;
}
}
export function updateCommentReportRes(
data: CommentReportView,
reports?: CommentReportView[]
) {
const found = reports?.find(
c => c.comment_report.id == data.comment_report.id
);
if (found) {
found.comment_report = data.comment_report;
}
}
export function updatePrivateMessageReportRes(
data: PrivateMessageReportView,
reports?: PrivateMessageReportView[]
) {
const found = reports?.find(
c => c.private_message_report.id == data.private_message_report.id
);
if (found) {
found.private_message_report = data.private_message_report;
}
}
export function updateRegistrationApplicationRes(
data: RegistrationApplicationView,
applications?: RegistrationApplicationView[]
) {
const found = applications?.find(
ra => ra.registration_application.id == data.registration_application.id
);
if (found) {
found.registration_application = data.registration_application;
found.admin = data.admin;
found.creator_local_user = data.creator_local_user;
}
}
export function commentsToFlatNodes(comments: CommentView[]): CommentNodeI[] {
const nodes: CommentNodeI[] = [];
for (const comment of comments) {
@ -1180,6 +1072,7 @@ export function getDepthFromComment(comment?: CommentI): number | undefined {
return len ? len - 2 : undefined;
}
// TODO make immutable
export function insertCommentIntoTree(
tree: CommentNodeI[],
cv: CommentView,
@ -1235,7 +1128,7 @@ export const colorList: string[] = [
];
function hsl(num: number) {
return `hsla(${num}, 35%, 50%, 1)`;
return `hsla(${num}, 35%, 50%, 0.5)`;
}
export function hostname(url: string): string {
@ -1276,20 +1169,6 @@ export function setIsoData(context: any): IsoData {
} else return context.router.staticContext;
}
export function wsSubscribe(parseMessage: any): Subscription | undefined {
if (isBrowser()) {
return WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => parseMessage(msg),
err => console.error(err),
() => console.log("complete")
);
} else {
return undefined;
}
}
moment.updateLocale("en", {
relativeTime: {
future: "in %s",
@ -1353,32 +1232,30 @@ export function personToChoice(pvs: PersonView): Choice {
};
}
export async function fetchCommunities(q: string) {
function fetchSearchResults(q: string, type_: SearchType) {
const form: Search = {
q,
type_: "Communities",
type_,
sort: "TopAll",
listing_type: "All",
page: 1,
limit: fetchLimit,
auth: myAuth(false),
auth: myAuth(),
};
const client = new LemmyHttp(getHttpBase());
return client.search(form);
return HttpService.client.search(form);
}
export async function fetchCommunities(q: string) {
const res = await fetchSearchResults(q, "Communities");
return res.state === "success" ? res.data.communities : [];
}
export async function fetchUsers(q: string) {
const form: Search = {
q,
type_: "Users",
sort: "TopAll",
listing_type: "All",
page: 1,
limit: fetchLimit,
auth: myAuth(false),
};
const client = new LemmyHttp(getHttpBase());
return client.search(form);
const res = await fetchSearchResults(q, "Users");
return res.state === "success" ? res.data.users : [];
}
export function communitySelectName(cv: CommunityView): string {
@ -1398,7 +1275,7 @@ export function initializeSite(site?: GetSiteResponse) {
UserService.Instance.myUserInfo = site?.my_user;
i18n.changeLanguage(getLanguages()[0]);
if (site) {
setupEmojiDataModel(site.custom_emojis);
setupEmojiDataModel(site.custom_emojis ?? []);
}
setupMarkdown();
}
@ -1429,8 +1306,12 @@ export function isBanned(ps: Person): boolean {
}
}
export function myAuth(throwErr = true): string | undefined {
return UserService.Instance.auth(throwErr);
export function myAuth(): string | undefined {
return UserService.Instance.auth();
}
export function myAuthRequired(): string {
return UserService.Instance.auth(true) ?? "";
}
export function enableDownvotes(siteRes: GetSiteResponse): boolean {
@ -1528,12 +1409,6 @@ export function selectableLanguages(
}
}
export function uploadImage(image: File): Promise<UploadImageResponse> {
const client = new LemmyHttp(getHttpBase());
return client.uploadImage({ image });
}
interface EmojiMartCategory {
id: string;
name: string;
@ -1594,7 +1469,7 @@ export function getQueryString<T extends Record<string, string | undefined>>(
}
export function isAuthPath(pathname: string) {
return /create_.*|inbox|settings|setup|admin|reports|registration_applications/g.test(
return /create_.*|inbox|settings|admin|reports|registration_applications/g.test(
pathname
);
}
@ -1608,3 +1483,26 @@ export function share(shareData: ShareData) {
navigator.share(shareData);
}
}
export function newVote(voteType: VoteType, myVote?: number): number {
if (voteType == VoteType.Upvote) {
return myVote == 1 ? 0 : 1;
} else {
return myVote == -1 ? 0 : -1;
}
}
function sleep(millis: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, millis));
}
/**
* Polls / repeatedly runs a promise, every X milliseconds
*/
export async function poll(promiseFn: any, millis: number) {
if (window.document.visibilityState !== "hidden") {
await promiseFn();
}
await sleep(millis);
return poll(promiseFn, millis);
}

View file

@ -69,10 +69,10 @@ const createServerConfig = (_env, mode) => {
});
if (mode === "development") {
config.cache = {
type: "filesystem",
name: "server",
};
// config.cache = {
// type: "filesystem",
// name: "server",
// };
config.plugins.push(
new RunNodeWebpackPlugin({
@ -94,7 +94,7 @@ const createClientConfig = (_env, mode) => {
plugins: [
...base.plugins,
new ServiceWorkerPlugin({
enableInDevelopment: true,
enableInDevelopment: mode !== "development", // this may seem counterintuitive, but it is correct
workbox: {
modifyURLPrefix: {
"/": "/static/",
@ -149,10 +149,10 @@ const createClientConfig = (_env, mode) => {
});
if (mode === "development") {
config.cache = {
type: "filesystem",
name: "client",
};
// config.cache = {
// type: "filesystem",
// name: "client",
// };
}
return config;

1397
yarn.lock

File diff suppressed because it is too large Load diff