diff --git a/.eslintrc.json b/.eslintrc.json index 44dab428..3a60a6bb 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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, diff --git a/.woodpecker.yml b/.woodpecker.yml index 8d3c6f1c..656903a1 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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 diff --git a/README.md b/README.md index 6c9ef63a..f1917bff 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/deploy.sh b/deploy.sh index c53988d2..e919779a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 diff --git a/lemmy-translations b/lemmy-translations index ddf0d3a4..f45ddff2 160000 --- a/lemmy-translations +++ b/lemmy-translations @@ -1 +1 @@ -Subproject commit ddf0d3a4dcfba5eddbcdb702db2470b52abb3815 +Subproject commit f45ddff206adb52ab0ac7555bf14978edac5d2f2 diff --git a/package.json b/package.json index 413144e3..fd7cf4ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lemmy-ui", - "version": "0.17.1", + "version": "0.18.0-beta.6", "description": "An isomorphic UI for lemmy", "repository": "https://github.com/LemmyNet/lemmy-ui", "license": "AGPL-3.0", @@ -17,16 +17,9 @@ "start": "yarn build:dev --watch" }, "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", @@ -49,6 +42,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", @@ -60,7 +54,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", @@ -73,7 +67,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", @@ -85,8 +78,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", diff --git a/src/client/index.tsx b/src/client/index.tsx index 99f12371..7b6b6b1c 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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 = ( - + - + ); const root = document.getElementById("root"); diff --git a/src/server/index.tsx b/src/server/index.tsx index 716a936d..43024076 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -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,27 +136,30 @@ 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") { - console.error( - "Incorrect JWT token, skipping auth so frontend can remove jwt cookie" - ); - getSiteForm.auth = undefined; - auth = undefined; - try_site = await client.getSite(getSiteForm); - } + const routeData: RequestState[] = []; + 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" + ); + getSiteForm.auth = undefined; + auth = undefined; + try_site = await client.getSite(getSiteForm); + } - if (!auth && isAuthPath(path)) { - res.redirect("/login"); - return; - } + if (!auth && isAuthPath(path)) { + 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([ - ...activeRoute.fetchInitialData(initialFetchReq), - ]); + 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 && `` } diff --git a/src/shared/components/app/navbar.tsx b/src/shared/components/app/navbar.tsx index 751bf9bd..bdbac9ff 100644 --- a/src/shared/components/app/navbar.tsx +++ b/src/shared/components/app/navbar.tsx @@ -1,35 +1,23 @@ 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, showAvatars, toast, - wsClient, - wsSubscribe, } from "../../utils"; import { Icon } from "../common/icon"; import { PictrsImage } from "../common/pictrs-image"; @@ -39,14 +27,16 @@ interface NavbarProps { } interface NavbarState { - unreadInboxCount: number; - unreadReportCount: number; - unreadApplicationCount: number; + unreadInboxCountRes: RequestState; + unreadReportCountRes: RequestState; + unreadApplicationCountRes: RequestState; onSiteBanner?(url: string): any; } function handleCollapseClick(i: Navbar) { - i.collapseButtonRef.current?.click(); + if (i.collapseButtonRef.current?.ariaExpanded === "true") { + i.collapseButtonRef.current?.click(); + } } function handleLogOut(i: Navbar) { @@ -55,77 +45,42 @@ function handleLogOut(i: Navbar) { } export class Navbar extends Component { - 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(); mobileMenuRef = createRef(); 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(); + await 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 +103,15 @@ export class Navbar extends Component { 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)} > - {this.state.unreadInboxCount > 0 && ( + {this.unreadInboxCount > 0 && ( - {numToSI(this.state.unreadInboxCount)} + {numToSI(this.unreadInboxCount)} )} @@ -167,15 +122,15 @@ export class Navbar extends Component { 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)} > - {this.state.unreadReportCount > 0 && ( + {this.unreadReportCount > 0 && ( - {numToSI(this.state.unreadReportCount)} + {numToSI(this.unreadReportCount)} )} @@ -187,15 +142,15 @@ export class Navbar extends Component { 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)} > - {this.state.unreadApplicationCount > 0 && ( + {this.unreadApplicationCount > 0 && ( - {numToSI(this.state.unreadApplicationCount)} + {numToSI(this.unreadApplicationCount)} )} @@ -272,20 +227,16 @@ export class Navbar extends Component {
    - {!this.context.router.history.location.pathname.match( - /^\/search/ - ) && ( -
  • - - - -
  • - )} +
  • + + + +
  • {amAdmin() && (
  • { 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)} > - {this.state.unreadInboxCount > 0 && ( - - {numToSI(this.state.unreadInboxCount)} + {this.unreadInboxCount > 0 && ( + + {numToSI(this.unreadInboxCount)} )} @@ -324,15 +275,15 @@ export class Navbar extends Component { 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)} > - {this.state.unreadReportCount > 0 && ( - - {numToSI(this.state.unreadReportCount)} + {this.unreadReportCount > 0 && ( + + {numToSI(this.unreadReportCount)} )} @@ -344,17 +295,15 @@ export class Navbar extends Component { 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)} > - {this.state.unreadApplicationCount > 0 && ( + {this.unreadApplicationCount > 0 && ( - {numToSI(this.state.unreadApplicationCount)} + {numToSI(this.unreadApplicationCount)} )} @@ -457,101 +406,65 @@ export class Navbar extends Component { 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(msg); + async fetchUnreads() { + const auth = myAuth(); + if (auth) { + this.setState({ unreadInboxCountRes: { state: "loading" } }); this.setState({ - unreadInboxCount: data.replies + data.mentions + data.private_messages, + unreadInboxCountRes: await HttpService.client.getUnreadCount({ + auth, + }), }); - this.sendUnreadCount(); - } else if (op == UserOperation.GetReportCount) { - const data = wsJsonToRes(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(msg); - this.setState({ unreadApplicationCount: data.registration_applications }); - this.sendApplicationUnread(); - } else if (op == UserOperation.CreateComment) { - const data = wsJsonToRes(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(msg); - if ( - data.private_message_view.recipient.id == - UserService.Instance.myUserInfo?.local_user_view.person.id - ) { + if (this.moderatesSomething) { + this.setState({ unreadReportCountRes: { state: "loading" } }); this.setState({ - unreadInboxCount: this.state.unreadInboxCount + 1, + unreadReportCountRes: await HttpService.client.getReportCount({ + auth, + }), + }); + } + + if (amAdmin()) { + this.setState({ unreadApplicationCountRes: { state: "loading" } }); + this.setState({ + unreadApplicationCountRes: + await HttpService.client.getUnreadRegistrationApplicationCount({ + auth, + }), }); - this.sendUnreadCount(); - notifyPrivateMessage(data.private_message_view, this.context.router); } } } - fetchUnreads() { - console.log("Fetching inbox unreads..."); + 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; + } + } - const auth = myAuth(); - if (auth) { - const unreadForm: GetUnreadCount = { - auth, - }; - WebSocketService.Instance.send(wsClient.getUnreadCount(unreadForm)); + 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; + } + } - console.log("Fetching reports..."); - - const reportCountForm: GetReportCount = { - auth, - }; - WebSocketService.Instance.send(wsClient.getReportCount(reportCountForm)); - - if (amAdmin()) { - console.log("Fetching applications..."); - - const applicationCountForm: GetUnreadRegistrationApplicationCount = { - auth, - }; - WebSocketService.Instance.send( - wsClient.getUnreadRegistrationApplicationCount(applicationCountForm) - ); - } + get unreadApplicationCount(): number { + if (this.state.unreadApplicationCountRes.state == "success") { + const data = this.state.unreadApplicationCountRes.data; + return data.registration_applications; + } else { + return 0; } } @@ -559,22 +472,6 @@ export class Navbar extends Component { 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 () { diff --git a/src/shared/components/comment/comment-form.tsx b/src/shared/components/comment/comment-form.tsx index 42ed226d..c60cde20 100644 --- a/src/shared/components/comment/comment-form.tsx +++ b/src/shared/components/comment/comment-form.tsx @@ -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 { - 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 { 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 { { ); } - 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) { - if (typeof node === "number") { - const postId = node; - const form: CreateComment = { + handleCommentSubmit(content: string, form_id: string, language_id?: number) { + const { node, onUpsertComment, edit } = this.props; + if (typeof node === "number") { + const post_id = node; + onUpsertComment({ + content, + post_id, + language_id, + form_id, + auth: myAuthRequired(), + }); + } else { + if (edit) { + const comment_id = node.comment_view.comment.id; + onUpsertComment({ content, - form_id: this.state.formId, - post_id: postId, + comment_id, + form_id, language_id, - auth, - }; - WebSocketService.Instance.send(wsClient.createComment(form)); + auth: myAuthRequired(), + }); } else { - if (this.props.edit) { - const form: EditComment = { - content, - form_id: this.state.formId, - comment_id: node.comment_view.comment.id, - language_id, - auth, - }; - WebSocketService.Instance.send(wsClient.editComment(form)); - } else { - const form: CreateComment = { - content, - form_id: this.state.formId, - post_id: node.comment_view.post.id, - parent_id: node.comment_view.comment.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(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 }); - } + const post_id = node.comment_view.post.id; + const parent_id = node.comment_view.comment.id; + this.props.onUpsertComment({ + content, + parent_id, + post_id, + form_id, + language_id, + auth: myAuthRequired(), + }); } } } diff --git a/src/shared/components/comment/comment-node.tsx b/src/shared/components/comment/comment-node.tsx index f80cc8b5..8559f38b 100644 --- a/src/shared/components/comment/comment-node.tsx +++ b/src/shared/components/comment/comment-node.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { Component, linkEvent } from "inferno"; +import { Component, InfernoNode, linkEvent } from "inferno"; import { Link } from "inferno-router"; import { AddAdmin, @@ -7,13 +7,16 @@ import { BanFromCommunity, BanPerson, BlockPerson, + CommentId, CommentReplyView, CommentView, CommunityModeratorView, + CreateComment, CreateCommentLike, CreateCommentReport, DeleteComment, DistinguishComment, + EditComment, GetComments, Language, MarkCommentReplyAsRead, @@ -33,8 +36,9 @@ import { CommentNodeI, CommentViewType, PurgeType, + VoteType, } from "../../interfaces"; -import { UserService, WebSocketService } from "../../services"; +import { UserService } from "../../services"; import { amCommunityCreator, canAdmin, @@ -49,10 +53,11 @@ import { mdToHtml, mdToHtmlNoImages, myAuth, + myAuthRequired, + newVote, numToSI, setupTippy, showScores, - wsClient, } from "../../utils"; import { Icon, PurgeWarning, Spinner } from "../common/icon"; import { MomentTime } from "../common/moment-time"; @@ -74,7 +79,6 @@ interface CommentNodeState { showPurgeDialog: boolean; purgeReason?: string; purgeType: PurgeType; - purgeLoading: boolean; showConfirmTransferSite: boolean; showConfirmTransferCommunity: boolean; showConfirmAppointAsMod: boolean; @@ -84,12 +88,22 @@ interface CommentNodeState { showAdvanced: boolean; showReportDialog: boolean; reportReason?: string; - my_vote?: number; - score: number; - upvotes: number; - downvotes: number; - readLoading: boolean; + createOrEditCommentLoading: boolean; + upvoteLoading: boolean; + downvoteLoading: boolean; saveLoading: boolean; + readLoading: boolean; + blockPersonLoading: boolean; + deleteLoading: boolean; + removeLoading: boolean; + distinguishLoading: boolean; + banLoading: boolean; + addModLoading: boolean; + addAdminLoading: boolean; + transferCommunityLoading: boolean; + fetchChildrenLoading: boolean; + reportLoading: boolean; + purgeLoading: boolean; } interface CommentNodeProps { @@ -108,6 +122,26 @@ interface CommentNodeProps { allLanguages: Language[]; siteLanguages: number[]; hideImages?: boolean; + finished: Map; + 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 CommentNode extends Component { @@ -119,7 +153,6 @@ export class CommentNode extends Component { removeData: false, banType: BanType.Community, showPurgeDialog: false, - purgeLoading: false, purgeType: PurgeType.Person, collapsed: false, viewSource: false, @@ -129,67 +162,109 @@ export class CommentNode extends Component { showConfirmAppointAsMod: false, showConfirmAppointAsAdmin: false, showReportDialog: false, - my_vote: this.props.node.comment_view.my_vote, - score: this.props.node.comment_view.counts.score, - upvotes: this.props.node.comment_view.counts.upvotes, - downvotes: this.props.node.comment_view.counts.downvotes, - readLoading: false, + createOrEditCommentLoading: false, + upvoteLoading: false, + downvoteLoading: false, saveLoading: false, + readLoading: false, + blockPersonLoading: false, + deleteLoading: false, + removeLoading: false, + distinguishLoading: false, + banLoading: false, + addModLoading: false, + addAdminLoading: false, + transferCommunityLoading: false, + fetchChildrenLoading: false, + reportLoading: false, + purgeLoading: false, }; constructor(props: any, context: any) { super(props, context); this.handleReplyCancel = this.handleReplyCancel.bind(this); - this.handleCommentUpvote = this.handleCommentUpvote.bind(this); - this.handleCommentDownvote = this.handleCommentDownvote.bind(this); } - // TODO see if there's a better way to do this, and all willReceiveProps - componentWillReceiveProps(nextProps: CommentNodeProps) { - const cv = nextProps.node.comment_view; - this.setState({ - my_vote: cv.my_vote, - upvotes: cv.counts.upvotes, - downvotes: cv.counts.downvotes, - score: cv.counts.score, - readLoading: false, - saveLoading: false, - }); + get commentView(): CommentView { + return this.props.node.comment_view; + } + + get commentId(): CommentId { + return this.commentView.comment.id; + } + + componentWillReceiveProps( + nextProps: Readonly<{ children?: InfernoNode } & CommentNodeProps> + ): void { + if (this.props != nextProps) { + this.setState({ + showReply: false, + showEdit: false, + showRemoveDialog: false, + showBanDialog: false, + removeData: false, + banType: BanType.Community, + showPurgeDialog: false, + purgeType: PurgeType.Person, + collapsed: false, + viewSource: false, + showAdvanced: false, + showConfirmTransferSite: false, + showConfirmTransferCommunity: false, + showConfirmAppointAsMod: false, + showConfirmAppointAsAdmin: false, + showReportDialog: false, + createOrEditCommentLoading: false, + upvoteLoading: false, + downvoteLoading: false, + saveLoading: false, + readLoading: false, + blockPersonLoading: false, + deleteLoading: false, + removeLoading: false, + distinguishLoading: false, + banLoading: false, + addModLoading: false, + addAdminLoading: false, + transferCommunityLoading: false, + fetchChildrenLoading: false, + reportLoading: false, + purgeLoading: false, + }); + } } render() { const node = this.props.node; - const cv = this.props.node.comment_view; + const cv = this.commentView; const purgeTypeText = this.state.purgeType == PurgeType.Comment ? i18n.t("purge_comment") : `${i18n.t("purge")} ${cv.creator.name}`; - const canMod_ = - canMod(cv.creator.id, this.props.moderators, this.props.admins) && - cv.community.local; - const canModOnSelf = - canMod( - cv.creator.id, - this.props.moderators, - this.props.admins, - UserService.Instance.myUserInfo, - true - ) && cv.community.local; - const canAdmin_ = - canAdmin(cv.creator.id, this.props.admins) && cv.community.local; - const canAdminOnSelf = - canAdmin( - cv.creator.id, - this.props.admins, - UserService.Instance.myUserInfo, - true - ) && cv.community.local; + const canMod_ = canMod( + cv.creator.id, + this.props.moderators, + this.props.admins + ); + const canModOnSelf = canMod( + cv.creator.id, + this.props.moderators, + this.props.admins, + UserService.Instance.myUserInfo, + true + ); + const canAdmin_ = canAdmin(cv.creator.id, this.props.admins); + const canAdminOnSelf = canAdmin( + cv.creator.id, + this.props.admins, + UserService.Instance.myUserInfo, + true + ); const isMod_ = isMod(cv.creator.id, this.props.moderators); - const isAdmin_ = - isAdmin(cv.creator.id, this.props.admins) && cv.community.local; + const isAdmin_ = isAdmin(cv.creator.id, this.props.admins); const amCommunityCreator_ = amCommunityCreator( cv.creator.id, this.props.moderators @@ -218,9 +293,7 @@ export class CommentNode extends Component { id={`comment-${cv.comment.id}`} className={classNames(`details comment-node py-2`, { "border-top border-light": !this.props.noBorder, - mark: - this.isCommentNew || - this.props.node.comment_view.comment.distinguished, + mark: this.isCommentNew || this.commentView.comment.distinguished, })} style={ !this.props.noIndent && this.props.node.depth @@ -297,18 +370,24 @@ export class CommentNode extends Component { <> - - {numToSI(this.state.score)} - + {this.state.upvoteLoading ? ( + + ) : ( + + {numToSI(this.commentView.counts.score)} + + )} @@ -327,9 +406,13 @@ export class CommentNode extends Component { edit onReplyCancel={this.handleReplyCancel} disabled={this.props.locked} + finished={this.props.finished.get( + this.props.node.comment_view.comment.id + )} focus allLanguages={this.props.allLanguages} siteLanguages={this.props.siteLanguages} + onUpsertComment={this.props.onEditComment} /> )} {!this.state.showEdit && !this.state.collapsed && ( @@ -351,7 +434,7 @@ export class CommentNode extends Component { {this.props.markable && ( {this.props.enableDownvotes && ( )} )} {(canModOnSelf || canAdminOnSelf) && ( @@ -546,7 +650,7 @@ export class CommentNode extends Component { className="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleDistinguishClick + this.handleDistinguishComment )} data-tippy-content={ !cv.comment.distinguished @@ -588,11 +692,15 @@ export class CommentNode extends Component { className="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleModRemoveSubmit + this.handleRemoveComment )} aria-label={i18n.t("restore")} > - {i18n.t("restore")} + {this.state.removeLoading ? ( + + ) : ( + i18n.t("restore") + )} )} @@ -617,11 +725,15 @@ export class CommentNode extends Component { className="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleModBanFromCommunitySubmit + this.handleBanPersonFromCommunity )} aria-label={i18n.t("unban")} > - {i18n.t("unban")} + {this.state.banLoading ? ( + + ) : ( + i18n.t("unban") + )} ))} {!cv.creator_banned_from_community && @@ -658,7 +770,11 @@ export class CommentNode extends Component { )} aria-label={i18n.t("yes")} > - {i18n.t("yes")} + {this.state.addModLoading ? ( + + ) : ( + i18n.t("yes") + )} )} @@ -803,7 +927,11 @@ export class CommentNode extends Component { )} aria-label={i18n.t("yes")} > - {i18n.t("yes")} + {this.state.addAdminLoading ? ( + + ) : ( + i18n.t("yes") + )} )} @@ -852,7 +988,7 @@ export class CommentNode extends Component { {this.state.showRemoveDialog && (