Merge branch 'main' into comment-depth

This commit is contained in:
SleeplessOne1917 2023-06-14 23:46:11 +00:00 committed by GitHub
commit 31e61e7449
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
94 changed files with 9939 additions and 9699 deletions

View file

@ -18,6 +18,7 @@
"@typescript-eslint/ban-ts-comment": 0, "@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/explicit-module-boundary-types": 0, "@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-empty-function": 0,
"arrow-body-style": 0, "arrow-body-style": 0,
"curly": 0, "curly": 0,
"eol-last": 0, "eol-last": 0,
@ -37,7 +38,7 @@
"no-useless-constructor": 0, "no-useless-constructor": 0,
"no-useless-escape": 0, "no-useless-escape": 0,
"no-var": 0, "no-var": 0,
"prefer-const": 0, "prefer-const": 1,
"prefer-rest-params": 0, "prefer-rest-params": 0,
"quote-props": 0, "quote-props": 0,
"unicorn/filename-case": 0 "unicorn/filename-case": 0

View file

@ -1,27 +0,0 @@
---
name: "\U0001F41E Bug Report"
about: Create a report to help us improve Lemmy
title: ""
labels: bug
assignees: ""
---
Found a bug? Please fill out the sections below. 👍
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy)
### Issue Summary
A summary of the bug.
### Steps to Reproduce
1. (for example) I clicked login, and an endless spinner show up.
2. I tried to install lemmy via this guide, and I'm getting this error.
3. ...
### Technical details
- Please post your log: `sudo docker-compose logs > lemmy_log.out`.
- What OS are you trying to install lemmy on?
- Any browser console errors?

47
.github/ISSUE_TEMPLATE/BUG_REPORT.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: "\U0001F41E Bug Report"
description: Create a report to help us improve lemmy-ui
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
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: textarea
id: summary
attributes:
label: Summary
description: A summary of the bug.
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to Reproduce
description: |
Describe the steps to reproduce the bug.
The better your description is _(go 'here', click 'there'...)_ the fastest you'll get an _(accurate)_ resolution.
value: |
1.
2.
3.
validations:
required: true
- type: textarea
id: technical
attributes:
label: Technical Details
description: |
- Any browser console errors?
validations:
required: true
- type: input
id: lemmy-ui-version
attributes:
label: Version
description: Which Lemmy UI version do you use? Displayed in the footer.
placeholder: ex. 0.17.4-rc.4
validations:
required: true

View file

@ -1,43 +0,0 @@
---
name: "\U0001F680 Feature request"
about: Suggest an idea for improving Lemmy
title: ""
labels: enhancement
assignees: ""
---
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy)
### Is your proposal related to a problem?
<!--
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
-->
(Write your answer here.)
### Describe the solution you'd like
<!--
Provide a clear and concise description of what you want to happen.
-->
(Describe your proposed solution here.)
### Describe alternatives you've considered
<!--
Let us know about other solutions you've tried or researched.
-->
(Write your answer here.)
### Additional context
<!--
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.
-->
(Write your answer here.)

View file

@ -0,0 +1,41 @@
name: "\U0001F680 Feature request"
description: Suggest an idea for improving Lemmy
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Have a suggestion about Lemmy's UI?
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy)
- type: textarea
id: problem
attributes:
label: Is your proposal related to a problem?
description: |
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like.
description: |
Provide a clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered.
description: |
Let us know about other solutions you've tried or researched.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: |
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.

View file

@ -1,9 +0,0 @@
---
name: "? Question"
about: General questions about Lemmy
title: ""
labels: question
assignees: ""
---
What's the question you have about lemmy?

17
.github/ISSUE_TEMPLATE/QUESTION.yml vendored Normal file
View file

@ -0,0 +1,17 @@
name: "? Question"
description: General questions about Lemmy
title: "Question: "
labels: ["question", "triage"]
body:
- type: markdown
attributes:
value: |
Have a question about Lemmy's UI?
Please check the docs first: https://join-lemmy.org/docs/en/index.html
- type: textarea
id: question
attributes:
label: Question
description: What's the question you have about Lemmy's UI?
validations:
required: true

View file

@ -1,9 +0,0 @@
---
name: Hexbear
about: For hexbear issues
title: ""
labels: hexbear
assignees: ""
---
For hexbear-related issues

11
.github/ISSUE_TEMPLATE/hexbear.yml vendored Normal file
View file

@ -0,0 +1,11 @@
name: "Hexbear"
description: For hexbear issues
labels: ["hexbear", "triage"]
body:
- type: textarea
id: question
attributes:
label: Question
description: What's the question you have about hexbear?
validations:
required: true

View file

@ -1,6 +1,6 @@
pipeline: pipeline:
fetch_git_submodules: fetch_git_submodules:
image: node:14-alpine image: node:alpine
commands: commands:
- apk add git - apk add git
- git submodule init - git submodule init
@ -8,93 +8,27 @@ pipeline:
# - git fetch --tags # - git fetch --tags
yarn: yarn:
image: node:14-alpine image: node:alpine
commands: commands:
- yarn - yarn
yarn_lint: yarn_lint:
image: node:14-alpine image: node:alpine
commands: commands:
- yarn lint - yarn lint
yarn_build_dev: yarn_build_dev:
image: node:14-alpine image: node:alpine
commands: commands:
- yarn build:dev - yarn build:dev
nightly_build: publish_release_docker:
image: plugins/docker image: woodpeckerci/plugin-docker-buildx
secrets: [docker_username, docker_password]
settings: settings:
dockerfile: Dockerfile
repo: dessalines/lemmy-ui 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 dockerfile: Dockerfile
repo: dessalines/lemmy-ui platforms: linux/amd64
auto_tag: true 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: when:
event: tag 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. 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_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_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_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_HTTPS` | `bool` | `false` | Whether to use https. |
| `LEMMY_UI_EXTRA_THEMES_FOLDER` | `string` | `./extra_themes` | A location for additional lemmy css themes. | | `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. | | `LEMMY_UI_DEBUG` | `bool` | `false` | Loads the [Eruda](https://github.com/liriliri/eruda) debugging utility. |

View file

@ -4,7 +4,8 @@ set -e
new_tag="$1" new_tag="$1"
# Old deploy # Old deploy
# sudo docker build . --tag dessalines/lemmy-ui:$new_tag # 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 # sudo docker push dessalines/lemmy-ui:$new_tag
# Upgrade version # Upgrade version

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

View file

@ -1,6 +1,6 @@
{ {
"name": "lemmy-ui", "name": "lemmy-ui",
"version": "0.17.1", "version": "0.18.0-beta.6",
"description": "An isomorphic UI for lemmy", "description": "An isomorphic UI for lemmy",
"repository": "https://github.com/LemmyNet/lemmy-ui", "repository": "https://github.com/LemmyNet/lemmy-ui",
"license": "AGPL-3.0", "license": "AGPL-3.0",
@ -17,16 +17,9 @@
"start": "yarn build:dev --watch" "start": "yarn build:dev --watch"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js}": [ "*.{ts,tsx,js}": ["prettier --write", "eslint --fix"],
"prettier --write", "*.{css, scss}": ["prettier --write"],
"eslint --fix" "package.json": ["sortpack"]
],
"*.{css, scss}": [
"prettier --write"
],
"package.json": [
"sortpack"
]
}, },
"dependencies": { "dependencies": {
"@babel/plugin-proposal-decorators": "^7.21.0", "@babel/plugin-proposal-decorators": "^7.21.0",
@ -49,6 +42,7 @@
"emoji-mart": "^5.4.0", "emoji-mart": "^5.4.0",
"emoji-short-name": "^2.0.0", "emoji-short-name": "^2.0.0",
"express": "~4.18.2", "express": "~4.18.2",
"history": "^5.3.0",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"i18next": "^22.4.15", "i18next": "^22.4.15",
"inferno": "^8.1.1", "inferno": "^8.1.1",
@ -60,7 +54,7 @@
"inferno-server": "^8.1.1", "inferno-server": "^8.1.1",
"isomorphic-cookie": "^1.2.4", "isomorphic-cookie": "^1.2.4",
"jwt-decode": "^3.1.2", "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", "lodash": "^4.17.21",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0", "markdown-it-container": "^3.0.0",
@ -73,7 +67,6 @@
"moment": "^2.29.4", "moment": "^2.29.4",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"run-node-webpack-plugin": "^1.3.0", "run-node-webpack-plugin": "^1.3.0",
"rxjs": "^7.8.1",
"sanitize-html": "^2.10.0", "sanitize-html": "^2.10.0",
"sass": "^1.62.1", "sass": "^1.62.1",
"sass-loader": "^13.2.2", "sass-loader": "^13.2.2",
@ -85,8 +78,7 @@
"tributejs": "^5.1.3", "tributejs": "^5.1.3",
"webpack": "5.82.1", "webpack": "5.82.1",
"webpack-cli": "^5.1.1", "webpack-cli": "^5.1.1",
"webpack-node-externals": "^3.0.0", "webpack-node-externals": "^3.0.0"
"websocket-ts": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.21.8", "@babel/core": "^7.21.8",
@ -120,6 +112,7 @@
"typescript": "^5.0.4", "typescript": "^5.0.4",
"webpack-dev-server": "4.15.0" "webpack-dev-server": "4.15.0"
}, },
"packageManager": "yarn@1.22.19",
"engines": { "engines": {
"node": ">=8.9.0" "node": ">=8.9.0"
}, },

View file

@ -75,6 +75,11 @@
font-size: 1.2rem; font-size: 1.2rem;
} }
.md-div pre {
white-space: pre;
overflow-x: auto;
}
.md-div table { .md-div table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
@ -275,6 +280,10 @@ hr {
-ms-filter: blur(10px); -ms-filter: blur(10px);
} }
.img-cover {
object-fit: cover;
}
.img-expanded { .img-expanded {
max-height: 90vh; max-height: 90vh;
} }

View file

@ -1,865 +0,0 @@
//
// Variables
// --------------------------------------------------
//== Colors
//
//## Gray and brand colors for use across Bootstrap.
//// colors from bs-2
// Grays
// -------------------------
$black: #000;
$grayDark: #555;
$gray: #bbb;
$grayLight: #bbb;
$white: #fff;
// Accent colors
// -------------------------
$blue: #5555ff;
$cyan: #55ffff;
$cyanDark: #00aaaa;
$blueDark: #000084;
$green: #55ff55;
$greenDark: #00aa00;
$magenta: #ff55ff;
$magentaDark: #aa00aa;
$red: #ff5555;
$redDark: #aa0000;
$yellow: #fefe54;
$brown: #aa5500;
$orange: #a85400;
$pink: #fe54fe;
$purple: #fe5454;
// end colors
$gray-base: $gray;
$gray-darker: $grayDark;
$gray-dark: $grayDark;
$gray-light: $grayLight;
$gray-lighter: $grayLight;
$brand-primary: $gray;
$brand-primary-bg: $cyanDark;
$brand-success: $greenDark;
$brand-info: $brown;
$brand-warning: $magentaDark;
$brand-danger: $redDark;
//== Scaffolding
//
//## Settings for some of the most global styles.
//** Background color for `<body>`.
$body-bg: $blueDark;
//** Global text color on `<body>`.
$text-color: $gray-light;
//** Global textual link color.
$link-color: $brand-primary;
//** Link hover color set via `darken()` function.
$link-hover-color: $white;
//** Link hover decoration.
$link-hover-decoration: none;
//== Typography
//
//## Font, line-height, and color for body text, headings, and more.
$font-family-sans-serif: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
$font-family-serif: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
//** Default monospace fonts for `<code>`, `<kbd>`, and `<pre>`.
$font-family-monospace: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
$font-family-base: $font-family-sans-serif;
$baseWidth: 10px;
$font-size-base: 18px;
$font-size-large: $font-size-base;
$font-size-small: $font-size-base;
$font-size-h1: $font-size-base;
$font-size-h2: $font-size-base;
$font-size-h3: $font-size-base;
$font-size-h4: $font-size-base;
$font-size-h5: $font-size-base;
$font-size-h6: $font-size-base;
//** Unit-less `line-height` for use in components like buttons.
$baseLineHeight: 19px;
$line-height-base: $baseLineHeight;
//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
$line-height-computed: $line-height-base;
//** By default, this inherits from the `<body>`.
$headings-font-family: inherit;
$headings-font-weight: normal;
$headings-line-height: $line-height-base;
$headings-color: inherit;
$space: $baseWidth;
$halfbaseLineHeight: ($baseLineHeight / 2);
$borderWidth: 2px;
$baseLineWidth: ($baseLineHeight / 2);
$halfSpace: ($baseWidth / 2);
$lhsNB: ($baseWidth / 2 + 1);
$rhsNB: ($baseWidth / 2 - 1);
$lhs: ($lhsNB - ($borderWidth));
$rhs: ($rhsNB - ($borderWidth / 2));
$tsNB: ($baseLineHeight / 2);
$bsNB: $tsNB;
$ts: ($tsNB - ($borderWidth / 2));
$bs: $ts;
$tsMargin: 3px;
//== Iconography
//
//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
//** Load fonts from this directory.
$icon-font-path: "../fonts/";
//** File name for all font files.
$icon-font-name: "glyphicons-halflings-regular";
//** Element ID within SVG icon file.
$icon-font-svg-id: "glyphicons_halflingsregular";
//== Components
//
//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
$padding-base-vertical: 0px;
$padding-base-horizontal: 0px;
$padding-large-vertical: 0px;
$padding-large-horizontal: $halfSpace;
$padding-small-vertical: 0px;
$padding-small-horizontal: 0px;
$padding-xs-vertical: 0px;
$padding-xs-horizontal: 0px;
$line-height-large: $baseLineHeight;
$line-height-small: $baseLineHeight;
$border-radius-base: 0;
$border-radius-large: 0;
$border-radius-small: 0;
//** Global color for active items (e.g., navs or dropdowns).
$component-active-color: $white;
//** Global background color for active items (e.g., navs or dropdowns).
$component-active-bg: $black;
//** Width of the `border` for generating carets that indicator dropdowns.
$caret-width-base: 4px;
//** Carets increase slightly in size for larger components.
$caret-width-large: 5px;
//== Tables
//
//## Customizes the `.table` component with basic values, each used across all table variations.
//** Padding for `<th>`s and `<td>`s.
$table-cell-padding: $ts $rhs $bs $lhs;
//** Padding for cells in `.table-condensed`.
$table-condensed-cell-padding: $ts $rhs $bs $lhs;
//** Default background color used for all tables.
$table-bg: transparent;
//** Background color used for `.table-striped`.
$table-bg-accent: $black;
//** Background color used for `.table-hover`.
$table-bg-hover: #f5f5f5;
$table-bg-active: $table-bg-hover;
//** Border color for table and cell borders.
$table-border-color: $gray;
//== Buttons
//
//## For each of Bootstrap's buttons, define text, background and border color.
$btn-font-weight: normal;
$btn-default-color: $black;
$btn-default-bg: $grayLight;
$btn-default-border: $grayLight;
$btn-primary-color: $black;
$btn-primary-bg: $cyanDark;
$btn-primary-border: $grayLight;
$btn-success-color: #fff;
$btn-success-bg: $brand-success;
$btn-success-border: $btn-success-bg;
$btn-info-color: #fff;
$btn-info-bg: $brand-info;
$btn-info-border: $btn-info-bg;
$btn-warning-color: #fff;
$btn-warning-bg: $brand-warning;
$btn-warning-border: $btn-warning-bg;
$btn-danger-color: #fff;
$btn-danger-bg: $brand-danger;
$btn-danger-border: $btn-danger-bg;
$btn-link-disabled-color: $gray-light;
//== Forms
//
//##
//** `<input>` background color
$input-bg: $cyanDark;
//** `<input disabled>` background color
$input-bg-disabled: $gray-lighter;
//** Text color for `<input>`s
$input-color: $white;
//** `<input>` border color
$input-border: #ccc;
// TODO: Rename `$input-border-radius` to `$input-border-radius-base` in v4
//** Default `.form-control` border radius
// This has no effect on `<select>`s in some browsers, due to the limited stylability of `<select>`s in CSS.
$input-border-radius: $border-radius-base;
//** Large `.form-control` border radius
$input-border-radius-large: $border-radius-large;
//** Small `.form-control` border radius
$input-border-radius-small: $border-radius-small;
//** Border color for inputs on focus
$input-border-focus: $black;
//** Placeholder text color
$input-color-placeholder: $black;
//** Default `.form-control` height
$input-height-base: $line-height-computed;
//** Large `.form-control` height
$input-height-large: $input-height-base;
//** Small `.form-control` height
$input-height-small: $input-height-base;
$legend-color: $gray-dark;
$legend-border-color: #e5e5e5;
//** Background color for textual input addons
$input-group-addon-bg: $gray-lighter;
//** Border color for textual input addons
$input-group-addon-border-color: $input-border;
//** Disabled cursor for form controls and buttons.
$cursor-disabled: not-allowed;
//== Dropdowns
//
//## Dropdown menu container and contents.
//** Background for the dropdown menu.
$dropdown-bg: $gray;
//** Dropdown menu `border-color`.
$dropdown-border: rgb(0, 0, 0);
//** Dropdown menu `border-color` **for IE8**.
$dropdown-fallback-border: #ccc;
//** Divider color for between dropdown items.
$dropdown-divider-bg: $black;
//** Dropdown link text color.
$dropdown-link-color: $black;
//** Hover color for dropdown links.
$dropdown-link-hover-color: $gray;
//** Hover background for dropdown links.
$dropdown-link-hover-bg: $black;
//** Active dropdown menu item text color.
$dropdown-link-active-color: $component-active-color;
//** Active dropdown menu item background color.
$dropdown-link-active-bg: $component-active-bg;
//** Disabled dropdown menu item background color.
$dropdown-link-disabled-color: $gray-light;
//** Text color for headers within dropdown menus.
$dropdown-header-color: $black;
//** Deprecated `$dropdown-caret-color` as of v3.1.0
$dropdown-caret-color: #000;
//-- Z-index master list
//
// Warning: Avoid customizing these values. They're used for a bird's eye view
// of components dependent on the z-axis and are designed to all work together.
//
// Note: These variables are not generated into the Customizer.
$zindex-navbar: 1000;
$zindex-dropdown: 1000;
$zindex-popover: 1060;
$zindex-tooltip: 1070;
$zindex-navbar-fixed: 1030;
$zindex-modal: 1040;
//== Media queries breakpoints
//
//## Define the breakpoints at which your layout will change, adapting to different screen sizes.
// Extra small screen / phone
//** Deprecated `$screen-xs` as of v3.0.1
$screen-xs: 480px;
//** Deprecated `$screen-xs-min` as of v3.2.0
$screen-xs-min: $screen-xs;
//** Deprecated `$screen-phone` as of v3.0.1
$screen-phone: $screen-xs-min;
// Small screen / tablet
//** Deprecated `$screen-sm` as of v3.0.1
$screen-sm: 768px;
$screen-sm-min: $screen-sm;
//** Deprecated `$screen-tablet` as of v3.0.1
$screen-tablet: $screen-sm-min;
// Medium screen / desktop
//** Deprecated `$screen-md` as of v3.0.1
$screen-md: 992px;
$screen-md-min: $screen-md;
//** Deprecated `$screen-desktop` as of v3.0.1
$screen-desktop: $screen-md-min;
// Large screen / wide desktop
//** Deprecated `$screen-lg` as of v3.0.1
$screen-lg: 1200px;
$screen-lg-min: $screen-lg;
//** Deprecated `$screen-lg-desktop` as of v3.0.1
$screen-lg-desktop: $screen-lg-min;
// So media queries don't overlap when required, provide a maximum
$screen-xs-max: ($screen-sm-min - 1);
$screen-sm-max: ($screen-md-min - 1);
$screen-md-max: ($screen-lg-min - 1);
//== Grid system
//
//## Define your custom responsive grid.
//** Number of columns in the grid.
$grid-columns: 12;
//** Padding between columns. Gets divided in half for the left and right.
$grid-gutter-width: ($baseWidth * 2);
// Navbar collapse
//** Point at which the navbar becomes uncollapsed.
$grid-float-breakpoint: $screen-sm-min;
//** Point at which the navbar begins collapsing.
$grid-float-breakpoint-max: ($grid-float-breakpoint);
//== Container sizes
//
//## Define the maximum width of `.container` for different screen sizes.
// Small screen / tablet
$container-tablet: (720px + $grid-gutter-width);
//** For `$screen-sm-min` and up.
$container-sm: $container-tablet;
// Medium screen / desktop
$container-desktop: (940px + $grid-gutter-width);
//** For `$screen-md-min` and up.
$container-md: $container-desktop;
// Large screen / wide desktop
$container-large-desktop: (1140px + $grid-gutter-width);
//** For `$screen-lg-min` and up.
$container-lg: $container-large-desktop;
//== Navbar
//
//##
// Basics of a navbar
$navbar-height: 0px;
$navbar-margin-bottom: $line-height-computed;
$navbar-border-radius: $border-radius-base;
$navbar-padding-horizontal: ($baseWidth * 2);
$navbar-padding-vertical: 0;
$navbar-collapse-max-height: 340px;
$navbar-default-color: $black;
$navbar-default-bg: $grayLight;
$navbar-default-border: $navbar-default-bg;
// Navbar links
$navbar-default-link-color: $black;
$navbar-default-link-hover-color: $white;
$navbar-default-link-hover-bg: $black;
$navbar-default-link-active-color: $white;
$navbar-default-link-active-bg: $black;
$navbar-default-link-disabled-color: $gray;
$navbar-default-link-disabled-bg: transparent;
// Navbar brand label
$navbar-default-brand-color: $navbar-default-link-color;
$navbar-default-brand-hover-color: $navbar-default-brand-color;
$navbar-default-brand-hover-bg: transparent;
// Navbar toggle
$navbar-default-toggle-hover-bg: #ddd;
$navbar-default-toggle-icon-bar-bg: #888;
$navbar-default-toggle-border-color: #ddd;
// Inverted navbar
// Reset inverted navbar basics
$navbar-inverse-color: $gray;
$navbar-inverse-bg: $black;
$navbar-inverse-border: $navbar-inverse-bg;
// Inverted navbar links
$navbar-inverse-link-color: $gray-light;
$navbar-inverse-link-hover-color: $black;
$navbar-inverse-link-hover-bg: $grayLight;
$navbar-inverse-link-active-color: $white;
$navbar-inverse-link-active-bg: $grayDark;
$navbar-inverse-link-disabled-color: $gray;
$navbar-inverse-link-disabled-bg: transparent;
// Inverted navbar brand label
$navbar-inverse-brand-color: $navbar-inverse-link-color;
$navbar-inverse-brand-hover-color: #fff;
$navbar-inverse-brand-hover-bg: transparent;
// Inverted navbar toggle
$navbar-inverse-toggle-hover-bg: $grayLight;
$navbar-inverse-toggle-icon-bar-bg: #fff;
$navbar-inverse-toggle-border-color: #333;
//== Navs
//
//##
//=== Shared nav styles
$nav-link-padding: 0 $baseWidth;
$nav-link-hover-bg: $gray-lighter;
$nav-disabled-link-color: $gray-light;
$nav-disabled-link-hover-color: $gray-light;
//== Tabs
$nav-tabs-border-color: #ddd;
$nav-tabs-link-hover-border-color: $gray-lighter;
$nav-tabs-active-link-hover-bg: $black;
$nav-tabs-active-link-hover-color: $white;
$nav-tabs-justified-active-link-border-color: $body-bg;
//== Pills
$nav-pills-border-radius: $border-radius-base;
$nav-pills-active-link-hover-bg: $component-active-bg;
$nav-pills-active-link-hover-color: $component-active-color;
//== Pagination
//
//##
$pagination-color: $black;
$pagination-bg: $gray;
$pagination-border: #ddd;
$pagination-hover-color: $link-hover-color;
$pagination-hover-bg: $gray-lighter;
$pagination-hover-border: #ddd;
$pagination-active-color: #fff;
$pagination-active-bg: $brand-primary;
$pagination-active-border: $brand-primary;
$pagination-disabled-color: $gray-light;
$pagination-disabled-bg: #fff;
$pagination-disabled-border: #ddd;
//== Pager
//
//##
$pager-bg: $pagination-bg;
$pager-border: $pagination-border;
$pager-border-radius: 0;
$pager-hover-bg: $pagination-hover-bg;
$pager-active-bg: $pagination-active-bg;
$pager-active-color: $pagination-active-color;
$pager-disabled-color: $pagination-disabled-color;
//== Jumbotron
//
//##
$jumbotron-padding: ($ts) ($rhs + $baseWidth) ($bs) ($lhs + $baseWidth);
$jumbotron-color: $white;
$jumbotron-bg: transparent;
$jumbotron-heading-color: inherit;
$jumbotron-font-size: $font-size-base;
//== Form states and alerts
//
//## Define colors for form feedback states and, by default, alerts.
$state-success-text: $green;
$state-success-bg: $greenDark;
$state-success-border: $state-success-bg;
$state-info-text: $yellow;
$state-info-bg: $brown;
$state-info-border: $state-info-bg;
$state-warning-text: $magenta;
$state-warning-bg: $magentaDark;
$state-warning-border: $state-warning-bg;
$state-danger-text: $red;
$state-danger-bg: $black;
$state-danger-border: $state-danger-bg;
//== Tooltips
//
//##
//** Tooltip max width
$tooltip-max-width: ($baseWidth * 25);
//** Tooltip text color
$tooltip-color: $white;
//** Tooltip background color
$tooltip-bg: $grayDark;
$tooltip-opacity: 1;
//** Tooltip arrow width
$tooltip-arrow-width: 0px;
//** Tooltip arrow color
$tooltip-arrow-color: $tooltip-bg;
//== Popovers
//
//##
//** Popover body background color
$popover-bg: $gray;
//** Popover maximum width
$popover-max-width: ($baseWidth * 20);
//** Popover border color
$popover-border-color: rgb(0, 0, 0);
//** Popover fallback border color
$popover-fallback-border-color: #ccc;
//** Popover title background color
$popover-title-bg: $greenDark;
//** Popover arrow width
$popover-arrow-width: 10px;
//** Popover arrow color
$popover-arrow-color: $popover-bg;
//** Popover outer arrow width
$popover-arrow-outer-width: ($popover-arrow-width + 1);
//** Popover outer arrow color
$popover-arrow-outer-color: $popover-border-color;
//** Popover outer arrow fallback color
$popover-arrow-outer-fallback-color: $popover-fallback-border-color;
//== Labels
//
//##
//** Default label background color
$label-default-bg: $gray-light;
//** Primary label background color
$label-primary-bg: $brand-primary-bg;
//** Success label background color
$label-success-bg: $brand-success;
//** Info label background color
$label-info-bg: $brand-info;
//** Warning label background color
$label-warning-bg: $brand-warning;
//** Danger label background color
$label-danger-bg: $brand-danger;
//** Default label text color
$label-color: #fff;
//** Default text color of a linked label
$label-link-hover-color: #fff;
//== Modals
//
//##
//** Padding applied to the modal body
$modal-inner-padding: 0 $baseWidth;
//** Padding applied to the modal title
$modal-title-padding: 0 $baseWidth;
//** Modal title line-height
$modal-title-line-height: $line-height-base;
//** Background color of modal content area
$modal-content-bg: $gray;
//** Modal content border color
$modal-content-border-color: rgb(0, 0, 0);
//** Modal content border color **for IE8**
$modal-content-fallback-border-color: #999;
//** Modal backdrop background color
$modal-backdrop-bg: #000;
//** Modal backdrop opacity
// $modal-backdrop-opacity: @include 5;
//** Modal header border color
$modal-header-border-color: #e5e5e5;
//** Modal footer border color
$modal-footer-border-color: $modal-header-border-color;
$modal-lg: 900px;
$modal-md: 600px;
$modal-sm: 300px;
//== Alerts
//
//## Define alert colors, border radius, and padding.
$alert-padding: $line-height-base ($baseWidth * 2);
$alert-border-radius: $border-radius-base;
$alert-link-font-weight: normal;
$alert-success-bg: $state-success-bg;
$alert-success-text: $state-success-text;
$alert-success-border: $state-success-border;
$alert-info-bg: $state-info-bg;
$alert-info-text: $state-info-text;
$alert-info-border: $state-info-border;
$alert-warning-bg: $state-warning-bg;
$alert-warning-text: $state-warning-text;
$alert-warning-border: $state-warning-border;
$alert-danger-bg: $state-danger-bg;
$alert-danger-text: $state-danger-text;
$alert-danger-border: $state-danger-border;
//== Progress bars
//
//##
//** Background color of the whole progress component
$progress-bg: $black;
//** Progress bar text color
$progress-bar-color: $black;
//** Variable for setting rounded corners on progress bar.
$progress-border-radius: $border-radius-base;
//** Default progress bar color
$progress-bar-bg: $brand-primary;
//** Success progress bar color
$progress-bar-success-bg: $brand-success;
//** Warning progress bar color
$progress-bar-warning-bg: $brand-warning;
//** Danger progress bar color
$progress-bar-danger-bg: $brand-danger;
//** Info progress bar color
$progress-bar-info-bg: $brand-info;
//== List group
//
//##
//** Background color on `.list-group-item`
$list-group-bg: $gray;
//** `.list-group-item` border color
$list-group-border: #ddd;
//** List group border radius
$list-group-border-radius: $border-radius-base;
//** Background color of single list items on hover
$list-group-hover-bg: $black;
//** Text color of active list items
$list-group-active-color: $component-active-color;
//** Background color of active list items
$list-group-active-bg: $component-active-bg;
//** Border color of active list elements
$list-group-active-border: $list-group-active-bg;
//** Text color for content within active list items
$list-group-active-text-color: $component-active-color;
//** Text color of disabled list items
$list-group-disabled-color: $gray-dark;
//** Background color of disabled list items
$list-group-disabled-bg: $gray-lighter;
//** Text color for content within disabled list items
$list-group-disabled-text-color: $list-group-disabled-color;
$list-group-link-color: $black;
$list-group-link-hover-color: $list-group-link-color;
$list-group-link-heading-color: #333;
//== Panels
//
//##
$panel-bg: $gray;
$panel-body-padding: 0 $rhsNB 0 $lhsNB;
$panel-heading-padding: 0 $rhsNB 0 $lhsNB;
$panel-footer-padding: $panel-heading-padding;
$panel-border-radius: $border-radius-base;
//** Border color for elements within panels
$panel-inner-border: #ddd;
$panel-footer-bg: #f5f5f5;
$panel-default-text: $white;
$panel-default-border: #ddd;
$panel-default-heading-bg: $grayDark;
$panel-primary-text: $white;
$panel-primary-border: $brand-primary;
$panel-primary-heading-bg: $cyanDark;
$panel-success-text: $state-success-text;
$panel-success-border: $state-success-border;
$panel-success-heading-bg: $state-success-bg;
$panel-info-text: $state-info-text;
$panel-info-border: $state-info-border;
$panel-info-heading-bg: $state-info-bg;
$panel-warning-text: $state-warning-text;
$panel-warning-border: $state-warning-border;
$panel-warning-heading-bg: $state-warning-bg;
$panel-danger-text: $state-danger-text;
$panel-danger-border: $state-danger-border;
$panel-danger-heading-bg: $state-danger-bg;
//== Thumbnails
//
//##
//** Padding around the thumbnail image
$thumbnail-padding: 4px;
//** Thumbnail background color
$thumbnail-bg: $body-bg;
//** Thumbnail border color
$thumbnail-border: #ddd;
//** Thumbnail border radius
$thumbnail-border-radius: $border-radius-base;
//** Custom text color for thumbnail captions
$thumbnail-caption-color: $text-color;
//** Padding around the thumbnail caption
$thumbnail-caption-padding: 9px;
//== Wells
//
//##
$well-bg: $greenDark;
$well-border: $well-bg;
//== Badges
//
//##
$badge-color: $black;
//** Linked badge text color on hover
$badge-link-hover-color: #fff;
$badge-bg: $gray-light;
//** Badge text color in active nav link
$badge-active-color: $link-color;
//** Badge background color in active nav link
$badge-active-bg: $black;
$badge-font-weight: normal;
$badge-line-height: $line-height-base;
$badge-border-radius: 0;
//== Breadcrumbs
//
//##
$breadcrumb-padding-vertical: 8px;
$breadcrumb-padding-horizontal: 15px;
//** Breadcrumb background color
$breadcrumb-bg: #f5f5f5;
//** Breadcrumb text color
$breadcrumb-color: #ccc;
//** Text color of current page in the breadcrumb
$breadcrumb-active-color: $gray-light;
//** Textual separator for between breadcrumb elements
$breadcrumb-separator: "/";
//== Carousel
//
//##
$carousel-text-shadow: none;
$carousel-control-color: #fff;
$carousel-control-width: 15%;
$carousel-control-opacity: 1;
$carousel-control-font-size: $font-size-base;
$carousel-indicator-active-bg: #fff;
$carousel-indicator-border-color: #fff;
$carousel-caption-color: #fff;
//== Close
//
//##
$close-font-weight: normal;
$close-color: #000;
$close-text-shadow: none;
//== Code
//
//##
$code-color: #c7254e;
$code-bg: #f9f2f4;
$kbd-color: #fff;
$kbd-bg: #333;
$pre-bg: #f5f5f5;
$pre-color: $gray-dark;
$pre-border-color: #ccc;
$pre-scrollable-max-height: 340px;
//== Type
//
//##
//** Horizontal offset for forms and lists.
$component-offset-horizontal: 180px;
//** Text muted color
$text-muted: $gray-dark;
//** Abbreviations and acronyms border color
$abbr-border-color: $gray-light;
//** Headings small color
$headings-small-color: $gray-light;
//** Blockquote small color
$blockquote-small-color: $gray-light;
//** Blockquote font size
$blockquote-font-size: $font-size-base;
//** Blockquote border color
$blockquote-border-color: $gray-lighter;
//** Page header border color
$page-header-border-color: $gray-lighter;
//** Width of horizontal description list titles
$dl-horizontal-offset: $component-offset-horizontal;
//** Horizontal line color.
$hr-border: $black;

View file

@ -1,39 +0,0 @@
$blue: #5555ff;
$cyan: #55ffff;
$green: #55ff55;
$indigo: #ff55ff;
$red: #ff5555;
$yellow: #fefe54;
$orange: #a85400;
$pink: #fe54fe;
$purple: #fe5454;
$primary: #fefe54;
$body-bg: #000084;
$gray-300: #bbb;
$body-color: $gray-300;
$link-hover-color: $white;
$font-family-sans-serif: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
$font-family-monospace: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
$navbar-dark-color: $gray-300;
$navbar-light-brand-color: $gray-300;
$success: #00aa00;
$danger: #aa0000;
$info: #00aaaa;
$warning: #aa00aa;
$navbar-dark-active-color: $gray-100;
$enable-rounded: false;
$input-color: $white;
$input-bg: rgb(102, 102, 102);
$input-disabled-bg: $gray-800;
$nav-tabs-link-active-color: $gray-100;
$navbar-dark-hover-color: rgba($gray-300, 0.75);
$light: $gray-800;
$navbar-light-disabled-color: $gray-800;
$navbar-light-active-color: $gray-100;
$navbar-light-hover-color: $gray-200;
$navbar-light-color: $gray-300;
$card-bg: $gray-800;
$card-border-color: $white;
$input-placeholder-color: $gray-500;
$mark-bg: #463b00;
$secondary: $gray-900;

View file

@ -1,37 +0,0 @@
$blue: #01cdfe;
$indigo: #b967ff;
$purple: #b967ff;
$pink: rgb(255, 64, 186);
$red: rgb(255, 95, 110);
$orange: rgb(255, 167, 93);
$yellow: #fffb96;
$green: #05ffa1;
$teal: #01cdfe;
$cyan: #01cdfe;
$enable-shadows: true;
$enable-gradients: true;
$enable-responsive-font-sizes: true;
$body-bg: $gray-900;
$body-color: $gray-200;
$border-radius: 1rem;
$border-radius-lg: 1rem;
$font-family-monospace: Arial, "Noto Sans", sans-serif;
$yiq-text-light: $gray-300;
$secondary: $blue;
$text-muted: $gray-500;
$primary: $pink;
$navbar-light-hover-color: rgba($primary, 0.7);
$light: darken($gray-100, 1.5);
$font-family-sans-serif: "Lucida Console", Monaco, monospace;
$card-bg: $body-bg;
$navbar-dark-color: rgba($body-bg, 0.5);
$navbar-light-active-color: rgba($gray-200, 0.9);
$navbar-light-disabled-color: rgba($gray-200, 0.3);
$navbar-light-color: rgba($white, 0.5);
$input-bg: $gray-700;
$input-color: $gray-200;
$input-disabled-bg: $gray-800;
$input-border-color: $gray-800;
$mark-bg: $gray-600;
$pre-color: $gray-200;
mark-bg: $gray-600;

View file

@ -1,25 +0,0 @@
$blue: #01cdfe;
$indigo: #b967ff;
$purple: #b967ff;
$pink: rgb(255, 64, 186);
$red: rgb(255, 95, 110);
$orange: rgb(255, 167, 93);
$yellow: #fffb96;
$green: #05ffa1;
$teal: #01cdfe;
$cyan: #01cdfe;
$enable-shadows: true;
$enable-gradients: true;
$enable-responsive-font-sizes: true;
$body-bg: $gray-100;
$body-color: $gray-700;
$border-radius: 1rem;
$border-radius-lg: 1rem;
$font-family-monospace: Arial, "Noto Sans", sans-serif;
$yiq-text-light: $gray-300;
$secondary: $blue;
$text-muted: $gray-500;
$primary: $pink;
$navbar-light-hover-color: rgba($primary, 0.7);
$light: darken($gray-100, 1.5);
$font-family-sans-serif: "Lucida Console", Monaco, monospace;

View file

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

View file

@ -6,19 +6,20 @@ import { Helmet } from "inferno-helmet";
import { matchPath, StaticRouter } from "inferno-router"; import { matchPath, StaticRouter } from "inferno-router";
import { renderToString } from "inferno-server"; import { renderToString } from "inferno-server";
import IsomorphicCookie from "isomorphic-cookie"; 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 path from "path";
import process from "process"; import process from "process";
import serialize from "serialize-javascript"; import serialize from "serialize-javascript";
import sharp from "sharp"; import sharp from "sharp";
import { App } from "../shared/components/app/app"; import { App } from "../shared/components/app/app";
import { getHttpBase, getHttpBaseInternal } from "../shared/env"; import { getHttpBaseExternal, getHttpBaseInternal } from "../shared/env";
import { import {
ILemmyConfig, ILemmyConfig,
InitialFetchRequest, InitialFetchRequest,
IsoDataOptionalSite, IsoDataOptionalSite,
} from "../shared/interfaces"; } from "../shared/interfaces";
import { routes } from "../shared/routes"; import { routes } from "../shared/routes";
import { RequestState, wrapClient } from "../shared/services/HttpService";
import { import {
ErrorPageData, ErrorPageData,
favIconPngUrl, favIconPngUrl,
@ -38,7 +39,7 @@ if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) {
server.use(function (_req, res, next) { server.use(function (_req, res, next) {
res.setHeader( res.setHeader(
"Content-Security-Policy", "Content-Security-Policy",
`default-src 'self'; manifest-src *; connect-src *; img-src * data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'self'; frame-src *` `default-src 'self'; manifest-src *; connect-src *; img-src * data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'self'; frame-src *; media-src *`
); );
next(); next();
}); });
@ -64,7 +65,13 @@ Disallow: /search/
server.get("/service-worker.js", async (_req, res) => { server.get("/service-worker.js", async (_req, res) => {
res.setHeader("Content-Type", "application/javascript"); 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) => { server.get("/robots.txt", async (_req, res) => {
@ -121,7 +128,7 @@ server.get("/*", async (req, res) => {
const getSiteForm: GetSite = { auth }; const getSiteForm: GetSite = { auth };
const headers = setForwardedHeaders(req.headers); const headers = setForwardedHeaders(req.headers);
const client = new LemmyHttp(getHttpBaseInternal(), headers); const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers));
const { path, url, query } = req; 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, // 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 // in order to remove the jwt on the browser. Necessary for wrong jwts
let site: GetSiteResponse | undefined = undefined; let site: GetSiteResponse | undefined = undefined;
let routeData: any[] = []; const routeData: RequestState<any>[] = [];
let errorPageData: ErrorPageData | undefined; let errorPageData: ErrorPageData | undefined = undefined;
try { let try_site = await client.getSite(getSiteForm);
let try_site: any = await client.getSite(getSiteForm); if (try_site.state === "failed" && try_site.msg == "not_logged_in") {
if (try_site.error == "not_logged_in") { console.error(
console.error( "Incorrect JWT token, skipping auth so frontend can remove jwt cookie"
"Incorrect JWT token, skipping auth so frontend can remove jwt cookie" );
); getSiteForm.auth = undefined;
getSiteForm.auth = undefined; auth = undefined;
auth = undefined; try_site = await client.getSite(getSiteForm);
try_site = await client.getSite(getSiteForm); }
}
if (!auth && isAuthPath(path)) { if (!auth && isAuthPath(path)) {
res.redirect("/login"); return res.redirect("/login");
return; }
}
site = try_site; if (try_site.state === "success") {
site = try_site.data;
initializeSite(site); initializeSite(site);
if (path != "/setup" && !site.site_view.local_site.site_setup) {
return res.redirect("/setup");
}
if (site) { if (site) {
const initialFetchReq: InitialFetchRequest = { const initialFetchReq: InitialFetchRequest = {
client, client,
@ -160,23 +170,25 @@ server.get("/*", async (req, res) => {
}; };
if (activeRoute?.fetchInitialData) { if (activeRoute?.fetchInitialData) {
routeData = await Promise.all([ routeData.push(
...activeRoute.fetchInitialData(initialFetchReq), ...(await Promise.all([
]); ...activeRoute.fetchInitialData(initialFetchReq),
]))
);
} }
} }
} catch (error) { } else if (try_site.state === "failed") {
errorPageData = getErrorPageData(error, site); errorPageData = getErrorPageData(new Error(try_site.msg), site);
} }
// Redirect to the 404 if there's an API error // Redirect to the 404 if there's an API error
if (routeData[0] && routeData[0].error) { if (routeData[0] && routeData[0].state === "failed") {
const error = routeData[0].error; const error = routeData[0].msg;
console.error(error); console.error(error);
if (error === "instance_is_private") { if (error === "instance_is_private") {
return res.redirect(`/signup`); return res.redirect(`/signup`);
} else { } else {
errorPageData = getErrorPageData(error, site); errorPageData = getErrorPageData(new Error(error), site);
} }
} }
@ -213,15 +225,15 @@ server.listen(Number(port), hostname, () => {
function setForwardedHeaders(headers: IncomingHttpHeaders): { function setForwardedHeaders(headers: IncomingHttpHeaders): {
[key: string]: string; [key: string]: string;
} { } {
let out: { [key: string]: string } = {}; const out: { [key: string]: string } = {};
if (headers.host) { if (headers.host) {
out.host = headers.host; out.host = headers.host;
} }
let realIp = headers["x-real-ip"]; const realIp = headers["x-real-ip"];
if (realIp) { if (realIp) {
out["x-real-ip"] = realIp as string; out["x-real-ip"] = realIp as string;
} }
let forwardedFor = headers["x-forwarded-for"]; const forwardedFor = headers["x-forwarded-for"];
if (forwardedFor) { if (forwardedFor) {
out["x-forwarded-for"] = forwardedFor as string; out["x-forwarded-for"] = forwardedFor as string;
} }
@ -234,7 +246,7 @@ process.on("SIGINT", () => {
process.exit(0); process.exit(0);
}); });
const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512]; const iconSizes = [72, 96, 144, 192, 512];
const defaultLogoPathDirectory = path.join( const defaultLogoPathDirectory = path.join(
process.cwd(), process.cwd(),
"dist", "dist",
@ -242,12 +254,15 @@ const defaultLogoPathDirectory = path.join(
"icons" "icons"
); );
export async function generateManifestBase64(site: Site) { export async function generateManifestBase64({
const url = ( my_user,
process.env.NODE_ENV === "development" site_view: {
? "http://localhost:1236/" site,
: getHttpBase() local_site: { community_creation_admin_only },
).replace(/\/$/g, ""); },
}: GetSiteResponse) {
const url = getHttpBaseExternal();
const icon = site.icon ? await fetchIconPng(site.icon) : null; const icon = site.icon ? await fetchIconPng(site.icon) : null;
const manifest = { 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"); return Buffer.from(JSON.stringify(manifest)).toString("base64");
} }
async function fetchIconPng(iconUrl: string) { async function fetchIconPng(iconUrl: string) {
return await fetch( return await fetch(iconUrl)
iconUrl.replace(/https?:\/\/[^\/]+/g, getHttpBaseInternal())
)
.then(res => res.blob()) .then(res => res.blob())
.then(blob => blob.arrayBuffer()); .then(blob => blob.arrayBuffer());
} }
@ -330,14 +388,15 @@ async function createSsrHtml(root: string, isoData: IsoDataOptionalSite) {
.then(buf => buf.toString("base64"))}` .then(buf => buf.toString("base64"))}`
: favIconPngUrl; : favIconPngUrl;
const eruda = ( const erudaStr =
<> process.env["LEMMY_UI_DEBUG"] === "true"
<script src="//cdn.jsdelivr.net/npm/eruda"></script> ? renderToString(
<script>eruda.init();</script> <>
</> <script src="//cdn.jsdelivr.net/npm/eruda"></script>
); <script>eruda.init();</script>
</>
const erudaStr = process.env["LEMMY_UI_DEBUG"] ? renderToString(eruda) : ""; )
: "";
const helmet = Helmet.renderStatic(); const helmet = Helmet.renderStatic();
@ -345,9 +404,9 @@ async function createSsrHtml(root: string, isoData: IsoDataOptionalSite) {
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()} lang="en"> <html ${helmet.htmlAttributes.toString()}>
<head> <head>
<script>window.isoData = ${JSON.stringify(isoData)}</script> <script>window.isoData = ${serialize(isoData)}</script>
<script>window.lemmyConfig = ${serialize(config)}</script> <script>window.lemmyConfig = ${serialize(config)}</script>
<!-- A remote debugging utility for mobile --> <!-- A remote debugging utility for mobile -->
@ -375,9 +434,9 @@ async function createSsrHtml(root: string, isoData: IsoDataOptionalSite) {
site && site &&
`<link `<link
rel="manifest" rel="manifest"
href={${`data:application/manifest+json;base64,${await generateManifestBase64( href=${`data:application/manifest+json;base64,${await generateManifestBase64(
site.site_view.site site
)}`}} )}`}
/>` />`
} }
<link rel="apple-touch-icon" href=${appleTouchIcon} /> <link rel="apple-touch-icon" href=${appleTouchIcon} />

View file

@ -1,8 +1,8 @@
import { Component } from "inferno"; import { Component } from "inferno";
import { Provider } from "inferno-i18next-dess"; import { Provider } from "inferno-i18next-dess";
import { Route, Switch } from "inferno-router"; import { Route, Switch } from "inferno-router";
import { IsoDataOptionalSite } from "shared/interfaces";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { IsoDataOptionalSite } from "../../interfaces";
import { routes } from "../../routes"; import { routes } from "../../routes";
import { isAuthPath, setIsoData } from "../../utils"; import { isAuthPath, setIsoData } from "../../utils";
import AuthGuard from "../common/auth-guard"; import AuthGuard from "../common/auth-guard";

View file

@ -1,35 +1,23 @@
import { Component, createRef, linkEvent } from "inferno"; import { Component, createRef, linkEvent } from "inferno";
import { NavLink } from "inferno-router"; import { NavLink } from "inferno-router";
import { import {
CommentResponse,
GetReportCount,
GetReportCountResponse, GetReportCountResponse,
GetSiteResponse, GetSiteResponse,
GetUnreadCount,
GetUnreadCountResponse, GetUnreadCountResponse,
GetUnreadRegistrationApplicationCount,
GetUnreadRegistrationApplicationCountResponse, GetUnreadRegistrationApplicationCountResponse,
PrivateMessageResponse,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services"; import { UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import { import {
amAdmin, amAdmin,
canCreateCommunity, canCreateCommunity,
donateLemmyUrl, donateLemmyUrl,
isBrowser, isBrowser,
myAuth, myAuth,
notifyComment,
notifyPrivateMessage,
numToSI, numToSI,
showAvatars, showAvatars,
toast, toast,
wsClient,
wsSubscribe,
} from "../../utils"; } from "../../utils";
import { Icon } from "../common/icon"; import { Icon } from "../common/icon";
import { PictrsImage } from "../common/pictrs-image"; import { PictrsImage } from "../common/pictrs-image";
@ -39,9 +27,9 @@ interface NavbarProps {
} }
interface NavbarState { interface NavbarState {
unreadInboxCount: number; unreadInboxCountRes: RequestState<GetUnreadCountResponse>;
unreadReportCount: number; unreadReportCountRes: RequestState<GetReportCountResponse>;
unreadApplicationCount: number; unreadApplicationCountRes: RequestState<GetUnreadRegistrationApplicationCountResponse>;
onSiteBanner?(url: string): any; onSiteBanner?(url: string): any;
} }
@ -51,77 +39,48 @@ function handleCollapseClick(i: Navbar) {
} }
} }
function handleLogOut() { function handleLogOut(i: Navbar) {
UserService.Instance.logout(); UserService.Instance.logout();
handleCollapseClick(i);
} }
export class Navbar extends Component<NavbarProps, NavbarState> { export class Navbar extends Component<NavbarProps, NavbarState> {
private wsSub: Subscription;
private userSub: Subscription;
private unreadInboxCountSub: Subscription;
private unreadReportCountSub: Subscription;
private unreadApplicationCountSub: Subscription;
state: NavbarState = { state: NavbarState = {
unreadInboxCount: 0, unreadInboxCountRes: { state: "empty" },
unreadReportCount: 0, unreadReportCountRes: { state: "empty" },
unreadApplicationCount: 0, unreadApplicationCountRes: { state: "empty" },
}; };
subscription: any;
collapseButtonRef = createRef<HTMLButtonElement>(); collapseButtonRef = createRef<HTMLButtonElement>();
mobileMenuRef = createRef<HTMLDivElement>();
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.parseMessage = this.parseMessage.bind(this); this.handleOutsideMenuClick = this.handleOutsideMenuClick.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
} }
componentDidMount() { async componentDidMount() {
// Subscribe to jwt changes // Subscribe to jwt changes
if (isBrowser()) { if (isBrowser()) {
// On the first load, check the unreads // On the first load, check the unreads
let auth = myAuth(false); this.requestNotificationPermission();
if (auth && UserService.Instance.myUserInfo) { await this.fetchUnreads();
this.requestNotificationPermission();
WebSocketService.Instance.send(
wsClient.userJoin({
auth,
})
);
this.fetchUnreads();
}
this.requestNotificationPermission(); this.requestNotificationPermission();
// Subscribe to unread count changes document.addEventListener("mouseup", this.handleOutsideMenuClick);
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 });
});
} }
} }
componentWillUnmount() { componentWillUnmount() {
this.wsSub.unsubscribe(); document.removeEventListener("mouseup", this.handleOutsideMenuClick);
this.userSub.unsubscribe(); }
this.unreadInboxCountSub.unsubscribe();
this.unreadReportCountSub.unsubscribe(); render() {
this.unreadApplicationCountSub.unsubscribe(); return this.navbar();
} }
// TODO class active corresponding to current page // TODO class active corresponding to current page
render() { navbar() {
const siteView = this.props.siteRes?.site_view; const siteView = this.props.siteRes?.site_view;
const person = UserService.Instance.myUserInfo?.local_user_view.person; const person = UserService.Instance.myUserInfo?.local_user_view.person;
return ( return (
@ -144,15 +103,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
to="/inbox" to="/inbox"
className="p-1 nav-link border-0" className="p-1 nav-link border-0"
title={i18n.t("unread_messages", { title={i18n.t("unread_messages", {
count: Number(this.state.unreadInboxCount), count: Number(this.state.unreadApplicationCountRes.state),
formattedCount: numToSI(this.state.unreadInboxCount), formattedCount: numToSI(this.unreadInboxCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="bell" /> <Icon icon="bell" />
{this.state.unreadInboxCount > 0 && ( {this.unreadInboxCount > 0 && (
<span className="mx-1 badge badge-light"> <span className="mx-1 badge badge-light">
{numToSI(this.state.unreadInboxCount)} {numToSI(this.unreadInboxCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
@ -163,15 +122,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
to="/reports" to="/reports"
className="p-1 nav-link border-0" className="p-1 nav-link border-0"
title={i18n.t("unread_reports", { title={i18n.t("unread_reports", {
count: Number(this.state.unreadReportCount), count: Number(this.unreadReportCount),
formattedCount: numToSI(this.state.unreadReportCount), formattedCount: numToSI(this.unreadReportCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="shield" /> <Icon icon="shield" />
{this.state.unreadReportCount > 0 && ( {this.unreadReportCount > 0 && (
<span className="mx-1 badge badge-light"> <span className="mx-1 badge badge-light">
{numToSI(this.state.unreadReportCount)} {numToSI(this.unreadReportCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
@ -183,15 +142,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
to="/registration_applications" to="/registration_applications"
className="p-1 nav-link border-0" className="p-1 nav-link border-0"
title={i18n.t("unread_registration_applications", { title={i18n.t("unread_registration_applications", {
count: Number(this.state.unreadApplicationCount), count: Number(this.unreadApplicationCount),
formattedCount: numToSI(this.state.unreadApplicationCount), formattedCount: numToSI(this.unreadApplicationCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="clipboard" /> <Icon icon="clipboard" />
{this.state.unreadApplicationCount > 0 && ( {this.unreadApplicationCount > 0 && (
<span className="mx-1 badge badge-light"> <span className="mx-1 badge badge-light">
{numToSI(this.state.unreadApplicationCount)} {numToSI(this.unreadApplicationCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
@ -212,7 +171,11 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
> >
<Icon icon="menu" /> <Icon icon="menu" />
</button> </button>
<div className="collapse navbar-collapse my-2" id="navbarDropdown"> <div
className="collapse navbar-collapse my-2"
id="navbarDropdown"
ref={this.mobileMenuRef}
>
<ul className="mr-auto navbar-nav"> <ul className="mr-auto navbar-nav">
<li className="nav-item"> <li className="nav-item">
<NavLink <NavLink
@ -264,20 +227,16 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
</li> </li>
</ul> </ul>
<ul className="navbar-nav"> <ul className="navbar-nav">
{!this.context.router.history.location.pathname.match( <li className="nav-item">
/^\/search/ <NavLink
) && ( to="/search"
<li className="nav-item"> className="nav-link"
<NavLink title={i18n.t("search")}
to="/search" onMouseUp={linkEvent(this, handleCollapseClick)}
className="nav-link" >
title={i18n.t("search")} <Icon icon="search" />
onMouseUp={linkEvent(this, handleCollapseClick)} </NavLink>
> </li>
<Icon icon="search" />
</NavLink>
</li>
)}
{amAdmin() && ( {amAdmin() && (
<li className="nav-item"> <li className="nav-item">
<NavLink <NavLink
@ -297,15 +256,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
className="nav-link" className="nav-link"
to="/inbox" to="/inbox"
title={i18n.t("unread_messages", { title={i18n.t("unread_messages", {
count: Number(this.state.unreadInboxCount), count: Number(this.unreadInboxCount),
formattedCount: numToSI(this.state.unreadInboxCount), formattedCount: numToSI(this.unreadInboxCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="bell" /> <Icon icon="bell" />
{this.state.unreadInboxCount > 0 && ( {this.unreadInboxCount > 0 && (
<span className="ml-1 badge badge-light"> <span className="mx-1 badge badge-light">
{numToSI(this.state.unreadInboxCount)} {numToSI(this.unreadInboxCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
@ -316,15 +275,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
className="nav-link" className="nav-link"
to="/reports" to="/reports"
title={i18n.t("unread_reports", { title={i18n.t("unread_reports", {
count: Number(this.state.unreadReportCount), count: Number(this.unreadReportCount),
formattedCount: numToSI(this.state.unreadReportCount), formattedCount: numToSI(this.unreadReportCount),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="shield" /> <Icon icon="shield" />
{this.state.unreadReportCount > 0 && ( {this.unreadReportCount > 0 && (
<span className="ml-1 badge badge-light"> <span className="mx-1 badge badge-light">
{numToSI(this.state.unreadReportCount)} {numToSI(this.unreadReportCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
@ -336,17 +295,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
to="/registration_applications" to="/registration_applications"
className="nav-link" className="nav-link"
title={i18n.t("unread_registration_applications", { title={i18n.t("unread_registration_applications", {
count: Number(this.state.unreadApplicationCount), count: Number(this.unreadApplicationCount),
formattedCount: numToSI( formattedCount: numToSI(this.unreadApplicationCount),
this.state.unreadApplicationCount
),
})} })}
onMouseUp={linkEvent(this, handleCollapseClick)} onMouseUp={linkEvent(this, handleCollapseClick)}
> >
<Icon icon="clipboard" /> <Icon icon="clipboard" />
{this.state.unreadApplicationCount > 0 && ( {this.unreadApplicationCount > 0 && (
<span className="mx-1 badge badge-light"> <span className="mx-1 badge badge-light">
{numToSI(this.state.unreadApplicationCount)} {numToSI(this.unreadApplicationCount)}
</span> </span>
)} )}
</NavLink> </NavLink>
@ -397,7 +354,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
<li> <li>
<button <button
className="dropdown-item btn btn-link px-2" className="dropdown-item btn btn-link px-2"
onClick={handleLogOut} onClick={linkEvent(this, handleLogOut)}
> >
<Icon icon="log-out" classes="mr-1" /> <Icon icon="log-out" classes="mr-1" />
{i18n.t("logout")} {i18n.t("logout")}
@ -437,107 +394,77 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
); );
} }
handleOutsideMenuClick(event: MouseEvent) {
if (!this.mobileMenuRef.current?.contains(event.target as Node | null)) {
handleCollapseClick(this);
}
}
get moderatesSomething(): boolean { get moderatesSomething(): boolean {
let mods = UserService.Instance.myUserInfo?.moderates; const mods = UserService.Instance.myUserInfo?.moderates;
let moderatesS = (mods && mods.length > 0) || false; const moderatesS = (mods && mods.length > 0) || false;
return amAdmin() || moderatesS; return amAdmin() || moderatesS;
} }
parseMessage(msg: any) { async fetchUnreads() {
let op = wsUserOp(msg); const auth = myAuth();
console.log(msg); if (auth) {
if (msg.error) { this.setState({ unreadInboxCountRes: { state: "loading" } });
if (msg.error == "not_logged_in") {
UserService.Instance.logout();
}
return;
} else if (msg.reconnect) {
console.log(i18n.t("websocket_reconnected"));
let auth = myAuth(false);
if (UserService.Instance.myUserInfo && auth) {
WebSocketService.Instance.send(
wsClient.userJoin({
auth,
})
);
this.fetchUnreads();
}
} else if (op == UserOperation.GetUnreadCount) {
let data = wsJsonToRes<GetUnreadCountResponse>(msg);
this.setState({ this.setState({
unreadInboxCount: data.replies + data.mentions + data.private_messages, unreadInboxCountRes: await HttpService.client.getUnreadCount({
auth,
}),
}); });
this.sendUnreadCount();
} else if (op == UserOperation.GetReportCount) {
let 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) {
let data =
wsJsonToRes<GetUnreadRegistrationApplicationCountResponse>(msg);
this.setState({ unreadApplicationCount: data.registration_applications });
this.sendApplicationUnread();
} else if (op == UserOperation.CreateComment) {
let data = wsJsonToRes<CommentResponse>(msg);
let 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) {
let data = wsJsonToRes<PrivateMessageResponse>(msg);
if ( if (this.moderatesSomething) {
data.private_message_view.recipient.id == this.setState({ unreadReportCountRes: { state: "loading" } });
UserService.Instance.myUserInfo?.local_user_view.person.id
) {
this.setState({ 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() { get unreadInboxCount(): number {
console.log("Fetching inbox unreads..."); if (this.state.unreadInboxCountRes.state == "success") {
const data = this.state.unreadInboxCountRes.data;
return data.replies + data.mentions + data.private_messages;
} else {
return 0;
}
}
let auth = myAuth(); get unreadReportCount(): number {
if (auth) { if (this.state.unreadReportCountRes.state == "success") {
let unreadForm: GetUnreadCount = { const data = this.state.unreadReportCountRes.data;
auth, return (
}; data.post_reports +
WebSocketService.Instance.send(wsClient.getUnreadCount(unreadForm)); data.comment_reports +
(data.private_message_reports ?? 0)
);
} else {
return 0;
}
}
console.log("Fetching reports..."); get unreadApplicationCount(): number {
if (this.state.unreadApplicationCountRes.state == "success") {
let reportCountForm: GetReportCount = { const data = this.state.unreadApplicationCountRes.data;
auth, return data.registration_applications;
}; } else {
WebSocketService.Instance.send(wsClient.getReportCount(reportCountForm)); return 0;
if (amAdmin()) {
console.log("Fetching applications...");
let applicationCountForm: GetUnreadRegistrationApplicationCount = {
auth,
};
WebSocketService.Instance.send(
wsClient.getUnreadRegistrationApplicationCount(applicationCountForm)
);
}
} }
} }
@ -545,22 +472,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
return this.context.router.history.location.pathname; 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() { requestNotificationPermission() {
if (UserService.Instance.myUserInfo) { if (UserService.Instance.myUserInfo) {
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {

View file

@ -8,8 +8,8 @@ interface Props {
export class Theme extends Component<Props> { export class Theme extends Component<Props> {
render() { render() {
let user = UserService.Instance.myUserInfo; const user = UserService.Instance.myUserInfo;
let hasTheme = user?.local_user_view.local_user.theme !== "browser"; const hasTheme = user?.local_user_view.local_user.theme !== "browser";
if (user && hasTheme) { if (user && hasTheme) {
return ( return (

View file

@ -1,25 +1,11 @@
import { Component } from "inferno"; import { Component } from "inferno";
import { T } from "inferno-i18next-dess"; import { T } from "inferno-i18next-dess";
import { Link } from "inferno-router"; import { Link } from "inferno-router";
import { import { CreateComment, EditComment, Language } from "lemmy-js-client";
CommentResponse,
CreateComment,
EditComment,
Language,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { CommentNodeI } from "shared/interfaces";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services"; import { CommentNodeI } from "../../interfaces";
import { import { UserService } from "../../services";
capitalizeFirstLetter, import { capitalizeFirstLetter, myAuthRequired } from "../../utils";
myAuth,
wsClient,
wsSubscribe,
} from "../../utils";
import { Icon } from "../common/icon"; import { Icon } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea"; import { MarkdownTextArea } from "../common/markdown-textarea";
@ -28,48 +14,25 @@ interface CommentFormProps {
* Can either be the parent, or the editable comment. The right side is a postId. * Can either be the parent, or the editable comment. The right side is a postId.
*/ */
node: CommentNodeI | number; node: CommentNodeI | number;
finished?: boolean;
edit?: boolean; edit?: boolean;
disabled?: boolean; disabled?: boolean;
focus?: boolean; focus?: boolean;
onReplyCancel?(): any; onReplyCancel?(): void;
allLanguages: Language[]; allLanguages: Language[];
siteLanguages: number[]; siteLanguages: number[];
onUpsertComment(form: EditComment | CreateComment): void;
} }
interface CommentFormState { export class CommentForm extends Component<CommentFormProps, any> {
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,
};
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.handleCommentSubmit = this.handleCommentSubmit.bind(this); 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() { render() {
let initialContent = const initialContent =
typeof this.props.node !== "number" typeof this.props.node !== "number"
? this.props.edit ? this.props.edit
? this.props.node.comment_view.comment.content ? this.props.node.comment_view.comment.content
@ -82,13 +45,13 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
<MarkdownTextArea <MarkdownTextArea
initialContent={initialContent} initialContent={initialContent}
showLanguage showLanguage
buttonTitle={this.state.buttonTitle} buttonTitle={this.buttonTitle}
finished={this.state.finished} finished={this.props.finished}
replyType={typeof this.props.node !== "number"} replyType={typeof this.props.node !== "number"}
focus={this.props.focus} focus={this.props.focus}
disabled={this.props.disabled} disabled={this.props.disabled}
onSubmit={this.handleCommentSubmit} onSubmit={this.handleCommentSubmit}
onReplyCancel={this.handleReplyCancel} onReplyCancel={this.props.onReplyCancel}
placeholder={i18n.t("comment_here")} placeholder={i18n.t("comment_here")}
allLanguages={this.props.allLanguages} allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages} siteLanguages={this.props.siteLanguages}
@ -108,77 +71,46 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
); );
} }
handleCommentSubmit(msg: { get buttonTitle(): string {
val: string; return typeof this.props.node === "number"
formId: string; ? capitalizeFirstLetter(i18n.t("post"))
languageId?: number; : this.props.edit
}) { ? capitalizeFirstLetter(i18n.t("save"))
let content = msg.val; : capitalizeFirstLetter(i18n.t("reply"));
let language_id = msg.languageId; }
let node = this.props.node;
this.setState({ formId: msg.formId }); handleCommentSubmit(content: string, form_id: string, language_id?: number) {
const { node, onUpsertComment, edit } = this.props;
let auth = myAuth(); if (typeof node === "number") {
if (auth) { const post_id = node;
if (typeof node === "number") { onUpsertComment({
let postId = node; content,
let form: CreateComment = { post_id,
language_id,
form_id,
auth: myAuthRequired(),
});
} else {
if (edit) {
const comment_id = node.comment_view.comment.id;
onUpsertComment({
content, content,
form_id: this.state.formId, comment_id,
post_id: postId, form_id,
language_id, language_id,
auth, auth: myAuthRequired(),
}; });
WebSocketService.Instance.send(wsClient.createComment(form));
} else { } else {
if (this.props.edit) { const post_id = node.comment_view.post.id;
let form: EditComment = { const parent_id = node.comment_view.comment.id;
content, this.props.onUpsertComment({
form_id: this.state.formId, content,
comment_id: node.comment_view.comment.id, parent_id,
language_id, post_id,
auth, form_id,
}; language_id,
WebSocketService.Instance.send(wsClient.editComment(form)); auth: myAuthRequired(),
} else { });
let 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) {
let 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
) {
let 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 });
}
} }
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,30 @@
import classNames from "classnames"; import classNames from "classnames";
import { Component } from "inferno"; 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 { CommentNodeI, CommentViewType } from "../../interfaces";
import { colorList } from "../../utils"; import { colorList } from "../../utils";
import { CommentNode } from "./comment-node"; import { CommentNode } from "./comment-node";
@ -24,6 +48,26 @@ interface CommentNodesProps {
hideImages?: boolean; hideImages?: boolean;
isChild?: boolean; isChild?: boolean;
depth?: number; 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> { export class CommentNodes extends Component<CommentNodesProps, any> {
@ -64,6 +108,26 @@ export class CommentNodes extends Component<CommentNodesProps, any> {
allLanguages={this.props.allLanguages} allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages} siteLanguages={this.props.siteLanguages}
hideImages={this.props.hideImages} 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}
/> />
))} ))}
</ul> </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 { T } from "inferno-i18next-dess";
import { import {
CommentReportView, CommentReportView,
@ -7,32 +7,50 @@ import {
} from "lemmy-js-client"; } from "lemmy-js-client";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { CommentNodeI, CommentViewType } from "../../interfaces"; import { CommentNodeI, CommentViewType } from "../../interfaces";
import { WebSocketService } from "../../services"; import { myAuthRequired } from "../../utils";
import { myAuth, wsClient } from "../../utils"; import { Icon, Spinner } from "../common/icon";
import { Icon } from "../common/icon";
import { PersonListing } from "../person/person-listing"; import { PersonListing } from "../person/person-listing";
import { CommentNode } from "./comment-node"; import { CommentNode } from "./comment-node";
interface CommentReportProps { interface CommentReportProps {
report: CommentReportView; 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) { constructor(props: any, context: any) {
super(props, context); super(props, context);
} }
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & CommentReportProps>
): void {
if (this.props != nextProps) {
this.setState({ loading: false });
}
}
render() { render() {
let r = this.props.report; const r = this.props.report;
let comment = r.comment; const comment = r.comment;
let tippyContent = i18n.t( const tippyContent = i18n.t(
r.comment_report.resolved ? "unresolve_report" : "resolve_report" r.comment_report.resolved ? "unresolve_report" : "resolve_report"
); );
// Set the original post data ( a troll could change it ) // Set the original post data ( a troll could change it )
comment.content = r.comment_report.original_comment_text; comment.content = r.comment_report.original_comment_text;
let comment_view: CommentView = { const comment_view: CommentView = {
comment, comment,
creator: r.comment_creator, creator: r.comment_creator,
post: r.post, post: r.post,
@ -45,7 +63,7 @@ export class CommentReport extends Component<CommentReportProps, any> {
my_vote: r.my_vote, my_vote: r.my_vote,
}; };
let node: CommentNodeI = { const node: CommentNodeI = {
comment_view, comment_view,
children: [], children: [],
depth: 0, depth: 0,
@ -62,6 +80,26 @@ export class CommentReport extends Component<CommentReportProps, any> {
allLanguages={[]} allLanguages={[]}
siteLanguages={[]} siteLanguages={[]}
hideImages 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> <div>
{i18n.t("reporter")}: <PersonListing person={r.creator} /> {i18n.t("reporter")}: <PersonListing person={r.creator} />
@ -90,26 +128,27 @@ export class CommentReport extends Component<CommentReportProps, any> {
data-tippy-content={tippyContent} data-tippy-content={tippyContent}
aria-label={tippyContent} aria-label={tippyContent}
> >
<Icon {this.state.loading ? (
icon="check" <Spinner />
classes={`icon-inline ${ ) : (
r.comment_report.resolved ? "text-success" : "text-danger" <Icon
}`} icon="check"
/> classes={`icon-inline ${
r.comment_report.resolved ? "text-success" : "text-danger"
}`}
/>
)}
</button> </button>
</div> </div>
); );
} }
handleResolveReport(i: CommentReport) { handleResolveReport(i: CommentReport) {
let auth = myAuth(); i.setState({ loading: true });
if (auth) { i.props.onResolveReport({
let form: ResolveCommentReport = { report_id: i.props.report.comment_report.id,
report_id: i.props.report.comment_report.id, resolved: !i.props.report.comment_report.resolved,
resolved: !i.props.report.comment_report.resolved, auth: myAuthRequired(),
auth, });
};
WebSocketService.Instance.send(wsClient.resolveCommentReport(form));
}
} }
} }

View file

@ -12,8 +12,8 @@ export class BannerIconHeader extends Component<BannerIconHeaderProps, any> {
} }
render() { render() {
let banner = this.props.banner; const banner = this.props.banner;
let icon = this.props.icon; const icon = this.props.icon;
return ( return (
<div className="position-relative mb-2"> <div className="position-relative mb-2">
{banner && <PictrsImage src={banner} banner alt="" />} {banner && <PictrsImage src={banner} banner alt="" />}

View file

@ -12,7 +12,7 @@ export class EmojiMart extends Component<EmojiMartProps> {
this.handleEmojiClick = this.handleEmojiClick.bind(this); this.handleEmojiClick = this.handleEmojiClick.bind(this);
} }
componentDidMount() { componentDidMount() {
let div: any = document.getElementById("emoji-picker"); const div: any = document.getElementById("emoji-picker");
if (div) { if (div) {
div.appendChild( div.appendChild(
getEmojiMart(this.handleEmojiClick, this.props.pickerOptions) getEmojiMart(this.handleEmojiClick, this.props.pickerOptions)

View file

@ -2,7 +2,7 @@ import { htmlToText } from "html-to-text";
import { Component } from "inferno"; import { Component } from "inferno";
import { Helmet } from "inferno-helmet"; import { Helmet } from "inferno-helmet";
import { httpExternalPath } from "../../env"; import { httpExternalPath } from "../../env";
import { md } from "../../utils"; import { getLanguages, md } from "../../utils";
interface HtmlTagsProps { interface HtmlTagsProps {
title: string; title: string;
@ -14,12 +14,15 @@ interface HtmlTagsProps {
/// Taken from https://metatags.io/ /// Taken from https://metatags.io/
export class HtmlTags extends Component<HtmlTagsProps, any> { export class HtmlTags extends Component<HtmlTagsProps, any> {
render() { render() {
let url = httpExternalPath(this.props.path); const url = httpExternalPath(this.props.path);
let desc = this.props.description; const desc = this.props.description;
let image = this.props.image; const image = this.props.image;
const lang = getLanguages()[0];
return ( return (
<Helmet title={this.props.title}> <Helmet title={this.props.title}>
<html lang={lang == "browser" ? "en" : lang} />
{["title", "og:title", "twitter:title"].map(t => ( {["title", "og:title", "twitter:title"].map(t => (
<meta key={t} property={t} content={this.props.title} /> <meta key={t} property={t} content={this.props.title} />
))} ))}

View file

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

View file

@ -16,6 +16,7 @@ interface LanguageSelectProps {
showSite?: boolean; showSite?: boolean;
iconVersion?: boolean; iconVersion?: boolean;
disabled?: boolean; disabled?: boolean;
showLanguageWarning?: boolean;
} }
export class LanguageSelect extends Component<LanguageSelectProps, any> { export class LanguageSelect extends Component<LanguageSelectProps, any> {
@ -31,12 +32,12 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
// Necessary because there is no HTML way to set selected for multiple in value= // Necessary because there is no HTML way to set selected for multiple in value=
setSelectedValues() { setSelectedValues() {
let ids = this.props.selectedLanguageIds?.map(toString); const ids = this.props.selectedLanguageIds?.map(toString);
if (ids) { if (ids) {
let select = (document.getElementById(this.id) as HTMLSelectElement) const select = (document.getElementById(this.id) as HTMLSelectElement)
.options; .options;
for (let i = 0; i < select.length; i++) { for (let i = 0; i < select.length; i++) {
let o = select[i]; const o = select[i];
if (ids.includes(o.value)) { if (ids.includes(o.value)) {
o.selected = true; o.selected = true;
} }
@ -49,7 +50,7 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
this.selectBtn this.selectBtn
) : ( ) : (
<div> <div>
{this.props.multiple && ( {this.props.multiple && this.props.showLanguageWarning && (
<div className="alert alert-warning" role="alert"> <div className="alert alert-warning" role="alert">
{i18n.t("undetermined_language_warning")} {i18n.t("undetermined_language_warning")}
</div> </div>
@ -107,7 +108,7 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
)} )}
id={this.id} id={this.id}
onChange={linkEvent(this, this.handleLanguageChange)} onChange={linkEvent(this, this.handleLanguageChange)}
aria-label="action" aria-label={i18n.t("language_select_placeholder")}
multiple={this.props.multiple} multiple={this.props.multiple}
disabled={this.props.disabled} disabled={this.props.disabled}
> >
@ -130,8 +131,8 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
} }
handleLanguageChange(i: LanguageSelect, event: any) { handleLanguageChange(i: LanguageSelect, event: any) {
let options: HTMLOptionElement[] = Array.from(event.target.options); const options: HTMLOptionElement[] = Array.from(event.target.options);
let selected: number[] = options const selected: number[] = options
.filter(o => o.selected) .filter(o => o.selected)
.map(o => Number(o.value)); .map(o => Number(o.value));

View file

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

View file

@ -1,10 +1,9 @@
import autosize from "autosize"; import autosize from "autosize";
import { NoOptionI18nKeys } from "i18next"; import { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { Prompt } from "inferno-router";
import { Language } from "lemmy-js-client"; import { Language } from "lemmy-js-client";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { UserService } from "../../services"; import { HttpService, UserService } from "../../services";
import { import {
concurrentImageUpload, concurrentImageUpload,
customEmojisLookup, customEmojisLookup,
@ -20,11 +19,11 @@ import {
setupTippy, setupTippy,
setupTribute, setupTribute,
toast, toast,
uploadImage,
} from "../../utils"; } from "../../utils";
import { EmojiPicker } from "./emoji-picker"; import { EmojiPicker } from "./emoji-picker";
import { Icon, Spinner } from "./icon"; import { Icon, Spinner } from "./icon";
import { LanguageSelect } from "./language-select"; import { LanguageSelect } from "./language-select";
import NavigationPrompt from "./navigation-prompt";
import ProgressBar from "./progress-bar"; import ProgressBar from "./progress-bar";
interface MarkdownTextAreaProps { interface MarkdownTextAreaProps {
@ -39,9 +38,9 @@ interface MarkdownTextAreaProps {
finished?: boolean; finished?: boolean;
showLanguage?: boolean; showLanguage?: boolean;
hideNavigationWarnings?: boolean; hideNavigationWarnings?: boolean;
onContentChange?(val: string): any; onContentChange?(val: string): void;
onReplyCancel?(): any; onReplyCancel?(): void;
onSubmit?(msg: { val?: string; formId: string; languageId?: number }): any; onSubmit?(content: string, formId: string, languageId?: number): void;
allLanguages: Language[]; // TODO should probably be nullable allLanguages: Language[]; // TODO should probably be nullable
siteLanguages: number[]; // TODO same siteLanguages: number[]; // TODO same
} }
@ -55,8 +54,9 @@ interface MarkdownTextAreaState {
content?: string; content?: string;
languageId?: number; languageId?: number;
previewMode: boolean; previewMode: boolean;
loading: boolean;
imageUploadStatus?: ImageUploadStatus; imageUploadStatus?: ImageUploadStatus;
loading: boolean;
submitted: boolean;
} }
export class MarkdownTextArea extends Component< export class MarkdownTextArea extends Component<
@ -72,6 +72,7 @@ export class MarkdownTextArea extends Component<
languageId: this.props.initialLanguageId, languageId: this.props.initialLanguageId,
previewMode: false, previewMode: false,
loading: false, loading: false,
submitted: false,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -85,7 +86,7 @@ export class MarkdownTextArea extends Component<
} }
componentDidMount() { componentDidMount() {
let textarea: any = document.getElementById(this.id); const textarea: any = document.getElementById(this.id);
if (textarea) { if (textarea) {
autosize(textarea); autosize(textarea);
this.tribute.attach(textarea); this.tribute.attach(textarea);
@ -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) { componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
if (nextProps.finished) { 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) { if (this.props.replyType) {
this.props.onReplyCancel?.(); this.props.onReplyCancel?.();
} }
@ -127,18 +125,22 @@ export class MarkdownTextArea extends Component<
} }
} }
componentWillUnmount() {
window.onbeforeunload = null;
}
render() { render() {
let languageId = this.state.languageId; 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 ( return (
<form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}> <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
<Prompt <NavigationPrompt
when={!this.props.hideNavigationWarnings && this.state.content} when={
message={i18n.t("block_leaving")} !this.props.hideNavigationWarnings &&
!!this.state.content &&
!this.state.submitted
}
/> />
<div className="form-group row"> <div className="form-group row">
<div className={`col-sm-12`}> <div className={`col-sm-12`}>
@ -148,6 +150,7 @@ export class MarkdownTextArea extends Component<
value={this.state.content} value={this.state.content}
onInput={linkEvent(this, this.handleContentChange)} onInput={linkEvent(this, this.handleContentChange)}
onPaste={linkEvent(this, this.handleImageUploadPaste)} onPaste={linkEvent(this, this.handleImageUploadPaste)}
onKeyDown={linkEvent(this, this.handleKeyBinds)}
required required
disabled={this.isDisabled} disabled={this.isDisabled}
rows={2} rows={2}
@ -210,7 +213,7 @@ export class MarkdownTextArea extends Component<
}`} }`}
onClick={linkEvent(this, this.handlePreviewToggle)} onClick={linkEvent(this, this.handlePreviewToggle)}
> >
{i18n.t("preview")} {this.state.previewMode ? i18n.t("edit") : i18n.t("preview")}
</button> </button>
)} )}
{/* A flex expander */} {/* A flex expander */}
@ -321,7 +324,7 @@ export class MarkdownTextArea extends Component<
handleEmoji(i: MarkdownTextArea, e: any) { handleEmoji(i: MarkdownTextArea, e: any) {
let value = e.native; let value = e.native;
if (value == null) { if (value == null) {
let emoji = customEmojisLookup.get(e.id)?.custom_emoji; const emoji = customEmojisLookup.get(e.id)?.custom_emoji;
if (emoji) { if (emoji) {
value = `![${emoji.alt_text}](${emoji.image_url} "${emoji.shortcode}")`; value = `![${emoji.alt_text}](${emoji.image_url} "${emoji.shortcode}")`;
} }
@ -330,7 +333,7 @@ export class MarkdownTextArea extends Component<
content: `${i.state.content ?? ""} ${value} `, content: `${i.state.content ?? ""} ${value} `,
}); });
i.contentChange(); i.contentChange();
let textarea: any = document.getElementById(i.id); const textarea: any = document.getElementById(i.id);
autosize.update(textarea); autosize.update(textarea);
} }
@ -392,35 +395,35 @@ export class MarkdownTextArea extends Component<
} }
} }
async uploadSingleImage(i: MarkdownTextArea, file: File) { async uploadSingleImage(i: MarkdownTextArea, image: File) {
try { const res = await HttpService.client.uploadImage({ image });
const res = await uploadImage(file); console.log("pictrs upload:");
console.log("pictrs upload:"); console.log(res);
console.log(res); if (res.state === "success") {
if (res.msg === "ok") { if (res.data.msg === "ok") {
const imageMarkdown = `![](${res.url})`; const imageMarkdown = `![](${res.data.url})`;
i.setState(({ content }) => ({ i.setState(({ content }) => ({
content: content ? `${content}\n${imageMarkdown}` : imageMarkdown, content: content ? `${content}\n${imageMarkdown}` : imageMarkdown,
})); }));
i.contentChange(); i.contentChange();
const textarea: any = document.getElementById(i.id); const textarea: any = document.getElementById(i.id);
autosize.update(textarea); autosize.update(textarea);
pictrsDeleteToast(file.name, res.delete_url as string); pictrsDeleteToast(image.name, res.data.delete_url as string);
} else { } else {
throw JSON.stringify(res); throw JSON.stringify(res.data);
} }
} catch (error) { } else if (res.state === "failed") {
i.setState({ imageUploadStatus: undefined }); i.setState({ imageUploadStatus: undefined });
console.error(error); console.error(res.msg);
toast(error, "danger"); toast(res.msg, "danger");
throw error; throw res.msg;
} }
} }
contentChange() { contentChange() {
// Coerces the undefineds to empty strings, for replacing in the DB // Coerces the undefineds to empty strings, for replacing in the DB
let content = this.state.content ?? ""; const content = this.state.content ?? "";
this.props.onContentChange?.(content); this.props.onContentChange?.(content);
} }
@ -429,6 +432,54 @@ export class MarkdownTextArea extends Component<
i.contentChange(); i.contentChange();
} }
// Keybind handler
// Keybinds inspired by github comment area
handleKeyBinds(i: MarkdownTextArea, event: KeyboardEvent) {
if (event.ctrlKey) {
switch (event.key) {
case "k": {
i.handleInsertLink(i, event);
break;
}
case "Enter": {
if (!this.isDisabled) {
i.handleSubmit(i, event);
}
break;
}
case "b": {
i.handleInsertBold(i, event);
break;
}
case "i": {
i.handleInsertItalic(i, event);
break;
}
case "e": {
i.handleInsertCode(i, event);
break;
}
case "8": {
i.handleInsertList(i, event);
break;
}
case "s": {
i.handleInsertSpoiler(i, event);
break;
}
case "p": {
if (i.state.content) i.handlePreviewToggle(i, event);
break;
}
case ".": {
i.handleInsertQuote(i, event);
break;
}
}
}
}
handlePreviewToggle(i: MarkdownTextArea, event: any) { handlePreviewToggle(i: MarkdownTextArea, event: any) {
event.preventDefault(); event.preventDefault();
i.setState({ previewMode: !i.state.previewMode }); i.setState({ previewMode: !i.state.previewMode });
@ -440,13 +491,10 @@ export class MarkdownTextArea extends Component<
handleSubmit(i: MarkdownTextArea, event: any) { handleSubmit(i: MarkdownTextArea, event: any) {
event.preventDefault(); event.preventDefault();
i.setState({ loading: true }); if (i.state.content) {
let msg = { i.setState({ loading: true, submitted: true });
val: i.state.content, i.props.onSubmit?.(i.state.content, i.formId, i.state.languageId);
formId: i.formId, }
languageId: i.state.languageId,
};
i.props.onSubmit?.(msg);
} }
handleReplyCancel(i: MarkdownTextArea) { handleReplyCancel(i: MarkdownTextArea) {
@ -565,7 +613,7 @@ export class MarkdownTextArea extends Component<
handleInsertList(i: MarkdownTextArea, event: any) { handleInsertList(i: MarkdownTextArea, event: any) {
event.preventDefault(); event.preventDefault();
i.simpleBeginningofLine("-"); i.simpleBeginningofLine(`-${i.getSelectedText() ? " " : ""}`);
} }
handleInsertQuote(i: MarkdownTextArea, event: any) { handleInsertQuote(i: MarkdownTextArea, event: any) {
@ -589,7 +637,7 @@ export class MarkdownTextArea extends Component<
} }
simpleInsert(chars: string) { simpleInsert(chars: string) {
let content = this.state.content; const content = this.state.content;
if (!content) { if (!content) {
this.setState({ content: `${chars} ` }); this.setState({ content: `${chars} ` });
} else { } else {
@ -598,7 +646,7 @@ export class MarkdownTextArea extends Component<
}); });
} }
let textarea: any = document.getElementById(this.id); const textarea: any = document.getElementById(this.id);
textarea.focus(); textarea.focus();
setTimeout(() => { setTimeout(() => {
autosize.update(textarea); autosize.update(textarea);
@ -608,8 +656,8 @@ export class MarkdownTextArea extends Component<
handleInsertSpoiler(i: MarkdownTextArea, event: any) { handleInsertSpoiler(i: MarkdownTextArea, event: any) {
event.preventDefault(); event.preventDefault();
let beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`; const beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
let afterChars = "\n:::\n"; const afterChars = "\n:::\n";
i.simpleSurroundBeforeAfter(beforeChars, afterChars); i.simpleSurroundBeforeAfter(beforeChars, afterChars);
} }

View file

@ -15,13 +15,13 @@ export class MomentTime extends Component<MomentTimeProps, any> {
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
let lang = getLanguages(); const lang = getLanguages();
moment.locale(lang); moment.locale(lang);
} }
createdAndModifiedTimes() { createdAndModifiedTimes() {
let updated = this.props.updated; const updated = this.props.updated;
let line = `${capitalizeFirstLetter(i18n.t("created"))}: ${this.format( let line = `${capitalizeFirstLetter(i18n.t("created"))}: ${this.format(
this.props.published this.props.published
)}`; )}`;
@ -45,7 +45,7 @@ export class MomentTime extends Component<MomentTimeProps, any> {
</span> </span>
); );
} else { } else {
let published = this.props.published; const published = this.props.published;
return ( return (
<span <span
className="pointer unselectable" className="pointer unselectable"

View file

@ -0,0 +1,53 @@
import { Component } from "inferno";
import { i18n } from "../../../shared/i18next";
export interface IPromptProps {
when: boolean;
}
export default class NavigationPrompt extends Component<IPromptProps, any> {
public unblock;
public enable() {
if (this.unblock) {
this.unblock();
}
this.unblock = this.context.router.history.block(tx => {
if (window.confirm(i18n.t("block_leaving"))) {
this.unblock();
tx.retry();
}
});
}
public disable() {
if (this.unblock) {
this.unblock();
this.unblock = null;
}
}
public componentWillMount() {
if (this.props.when) {
this.enable();
}
}
public componentWillReceiveProps(nextProps: IPromptProps) {
if (nextProps.when) {
if (!this.props.when) {
this.enable();
}
} else {
this.disable();
}
}
public componentWillUnmount() {
this.disable();
}
public render() {
return null;
}
}

View file

@ -29,6 +29,7 @@ export class PictrsImage extends Component<PictrsImageProps, any> {
<img <img
src={this.props.src} src={this.props.src}
alt={this.alt()} alt={this.alt()}
title={this.alt()}
loading="lazy" loading="lazy"
className={classNames({ className={classNames({
"img-fluid": !this.props.icon && !this.props.iconOverlay, "img-fluid": !this.props.icon && !this.props.iconOverlay,
@ -38,8 +39,9 @@ export class PictrsImage extends Component<PictrsImageProps, any> {
"img-expanded slight-radius": "img-expanded slight-radius":
!this.props.thumbnail && !this.props.icon, !this.props.thumbnail && !this.props.icon,
"img-blur": this.props.thumbnail && this.props.nsfw, "img-blur": this.props.thumbnail && this.props.nsfw,
"rounded-circle img-icon mr-2": this.props.icon, "rounded-circle img-cover img-icon mr-2": this.props.icon,
"ml-2 mb-0 rounded-circle avatar-overlay": this.props.iconOverlay, "ml-2 mb-0 rounded-circle img-cover avatar-overlay":
this.props.iconOverlay,
"avatar-pushup": this.props.pushup, "avatar-pushup": this.props.pushup,
})} })}
/> />
@ -51,17 +53,17 @@ export class PictrsImage extends Component<PictrsImageProps, any> {
// sample url: // sample url:
// http://localhost:8535/pictrs/image/file.png?thumbnail=256&format=jpg // http://localhost:8535/pictrs/image/file.png?thumbnail=256&format=jpg
let split = this.props.src.split("/pictrs/image/"); const split = this.props.src.split("/pictrs/image/");
// If theres not multiple, then its not a pictrs image // If theres not multiple, then its not a pictrs image
if (split.length == 1) { if (split.length == 1) {
return this.props.src; return this.props.src;
} }
let host = split[0]; const host = split[0];
let path = split[1]; const path = split[1];
let params = { format }; const params = { format };
if (this.props.thumbnail) { if (this.props.thumbnail) {
params["thumbnail"] = thumbnailSize; params["thumbnail"] = thumbnailSize;
@ -69,8 +71,8 @@ export class PictrsImage extends Component<PictrsImageProps, any> {
params["thumbnail"] = iconThumbnailSize; params["thumbnail"] = iconThumbnailSize;
} }
let paramsStr = new URLSearchParams(params).toString(); const paramsStr = new URLSearchParams(params).toString();
let out = `${host}/pictrs/image/${path}?${paramsStr}`; const out = `${host}/pictrs/image/${path}?${paramsStr}`;
return out; return out;
} }

View file

@ -1,5 +1,5 @@
import classNames from "classnames"; import classNames from "classnames";
import { ThemeColor } from "shared/utils"; import { ThemeColor } from "../../utils";
interface ProgressBarProps { interface ProgressBarProps {
className?: string; className?: string;

View file

@ -1,23 +1,26 @@
import { Component, linkEvent } from "inferno"; import { Component, InfernoNode, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess"; import { T } from "inferno-i18next-dess";
import { import {
ApproveRegistrationApplication, ApproveRegistrationApplication,
RegistrationApplicationView, RegistrationApplicationView,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { WebSocketService } from "../../services"; import { mdToHtml, myAuthRequired } from "../../utils";
import { mdToHtml, myAuth, wsClient } from "../../utils";
import { PersonListing } from "../person/person-listing"; import { PersonListing } from "../person/person-listing";
import { Spinner } from "./icon";
import { MarkdownTextArea } from "./markdown-textarea"; import { MarkdownTextArea } from "./markdown-textarea";
import { MomentTime } from "./moment-time"; import { MomentTime } from "./moment-time";
interface RegistrationApplicationProps { interface RegistrationApplicationProps {
application: RegistrationApplicationView; application: RegistrationApplicationView;
onApproveApplication(form: ApproveRegistrationApplication): void;
} }
interface RegistrationApplicationState { interface RegistrationApplicationState {
denyReason?: string; denyReason?: string;
denyExpanded: boolean; denyExpanded: boolean;
approveLoading: boolean;
denyLoading: boolean;
} }
export class RegistrationApplication extends Component< export class RegistrationApplication extends Component<
@ -27,17 +30,32 @@ export class RegistrationApplication extends Component<
state: RegistrationApplicationState = { state: RegistrationApplicationState = {
denyReason: this.props.application.registration_application.deny_reason, denyReason: this.props.application.registration_application.deny_reason,
denyExpanded: false, denyExpanded: false,
approveLoading: false,
denyLoading: false,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.handleDenyReasonChange = this.handleDenyReasonChange.bind(this); 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() { render() {
let a = this.props.application; const a = this.props.application;
let ra = this.props.application.registration_application; const ra = this.props.application.registration_application;
let accepted = a.creator_local_user.accepted_application; const accepted = a.creator_local_user.accepted_application;
return ( return (
<div> <div>
@ -99,7 +117,7 @@ export class RegistrationApplication extends Component<
onClick={linkEvent(this, this.handleApprove)} onClick={linkEvent(this, this.handleApprove)}
aria-label={i18n.t("approve")} aria-label={i18n.t("approve")}
> >
{i18n.t("approve")} {this.state.approveLoading ? <Spinner /> : i18n.t("approve")}
</button> </button>
)} )}
{(!ra.admin_id || (ra.admin_id && accepted)) && ( {(!ra.admin_id || (ra.admin_id && accepted)) && (
@ -108,7 +126,7 @@ export class RegistrationApplication extends Component<
onClick={linkEvent(this, this.handleDeny)} onClick={linkEvent(this, this.handleDeny)}
aria-label={i18n.t("deny")} aria-label={i18n.t("deny")}
> >
{i18n.t("deny")} {this.state.denyLoading ? <Spinner /> : i18n.t("deny")}
</button> </button>
)} )}
</div> </div>
@ -116,35 +134,23 @@ export class RegistrationApplication extends Component<
} }
handleApprove(i: RegistrationApplication) { handleApprove(i: RegistrationApplication) {
let auth = myAuth(); i.setState({ denyExpanded: false, approveLoading: true });
if (auth) { i.props.onApproveApplication({
i.setState({ denyExpanded: false }); id: i.props.application.registration_application.id,
let form: ApproveRegistrationApplication = { approve: true,
id: i.props.application.registration_application.id, auth: myAuthRequired(),
approve: true, });
auth,
};
WebSocketService.Instance.send(
wsClient.approveRegistrationApplication(form)
);
}
} }
handleDeny(i: RegistrationApplication) { handleDeny(i: RegistrationApplication) {
if (i.state.denyExpanded) { if (i.state.denyExpanded) {
i.setState({ denyExpanded: false }); i.setState({ denyExpanded: false, denyLoading: true });
let auth = myAuth(); i.props.onApproveApplication({
if (auth) { id: i.props.application.registration_application.id,
let form: ApproveRegistrationApplication = { approve: false,
id: i.props.application.registration_application.id, deny_reason: i.state.denyReason,
approve: false, auth: myAuthRequired(),
deny_reason: i.state.denyReason, });
auth,
};
WebSocketService.Instance.send(
wsClient.approveRegistrationApplication(form)
);
}
} else { } else {
i.setState({ denyExpanded: true }); 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< export class SearchableSelect extends Component<
SearchableSelectProps, SearchableSelectProps,
SearchableSelectState SearchableSelectState
> { > {
private searchInputRef: RefObject<HTMLInputElement> = createRef(); searchInputRef: RefObject<HTMLInputElement> = createRef();
private toggleButtonRef: RefObject<HTMLButtonElement> = createRef(); toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
private loadingEllipsesInterval?: NodeJS.Timer = undefined; private loadingEllipsesInterval?: NodeJS.Timer = undefined;
state: SearchableSelectState = { state: SearchableSelectState = {
@ -55,9 +81,6 @@ export class SearchableSelect extends Component<
constructor(props: SearchableSelectProps, context: any) { constructor(props: SearchableSelectProps, context: any) {
super(props, context); super(props, context);
this.handleChange = this.handleChange.bind(this);
this.focusSearch = this.focusSearch.bind(this);
if (props.value) { if (props.value) {
let selectedIndex = props.options.findIndex( let selectedIndex = props.options.findIndex(
({ value }) => value === props.value?.toString() ({ value }) => value === props.value?.toString()
@ -86,7 +109,8 @@ export class SearchableSelect extends Component<
className="custom-select text-start" className="custom-select text-start"
aria-haspopup="listbox" aria-haspopup="listbox"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
onClick={this.focusSearch} onClick={linkEvent(this, focusSearch)}
ref={this.toggleButtonRef}
> >
{loading {loading
? `${i18n.t("loading")}${loadingEllipses}` ? `${i18n.t("loading")}${loadingEllipses}`
@ -127,7 +151,7 @@ export class SearchableSelect extends Component<
aria-disabled={option.disabled} aria-disabled={option.disabled}
disabled={option.disabled} disabled={option.disabled}
aria-selected={selectedIndex === index} aria-selected={selectedIndex === index}
onClick={() => this.handleChange(option)} onClick={linkEvent({ i: this, option }, handleChange)}
type="button" type="button"
> >
{option.label} {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({ static getDerivedStateFromProps({
value, value,
options, options,
@ -189,16 +199,4 @@ export class SearchableSelect extends Component<
clearInterval(this.loadingEllipsesInterval); 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 { interface SortSelectProps {
sort: SortType; sort: SortType;
onChange?(val: SortType): any; onChange(val: SortType): void;
hideHot?: boolean; hideHot?: boolean;
hideMostComments?: boolean; hideMostComments?: boolean;
} }
@ -25,7 +25,7 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
super(props, context); super(props, context);
} }
static getDerivedStateFromProps(props: any): SortSelectState { static getDerivedStateFromProps(props: SortSelectProps): SortSelectState {
return { return {
sort: props.sort, sort: props.sort,
}; };
@ -85,6 +85,6 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
} }
handleSortChange(i: SortSelect, event: any) { 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 { Component, linkEvent } from "inferno";
import { import {
CommunityResponse, CommunityResponse,
FollowCommunity,
GetSiteResponse, GetSiteResponse,
ListCommunities, ListCommunities,
ListCommunitiesResponse, ListCommunitiesResponse,
ListingType, ListingType,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { InitialFetchRequest } from "shared/interfaces";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { WebSocketService } from "../../services"; import { InitialFetchRequest } from "../../interfaces";
import { FirstLoadService } from "../../services/FirstLoadService";
import { HttpService, RequestState } from "../../services/HttpService";
import { import {
QueryParams, QueryParams,
editCommunity,
getPageFromString, getPageFromString,
getQueryParams, getQueryParams,
getQueryString, getQueryString,
isBrowser,
myAuth, myAuth,
myAuthRequired,
numToSI, numToSI,
setIsoData, setIsoData,
showLocal, showLocal,
toast,
wsClient,
wsSubscribe,
} from "../../utils"; } from "../../utils";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
@ -37,10 +31,10 @@ import { CommunityLink } from "./community-link";
const communityLimit = 50; const communityLimit = 50;
interface CommunitiesState { interface CommunitiesState {
listCommunitiesResponse?: ListCommunitiesResponse; listCommunitiesResponse: RequestState<ListCommunitiesResponse>;
loading: boolean;
siteRes: GetSiteResponse; siteRes: GetSiteResponse;
searchText: string; searchText: string;
isIsomorphic: boolean;
} }
interface CommunitiesProps { interface CommunitiesProps {
@ -48,51 +42,17 @@ interface CommunitiesProps {
page: number; page: number;
} }
function getCommunitiesQueryParams() {
return getQueryParams<CommunitiesProps>({
listingType: getListingTypeFromQuery,
page: getPageFromString,
});
}
function getListingTypeFromQuery(listingType?: string): ListingType { function getListingTypeFromQuery(listingType?: string): ListingType {
return listingType ? (listingType as ListingType) : "Local"; 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> { export class Communities extends Component<any, CommunitiesState> {
private subscription?: Subscription;
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
state: CommunitiesState = { state: CommunitiesState = {
loading: true, listCommunitiesResponse: { state: "empty" },
siteRes: this.isoData.site_res, siteRes: this.isoData.site_res,
searchText: "", searchText: "",
isIsomorphic: false,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -100,25 +60,19 @@ export class Communities extends Component<any, CommunitiesState> {
this.handlePageChange = this.handlePageChange.bind(this); this.handlePageChange = this.handlePageChange.bind(this);
this.handleListingTypeChange = this.handleListingTypeChange.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 // Only fetch the data if coming from another route
if (this.isoData.path === this.context.router.route.match.url) { if (FirstLoadService.isFirstLoad) {
const listRes = this.isoData.routeData[0] as ListCommunitiesResponse;
this.state = { this.state = {
...this.state, ...this.state,
listCommunitiesResponse: listRes, listCommunitiesResponse: this.isoData.routeData[0],
loading: false, isIsomorphic: true,
}; };
} else {
refetch();
} }
} }
componentWillUnmount() { async componentDidMount() {
if (isBrowser()) { if (!this.state.isIsomorphic) {
this.subscription?.unsubscribe(); await this.refetch();
} }
} }
@ -128,20 +82,17 @@ export class Communities extends Component<any, CommunitiesState> {
}`; }`;
} }
render() { renderListings() {
const { listingType, page } = getCommunitiesQueryParams(); switch (this.state.listCommunitiesResponse.state) {
case "loading":
return ( return (
<div className="container-lg">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
{this.state.loading ? (
<h5> <h5>
<Spinner large /> <Spinner large />
</h5> </h5>
) : ( );
case "success": {
const { listingType, page } = this.getCommunitiesQueryParams();
return (
<div> <div>
<div className="row"> <div className="row">
<div className="col-md-6"> <div className="col-md-6">
@ -182,60 +133,82 @@ export class Communities extends Component<any, CommunitiesState> {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{this.state.listCommunitiesResponse?.communities.map(cv => ( {this.state.listCommunitiesResponse.data.communities.map(
<tr key={cv.community.id}> cv => (
<td> <tr key={cv.community.id}>
<CommunityLink community={cv.community} /> <td>
</td> <CommunityLink community={cv.community} />
<td className="text-right"> </td>
{numToSI(cv.counts.subscribers)} <td className="text-right">
</td> {numToSI(cv.counts.subscribers)}
<td className="text-right"> </td>
{numToSI(cv.counts.users_active_month)} <td className="text-right">
</td> {numToSI(cv.counts.users_active_month)}
<td className="text-right d-none d-lg-table-cell"> </td>
{numToSI(cv.counts.posts)} <td className="text-right d-none d-lg-table-cell">
</td> {numToSI(cv.counts.posts)}
<td className="text-right d-none d-lg-table-cell"> </td>
{numToSI(cv.counts.comments)} <td className="text-right d-none d-lg-table-cell">
</td> {numToSI(cv.counts.comments)}
<td className="text-right"> </td>
{cv.subscribed == "Subscribed" && ( <td className="text-right">
<button {cv.subscribed == "Subscribed" && (
className="btn btn-link d-inline-block" <button
onClick={linkEvent( className="btn btn-link d-inline-block"
cv.community.id, onClick={linkEvent(
this.handleUnsubscribe {
)} i: this,
> communityId: cv.community.id,
{i18n.t("unsubscribe")} follow: false,
</button> },
)} this.handleFollow
{cv.subscribed === "NotSubscribed" && ( )}
<button >
className="btn btn-link d-inline-block" {i18n.t("unsubscribe")}
onClick={linkEvent( </button>
cv.community.id, )}
this.handleSubscribe {cv.subscribed === "NotSubscribed" && (
)} <button
> className="btn btn-link d-inline-block"
{i18n.t("subscribe")} onClick={linkEvent(
</button> {
)} i: this,
{cv.subscribed === "Pending" && ( communityId: cv.community.id,
<div className="text-warning d-inline-block"> follow: true,
{i18n.t("subscribe_pending")} },
</div> this.handleFollow
)} )}
</td> >
</tr> {i18n.t("subscribe")}
))} </button>
)}
{cv.subscribed === "Pending" && (
<div className="text-warning d-inline-block">
{i18n.t("subscribe_pending")}
</div>
)}
</td>
</tr>
)
)}
</tbody> </tbody>
</table> </table>
</div> </div>
<Paginator page={page} onChange={this.handlePageChange} /> <Paginator page={page} onChange={this.handlePageChange} />
</div> </div>
)} );
}
}
}
render() {
return (
<div className="container-lg">
<HtmlTags
title={this.documentTitle}
path={this.context.router.route.match.url}
/>
{this.renderListings()}
</div> </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 } = const { listingType: urlListingType, page: urlPage } =
getCommunitiesQueryParams(); this.getCommunitiesQueryParams();
const queryParams: QueryParams<CommunitiesProps> = { const queryParams: QueryParams<CommunitiesProps> = {
listingType: listingType ?? urlListingType, listingType: listingType ?? urlListingType,
@ -277,7 +250,7 @@ export class Communities extends Component<any, CommunitiesState> {
this.props.history.push(`/communities${getQueryString(queryParams)}`); this.props.history.push(`/communities${getQueryString(queryParams)}`);
refetch(); await this.refetch();
} }
handlePageChange(page: number) { 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) { handleSearchChange(i: Communities, event: any) {
i.setState({ searchText: event.target.value }); i.setState({ searchText: event.target.value });
} }
handleSearchSubmit(i: Communities) { handleSearchSubmit(i: Communities, event: any) {
event.preventDefault();
const searchParamEncoded = encodeURIComponent(i.state.searchText); const searchParamEncoded = encodeURIComponent(i.state.searchText);
i.context.router.history.push(`/search?q=${searchParamEncoded}`); i.context.router.history.push(`/search?q=${searchParamEncoded}`);
} }
@ -312,7 +278,9 @@ export class Communities extends Component<any, CommunitiesState> {
query: { listingType, page }, query: { listingType, page },
client, client,
auth, auth,
}: InitialFetchRequest<QueryParams<CommunitiesProps>>): Promise<any>[] { }: InitialFetchRequest<QueryParams<CommunitiesProps>>): Promise<
RequestState<any>
>[] {
const listCommunitiesForm: ListCommunities = { const listCommunitiesForm: ListCommunities = {
type_: getListingTypeFromQuery(listingType), type_: getListingTypeFromQuery(listingType),
sort: "TopMonth", sort: "TopMonth",
@ -324,33 +292,56 @@ export class Communities extends Component<any, CommunitiesState> {
return [client.listCommunities(listCommunitiesForm)]; return [client.listCommunities(listCommunitiesForm)];
} }
parseMessage(msg: any) { getCommunitiesQueryParams() {
const op = wsUserOp(msg); return getQueryParams<CommunitiesProps>({
console.log(msg); listingType: getListingTypeFromQuery,
if (msg.error) { page: getPageFromString,
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
);
if (found) { async handleFollow(data: {
found.subscribed = subscribed; i: Communities;
found.counts.subscribers = subscribers; communityId: number;
this.setState(this.state); 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,29 +1,17 @@
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { Prompt } from "inferno-router";
import { import {
CommunityResponse,
CommunityView, CommunityView,
CreateCommunity, CreateCommunity,
EditCommunity, EditCommunity,
Language, Language,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services"; import { capitalizeFirstLetter, myAuthRequired, randomStr } from "../../utils";
import {
capitalizeFirstLetter,
myAuth,
randomStr,
wsClient,
wsSubscribe,
} from "../../utils";
import { Icon, Spinner } from "../common/icon"; import { Icon, Spinner } from "../common/icon";
import { ImageUploadForm } from "../common/image-upload-form"; import { ImageUploadForm } from "../common/image-upload-form";
import { LanguageSelect } from "../common/language-select"; import { LanguageSelect } from "../common/language-select";
import { MarkdownTextArea } from "../common/markdown-textarea"; import { MarkdownTextArea } from "../common/markdown-textarea";
import NavigationPrompt from "../common/navigation-prompt";
interface CommunityFormProps { interface CommunityFormProps {
community_view?: CommunityView; // If a community is given, that means this is an edit community_view?: CommunityView; // If a community is given, that means this is an edit
@ -31,9 +19,9 @@ interface CommunityFormProps {
siteLanguages: number[]; siteLanguages: number[];
communityLanguages?: number[]; communityLanguages?: number[];
onCancel?(): any; onCancel?(): any;
onCreate?(community: CommunityView): any; onUpsertCommunity(form: CreateCommunity | EditCommunity): void;
onEdit?(community: CommunityView): any;
enableNsfw?: boolean; enableNsfw?: boolean;
loading?: boolean;
} }
interface CommunityFormState { interface CommunityFormState {
@ -47,7 +35,7 @@ interface CommunityFormState {
posting_restricted_to_mods?: boolean; posting_restricted_to_mods?: boolean;
discussion_languages?: number[]; discussion_languages?: number[];
}; };
loading: boolean; submitted: boolean;
} }
export class CommunityForm extends Component< export class CommunityForm extends Component<
@ -55,11 +43,10 @@ export class CommunityForm extends Component<
CommunityFormState CommunityFormState
> { > {
private id = `community-form-${randomStr()}`; private id = `community-form-${randomStr()}`;
private subscription?: Subscription;
state: CommunityFormState = { state: CommunityFormState = {
form: {}, form: {},
loading: false, submitted: false,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -77,12 +64,11 @@ export class CommunityForm extends Component<
this.handleDiscussionLanguageChange = this.handleDiscussionLanguageChange =
this.handleDiscussionLanguageChange.bind(this); this.handleDiscussionLanguageChange.bind(this);
this.parseMessage = this.parseMessage.bind(this); const cv = this.props.community_view;
this.subscription = wsSubscribe(this.parseMessage);
let cv = this.props.community_view;
if (cv) { if (cv) {
this.state = { this.state = {
...this.state,
form: { form: {
name: cv.community.name, name: cv.community.name,
title: cv.community.title, title: cv.community.title,
@ -93,80 +79,34 @@ export class CommunityForm extends Component<
posting_restricted_to_mods: cv.community.posting_restricted_to_mods, posting_restricted_to_mods: cv.community.posting_restricted_to_mods,
discussion_languages: this.props.communityLanguages, 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() { render() {
return ( return (
<> <form onSubmit={linkEvent(this, this.handleCreateCommunitySubmit)}>
<Prompt <NavigationPrompt
when={ when={
!this.state.loading && !this.props.loading &&
(this.state.form.name || !!(
this.state.form.name ||
this.state.form.title || this.state.form.title ||
this.state.form.description) this.state.form.description
) &&
!this.state.submitted
} }
message={i18n.t("block_leaving")}
/> />
<form onSubmit={linkEvent(this, this.handleCreateCommunitySubmit)}> {!this.props.community_view && (
{!this.props.community_view && (
<div className="form-group row">
<label
className="col-12 col-sm-2 col-form-label"
htmlFor="community-name"
>
{i18n.t("name")}
<span
className="position-absolute pointer unselectable ml-2 text-muted"
data-tippy-content={i18n.t("name_explain")}
>
<Icon icon="help-circle" classes="icon-inline" />
</span>
</label>
<div className="col-12 col-sm-10">
<input
type="text"
id="community-name"
className="form-control"
value={this.state.form.name}
onInput={linkEvent(this, this.handleCommunityNameChange)}
required
minLength={3}
pattern="[a-z0-9_]+"
title={i18n.t("community_reqs")}
/>
</div>
</div>
)}
<div className="form-group row"> <div className="form-group row">
<label <label
className="col-12 col-sm-2 col-form-label" className="col-12 col-sm-2 col-form-label"
htmlFor="community-title" htmlFor="community-name"
> >
{i18n.t("display_name")} {i18n.t("name")}
<span <span
className="position-absolute pointer unselectable ml-2 text-muted" className="position-absolute pointer unselectable ml-2 text-muted"
data-tippy-content={i18n.t("display_name_explain")} data-tippy-content={i18n.t("name_explain")}
> >
<Icon icon="help-circle" classes="icon-inline" /> <Icon icon="help-circle" classes="icon-inline" />
</span> </span>
@ -174,142 +114,182 @@ export class CommunityForm extends Component<
<div className="col-12 col-sm-10"> <div className="col-12 col-sm-10">
<input <input
type="text" type="text"
id="community-title" id="community-name"
value={this.state.form.title}
onInput={linkEvent(this, this.handleCommunityTitleChange)}
className="form-control" className="form-control"
value={this.state.form.name}
onInput={linkEvent(this, this.handleCommunityNameChange)}
required required
minLength={3} minLength={3}
maxLength={100} pattern="[a-z0-9_]+"
title={i18n.t("community_reqs")}
/> />
</div> </div>
</div> </div>
<div className="form-group row"> )}
<label className="col-12 col-sm-2">{i18n.t("icon")}</label> <div className="form-group row">
<div className="col-12 col-sm-10"> <label
<ImageUploadForm className="col-12 col-sm-2 col-form-label"
uploadTitle={i18n.t("upload_icon")} htmlFor="community-title"
imageSrc={this.state.form.icon} >
onUpload={this.handleIconUpload} {i18n.t("display_name")}
onRemove={this.handleIconRemove} <span
rounded className="position-absolute pointer unselectable ml-2 text-muted"
/> data-tippy-content={i18n.t("display_name_explain")}
</div> >
<Icon icon="help-circle" classes="icon-inline" />
</span>
</label>
<div className="col-12 col-sm-10">
<input
type="text"
id="community-title"
value={this.state.form.title}
onInput={linkEvent(this, this.handleCommunityTitleChange)}
className="form-control"
required
minLength={3}
maxLength={100}
/>
</div> </div>
<div className="form-group row"> </div>
<label className="col-12 col-sm-2">{i18n.t("banner")}</label> <div className="form-group row">
<div className="col-12 col-sm-10"> <label className="col-12 col-sm-2">{i18n.t("icon")}</label>
<ImageUploadForm <div className="col-12 col-sm-10">
uploadTitle={i18n.t("upload_banner")} <ImageUploadForm
imageSrc={this.state.form.banner} uploadTitle={i18n.t("upload_icon")}
onUpload={this.handleBannerUpload} imageSrc={this.state.form.icon}
onRemove={this.handleBannerRemove} onUpload={this.handleIconUpload}
/> onRemove={this.handleIconRemove}
</div> rounded
/>
</div> </div>
<div className="form-group row"> </div>
<label className="col-12 col-sm-2 col-form-label" htmlFor={this.id}> <div className="form-group row">
{i18n.t("sidebar")} <label className="col-12 col-sm-2">{i18n.t("banner")}</label>
</label> <div className="col-12 col-sm-10">
<div className="col-12 col-sm-10"> <ImageUploadForm
<MarkdownTextArea uploadTitle={i18n.t("upload_banner")}
initialContent={this.state.form.description} imageSrc={this.state.form.banner}
placeholder={i18n.t("description")} onUpload={this.handleBannerUpload}
onContentChange={this.handleCommunityDescriptionChange} onRemove={this.handleBannerRemove}
allLanguages={[]} />
siteLanguages={[]}
/>
</div>
</div> </div>
</div>
<div className="form-group row">
<label className="col-12 col-sm-2 col-form-label" htmlFor={this.id}>
{i18n.t("sidebar")}
</label>
<div className="col-12 col-sm-10">
<MarkdownTextArea
initialContent={this.state.form.description}
placeholder={i18n.t("description")}
onContentChange={this.handleCommunityDescriptionChange}
hideNavigationWarnings
allLanguages={[]}
siteLanguages={[]}
/>
</div>
</div>
{this.props.enableNsfw && ( {this.props.enableNsfw && (
<div className="form-group row">
<legend className="col-form-label col-sm-2 pt-0">
{i18n.t("nsfw")}
</legend>
<div className="col-10">
<div className="form-check">
<input
className="form-check-input position-static"
id="community-nsfw"
type="checkbox"
checked={this.state.form.nsfw}
onChange={linkEvent(this, this.handleCommunityNsfwChange)}
/>
</div>
</div>
</div>
)}
<div className="form-group row"> <div className="form-group row">
<legend className="col-form-label col-6 pt-0"> <legend className="col-form-label col-sm-2 pt-0">
{i18n.t("only_mods_can_post_in_community")} {i18n.t("nsfw")}
</legend> </legend>
<div className="col-6"> <div className="col-10">
<div className="form-check"> <div className="form-check">
<input <input
className="form-check-input position-static" className="form-check-input position-static"
id="community-only-mods-can-post" id="community-nsfw"
type="checkbox" type="checkbox"
checked={this.state.form.posting_restricted_to_mods} checked={this.state.form.nsfw}
onChange={linkEvent( onChange={linkEvent(this, this.handleCommunityNsfwChange)}
this,
this.handleCommunityPostingRestrictedToMods
)}
/> />
</div> </div>
</div> </div>
</div> </div>
<LanguageSelect )}
allLanguages={this.props.allLanguages} <div className="form-group row">
siteLanguages={this.props.siteLanguages} <legend className="col-form-label col-6 pt-0">
showSite {i18n.t("only_mods_can_post_in_community")}
selectedLanguageIds={this.state.form.discussion_languages} </legend>
multiple={true} <div className="col-6">
onChange={this.handleDiscussionLanguageChange} <div className="form-check">
/> <input
<div className="form-group row"> className="form-check-input position-static"
<div className="col-12"> id="community-only-mods-can-post"
<button type="checkbox"
type="submit" checked={this.state.form.posting_restricted_to_mods}
className="btn btn-secondary mr-2" onChange={linkEvent(
disabled={this.state.loading} this,
> this.handleCommunityPostingRestrictedToMods
{this.state.loading ? (
<Spinner />
) : this.props.community_view ? (
capitalizeFirstLetter(i18n.t("save"))
) : (
capitalizeFirstLetter(i18n.t("create"))
)} )}
</button> />
{this.props.community_view && (
<button
type="button"
className="btn btn-secondary"
onClick={linkEvent(this, this.handleCancel)}
>
{i18n.t("cancel")}
</button>
)}
</div> </div>
</div> </div>
</form> </div>
</> <LanguageSelect
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
showSite
selectedLanguageIds={this.state.form.discussion_languages}
multiple={true}
onChange={this.handleDiscussionLanguageChange}
/>
<div className="form-group row">
<div className="col-12">
<button
type="submit"
className="btn btn-secondary mr-2"
disabled={this.props.loading}
>
{this.props.loading ? (
<Spinner />
) : this.props.community_view ? (
capitalizeFirstLetter(i18n.t("save"))
) : (
capitalizeFirstLetter(i18n.t("create"))
)}
</button>
{this.props.community_view && (
<button
type="button"
className="btn btn-secondary"
onClick={linkEvent(this, this.handleCancel)}
>
{i18n.t("cancel")}
</button>
)}
</div>
</div>
</form>
); );
} }
handleCreateCommunitySubmit(i: CommunityForm, event: any) { handleCreateCommunitySubmit(i: CommunityForm, event: any) {
event.preventDefault(); event.preventDefault();
i.setState({ loading: true }); i.setState({ submitted: true });
let cForm = i.state.form; const cForm = i.state.form;
let auth = myAuth(); const auth = myAuthRequired();
let cv = i.props.community_view; const cv = i.props.community_view;
if (auth) { if (cv) {
if (cv) { i.props.onUpsertCommunity({
let form: EditCommunity = { community_id: cv.community.id,
community_id: cv.community.id, title: cForm.title,
description: cForm.description,
icon: cForm.icon,
banner: cForm.banner,
nsfw: cForm.nsfw,
posting_restricted_to_mods: cForm.posting_restricted_to_mods,
discussion_languages: cForm.discussion_languages,
auth,
});
} else {
if (cForm.title && cForm.name) {
i.props.onUpsertCommunity({
name: cForm.name,
title: cForm.title, title: cForm.title,
description: cForm.description, description: cForm.description,
icon: cForm.icon, icon: cForm.icon,
@ -318,37 +298,17 @@ export class CommunityForm extends Component<
posting_restricted_to_mods: cForm.posting_restricted_to_mods, posting_restricted_to_mods: cForm.posting_restricted_to_mods,
discussion_languages: cForm.discussion_languages, discussion_languages: cForm.discussion_languages,
auth, auth,
}; });
WebSocketService.Instance.send(wsClient.editCommunity(form));
} else {
if (cForm.title && cForm.name) {
let form: CreateCommunity = {
name: cForm.name,
title: cForm.title,
description: cForm.description,
icon: cForm.icon,
banner: cForm.banner,
nsfw: cForm.nsfw,
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) { handleCommunityNameChange(i: CommunityForm, event: any) {
i.state.form.name = event.target.value; i.setState(s => ((s.form.name = event.target.value), s));
i.setState(i.state);
} }
handleCommunityTitleChange(i: CommunityForm, event: any) { handleCommunityTitleChange(i: CommunityForm, event: any) {
i.state.form.title = event.target.value; i.setState(s => ((s.form.title = event.target.value), s));
i.setState(i.state);
} }
handleCommunityDescriptionChange(val: string) { handleCommunityDescriptionChange(val: string) {
@ -356,13 +316,13 @@ export class CommunityForm extends Component<
} }
handleCommunityNsfwChange(i: CommunityForm, event: any) { handleCommunityNsfwChange(i: CommunityForm, event: any) {
i.state.form.nsfw = event.target.checked; i.setState(s => ((s.form.nsfw = event.target.checked), s));
i.setState(i.state);
} }
handleCommunityPostingRestrictedToMods(i: CommunityForm, event: any) { handleCommunityPostingRestrictedToMods(i: CommunityForm, event: any) {
i.state.form.posting_restricted_to_mods = event.target.checked; i.setState(
i.setState(i.state); s => ((s.form.posting_restricted_to_mods = event.target.checked), s)
);
} }
handleCancel(i: CommunityForm) { handleCancel(i: CommunityForm) {
@ -388,56 +348,4 @@ export class CommunityForm extends Component<
handleDiscussionLanguageChange(val: number[]) { handleDiscussionLanguageChange(val: number[]) {
this.setState(s => ((s.form.discussion_languages = val), s)); this.setState(s => ((s.form.discussion_languages = val), s));
} }
parseMessage(msg: any) {
let 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) {
let data = wsJsonToRes<CommunityResponse>(msg);
this.props.onCreate?.(data.community_view);
// Update myUserInfo
let community = data.community_view.community;
let mui = UserService.Instance.myUserInfo;
if (mui) {
let person = mui.local_user_view.person;
mui.follows.push({
community,
follower: person,
});
mui.moderates.push({
community,
moderator: person,
});
}
} else if (op == UserOperation.EditCommunity) {
let data = wsJsonToRes<CommunityResponse>(msg);
this.setState({ loading: false });
this.props.onEdit?.(data.community_view);
let community = data.community_view.community;
let mui = UserService.Instance.myUserInfo;
if (mui) {
let followFound = mui.follows.findIndex(
f => f.community.id == community.id
);
if (followFound) {
mui.follows[followFound].community = community;
}
let moderatesFound = mui.moderates.findIndex(
f => f.community.id == community.id
);
if (moderatesFound) {
mui.moderates[moderatesFound].community = community;
}
}
}
}
} }

View file

@ -18,22 +18,22 @@ export class CommunityLink extends Component<CommunityLinkProps, any> {
} }
render() { render() {
let community = this.props.community; const community = this.props.community;
let name_: string, title: string, link: string; let name_: string, title: string, link: string;
let local = community.local == null ? true : community.local; const local = community.local == null ? true : community.local;
if (local) { if (local) {
name_ = community.name; name_ = community.name;
title = community.title; title = community.title;
link = `/c/${community.name}`; link = `/c/${community.name}`;
} else { } else {
let domain = hostname(community.actor_id); const domain = hostname(community.actor_id);
name_ = `${community.name}@${domain}`; name_ = `${community.name}@${domain}`;
title = `${community.title}@${domain}`; title = `${community.title}@${domain}`;
link = !this.props.realLink ? `/c/${name_}` : community.actor_id; link = !this.props.realLink ? `/c/${name_}` : community.actor_id;
} }
let apubName = `!${name_}`; const apubName = `!${name_}`;
let displayName = this.props.useApubName ? apubName : title; const displayName = this.props.useApubName ? apubName : title;
return !this.props.realLink ? ( return !this.props.realLink ? (
<Link <Link
title={apubName} title={apubName}
@ -55,7 +55,7 @@ export class CommunityLink extends Component<CommunityLinkProps, any> {
} }
avatarAndName(displayName: string) { avatarAndName(displayName: string) {
let icon = this.props.community.icon; const icon = this.props.community.icon;
return ( return (
<> <>
{!this.props.hideAvatar && {!this.props.hideAvatar &&

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,12 @@
import { Component } from "inferno"; import { Component } from "inferno";
import { CommunityView, GetSiteResponse } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next";
import { import {
enableNsfw, CreateCommunity as CreateCommunityI,
isBrowser, GetSiteResponse,
setIsoData, } from "lemmy-js-client";
toast, import { i18n } from "../../i18next";
wsSubscribe, import { HttpService } from "../../services/HttpService";
} from "../../utils"; import { enableNsfw, setIsoData } from "../../utils";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import { CommunityForm } from "./community-form"; import { CommunityForm } from "./community-form";
interface CreateCommunityState { interface CreateCommunityState {
@ -20,7 +16,6 @@ interface CreateCommunityState {
export class CreateCommunity extends Component<any, CreateCommunityState> { export class CreateCommunity extends Component<any, CreateCommunityState> {
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: CreateCommunityState = { state: CreateCommunityState = {
siteRes: this.isoData.site_res, siteRes: this.isoData.site_res,
loading: false, loading: false,
@ -28,15 +23,6 @@ export class CreateCommunity extends Component<any, CreateCommunityState> {
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.handleCommunityCreate = this.handleCommunityCreate.bind(this); 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 { get documentTitle(): string {
@ -52,35 +38,33 @@ export class CreateCommunity extends Component<any, CreateCommunityState> {
title={this.documentTitle} title={this.documentTitle}
path={this.context.router.route.match.url} path={this.context.router.route.match.url}
/> />
{this.state.loading ? ( <div className="row">
<h5> <div className="col-12 col-lg-6 offset-lg-3 mb-4">
<Spinner large /> <h5>{i18n.t("create_community")}</h5>
</h5> <CommunityForm
) : ( onUpsertCommunity={this.handleCommunityCreate}
<div className="row"> enableNsfw={enableNsfw(this.state.siteRes)}
<div className="col-12 col-lg-6 offset-lg-3 mb-4"> allLanguages={this.state.siteRes.all_languages}
<h5>{i18n.t("create_community")}</h5> siteLanguages={this.state.siteRes.discussion_languages}
<CommunityForm communityLanguages={this.state.siteRes.discussion_languages}
onCreate={this.handleCommunityCreate} loading={this.state.loading}
enableNsfw={enableNsfw(this.state.siteRes)} />
allLanguages={this.state.siteRes.all_languages}
siteLanguages={this.state.siteRes.discussion_languages}
communityLanguages={this.state.siteRes.discussion_languages}
/>
</div>
</div> </div>
)} </div>
</div> </div>
); );
} }
handleCommunityCreate(cv: CommunityView) { async handleCommunityCreate(form: CreateCommunityI) {
this.props.history.push(`/c/${cv.community.name}`); this.setState({ loading: true });
}
parseMessage(msg: any) { const res = await HttpService.client.createCommunity(form);
if (msg.error) {
toast(i18n.t(msg.error), "danger"); 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 { Link } from "inferno-router";
import { import {
AddModToCommunity, AddModToCommunity,
@ -6,6 +6,7 @@ import {
CommunityModeratorView, CommunityModeratorView,
CommunityView, CommunityView,
DeleteCommunity, DeleteCommunity,
EditCommunity,
FollowCommunity, FollowCommunity,
Language, Language,
PersonView, PersonView,
@ -13,7 +14,7 @@ import {
RemoveCommunity, RemoveCommunity,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services"; import { UserService } from "../../services";
import { import {
amAdmin, amAdmin,
amMod, amMod,
@ -21,9 +22,8 @@ import {
getUnixTime, getUnixTime,
hostname, hostname,
mdToHtml, mdToHtml,
myAuth, myAuthRequired,
numToSI, numToSI,
wsClient,
} from "../../utils"; } from "../../utils";
import { BannerIconHeader } from "../common/banner-icon-header"; import { BannerIconHeader } from "../common/banner-icon-header";
import { Icon, PurgeWarning, Spinner } from "../common/icon"; import { Icon, PurgeWarning, Spinner } from "../common/icon";
@ -42,6 +42,13 @@ interface SidebarProps {
enableNsfw?: boolean; enableNsfw?: boolean;
showIcon?: boolean; showIcon?: boolean;
editable?: 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 { interface SidebarState {
@ -51,8 +58,13 @@ interface SidebarState {
showRemoveDialog: boolean; showRemoveDialog: boolean;
showPurgeDialog: boolean; showPurgeDialog: boolean;
purgeReason?: string; purgeReason?: string;
purgeLoading: boolean;
showConfirmLeaveModTeam: boolean; showConfirmLeaveModTeam: boolean;
deleteCommunityLoading: boolean;
removeCommunityLoading: boolean;
leaveModTeamLoading: boolean;
followCommunityLoading: boolean;
blockCommunityLoading: boolean;
purgeCommunityLoading: boolean;
} }
export class Sidebar extends Component<SidebarProps, SidebarState> { export class Sidebar extends Component<SidebarProps, SidebarState> {
@ -60,16 +72,44 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
showEdit: false, showEdit: false,
showRemoveDialog: false, showRemoveDialog: false,
showPurgeDialog: false, showPurgeDialog: false,
purgeLoading: false,
showConfirmLeaveModTeam: false, showConfirmLeaveModTeam: false,
deleteCommunityLoading: false,
removeCommunityLoading: false,
leaveModTeamLoading: false,
followCommunityLoading: false,
blockCommunityLoading: false,
purgeCommunityLoading: false,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.handleEditCommunity = this.handleEditCommunity.bind(this);
this.handleEditCancel = this.handleEditCancel.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() { render() {
return ( return (
<div> <div>
@ -81,7 +121,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
allLanguages={this.props.allLanguages} allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages} siteLanguages={this.props.siteLanguages}
communityLanguages={this.props.communityLanguages} communityLanguages={this.props.communityLanguages}
onEdit={this.handleEditCommunity} onUpsertCommunity={this.props.onEditCommunity}
onCancel={this.handleEditCancel} onCancel={this.handleEditCancel}
enableNsfw={this.props.enableNsfw} enableNsfw={this.props.enableNsfw}
/> />
@ -124,30 +164,42 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
} }
communityTitle() { communityTitle() {
let community = this.props.community_view.community; const community = this.props.community_view.community;
let subscribed = this.props.community_view.subscribed; const subscribed = this.props.community_view.subscribed;
return ( return (
<div> <div>
<h5 className="mb-0"> <h5 className="mb-0">
{this.props.showIcon && !community.removed && ( {this.props.showIcon && !community.removed && (
<BannerIconHeader icon={community.icon} banner={community.banner} /> <BannerIconHeader icon={community.icon} banner={community.banner} />
)} )}
<span className="mr-2">{community.title}</span> <span className="mr-2">
<CommunityLink community={community} hideAvatar />
</span>
{subscribed === "Subscribed" && ( {subscribed === "Subscribed" && (
<button <button
className="btn btn-secondary btn-sm mr-2" className="btn btn-secondary btn-sm mr-2"
onClick={linkEvent(this, this.handleUnsubscribe)} onClick={linkEvent(this, this.handleUnfollowCommunity)}
> >
<Icon icon="check" classes="icon-inline text-success mr-1" /> {this.state.followCommunityLoading ? (
{i18n.t("joined")} <Spinner />
) : (
<>
<Icon icon="check" classes="icon-inline text-success mr-1" />
{i18n.t("joined")}
</>
)}
</button> </button>
)} )}
{subscribed === "Pending" && ( {subscribed === "Pending" && (
<button <button
className="btn btn-warning mr-2" 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> </button>
)} )}
{community.removed && ( {community.removed && (
@ -178,8 +230,8 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
} }
badges() { badges() {
let community_view = this.props.community_view; const community_view = this.props.community_view;
let counts = community_view.counts; const counts = community_view.counts;
return ( return (
<ul className="my-1 list-inline"> <ul className="my-1 list-inline">
<li className="list-inline-item badge badge-secondary"> <li className="list-inline-item badge badge-secondary">
@ -284,7 +336,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
} }
createPost() { createPost() {
let cv = this.props.community_view; const cv = this.props.community_view;
return ( return (
<Link <Link
className={`btn btn-secondary btn-block mb-2 ${ className={`btn btn-secondary btn-block mb-2 ${
@ -298,15 +350,19 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
} }
subscribe() { subscribe() {
let community_view = this.props.community_view; const community_view = this.props.community_view;
return ( return (
<div className="mb-2"> <div className="mb-2">
{community_view.subscribed == "NotSubscribed" && ( {community_view.subscribed == "NotSubscribed" && (
<button <button
className="btn btn-secondary btn-block" 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> </button>
)} )}
</div> </div>
@ -314,8 +370,8 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
} }
blockCommunity() { blockCommunity() {
let community_view = this.props.community_view; const community_view = this.props.community_view;
let blocked = this.props.community_view.blocked; const blocked = this.props.community_view.blocked;
return ( return (
<div className="mb-2"> <div className="mb-2">
@ -323,16 +379,24 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
(blocked ? ( (blocked ? (
<button <button
className="btn btn-danger btn-block" 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>
) : ( ) : (
<button <button
className="btn btn-danger btn-block" 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> </button>
))} ))}
</div> </div>
@ -340,7 +404,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
} }
description() { description() {
let desc = this.props.community_view.community.description; const desc = this.props.community_view.community.description;
return ( return (
desc && ( desc && (
<div className="md-div" dangerouslySetInnerHTML={mdToHtml(desc)} /> <div className="md-div" dangerouslySetInnerHTML={mdToHtml(desc)} />
@ -349,7 +413,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
} }
adminButtons() { adminButtons() {
let community_view = this.props.community_view; const community_view = this.props.community_view;
return ( return (
<> <>
<ul className="list-inline mb-1 text-muted font-weight-bold"> <ul className="list-inline mb-1 text-muted font-weight-bold">
@ -386,7 +450,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
<li className="list-inline-item-action"> <li className="list-inline-item-action">
<button <button
className="btn btn-link text-muted d-inline-block" className="btn btn-link text-muted d-inline-block"
onClick={linkEvent(this, this.handleLeaveModTeamClick)} onClick={linkEvent(this, this.handleLeaveModTeam)}
> >
{i18n.t("yes")} {i18n.t("yes")}
</button> </button>
@ -408,7 +472,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
<li className="list-inline-item-action"> <li className="list-inline-item-action">
<button <button
className="btn btn-link text-muted d-inline-block" className="btn btn-link text-muted d-inline-block"
onClick={linkEvent(this, this.handleDeleteClick)} onClick={linkEvent(this, this.handleDeleteCommunity)}
data-tippy-content={ data-tippy-content={
!community_view.community.deleted !community_view.community.deleted
? i18n.t("delete") ? i18n.t("delete")
@ -420,12 +484,16 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
: i18n.t("restore") : i18n.t("restore")
} }
> >
<Icon {this.state.deleteCommunityLoading ? (
icon="trash" <Spinner />
classes={`icon-inline ${ ) : (
community_view.community.deleted && "text-danger" <Icon
}`} icon="trash"
/> classes={`icon-inline ${
community_view.community.deleted && "text-danger"
}`}
/>
)}{" "}
</button> </button>
</li> </li>
)} )}
@ -443,9 +511,13 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
) : ( ) : (
<button <button
className="btn btn-link text-muted d-inline-block" 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>
)} )}
<button <button
@ -459,7 +531,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
)} )}
</ul> </ul>
{this.state.showRemoveDialog && ( {this.state.showRemoveDialog && (
<form onSubmit={linkEvent(this, this.handleModRemoveSubmit)}> <form onSubmit={linkEvent(this, this.handleRemoveCommunity)}>
<div className="form-group"> <div className="form-group">
<label className="col-form-label" htmlFor="remove-reason"> <label className="col-form-label" htmlFor="remove-reason">
{i18n.t("reason")} {i18n.t("reason")}
@ -480,13 +552,17 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
{/* </div> */} {/* </div> */}
<div className="form-group"> <div className="form-group">
<button type="submit" className="btn btn-secondary"> <button type="submit" className="btn btn-secondary">
{i18n.t("remove_community")} {this.state.removeCommunityLoading ? (
<Spinner />
) : (
i18n.t("remove_community")
)}
</button> </button>
</div> </div>
</form> </form>
)} )}
{this.state.showPurgeDialog && ( {this.state.showPurgeDialog && (
<form onSubmit={linkEvent(this, this.handlePurgeSubmit)}> <form onSubmit={linkEvent(this, this.handlePurgeCommunity)}>
<div className="form-group"> <div className="form-group">
<PurgeWarning /> <PurgeWarning />
</div> </div>
@ -504,7 +580,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
{this.state.purgeLoading ? ( {this.state.purgeCommunityLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
<button <button
@ -526,93 +602,18 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
i.setState({ showEdit: true }); i.setState({ showEdit: true });
} }
handleEditCommunity() {
this.setState({ showEdit: false });
}
handleEditCancel() { handleEditCancel() {
this.setState({ showEdit: false }); this.setState({ showEdit: false });
} }
handleDeleteClick(i: Sidebar, event: any) {
event.preventDefault();
let auth = myAuth();
if (auth) {
let 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) { handleShowConfirmLeaveModTeamClick(i: Sidebar) {
i.setState({ showConfirmLeaveModTeam: true }); i.setState({ showConfirmLeaveModTeam: true });
} }
handleLeaveModTeamClick(i: Sidebar) {
let mui = UserService.Instance.myUserInfo;
let auth = myAuth();
if (auth && mui) {
let 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) { handleCancelLeaveModTeamClick(i: Sidebar) {
i.setState({ showConfirmLeaveModTeam: false }); i.setState({ showConfirmLeaveModTeam: false });
} }
handleUnsubscribe(i: Sidebar, event: any) {
event.preventDefault();
let community_id = i.props.community_view.community.id;
let auth = myAuth();
if (auth) {
let form: FollowCommunity = {
community_id,
follow: false,
auth,
};
WebSocketService.Instance.send(wsClient.followCommunity(form));
}
// Update myUserInfo
let mui = UserService.Instance.myUserInfo;
if (mui) {
mui.follows = mui.follows.filter(i => i.community.id != community_id);
}
}
handleSubscribe(i: Sidebar, event: any) {
event.preventDefault();
let community_id = i.props.community_view.community.id;
let auth = myAuth();
if (auth) {
let form: FollowCommunity = {
community_id,
follow: true,
auth,
};
WebSocketService.Instance.send(wsClient.followCommunity(form));
}
// Update myUserInfo
let mui = UserService.Instance.myUserInfo;
if (mui) {
mui.follows.push({
community: i.props.community_view.community,
follower: mui.local_user_view.person,
});
}
}
get canPost(): boolean { get canPost(): boolean {
return ( return (
!this.props.community_view.community.posting_restricted_to_mods || !this.props.community_view.community.posting_restricted_to_mods ||
@ -633,23 +634,6 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
i.setState({ removeExpires: event.target.value }); i.setState({ removeExpires: event.target.value });
} }
handleModRemoveSubmit(i: Sidebar, event: any) {
event.preventDefault();
let auth = myAuth();
if (auth) {
let 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) { handlePurgeCommunityShow(i: Sidebar) {
i.setState({ showPurgeDialog: true, showRemoveDialog: false }); i.setState({ showPurgeDialog: true, showRemoveDialog: false });
} }
@ -658,48 +642,75 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
i.setState({ purgeReason: event.target.value }); i.setState({ purgeReason: event.target.value });
} }
handlePurgeSubmit(i: Sidebar, event: any) { // TODO Do we need two of these?
event.preventDefault(); handleUnfollowCommunity(i: Sidebar) {
i.setState({ followCommunityLoading: true });
i.props.onFollowCommunity({
community_id: i.props.community_view.community.id,
follow: false,
auth: myAuthRequired(),
});
}
let auth = myAuth(); handleFollowCommunity(i: Sidebar) {
if (auth) { i.setState({ followCommunityLoading: true });
let form: PurgeCommunity = { 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, community_id: i.props.community_view.community.id,
reason: i.state.purgeReason, person_id: 92,
auth, added: false,
}; auth: myAuthRequired(),
WebSocketService.Instance.send(wsClient.purgeCommunity(form)); });
i.setState({ purgeLoading: true });
} }
} }
handleBlock(i: Sidebar, event: any) { handleDeleteCommunity(i: Sidebar) {
event.preventDefault(); i.setState({ deleteCommunityLoading: true });
let auth = myAuth(); i.props.onDeleteCommunity({
if (auth) { community_id: i.props.community_view.community.id,
let blockCommunityForm: BlockCommunity = { deleted: !i.props.community_view.community.deleted,
community_id: i.props.community_view.community.id, auth: myAuthRequired(),
block: true, });
auth,
};
WebSocketService.Instance.send(
wsClient.blockCommunity(blockCommunityForm)
);
}
} }
handleUnblock(i: Sidebar, event: any) { handleRemoveCommunity(i: Sidebar, event: any) {
event.preventDefault(); event.preventDefault();
let auth = myAuth(); i.setState({ removeCommunityLoading: true });
if (auth) { i.props.onRemoveCommunity({
let blockCommunityForm: BlockCommunity = { community_id: i.props.community_view.community.id,
community_id: i.props.community_view.community.id, removed: !i.props.community_view.community.removed,
block: false, reason: i.state.removeReason,
auth, expires: getUnixTime(i.state.removeExpires), // TODO fix this
}; auth: myAuthRequired(),
WebSocketService.Instance.send( });
wsClient.blockCommunity(blockCommunityForm) }
);
} 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: myAuthRequired(),
});
} }
} }

View file

@ -1,30 +1,27 @@
import autosize from "autosize";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { import {
BannedPersonsResponse, BannedPersonsResponse,
GetBannedPersons, CreateCustomEmoji,
DeleteCustomEmoji,
EditCustomEmoji,
EditSite,
GetFederatedInstancesResponse, GetFederatedInstancesResponse,
GetSiteResponse, GetSiteResponse,
PersonView, PersonView,
SiteResponse,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { InitialFetchRequest } from "../../interfaces"; import { InitialFetchRequest } from "../../interfaces";
import { WebSocketService } from "../../services"; import { FirstLoadService } from "../../services/FirstLoadService";
import { HttpService, RequestState } from "../../services/HttpService";
import { import {
capitalizeFirstLetter, capitalizeFirstLetter,
isBrowser, fetchThemeList,
myAuth, myAuthRequired,
randomStr, removeFromEmojiDataModel,
setIsoData, setIsoData,
showLocal, showLocal,
toast, toast,
wsClient, updateEmojiDataModel,
wsSubscribe,
} from "../../utils"; } from "../../utils";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
@ -37,76 +34,92 @@ import { TaglineForm } from "./tagline-form";
interface AdminSettingsState { interface AdminSettingsState {
siteRes: GetSiteResponse; siteRes: GetSiteResponse;
instancesRes?: GetFederatedInstancesResponse;
banned: PersonView[]; banned: PersonView[];
loading: boolean; currentTab: string;
leaveAdminTeamLoading: boolean; instancesRes: RequestState<GetFederatedInstancesResponse>;
bannedRes: RequestState<BannedPersonsResponse>;
leaveAdminTeamRes: RequestState<GetSiteResponse>;
themeList: string[];
isIsomorphic: boolean;
} }
export class AdminSettings extends Component<any, AdminSettingsState> { export class AdminSettings extends Component<any, AdminSettingsState> {
private siteConfigTextAreaId = `site-config-${randomStr()}`;
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: AdminSettingsState = { state: AdminSettingsState = {
siteRes: this.isoData.site_res, siteRes: this.isoData.site_res,
banned: [], banned: [],
loading: true, currentTab: "site",
leaveAdminTeamLoading: false, bannedRes: { state: "empty" },
instancesRes: { state: "empty" },
leaveAdminTeamRes: { state: "empty" },
themeList: [],
isIsomorphic: false,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.parseMessage = this.parseMessage.bind(this); this.handleEditSite = this.handleEditSite.bind(this);
this.subscription = wsSubscribe(this.parseMessage); 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 // 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 = {
...this.state, ...this.state,
banned: (this.isoData.routeData[0] as BannedPersonsResponse).banned, bannedRes,
instancesRes: this.isoData instancesRes,
.routeData[1] as GetFederatedInstancesResponse, isIsomorphic: true,
loading: false,
}; };
} else {
let cAuth = myAuth();
if (cAuth) {
WebSocketService.Instance.send(
wsClient.getBannedPersons({
auth: cAuth,
})
);
WebSocketService.Instance.send(
wsClient.getFederatedInstances({ auth: cAuth })
);
}
} }
} }
static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { async fetchData() {
let promises: Promise<any>[] = []; 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>>[] = [];
let auth = req.auth;
if (auth) { if (auth) {
let bannedPersonsForm: GetBannedPersons = { auth }; promises.push(client.getBannedPersons({ auth }));
promises.push(req.client.getBannedPersons(bannedPersonsForm)); promises.push(client.getFederatedInstances({ auth }));
promises.push(req.client.getFederatedInstances({ auth })); } else {
promises.push(
Promise.resolve({ state: "empty" }),
Promise.resolve({ state: "empty" })
);
} }
return promises; return promises;
} }
componentDidMount() { async componentDidMount() {
if (isBrowser()) { if (!this.state.isIsomorphic) {
var textarea: any = document.getElementById(this.siteConfigTextAreaId); await this.fetchData();
autosize(textarea);
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
} }
} }
@ -117,74 +130,80 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
} }
render() { render() {
const federationData =
this.state.instancesRes.state === "success"
? this.state.instancesRes.data.federated_instances
: undefined;
return ( return (
<div className="container-lg"> <div className="container-lg">
<HtmlTags <HtmlTags
title={this.documentTitle} title={this.documentTitle}
path={this.context.router.route.match.url} path={this.context.router.route.match.url}
/> />
{this.state.loading ? ( <Tabs
<h5> tabs={[
<Spinner large /> {
</h5> key: "site",
) : ( label: i18n.t("site"),
<Tabs getNode: () => (
tabs={[ <div className="row">
{ <div className="col-12 col-md-6">
key: "site", <SiteForm
label: i18n.t("site"), showLocal={showLocal(this.isoData)}
getNode: () => ( allowedInstances={federationData?.allowed}
<div className="row"> blockedInstances={federationData?.blocked}
<div className="col-12 col-md-6"> onSaveSite={this.handleEditSite}
<SiteForm siteRes={this.state.siteRes}
siteRes={this.state.siteRes} themeList={this.state.themeList}
instancesRes={this.state.instancesRes} />
showLocal={showLocal(this.isoData)}
/>
</div>
<div className="col-12 col-md-6">
{this.admins()}
{this.bannedUsers()}
</div>
</div> </div>
), <div className="col-12 col-md-6">
}, {this.admins()}
{ {this.bannedUsers()}
key: "rate_limiting", </div>
label: "Rate Limiting", </div>
getNode: () => ( ),
<RateLimitForm },
localSiteRateLimit={ {
this.state.siteRes.site_view.local_site_rate_limit key: "rate_limiting",
} label: "Rate Limiting",
applicationQuestion={ getNode: () => (
this.state.siteRes.site_view.local_site <RateLimitForm
.application_question rateLimits={
} this.state.siteRes.site_view.local_site_rate_limit
}
onSaveSite={this.handleEditSite}
/>
),
},
{
key: "taglines",
label: i18n.t("taglines"),
getNode: () => (
<div className="row">
<TaglineForm
taglines={this.state.siteRes.taglines}
onSaveSite={this.handleEditSite}
/> />
), </div>
}, ),
{ },
key: "taglines", {
label: i18n.t("taglines"), key: "emojis",
getNode: () => ( label: i18n.t("emojis"),
<div className="row"> getNode: () => (
<TaglineForm siteRes={this.state.siteRes} /> <div className="row">
</div> <EmojiForm
), onCreate={this.handleCreateEmoji}
}, onDelete={this.handleDeleteEmoji}
{ onEdit={this.handleEditEmoji}
key: "emojis", />
label: i18n.t("emojis"), </div>
getNode: () => ( ),
<div className="row"> },
<EmojiForm /> ]}
</div> />
),
},
]}
/>
)}
</div> </div>
); );
} }
@ -211,7 +230,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
onClick={linkEvent(this, this.handleLeaveAdminTeam)} onClick={linkEvent(this, this.handleLeaveAdminTeam)}
className="btn btn-danger mb-2" className="btn btn-danger mb-2"
> >
{this.state.leaveAdminTeamLoading ? ( {this.state.leaveAdminTeamRes.state == "loading" ? (
<Spinner /> <Spinner />
) : ( ) : (
i18n.t("leave_admin_team") i18n.t("leave_admin_team")
@ -221,52 +240,83 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
} }
bannedUsers() { bannedUsers() {
return ( switch (this.state.bannedRes.state) {
<> case "loading":
<h5>{i18n.t("banned_users")}</h5> return (
<ul className="list-unstyled"> <h5>
{this.state.banned.map(banned => ( <Spinner large />
<li key={banned.person.id} className="list-inline-item"> </h5>
<PersonListing person={banned.person} /> );
</li> case "success": {
))} const bans = this.state.bannedRes.data.banned;
</ul> return (
</> <>
); <h5>{i18n.t("banned_users")}</h5>
} <ul className="list-unstyled">
{bans.map(banned => (
handleLeaveAdminTeam(i: AdminSettings) { <li key={banned.person.id} className="list-inline-item">
let auth = myAuth(); <PersonListing person={banned.person} />
if (auth) { </li>
i.setState({ leaveAdminTeamLoading: true }); ))}
WebSocketService.Instance.send(wsClient.leaveAdmin({ auth })); </ul>
</>
);
}
} }
} }
parseMessage(msg: any) { async handleEditSite(form: EditSite) {
let op = wsUserOp(msg); const editRes = await HttpService.client.editSite(form);
console.log(msg);
if (msg.error) { if (editRes.state === "success") {
toast(i18n.t(msg.error), "danger"); this.setState(s => {
this.context.router.history.push("/"); s.siteRes.site_view = editRes.data.site_view;
this.setState({ loading: false }); // TODO: Where to get taglines from?
return; s.siteRes.taglines = editRes.data.taglines;
} else if (op == UserOperation.EditSite) { return s;
let data = wsJsonToRes<SiteResponse>(msg); });
this.setState(s => ((s.siteRes.site_view = data.site_view), s));
toast(i18n.t("site_saved")); toast(i18n.t("site_saved"));
} else if (op == UserOperation.GetBannedPersons) { }
let data = wsJsonToRes<BannedPersonsResponse>(msg);
this.setState({ banned: data.banned, loading: false }); return editRes;
} else if (op == UserOperation.LeaveAdmin) { }
let data = wsJsonToRes<GetSiteResponse>(msg);
this.setState(s => ((s.siteRes.site_view = data.site_view), s)); handleSwitchTab(i: { ctx: AdminSettings; tab: string }) {
this.setState({ leaveAdminTeamLoading: false }); 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")); toast(i18n.t("left_admin_team"));
this.context.router.history.push("/"); this.context.router.history.replace("/");
} else if (op == UserOperation.GetFederatedInstances) { }
let data = wsJsonToRes<GetFederatedInstancesResponse>(msg); }
this.setState({ instancesRes: data });
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 { Component, linkEvent } from "inferno";
import { import {
CreateCustomEmoji, CreateCustomEmoji,
CustomEmojiResponse,
DeleteCustomEmoji, DeleteCustomEmoji,
DeleteCustomEmojiResponse,
EditCustomEmoji, EditCustomEmoji,
GetSiteResponse, GetSiteResponse,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { WebSocketService } from "../../services"; import { HttpService } from "../../services/HttpService";
import { import {
customEmojisLookup, customEmojisLookup,
isBrowser, myAuthRequired,
myAuth,
pictrsDeleteToast, pictrsDeleteToast,
removeFromEmojiDataModel,
setIsoData, setIsoData,
toast, toast,
updateEmojiDataModel,
uploadImage,
wsClient,
wsSubscribe,
} from "../../utils"; } from "../../utils";
import { EmojiMart } from "../common/emoji-mart"; import { EmojiMart } from "../common/emoji-mart";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Icon } from "../common/icon"; import { Icon } from "../common/icon";
import { Paginator } from "../common/paginator"; import { Paginator } from "../common/paginator";
interface EmojiFormProps {
onEdit(form: EditCustomEmoji): void;
onCreate(form: CreateCustomEmoji): void;
onDelete(form: DeleteCustomEmoji): void;
}
interface EmojiFormState { interface EmojiFormState {
siteRes: GetSiteResponse; siteRes: GetSiteResponse;
customEmojis: CustomEmojiViewForm[]; customEmojis: CustomEmojiViewForm[];
@ -49,9 +43,8 @@ interface CustomEmojiViewForm {
page: number; page: number;
} }
export class EmojiForm extends Component<any, EmojiFormState> { export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
private subscription: Subscription | undefined;
private itemsPerPage = 15; private itemsPerPage = 15;
private emptyState: EmojiFormState = { private emptyState: EmojiFormState = {
loading: false, loading: false,
@ -75,20 +68,12 @@ export class EmojiForm extends Component<any, EmojiFormState> {
this.state = this.emptyState; this.state = this.emptyState;
this.handlePageChange = this.handlePageChange.bind(this); this.handlePageChange = this.handlePageChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.handleEmojiClick = this.handleEmojiClick.bind(this); this.handleEmojiClick = this.handleEmojiClick.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
} }
get documentTitle(): string { get documentTitle(): string {
return i18n.t("custom_emojis"); return i18n.t("custom_emojis");
} }
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
}
render() { render() {
return ( return (
<div className="col-12"> <div className="col-12">
@ -232,7 +217,7 @@ export class EmojiForm extends Component<any, EmojiFormState> {
"btn btn-link btn-animate" "btn btn-link btn-animate"
} }
onClick={linkEvent( onClick={linkEvent(
{ form: this, cv: cv }, { i: this, cv: cv },
this.handleEditEmojiClick this.handleEditEmojiClick
)} )}
data-tippy-content={i18n.t("save")} data-tippy-content={i18n.t("save")}
@ -253,7 +238,7 @@ export class EmojiForm extends Component<any, EmojiFormState> {
<button <button
className="btn btn-link btn-animate text-muted" className="btn btn-link btn-animate text-muted"
onClick={linkEvent( onClick={linkEvent(
{ form: this, index: index, cv: cv }, { i: this, index: index, cv: cv },
this.handleDeleteEmojiClick this.handleDeleteEmojiClick
)} )}
data-tippy-content={i18n.t("delete")} data-tippy-content={i18n.t("delete")}
@ -325,10 +310,10 @@ export class EmojiForm extends Component<any, EmojiFormState> {
props: { form: EmojiForm; index: number }, props: { form: EmojiForm; index: number },
event: any event: any
) { ) {
let custom_emojis = [...props.form.state.customEmojis]; const custom_emojis = [...props.form.state.customEmojis];
let pagedIndex = const pagedIndex =
(props.form.state.page - 1) * props.form.itemsPerPage + props.index; (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
let item = { const item = {
...props.form.state.customEmojis[pagedIndex], ...props.form.state.customEmojis[pagedIndex],
category: event.target.value, category: event.target.value,
changed: true, changed: true,
@ -341,10 +326,10 @@ export class EmojiForm extends Component<any, EmojiFormState> {
props: { form: EmojiForm; index: number }, props: { form: EmojiForm; index: number },
event: any event: any
) { ) {
let custom_emojis = [...props.form.state.customEmojis]; const custom_emojis = [...props.form.state.customEmojis];
let pagedIndex = const pagedIndex =
(props.form.state.page - 1) * props.form.itemsPerPage + props.index; (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
let item = { const item = {
...props.form.state.customEmojis[pagedIndex], ...props.form.state.customEmojis[pagedIndex],
shortcode: event.target.value, shortcode: event.target.value,
changed: true, changed: true,
@ -357,10 +342,10 @@ export class EmojiForm extends Component<any, EmojiFormState> {
props: { form: EmojiForm; index: number; overrideValue: string | null }, props: { form: EmojiForm; index: number; overrideValue: string | null },
event: any event: any
) { ) {
let custom_emojis = [...props.form.state.customEmojis]; const custom_emojis = [...props.form.state.customEmojis];
let pagedIndex = const pagedIndex =
(props.form.state.page - 1) * props.form.itemsPerPage + props.index; (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
let item = { const item = {
...props.form.state.customEmojis[pagedIndex], ...props.form.state.customEmojis[pagedIndex],
image_url: props.overrideValue ?? event.target.value, image_url: props.overrideValue ?? event.target.value,
changed: true, changed: true,
@ -373,10 +358,10 @@ export class EmojiForm extends Component<any, EmojiFormState> {
props: { form: EmojiForm; index: number }, props: { form: EmojiForm; index: number },
event: any event: any
) { ) {
let custom_emojis = [...props.form.state.customEmojis]; const custom_emojis = [...props.form.state.customEmojis];
let pagedIndex = const pagedIndex =
(props.form.state.page - 1) * props.form.itemsPerPage + props.index; (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
let item = { const item = {
...props.form.state.customEmojis[pagedIndex], ...props.form.state.customEmojis[pagedIndex],
alt_text: event.target.value, alt_text: event.target.value,
changed: true, changed: true,
@ -389,10 +374,10 @@ export class EmojiForm extends Component<any, EmojiFormState> {
props: { form: EmojiForm; index: number }, props: { form: EmojiForm; index: number },
event: any event: any
) { ) {
let custom_emojis = [...props.form.state.customEmojis]; const custom_emojis = [...props.form.state.customEmojis];
let pagedIndex = const pagedIndex =
(props.form.state.page - 1) * props.form.itemsPerPage + props.index; (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
let item = { const item = {
...props.form.state.customEmojis[pagedIndex], ...props.form.state.customEmojis[pagedIndex],
keywords: event.target.value, keywords: event.target.value,
changed: true, changed: true,
@ -401,60 +386,56 @@ export class EmojiForm extends Component<any, EmojiFormState> {
props.form.setState({ customEmojis: custom_emojis }); props.form.setState({ customEmojis: custom_emojis });
} }
handleDeleteEmojiClick(props: { handleDeleteEmojiClick(d: {
form: EmojiForm; i: EmojiForm;
index: number; index: number;
cv: CustomEmojiViewForm; cv: CustomEmojiViewForm;
}) { }) {
let pagedIndex = const pagedIndex = (d.i.state.page - 1) * d.i.itemsPerPage + d.index;
(props.form.state.page - 1) * props.form.itemsPerPage + props.index; if (d.cv.id != 0) {
if (props.cv.id != 0) { d.i.props.onDelete({
const deleteForm: DeleteCustomEmoji = { id: d.cv.id,
id: props.cv.id, auth: myAuthRequired(),
auth: myAuth() ?? "", });
};
WebSocketService.Instance.send(wsClient.deleteCustomEmoji(deleteForm));
} else { } else {
let custom_emojis = [...props.form.state.customEmojis]; const custom_emojis = [...d.i.state.customEmojis];
custom_emojis.splice(Number(pagedIndex), 1); custom_emojis.splice(Number(pagedIndex), 1);
props.form.setState({ customEmojis: custom_emojis }); d.i.setState({ customEmojis: custom_emojis });
} }
} }
handleEditEmojiClick(props: { form: EmojiForm; cv: CustomEmojiViewForm }) { handleEditEmojiClick(d: { i: EmojiForm; cv: CustomEmojiViewForm }) {
const keywords = props.cv.keywords const keywords = d.cv.keywords
.split(" ") .split(" ")
.filter(x => x.length > 0) as string[]; .filter(x => x.length > 0) as string[];
const uniqueKeywords = Array.from(new Set(keywords)); const uniqueKeywords = Array.from(new Set(keywords));
if (props.cv.id != 0) { if (d.cv.id != 0) {
const editForm: EditCustomEmoji = { d.i.props.onEdit({
id: props.cv.id, id: d.cv.id,
category: props.cv.category, category: d.cv.category,
image_url: props.cv.image_url, image_url: d.cv.image_url,
alt_text: props.cv.alt_text, alt_text: d.cv.alt_text,
keywords: uniqueKeywords, keywords: uniqueKeywords,
auth: myAuth() ?? "", auth: myAuthRequired(),
}; });
WebSocketService.Instance.send(wsClient.editCustomEmoji(editForm));
} else { } else {
const createForm: CreateCustomEmoji = { d.i.props.onCreate({
category: props.cv.category, category: d.cv.category,
shortcode: props.cv.shortcode, shortcode: d.cv.shortcode,
image_url: props.cv.image_url, image_url: d.cv.image_url,
alt_text: props.cv.alt_text, alt_text: d.cv.alt_text,
keywords: uniqueKeywords, keywords: uniqueKeywords,
auth: myAuth() ?? "", auth: myAuthRequired(),
}; });
WebSocketService.Instance.send(wsClient.createCustomEmoji(createForm));
} }
} }
handleAddEmojiClick(form: EmojiForm, event: any) { handleAddEmojiClick(form: EmojiForm, event: any) {
event.preventDefault(); event.preventDefault();
let custom_emojis = [...form.state.customEmojis]; const custom_emojis = [...form.state.customEmojis];
const page = const page =
1 + Math.floor(form.state.customEmojis.length / form.itemsPerPage); 1 + Math.floor(form.state.customEmojis.length / form.itemsPerPage);
let item: CustomEmojiViewForm = { const item: CustomEmojiViewForm = {
id: 0, id: 0,
shortcode: "", shortcode: "",
alt_text: "", alt_text: "",
@ -477,26 +458,26 @@ export class EmojiForm extends Component<any, EmojiFormState> {
file = event; file = event;
} }
uploadImage(file) HttpService.client.uploadImage({ image: file }).then(res => {
.then(res => { console.log("pictrs upload:");
console.log("pictrs upload:"); console.log(res);
console.log(res); if (res.state === "success") {
if (res.msg === "ok") { if (res.data.msg === "ok") {
pictrsDeleteToast(file.name, res.delete_url as string); pictrsDeleteToast(file.name, res.data.delete_url as string);
} else { } else {
toast(JSON.stringify(res), "danger"); toast(JSON.stringify(res), "danger");
let hash = res.files?.at(0)?.file; const hash = res.data.files?.at(0)?.file;
let url = `${res.url}/${hash}`; const url = `${res.data.url}/${hash}`;
props.form.handleEmojiImageUrlChange( props.form.handleEmojiImageUrlChange(
{ form: props.form, index: props.index, overrideValue: url }, { form: props.form, index: props.index, overrideValue: url },
event event
); );
} }
}) } else if (res.state === "failed") {
.catch(error => { console.error(res.msg);
console.error(error); toast(res.msg, "danger");
toast(error, "danger"); }
}); });
} }
configurePicker(): any { configurePicker(): any {
@ -506,51 +487,4 @@ export class EmojiForm extends Component<any, EmojiFormState> {
dynamicWidth: true, dynamicWidth: true,
}; };
} }
parseMessage(msg: any) {
let 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) {
let data = wsJsonToRes<CustomEmojiResponse>(msg);
const custom_emoji_view = data.custom_emoji;
updateEmojiDataModel(custom_emoji_view);
let currentEmojis = this.state.customEmojis;
let 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) {
let data = wsJsonToRes<CustomEmojiResponse>(msg);
const custom_emoji_view = data.custom_emoji;
updateEmojiDataModel(data.custom_emoji);
let currentEmojis = this.state.customEmojis;
let 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) {
let data = wsJsonToRes<DeleteCustomEmojiResponse>(msg);
if (data.success) {
removeFromEmojiDataModel(data.id);
let 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, GetFederatedInstancesResponse,
GetSiteResponse, GetSiteResponse,
Instance, Instance,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { InitialFetchRequest } from "../../interfaces"; import { InitialFetchRequest } from "../../interfaces";
import { WebSocketService } from "../../services"; import { FirstLoadService } from "../../services/FirstLoadService";
import { import { HttpService, RequestState } from "../../services/HttpService";
isBrowser, import { relTags, setIsoData } from "../../utils";
relTags,
setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../../utils";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
interface InstancesState { interface InstancesState {
instancesRes: RequestState<GetFederatedInstancesResponse>;
siteRes: GetSiteResponse; siteRes: GetSiteResponse;
instancesRes?: GetFederatedInstancesResponse; isIsomorphic: boolean;
loading: boolean;
} }
export class Instances extends Component<any, InstancesState> { export class Instances extends Component<any, InstancesState> {
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
state: InstancesState = { state: InstancesState = {
instancesRes: { state: "empty" },
siteRes: this.isoData.site_res, siteRes: this.isoData.site_res,
loading: true, isIsomorphic: false,
}; };
private subscription?: Subscription;
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
// Only fetch the data if coming from another route // 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 = {
...this.state, ...this.state,
instancesRes: this.isoData instancesRes: this.isoData.routeData[0],
.routeData[0] as GetFederatedInstancesResponse, isIsomorphic: true,
loading: false,
}; };
} else {
WebSocketService.Instance.send(wsClient.getFederatedInstances({}));
} }
} }
static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { async componentDidMount() {
let promises: Promise<any>[] = []; 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 { get documentTitle(): string {
return `${i18n.t("instances")} - ${this.state.siteRes.site_view.site.name}`; return `${i18n.t("instances")} - ${this.state.siteRes.site_view.site.name}`;
} }
componentWillUnmount() { renderInstances() {
if (isBrowser()) { switch (this.state.instancesRes.state) {
this.subscription?.unsubscribe(); 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() { render() {
let federated_instances = this.state.instancesRes?.federated_instances; return (
return federated_instances ? (
<div className="container-lg"> <div className="container-lg">
<HtmlTags <HtmlTags
title={this.documentTitle} title={this.documentTitle}
path={this.context.router.route.match.url} path={this.context.router.route.match.url}
/> />
<div className="row"> {this.renderInstances()}
<div className="col-md-6">
<h5>{i18n.t("linked_instances")}</h5>
{this.itemList(federated_instances.linked)}
</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> </div>
) : (
<></>
); );
} }
@ -136,17 +143,4 @@ export class Instances extends Component<any, InstancesState> {
<div>{i18n.t("none_found")}</div> <div>{i18n.t("none_found")}</div>
); );
} }
parseMessage(msg: any) {
let 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) {
let data = wsJsonToRes<GetFederatedInstancesResponse>(msg);
this.setState({ loading: false, instancesRes: data });
}
}
} }

View file

@ -23,7 +23,7 @@ export class Legal extends Component<any, LegalState> {
} }
render() { render() {
let legal = this.state.siteRes.site_view.local_site.legal_information; const legal = this.state.siteRes.site_view.local_site.legal_information;
return ( return (
<div className="container-lg"> <div className="container-lg">
<HtmlTags <HtmlTags

View file

@ -1,58 +1,35 @@
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
GetSiteResponse,
Login as LoginI,
LoginResponse,
PasswordReset,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services"; import { UserService } from "../../services";
import { import { HttpService, RequestState } from "../../services/HttpService";
isBrowser, import { isBrowser, myAuth, setIsoData, toast, validEmail } from "../../utils";
setIsoData,
toast,
validEmail,
wsClient,
wsSubscribe,
} from "../../utils";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
interface State { interface State {
loginRes: RequestState<LoginResponse>;
form: { form: {
username_or_email?: string; username_or_email?: string;
password?: string; password?: string;
totp_2fa_token?: string; totp_2fa_token?: string;
}; };
loginLoading: boolean;
showTotp: boolean; showTotp: boolean;
siteRes: GetSiteResponse; siteRes: GetSiteResponse;
} }
export class Login extends Component<any, State> { export class Login extends Component<any, State> {
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: State = { state: State = {
loginRes: { state: "empty" },
form: {}, form: {},
loginLoading: false,
showTotp: false, showTotp: false,
siteRes: this.isoData.site_res, siteRes: this.isoData.site_res,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
if (isBrowser()) {
WebSocketService.Instance.send(wsClient.getCaptcha({}));
}
} }
componentDidMount() { componentDidMount() {
@ -62,12 +39,6 @@ export class Login extends Component<any, State> {
} }
} }
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
}
get documentTitle(): string { get documentTitle(): string {
return `${i18n.t("login")} - ${this.state.siteRes.site_view.site.name}`; 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="form-group row">
<div className="col-sm-10"> <div className="col-sm-10">
<button type="submit" className="btn btn-secondary"> <button type="submit" className="btn btn-secondary">
{this.state.loginLoading ? <Spinner /> : i18n.t("login")} {this.state.loginRes.state == "loading" ? (
<Spinner />
) : (
i18n.t("login")
)}
</button> </button>
</div> </div>
</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(); event.preventDefault();
i.setState({ loginLoading: true }); const { password, totp_2fa_token, username_or_email } = i.state.form;
let lForm = i.state.form;
let username_or_email = lForm.username_or_email;
let password = lForm.password;
let totp_2fa_token = lForm.totp_2fa_token;
if (username_or_email && password) { if (username_or_email && password) {
let form: LoginI = { i.setState({ loginRes: { state: "loading" } });
const loginRes = await HttpService.client.login({
username_or_email, username_or_email,
password, password,
totp_2fa_token, 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) { 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); i.setState(i.state);
} }
@ -210,40 +209,13 @@ export class Login extends Component<any, State> {
i.setState(i.state); i.setState(i.state);
} }
handlePasswordReset(i: Login, event: any) { async handlePasswordReset(i: Login, event: any) {
event.preventDefault(); event.preventDefault();
let email = i.state.form.username_or_email; const email = i.state.form.username_or_email;
if (email) { if (email) {
let resetForm: PasswordReset = { email }; const res = await HttpService.client.passwordReset({ email });
WebSocketService.Instance.send(wsClient.passwordReset(resetForm)); if (res.state == "success") {
}
}
parseMessage(msg: any) {
let 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) {
let data = wsJsonToRes<LoginResponse>(msg);
UserService.Instance.login(data);
this.props.history.push("/");
location.reload();
} else if (op == UserOperation.PasswordReset) {
toast(i18n.t("reset_password_mail_sent")); toast(i18n.t("reset_password_mail_sent"));
} else if (op == UserOperation.GetSite) {
let data = wsJsonToRes<GetSiteResponse>(msg);
this.setState({ siteRes: data });
} }
} }
} }

View file

@ -1,8 +1,7 @@
import { Component, FormEventHandler, linkEvent } from "inferno"; import { Component, FormEventHandler, linkEvent } from "inferno";
import { EditSite, LocalSiteRateLimit } from "lemmy-js-client"; import { EditSite, LocalSiteRateLimit } from "lemmy-js-client";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { WebSocketService } from "../../services"; import { capitalizeFirstLetter, myAuthRequired } from "../../utils";
import { capitalizeFirstLetter, myAuth, wsClient } from "../../utils";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
import Tabs from "../common/tabs"; import Tabs from "../common/tabs";
@ -23,8 +22,8 @@ interface RateLimitsProps {
} }
interface RateLimitFormProps { interface RateLimitFormProps {
localSiteRateLimit: LocalSiteRateLimit; rateLimits: LocalSiteRateLimit;
applicationQuestion?: string; onSaveSite(form: EditSite): void;
} }
interface RateLimitFormState { interface RateLimitFormState {
@ -107,18 +106,19 @@ function handlePerSecondChange(
function submitRateLimitForm(i: RateLimitsForm, event: any) { function submitRateLimitForm(i: RateLimitsForm, event: any) {
event.preventDefault(); event.preventDefault();
const auth = myAuth() ?? "TODO"; const auth = myAuthRequired();
const form: EditSite = Object.entries(i.state.form).reduce( const form: EditSite = Object.entries(i.state.form).reduce(
(acc, [key, val]) => { (acc, [key, val]) => {
acc[`rate_limit_${key}`] = val; acc[`rate_limit_${key}`] = val;
return acc; return acc;
}, },
{ auth, application_question: i.props.applicationQuestion } {
auth,
}
); );
i.setState({ loading: true }); i.setState({ loading: true });
i.props.onSaveSite(form);
WebSocketService.Instance.send(wsClient.editSite(form));
} }
export default class RateLimitsForm extends Component< export default class RateLimitsForm extends Component<
@ -127,43 +127,10 @@ export default class RateLimitsForm extends Component<
> { > {
state: RateLimitFormState = { state: RateLimitFormState = {
loading: false, loading: false,
form: {}, form: this.props.rateLimits,
}; };
constructor(props: RateLimitFormProps, context) { constructor(props: RateLimitFormProps, context: any) {
super(props, context); 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() { render() {
@ -210,15 +177,4 @@ export default class RateLimitsForm extends Component<
</form> </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 { Component, linkEvent } from "inferno";
import { Helmet } from "inferno-helmet"; import { Helmet } from "inferno-helmet";
import { import {
CreateSite,
GetSiteResponse, GetSiteResponse,
LoginResponse, LoginResponse,
Register, Register,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { delay, retryWhen, take } from "rxjs/operators";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services"; import { UserService } from "../../services";
import { setIsoData, toast, wsClient } from "../../utils"; import { HttpService, RequestState } from "../../services/HttpService";
import { fetchThemeList, setIsoData } from "../../utils";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
import { SiteForm } from "./site-form"; import { SiteForm } from "./site-form";
@ -29,37 +26,32 @@ interface State {
answer?: string; answer?: string;
}; };
doneRegisteringUser: boolean; doneRegisteringUser: boolean;
userLoading: boolean; registerRes: RequestState<LoginResponse>;
themeList: string[];
siteRes: GetSiteResponse; siteRes: GetSiteResponse;
} }
export class Setup extends Component<any, State> { export class Setup extends Component<any, State> {
private subscription: Subscription;
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
state: State = { state: State = {
registerRes: { state: "empty" },
themeList: [],
form: { form: {
show_nsfw: true, show_nsfw: true,
}, },
doneRegisteringUser: !!UserService.Instance.myUserInfo, doneRegisteringUser: !!UserService.Instance.myUserInfo,
userLoading: false,
siteRes: this.isoData.site_res, siteRes: this.isoData.site_res,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.subscription = WebSocketService.Instance.subject this.handleCreateSite = this.handleCreateSite.bind(this);
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log("complete")
);
} }
componentWillUnmount() { async componentDidMount() {
this.subscription.unsubscribe(); this.setState({ themeList: await fetchThemeList() });
} }
get documentTitle(): string { get documentTitle(): string {
@ -76,7 +68,12 @@ export class Setup extends Component<any, State> {
{!this.state.doneRegisteringUser ? ( {!this.state.doneRegisteringUser ? (
this.registerUser() this.registerUser()
) : ( ) : (
<SiteForm siteRes={this.state.siteRes} showLocal /> <SiteForm
showLocal
onSaveSite={this.handleCreateSite}
siteRes={this.state.siteRes}
themeList={this.state.themeList}
/>
)} )}
</div> </div>
</div> </div>
@ -161,7 +158,11 @@ export class Setup extends Component<any, State> {
<div className="form-group row"> <div className="form-group row">
<div className="col-sm-10"> <div className="col-sm-10">
<button type="submit" className="btn btn-secondary"> <button type="submit" className="btn btn-secondary">
{this.state.userLoading ? <Spinner /> : i18n.t("sign_up")} {this.state.registerRes.state == "loading" ? (
<Spinner />
) : (
i18n.t("sign_up")
)}
</button> </button>
</div> </div>
</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(); event.preventDefault();
i.setState({ userLoading: true }); i.setState({ registerRes: { state: "loading" } });
event.preventDefault(); const {
let cForm = i.state.form; username,
if (cForm.username && cForm.password && cForm.password_verify) { password_verify,
let form: Register = { password,
username: cForm.username, email,
password: cForm.password, show_nsfw,
password_verify: cForm.password_verify, captcha_uuid,
email: cForm.email, captcha_answer,
show_nsfw: cForm.show_nsfw, honeypot,
captcha_uuid: cForm.captcha_uuid, answer,
captcha_answer: cForm.captcha_answer, } = i.state.form;
honeypot: cForm.honeypot,
answer: cForm.answer, if (username && password && password_verify) {
const form: Register = {
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) { handleRegisterUsernameChange(i: Setup, event: any) {
i.state.form.username = event.target.value; i.state.form.username = event.target.value.trim();
i.setState(i.state); i.setState(i.state);
} }
@ -209,22 +239,4 @@ export class Setup extends Component<any, State> {
i.state.form.password_verify = event.target.value; i.state.form.password_verify = event.target.value;
i.setState(i.state); i.setState(i.state);
} }
parseMessage(msg: any) {
let op = wsUserOp(msg);
if (msg.error) {
toast(i18n.t(msg.error), "danger");
this.setState({ userLoading: false });
return;
} else if (op == UserOperation.Register) {
let 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, GetCaptchaResponse,
GetSiteResponse, GetSiteResponse,
LoginResponse, LoginResponse,
Register,
SiteView, SiteView,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services"; import { UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import { import {
isBrowser, isBrowser,
joinLemmyUrl, joinLemmyUrl,
mdToHtml, mdToHtml,
myAuth,
setIsoData, setIsoData,
toast, toast,
validEmail, validEmail,
wsClient,
wsSubscribe,
} from "../../utils"; } from "../../utils";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon"; import { Icon, Spinner } from "../common/icon";
@ -58,6 +53,8 @@ const passwordStrengthOptions: Options<string> = [
]; ];
interface State { interface State {
registerRes: RequestState<LoginResponse>;
captchaRes: RequestState<GetCaptchaResponse>;
form: { form: {
username?: string; username?: string;
email?: string; email?: string;
@ -69,22 +66,20 @@ interface State {
honeypot?: string; honeypot?: string;
answer?: string; answer?: string;
}; };
registerLoading: boolean;
captcha?: GetCaptchaResponse;
captchaPlaying: boolean; captchaPlaying: boolean;
siteRes: GetSiteResponse; siteRes: GetSiteResponse;
} }
export class Signup extends Component<any, State> { export class Signup extends Component<any, State> {
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
private subscription?: Subscription;
private audio?: HTMLAudioElement; private audio?: HTMLAudioElement;
state: State = { state: State = {
registerRes: { state: "empty" },
captchaRes: { state: "empty" },
form: { form: {
show_nsfw: false, show_nsfw: false,
}, },
registerLoading: false,
captchaPlaying: false, captchaPlaying: false,
siteRes: this.isoData.site_res, siteRes: this.isoData.site_res,
}; };
@ -93,23 +88,30 @@ export class Signup extends Component<any, State> {
super(props, context); super(props, context);
this.handleAnswerChange = this.handleAnswerChange.bind(this); this.handleAnswerChange = this.handleAnswerChange.bind(this);
}
this.parseMessage = this.parseMessage.bind(this); async componentDidMount() {
this.subscription = wsSubscribe(this.parseMessage); if (this.state.siteRes.site_view.local_site.captcha_enabled) {
await this.fetchCaptcha();
if (isBrowser()) {
WebSocketService.Instance.send(wsClient.getCaptcha({}));
} }
} }
componentWillUnmount() { async fetchCaptcha() {
if (isBrowser()) { this.setState({ captchaRes: { state: "loading" } });
this.subscription?.unsubscribe(); 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 { get documentTitle(): string {
let siteView = this.state.siteRes.site_view; const siteView = this.state.siteRes.site_view;
return `${this.titleName(siteView)} - ${siteView.site.name}`; return `${this.titleName(siteView)} - ${siteView.site.name}`;
} }
@ -140,7 +142,7 @@ export class Signup extends Component<any, State> {
} }
registerForm() { registerForm() {
let siteView = this.state.siteRes.site_view; const siteView = this.state.siteRes.site_view;
return ( return (
<form onSubmit={linkEvent(this, this.handleRegisterSubmit)}> <form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
<h5>{this.titleName(siteView)}</h5> <h5>{this.titleName(siteView)}</h5>
@ -285,6 +287,7 @@ export class Signup extends Component<any, State> {
</label> </label>
<div className="col-sm-10"> <div className="col-sm-10">
<MarkdownTextArea <MarkdownTextArea
initialContent=""
onContentChange={this.handleAnswerChange} onContentChange={this.handleAnswerChange}
hideNavigationWarnings hideNavigationWarnings
allLanguages={[]} allLanguages={[]}
@ -294,36 +297,7 @@ export class Signup extends Component<any, State> {
</div> </div>
</> </>
)} )}
{this.renderCaptcha()}
{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>
)}
{siteView.local_site.enable_nsfw && ( {siteView.local_site.enable_nsfw && (
<div className="form-group row"> <div className="form-group row">
<div className="col-sm-10"> <div className="col-sm-10">
@ -358,7 +332,7 @@ export class Signup extends Component<any, State> {
<div className="form-group row"> <div className="form-group row">
<div className="col-sm-10"> <div className="col-sm-10">
<button type="submit" className="btn btn-secondary"> <button type="submit" className="btn btn-secondary">
{this.state.registerLoading ? ( {this.state.registerRes.state == "loading" ? (
<Spinner /> <Spinner />
) : ( ) : (
this.titleName(siteView) this.titleName(siteView)
@ -370,8 +344,47 @@ export class Signup extends Component<any, State> {
); );
} }
showCaptcha() { renderCaptcha() {
let captchaRes = this.state.captcha?.ok; 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 ? ( return captchaRes ? (
<div className="col-sm-4"> <div className="col-sm-4">
<> <>
@ -401,14 +414,14 @@ export class Signup extends Component<any, State> {
} }
get passwordStrength(): string | undefined { get passwordStrength(): string | undefined {
let password = this.state.form.password; const password = this.state.form.password;
return password return password
? passwordStrength(password, passwordStrengthOptions).value ? passwordStrength(password, passwordStrengthOptions).value
: undefined; : undefined;
} }
get passwordColorClass(): string { get passwordColorClass(): string {
let strength = this.passwordStrength; const strength = this.passwordStrength;
if (strength && ["weak", "medium"].includes(strength)) { if (strength && ["weak", "medium"].includes(strength)) {
return "text-warning"; return "text-warning";
@ -419,28 +432,71 @@ export class Signup extends Component<any, State> {
} }
} }
handleRegisterSubmit(i: Signup, event: any) { async handleRegisterSubmit(i: Signup, event: any) {
event.preventDefault(); event.preventDefault();
i.setState({ registerLoading: true }); const {
let cForm = i.state.form; show_nsfw,
if (cForm.username && cForm.password && cForm.password_verify) { answer,
let form: Register = { captcha_answer,
username: cForm.username, captcha_uuid,
password: cForm.password, email,
password_verify: cForm.password_verify, honeypot,
email: cForm.email, password,
show_nsfw: cForm.show_nsfw, password_verify,
captcha_uuid: cForm.captcha_uuid, username,
captcha_answer: cForm.captcha_answer, } = i.state.form;
honeypot: cForm.honeypot, if (username && password && password_verify) {
answer: cForm.answer, i.setState({ registerRes: { state: "loading" } });
};
WebSocketService.Instance.send(wsClient.register(form)); 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) { handleRegisterUsernameChange(i: Signup, event: any) {
i.state.form.username = event.target.value; i.state.form.username = event.target.value.trim();
i.setState(i.state); i.setState(i.state);
} }
@ -481,19 +537,20 @@ export class Signup extends Component<any, State> {
i.setState(i.state); i.setState(i.state);
} }
handleRegenCaptcha(i: Signup) { async handleRegenCaptcha(i: Signup) {
i.audio = undefined; i.audio = undefined;
i.setState({ captchaPlaying: false }); i.setState({ captchaPlaying: false });
WebSocketService.Instance.send(wsClient.getCaptcha({})); await i.fetchCaptcha();
} }
handleCaptchaPlay(i: Signup) { handleCaptchaPlay(i: Signup) {
// This was a bad bug, it should only build the new audio on a new file. // 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. // Replays would stop prematurely if this was rebuilt every time.
let 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) { if (!i.audio) {
let base64 = `data:audio/wav;base64,${captchaRes.wav}`; const base64 = `data:audio/wav;base64,${captchaRes.wav}`;
i.audio = new Audio(base64); i.audio = new Audio(base64);
i.audio.play(); i.audio.play();
@ -512,45 +569,4 @@ export class Signup extends Component<any, State> {
captchaPngSrc(captcha: CaptchaResponse) { captchaPngSrc(captcha: CaptchaResponse) {
return `data:image/png;base64,${captcha.png}`; return `data:image/png;base64,${captcha.png}`;
} }
parseMessage(msg: any) {
let 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) {
let 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) {
let 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) {
let data = wsJsonToRes<GetSiteResponse>(msg);
this.setState({ siteRes: data });
}
}
}
} }

File diff suppressed because it is too large Load diff

View file

@ -67,7 +67,7 @@ export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> {
} }
siteInfo() { siteInfo() {
let site = this.props.site; const site = this.props.site;
return ( return (
<div> <div>
{site.description && <h6>{site.description}</h6>} {site.description && <h6>{site.description}</h6>}
@ -98,8 +98,8 @@ export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> {
} }
badges(siteAggregates: SiteAggregates) { badges(siteAggregates: SiteAggregates) {
let counts = siteAggregates; const counts = siteAggregates;
let online = this.props.online ?? 1; const online = this.props.online ?? 1;
return ( return (
<ul className="my-2 list-inline"> <ul className="my-2 list-inline">
<li className="list-inline-item badge badge-secondary"> <li className="list-inline-item badge badge-secondary">

View file

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

View file

@ -8,13 +8,11 @@ import {
AdminPurgeCommunityView, AdminPurgeCommunityView,
AdminPurgePersonView, AdminPurgePersonView,
AdminPurgePostView, AdminPurgePostView,
CommunityModeratorView,
GetCommunity, GetCommunity,
GetCommunityResponse, GetCommunityResponse,
GetModlog, GetModlog,
GetModlogResponse, GetModlogResponse,
GetPersonDetails, GetPersonDetails,
GetPersonDetailsResponse,
ModAddCommunityView, ModAddCommunityView,
ModAddView, ModAddView,
ModBanFromCommunityView, ModBanFromCommunityView,
@ -27,15 +25,12 @@ import {
ModTransferCommunityView, ModTransferCommunityView,
ModlogActionType, ModlogActionType,
Person, Person,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import moment from "moment"; import moment from "moment";
import { Subscription } from "rxjs";
import { i18n } from "../i18next"; import { i18n } from "../i18next";
import { InitialFetchRequest } from "../interfaces"; import { InitialFetchRequest } from "../interfaces";
import { WebSocketService } from "../services"; import { FirstLoadService } from "../services/FirstLoadService";
import { HttpService, RequestState } from "../services/HttpService";
import { import {
Choice, Choice,
QueryParams, QueryParams,
@ -49,13 +44,9 @@ import {
getQueryParams, getQueryParams,
getQueryString, getQueryString,
getUpdatedSearchId, getUpdatedSearchId,
isBrowser,
myAuth, myAuth,
personToChoice, personToChoice,
setIsoData, setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../utils"; } from "../utils";
import { HtmlTags } from "./common/html-tags"; import { HtmlTags } from "./common/html-tags";
import { Icon, Spinner } from "./common/icon"; import { Icon, Spinner } from "./common/icon";
@ -100,10 +91,8 @@ const getModlogQueryParams = () =>
}); });
interface ModlogState { interface ModlogState {
res?: GetModlogResponse; res: RequestState<GetModlogResponse>;
communityMods?: CommunityModeratorView[]; communityRes: RequestState<GetCommunityResponse>;
communityName?: string;
loadingModlog: boolean;
loadingModSearch: boolean; loadingModSearch: boolean;
loadingUserSearch: boolean; loadingUserSearch: boolean;
modSearchOptions: Choice[]; modSearchOptions: Choice[];
@ -629,7 +618,7 @@ async function createNewOptions({
if (text.length > 0) { if (text.length > 0) {
newOptions.push( newOptions.push(
...(await fetchUsers(text)).users ...(await fetchUsers(text))
.slice(0, Number(fetchLimit)) .slice(0, Number(fetchLimit))
.map<Choice>(personToChoice) .map<Choice>(personToChoice)
); );
@ -643,10 +632,10 @@ export class Modlog extends Component<
ModlogState ModlogState
> { > {
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: ModlogState = { state: ModlogState = {
loadingModlog: true, res: { state: "empty" },
communityRes: { state: "empty" },
loadingModSearch: false, loadingModSearch: false,
loadingUserSearch: false, loadingUserSearch: false,
userSearchOptions: [], userSearchOptions: [],
@ -662,58 +651,35 @@ export class Modlog extends Component<
this.handleUserChange = this.handleUserChange.bind(this); this.handleUserChange = this.handleUserChange.bind(this);
this.handleModChange = this.handleModChange.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 // 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 = {
...this.state, ...this.state,
res: this.isoData.routeData[0] as GetModlogResponse, res,
communityRes,
}; };
const communityRes: GetCommunityResponse | undefined = if (filteredModRes.state === "success") {
this.isoData.routeData[1];
// Getting the moderators
this.state = {
...this.state,
communityMods: communityRes?.moderators,
};
const filteredModRes: GetPersonDetailsResponse | undefined =
this.isoData.routeData[2];
if (filteredModRes) {
this.state = { this.state = {
...this.state, ...this.state,
modSearchOptions: [personToChoice(filteredModRes.person_view)], modSearchOptions: [personToChoice(filteredModRes.data.person_view)],
}; };
} }
const filteredUserRes: GetPersonDetailsResponse | undefined = if (filteredUserRes.state === "success") {
this.isoData.routeData[3];
if (filteredUserRes) {
this.state = { this.state = {
...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() { get combined() {
const res = this.state.res; const res = this.state.res;
const combined = res ? buildCombined(res) : []; const combined = res.state == "success" ? buildCombined(res.data) : [];
return ( return (
<tbody> <tbody>
@ -737,7 +703,10 @@ export class Modlog extends Component<
} }
get amAdminOrMod(): boolean { 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 { modOrAdminText(person?: Person): string {
@ -755,14 +724,12 @@ export class Modlog extends Component<
render() { render() {
const { const {
communityName,
loadingModlog,
loadingModSearch, loadingModSearch,
loadingUserSearch, loadingUserSearch,
userSearchOptions, userSearchOptions,
modSearchOptions, modSearchOptions,
} = this.state; } = this.state;
const { actionType, page, modId, userId } = getModlogQueryParams(); const { actionType, modId, userId } = getModlogQueryParams();
return ( return (
<div className="container-lg"> <div className="container-lg">
@ -785,14 +752,17 @@ export class Modlog extends Component<
#<strong>#</strong># #<strong>#</strong>#
</T> </T>
</div> </div>
<h5> {this.state.communityRes.state === "success" && (
{communityName && ( <h5>
<Link className="text-body" to={`/c/${communityName}`}> <Link
/c/{communityName}{" "} className="text-body"
to={`/c/${this.state.communityRes.data.community_view.community.name}`}
>
/c/{this.state.communityRes.data.community_view.community.name}{" "}
</Link> </Link>
)} <span>{i18n.t("modlog")}</span>
<span>{i18n.t("modlog")}</span> </h5>
</h5> )}
<div className="form-row"> <div className="form-row">
<select <select
value={actionType} value={actionType}
@ -841,30 +811,41 @@ export class Modlog extends Component<
/> />
)} )}
</div> </div>
<div className="table-responsive"> {this.renderModlogTable()}
{loadingModlog ? (
<h5>
<Spinner large />
</h5>
) : (
<table id="modlog_table" className="table table-sm table-hover">
<thead className="pointer">
<tr>
<th> {i18n.t("time")}</th>
<th>{i18n.t("mod")}</th>
<th>{i18n.t("action")}</th>
</tr>
</thead>
{this.combined}
</table>
)}
<Paginator page={page} onChange={this.handlePageChange} />
</div>
</div> </div>
</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>
<th> {i18n.t("time")}</th>
<th>{i18n.t("mod")}</th>
<th>{i18n.t("action")}</th>
</tr>
</thead>
{this.combined}
</table>
<Paginator page={page} onChange={this.handlePageChange} />
</div>
);
}
}
}
handleFilterActionChange(i: Modlog, event: any) { handleFilterActionChange(i: Modlog, event: any) {
i.updateUrl({ i.updateUrl({
actionType: event.target.value as ModlogActionType, actionType: event.target.value as ModlogActionType,
@ -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 { const {
page: urlPage, page: urlPage,
actionType: urlActionType, actionType: urlActionType,
@ -941,42 +922,39 @@ export class Modlog extends Component<
)}` )}`
); );
this.setState({ await this.refetch();
loadingModlog: true,
res: undefined,
});
this.refetch();
} }
refetch() { async refetch() {
const auth = myAuth(false); const auth = myAuth();
const { actionType, page, modId, userId } = getModlogQueryParams(); const { actionType, page, modId, userId } = getModlogQueryParams();
const { communityId: urlCommunityId } = this.props.match.params; const { communityId: urlCommunityId } = this.props.match.params;
const communityId = getIdFromString(urlCommunityId); const communityId = getIdFromString(urlCommunityId);
const modlogForm: GetModlog = { this.setState({ res: { state: "loading" } });
community_id: communityId, this.setState({
page, res: await HttpService.client.getModlog({
limit: fetchLimit, community_id: communityId,
type_: actionType, page,
other_person_id: userId ?? undefined, limit: fetchLimit,
mod_person_id: !this.isoData.site_res.site_view.local_site type_: actionType,
.hide_modlog_mod_names other_person_id: userId ?? undefined,
? modId ?? undefined mod_person_id: !this.isoData.site_res.site_view.local_site
: undefined, .hide_modlog_mod_names
auth, ? modId ?? undefined
}; : undefined,
auth,
WebSocketService.Instance.send(wsClient.getModlog(modlogForm)); }),
});
if (communityId) { if (communityId) {
const communityForm: GetCommunity = { this.setState({ communityRes: { state: "loading" } });
id: communityId, this.setState({
auth, 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 }, query: { modId: urlModId, page, userId: urlUserId, actionType },
auth, auth,
site, site,
}: InitialFetchRequest<QueryParams<ModlogProps>>): Promise<any>[] { }: InitialFetchRequest<QueryParams<ModlogProps>>): Promise<
RequestState<any>
>[] {
const pathSplit = path.split("/"); const pathSplit = path.split("/");
const promises: Promise<any>[] = []; const promises: Promise<RequestState<any>>[] = [];
const communityId = getIdFromString(pathSplit[2]); const communityId = getIdFromString(pathSplit[2]);
const modId = !site.site_view.local_site.hide_modlog_mod_names const modId = !site.site_view.local_site.hide_modlog_mod_names
? getIdFromString(urlModId) ? getIdFromString(urlModId)
@ -1014,7 +994,7 @@ export class Modlog extends Component<
}; };
promises.push(client.getCommunity(communityForm)); promises.push(client.getCommunity(communityForm));
} else { } else {
promises.push(Promise.resolve()); promises.push(Promise.resolve({ state: "empty" }));
} }
if (modId) { if (modId) {
@ -1025,7 +1005,7 @@ export class Modlog extends Component<
promises.push(client.getPersonDetails(getPersonForm)); promises.push(client.getPersonDetails(getPersonForm));
} else { } else {
promises.push(Promise.resolve()); promises.push(Promise.resolve({ state: "empty" }));
} }
if (userId) { if (userId) {
@ -1036,43 +1016,9 @@ export class Modlog extends Component<
promises.push(client.getPersonDetails(getPersonForm)); promises.push(client.getPersonDetails(getPersonForm));
} else { } else {
promises.push(Promise.resolve()); promises.push(Promise.resolve({ state: "empty" }));
} }
return promises; 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 { Component, linkEvent } from "inferno";
import { import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
GetSiteResponse,
LoginResponse,
PasswordChangeAfterReset,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { UserService, WebSocketService } from "../../services"; import { HttpService, UserService } from "../../services";
import { import { RequestState } from "../../services/HttpService";
capitalizeFirstLetter, import { capitalizeFirstLetter, myAuth, setIsoData } from "../../utils";
isBrowser,
setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../../utils";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
interface State { interface State {
passwordChangeRes: RequestState<LoginResponse>;
form: { form: {
token: string; token: string;
password?: string; password?: string;
password_verify?: string; password_verify?: string;
}; };
loading: boolean;
siteRes: GetSiteResponse; siteRes: GetSiteResponse;
} }
export class PasswordChange extends Component<any, State> { export class PasswordChange extends Component<any, State> {
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: State = { state: State = {
passwordChangeRes: { state: "empty" },
siteRes: this.isoData.site_res,
form: { form: {
token: this.props.match.params.token, token: this.props.match.params.token,
}, },
loading: false,
siteRes: this.isoData.site_res,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
}
} }
get documentTitle(): string { get documentTitle(): string {
@ -117,7 +93,7 @@ export class PasswordChange extends Component<any, State> {
<div className="form-group row"> <div className="form-group row">
<div className="col-sm-10"> <div className="col-sm-10">
<button type="submit" className="btn btn-secondary"> <button type="submit" className="btn btn-secondary">
{this.state.loading ? ( {this.state.passwordChangeRes.state == "loading" ? (
<Spinner /> <Spinner />
) : ( ) : (
capitalizeFirstLetter(i18n.t("save")) capitalizeFirstLetter(i18n.t("save"))
@ -139,36 +115,33 @@ export class PasswordChange extends Component<any, State> {
i.setState(i.state); i.setState(i.state);
} }
handlePasswordChangeSubmit(i: PasswordChange, event: any) { async handlePasswordChangeSubmit(i: PasswordChange, event: any) {
event.preventDefault(); event.preventDefault();
i.setState({ loading: true }); i.setState({ passwordChangeRes: { state: "loading" } });
let password = i.state.form.password; const password = i.state.form.password;
let password_verify = i.state.form.password_verify; const password_verify = i.state.form.password_verify;
if (password && password_verify) { if (password && password_verify) {
let form: PasswordChangeAfterReset = { i.setState({
token: i.state.form.token, passwordChangeRes: await HttpService.client.passwordChangeAfterReset({
password, token: i.state.form.token,
password_verify, password,
}; password_verify,
}),
});
WebSocketService.Instance.send(wsClient.passwordChange(form)); if (i.state.passwordChangeRes.state === "success") {
} const data = i.state.passwordChangeRes.data;
} UserService.Instance.login(data);
parseMessage(msg: any) { const site = await HttpService.client.getSite({ auth: myAuth() });
let op = wsUserOp(msg); if (site.state === "success") {
console.log(msg); UserService.Instance.myUserInfo = site.data.my_user;
if (msg.error) { }
toast(i18n.t(msg.error), "danger");
this.setState({ loading: false }); this.props.history.replace("/");
return; }
} else if (op == UserOperation.PasswordChangeAfterReset) {
let data = wsJsonToRes<LoginResponse>(msg);
UserService.Instance.login(data);
this.props.history.push("/");
location.reload();
} }
} }
} }

View file

@ -1,11 +1,40 @@
import { Component } from "inferno"; import { Component } from "inferno";
import { import {
AddAdmin,
AddModToCommunity,
BanFromCommunity,
BanPerson,
BlockPerson,
CommentId,
CommentView, CommentView,
CreateComment,
CreateCommentLike,
CreateCommentReport,
CreatePostLike,
CreatePostReport,
DeleteComment,
DeletePost,
DistinguishComment,
EditComment,
EditPost,
FeaturePost,
GetComments,
GetPersonDetailsResponse, GetPersonDetailsResponse,
Language, Language,
LockPost,
MarkCommentReplyAsRead,
MarkPersonMentionAsRead,
PersonView, PersonView,
PostView, PostView,
PurgeComment,
PurgePerson,
PurgePost,
RemoveComment,
RemovePost,
SaveComment,
SavePost,
SortType, SortType,
TransferCommunity,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { CommentViewType, PersonDetailsView } from "../../interfaces"; import { CommentViewType, PersonDetailsView } from "../../interfaces";
import { commentsToFlatNodes, setupTippy } from "../../utils"; import { commentsToFlatNodes, setupTippy } from "../../utils";
@ -15,6 +44,7 @@ import { PostListing } from "../post/post-listing";
interface PersonDetailsProps { interface PersonDetailsProps {
personRes: GetPersonDetailsResponse; personRes: GetPersonDetailsResponse;
finished: Map<CommentId, boolean | undefined>;
admins: PersonView[]; admins: PersonView[];
allLanguages: Language[]; allLanguages: Language[];
siteLanguages: number[]; siteLanguages: number[];
@ -25,6 +55,34 @@ interface PersonDetailsProps {
enableNsfw: boolean; enableNsfw: boolean;
view: PersonDetailsView; view: PersonDetailsView;
onPageChange(page: number): number | any; 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 { enum ItemEnum {
@ -87,12 +145,13 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
renderItemType(i: ItemType) { renderItemType(i: ItemType) {
switch (i.type_) { switch (i.type_) {
case ItemEnum.Comment: { case ItemEnum.Comment: {
let c = i.view as CommentView; const c = i.view as CommentView;
return ( return (
<CommentNodes <CommentNodes
key={i.id} key={i.id}
nodes={[{ comment_view: c, children: [], depth: 0 }]} nodes={[{ comment_view: c, children: [], depth: 0 }]}
viewType={CommentViewType.Flat} viewType={CommentViewType.Flat}
finished={this.props.finished}
admins={this.props.admins} admins={this.props.admins}
noBorder noBorder
noIndent noIndent
@ -101,11 +160,30 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
enableDownvotes={this.props.enableDownvotes} enableDownvotes={this.props.enableDownvotes}
allLanguages={this.props.allLanguages} allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages} 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}
/> />
); );
} }
case ItemEnum.Post: { case ItemEnum.Post: {
let p = i.view as PostView; const p = i.view as PostView;
return ( return (
<PostListing <PostListing
key={i.id} key={i.id}
@ -116,6 +194,22 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
enableNsfw={this.props.enableNsfw} enableNsfw={this.props.enableNsfw}
allLanguages={this.props.allLanguages} allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages} 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}
/> />
); );
} }
@ -126,14 +220,14 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
overview() { overview() {
let id = 0; let id = 0;
let comments: ItemType[] = this.props.personRes.comments.map(r => ({ const comments: ItemType[] = this.props.personRes.comments.map(r => ({
id: id++, id: id++,
type_: ItemEnum.Comment, type_: ItemEnum.Comment,
view: r, view: r,
published: r.comment.published, published: r.comment.published,
score: r.counts.score, score: r.counts.score,
})); }));
let posts: ItemType[] = this.props.personRes.posts.map(r => ({ const posts: ItemType[] = this.props.personRes.posts.map(r => ({
id: id++, id: id++,
type_: ItemEnum.Post, type_: ItemEnum.Post,
view: r, view: r,
@ -141,7 +235,7 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
score: r.counts.score, score: r.counts.score,
})); }));
let combined = [...comments, ...posts]; const combined = [...comments, ...posts];
// Sort it // Sort it
if (this.props.sort === "New") { if (this.props.sort === "New") {
@ -167,12 +261,32 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
nodes={commentsToFlatNodes(this.props.personRes.comments)} nodes={commentsToFlatNodes(this.props.personRes.comments)}
viewType={CommentViewType.Flat} viewType={CommentViewType.Flat}
admins={this.props.admins} admins={this.props.admins}
finished={this.props.finished}
noIndent noIndent
showCommunity showCommunity
showContext showContext
enableDownvotes={this.props.enableDownvotes} enableDownvotes={this.props.enableDownvotes}
allLanguages={this.props.allLanguages} allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages} 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> </div>
); );
@ -191,6 +305,22 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
enableNsfw={this.props.enableNsfw} enableNsfw={this.props.enableNsfw}
allLanguages={this.props.allLanguages} allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages} 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" /> <hr className="my-3" />
</> </>

View file

@ -20,15 +20,15 @@ export class PersonListing extends Component<PersonListingProps, any> {
} }
render() { render() {
let person = this.props.person; const person = this.props.person;
let local = person.local; const local = person.local;
let apubName: string, link: string; let apubName: string, link: string;
if (local) { if (local) {
apubName = `@${person.name}`; apubName = `@${person.name}`;
link = `/u/${person.name}`; link = `/u/${person.name}`;
} else { } else {
let domain = hostname(person.actor_id); const domain = hostname(person.actor_id);
apubName = `@${person.name}@${domain}`; apubName = `@${person.name}@${domain}`;
link = !this.props.realLink link = !this.props.realLink
? `/u/${person.name}@${domain}` ? `/u/${person.name}@${domain}`
@ -70,7 +70,7 @@ export class PersonListing extends Component<PersonListingProps, any> {
} }
avatarAndName(displayName: string) { avatarAndName(displayName: string) {
let avatar = this.props.person.avatar; const avatar = this.props.person.avatar;
return ( return (
<> <>
{avatar && {avatar &&

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -1,58 +1,49 @@
import { Component } from "inferno"; import { Component } from "inferno";
import { import { GetSiteResponse, VerifyEmailResponse } from "lemmy-js-client";
GetSiteResponse,
UserOperation,
VerifyEmail as VerifyEmailForm,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { WebSocketService } from "../../services"; import { HttpService, RequestState } from "../../services/HttpService";
import { import { setIsoData, toast } from "../../utils";
isBrowser,
setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../../utils";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
interface State { interface State {
verifyEmailForm: VerifyEmailForm; verifyRes: RequestState<VerifyEmailResponse>;
siteRes: GetSiteResponse; siteRes: GetSiteResponse;
} }
export class VerifyEmail extends Component<any, State> { export class VerifyEmail extends Component<any, State> {
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: State = { state: State = {
verifyEmailForm: { verifyRes: { state: "empty" },
token: this.props.match.params.token,
},
siteRes: this.isoData.site_res, siteRes: this.isoData.site_res,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
} }
componentDidMount() { async verify() {
WebSocketService.Instance.send( this.setState({
wsClient.verifyEmail(this.state.verifyEmailForm) verifyRes: { state: "loading" },
); });
}
componentWillUnmount() { this.setState({
if (isBrowser()) { verifyRes: await HttpService.client.verifyEmail({
this.subscription?.unsubscribe(); token: this.props.match.params.token,
}),
});
if (this.state.verifyRes.state == "success") {
toast(i18n.t("email_verified"));
this.props.history.push("/login");
} }
} }
async componentDidMount() {
await this.verify();
}
get documentTitle(): string { get documentTitle(): string {
return `${i18n.t("verify_email")} - ${ return `${i18n.t("verify_email")} - ${
this.state.siteRes.site_view.site.name this.state.siteRes.site_view.site.name
@ -69,26 +60,14 @@ export class VerifyEmail extends Component<any, State> {
<div className="row"> <div className="row">
<div className="col-12 col-lg-6 offset-lg-3 mb-4"> <div className="col-12 col-lg-6 offset-lg-3 mb-4">
<h5>{i18n.t("verify_email")}</h5> <h5>{i18n.t("verify_email")}</h5>
{this.state.verifyRes.state == "loading" && (
<h5>
<Spinner large />
</h5>
)}
</div> </div>
</div> </div>
</div> </div>
); );
} }
parseMessage(msg: any) {
let 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) {
let 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 { Component } from "inferno";
import { RouteComponentProps } from "inferno-router/dist/Route"; import { RouteComponentProps } from "inferno-router/dist/Route";
import { import {
CreatePost as CreatePostI,
GetCommunity, GetCommunity,
GetCommunityResponse,
GetSiteResponse, GetSiteResponse,
PostView, ListCommunitiesResponse,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { InitialFetchRequest, PostFormParams } from "shared/interfaces";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { WebSocketService } from "../../services"; import { InitialFetchRequest, PostFormParams } from "../../interfaces";
import { FirstLoadService } from "../../services/FirstLoadService";
import {
HttpService,
RequestState,
WrappedLemmyHttp,
} from "../../services/HttpService";
import { import {
Choice, Choice,
QueryParams, QueryParams,
@ -20,12 +21,8 @@ import {
enableNsfw, enableNsfw,
getIdFromString, getIdFromString,
getQueryParams, getQueryParams,
isBrowser,
myAuth, myAuth,
setIsoData, setIsoData,
toast,
wsClient,
wsSubscribe,
} from "../../utils"; } from "../../utils";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; 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 { interface CreatePostState {
siteRes: GetSiteResponse; siteRes: GetSiteResponse;
loading: boolean; loading: boolean;
selectedCommunityChoice?: Choice; selectedCommunityChoice?: Choice;
initialCommunitiesRes: RequestState<ListCommunitiesResponse>;
isIsomorphic: boolean;
} }
export class CreatePost extends Component< export class CreatePost extends Component<
@ -52,10 +55,11 @@ export class CreatePost extends Component<
CreatePostState CreatePostState
> { > {
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
private subscription?: Subscription;
state: CreatePostState = { state: CreatePostState = {
siteRes: this.isoData.site_res, siteRes: this.isoData.site_res,
loading: true, loading: true,
initialCommunitiesRes: { state: "empty" },
isIsomorphic: false,
}; };
constructor(props: RouteComponentProps<Record<string, never>>, context: any) { constructor(props: RouteComponentProps<Record<string, never>>, context: any) {
@ -65,19 +69,14 @@ export class CreatePost extends Component<
this.handleSelectedCommunityChange = this.handleSelectedCommunityChange =
this.handleSelectedCommunityChange.bind(this); this.handleSelectedCommunityChange.bind(this);
this.parseMessage = this.parseMessage.bind(this);
this.subscription = wsSubscribe(this.parseMessage);
// Only fetch the data if coming from another route // Only fetch the data if coming from another route
if (this.isoData.path === this.context.router.route.match.url) { if (FirstLoadService.isFirstLoad) {
const communityRes = this.isoData.routeData[0] as const [communityRes, listCommunitiesRes] = this.isoData.routeData;
| GetCommunityResponse
| undefined;
if (communityRes) { if (communityRes?.state === "success") {
const communityChoice: Choice = { const communityChoice: Choice = {
label: communityRes.community_view.community.title, label: communityRes.data.community_view.community.title,
value: communityRes.community_view.community.id.toString(), value: communityRes.data.community_view.community.id.toString(),
}; };
this.state = { this.state = {
@ -89,42 +88,56 @@ export class CreatePost extends Component<
this.state = { this.state = {
...this.state, ...this.state,
loading: false, loading: false,
initialCommunitiesRes: listCommunitiesRes,
isIsomorphic: true,
}; };
} else {
this.fetchCommunity();
} }
} }
fetchCommunity() { async fetchCommunity() {
const { communityId } = getCreatePostQueryParams(); const { communityId } = getCreatePostQueryParams();
const auth = myAuth(false); const auth = myAuth();
if (communityId) { if (communityId) {
const form: GetCommunity = { const res = await HttpService.client.getCommunity({
id: communityId, id: communityId,
auth, auth,
};
WebSocketService.Instance.send(wsClient.getCommunity(form));
}
}
componentDidMount(): void {
const { communityId } = getCreatePostQueryParams();
if (communityId?.toString() !== this.state.selectedCommunityChoice?.value) {
this.fetchCommunity();
} else if (!communityId) {
this.setState({
selectedCommunityChoice: undefined,
loading: false,
}); });
if (res.state === "success") {
this.setState({
selectedCommunityChoice: {
label: res.data.community_view.community.name,
value: res.data.community_view.community.id.toString(),
},
loading: false,
});
}
} }
} }
componentWillUnmount() { async componentDidMount() {
if (isBrowser()) { // TODO test this
this.subscription?.unsubscribe(); if (!this.state.isIsomorphic) {
const { communityId } = getCreatePostQueryParams();
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,
loading: false,
});
}
} }
} }
@ -164,6 +177,11 @@ export class CreatePost extends Component<
siteLanguages={this.state.siteRes.discussion_languages} siteLanguages={this.state.siteRes.discussion_languages}
selectedCommunityChoice={selectedCommunityChoice} selectedCommunityChoice={selectedCommunityChoice}
onSelectCommunity={this.handleSelectedCommunityChange} onSelectCommunity={this.handleSelectedCommunityChange}
initialCommunities={
this.state.initialCommunitiesRes.state === "success"
? this.state.initialCommunitiesRes.data.communities
: []
}
/> />
</div> </div>
</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 { communityId: urlCommunityId } = getCreatePostQueryParams();
const locationState = this.props.history.location.state as const locationState = this.props.history.location.state as
@ -191,7 +209,7 @@ export class CreatePost extends Component<
history.replaceState(locationState, "", url); history.replaceState(locationState, "", url);
this.fetchCommunity(); await this.fetchCommunity();
} }
handleSelectedCommunityChange(choice: Choice) { handleSelectedCommunityChange(choice: Choice) {
@ -200,16 +218,23 @@ export class CreatePost extends Component<
}); });
} }
handlePostCreate(post_view: PostView) { async handlePostCreate(form: CreatePostI) {
this.props.history.replace(`/post/${post_view.post.id}`); 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({ static fetchInitialData({
client, client,
query: { communityId }, query: { communityId },
auth, auth,
}: InitialFetchRequest<QueryParams<CreatePostProps>>): Promise<any>[] { }: InitialFetchRequest<QueryParams<CreatePostProps>>): Promise<
const promises: Promise<any>[] = []; RequestState<any>
>[] {
const promises: Promise<RequestState<any>>[] = [];
if (communityId) { if (communityId) {
const form: GetCommunity = { const form: GetCommunity = {
@ -219,31 +244,11 @@ export class CreatePost extends Component<
promises.push(client.getCommunity(form)); promises.push(client.getCommunity(form));
} else { } else {
promises.push(Promise.resolve()); promises.push(Promise.resolve({ state: "empty" }));
} }
promises.push(fetchCommunitiesForOptions(client));
return promises; 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

@ -26,7 +26,7 @@ export class MetadataCard extends Component<
} }
render() { render() {
let post = this.props.post; const post = this.props.post;
return ( return (
<> <>
{!this.state.expanded && post.embed_title && post.url && ( {!this.state.expanded && post.embed_title && post.url && (

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,26 @@
import { Component } from "inferno"; import { Component } from "inferno";
import { T } from "inferno-i18next-dess"; import { T } from "inferno-i18next-dess";
import { Link } from "inferno-router"; 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 { i18n } from "../../i18next";
import { PostListing } from "./post-listing"; import { PostListing } from "./post-listing";
@ -13,6 +32,23 @@ interface PostListingsProps {
removeDuplicates?: boolean; removeDuplicates?: boolean;
enableDownvotes?: boolean; enableDownvotes?: boolean;
enableNsfw?: 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> { export class PostListings extends Component<PostListingsProps, any> {
@ -36,12 +72,29 @@ export class PostListings extends Component<PostListingsProps, any> {
<> <>
<PostListing <PostListing
post_view={post_view} post_view={post_view}
duplicates={this.duplicatesMap.get(post_view.post.id)} crossPosts={this.duplicatesMap.get(post_view.post.id)}
showCommunity={this.props.showCommunity} showCommunity={this.props.showCommunity}
enableDownvotes={this.props.enableDownvotes} enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw} enableNsfw={this.props.enableNsfw}
viewOnly={this.props.viewOnly}
allLanguages={this.props.allLanguages} allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages} 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" /> <hr className="my-3" />
</> </>
@ -62,14 +115,14 @@ export class PostListings extends Component<PostListingsProps, any> {
removeDuplicates(): PostView[] { removeDuplicates(): PostView[] {
// Must use a spread to clone the props, because splice will fail below otherwise. // Must use a spread to clone the props, because splice will fail below otherwise.
let posts = [...this.props.posts]; const posts = [...this.props.posts].filter(empty => empty);
// A map from post url to list of posts (dupes) // A map from post url to list of posts (dupes)
let urlMap = new Map<string, PostView[]>(); const urlMap = new Map<string, PostView[]>();
// Loop over the posts, find ones with same urls // Loop over the posts, find ones with same urls
for (let pv of posts) { for (const pv of posts) {
let url = pv.post.url; const url = pv.post.url;
if ( if (
!pv.post.deleted && !pv.post.deleted &&
!pv.post.removed && !pv.post.removed &&
@ -87,7 +140,7 @@ export class PostListings extends Component<PostListingsProps, any> {
// Sort by oldest // Sort by oldest
// Remove the ones that have no length // Remove the ones that have no length
for (let e of urlMap.entries()) { for (const e of urlMap.entries()) {
if (e[1].length == 1) { if (e[1].length == 1) {
urlMap.delete(e[0]); urlMap.delete(e[0]);
} else { } else {
@ -96,10 +149,10 @@ export class PostListings extends Component<PostListingsProps, any> {
} }
for (let i = 0; i < posts.length; i++) { for (let i = 0; i < posts.length; i++) {
let pv = posts[i]; const pv = posts[i];
let url = pv.post.url; const url = pv.post.url;
if (url) { if (url) {
let found = urlMap.get(url); const found = urlMap.get(url);
if (found) { if (found) {
// If its the oldest, add // If its the oldest, add
if (pv.post.id == found[0].post.id) { if (pv.post.id == found[0].post.id) {

View file

@ -1,27 +1,43 @@
import { Component, linkEvent } from "inferno"; import { Component, InfernoNode, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess"; import { T } from "inferno-i18next-dess";
import { PostReportView, PostView, ResolvePostReport } from "lemmy-js-client"; import { PostReportView, PostView, ResolvePostReport } from "lemmy-js-client";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { WebSocketService } from "../../services"; import { myAuthRequired } from "../../utils";
import { myAuth, wsClient } from "../../utils"; import { Icon, Spinner } from "../common/icon";
import { Icon } from "../common/icon";
import { PersonListing } from "../person/person-listing"; import { PersonListing } from "../person/person-listing";
import { PostListing } from "./post-listing"; import { PostListing } from "./post-listing";
interface PostReportProps { interface PostReportProps {
report: PostReportView; 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) { constructor(props: any, context: any) {
super(props, context); super(props, context);
} }
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & PostReportProps>
): void {
if (this.props != nextProps) {
this.setState({ loading: false });
}
}
render() { render() {
let r = this.props.report; const r = this.props.report;
let resolver = r.resolver; const resolver = r.resolver;
let post = r.post; const post = r.post;
let tippyContent = i18n.t( const tippyContent = i18n.t(
r.post_report.resolved ? "unresolve_report" : "resolve_report" r.post_report.resolved ? "unresolve_report" : "resolve_report"
); );
@ -29,7 +45,7 @@ export class PostReport extends Component<PostReportProps, any> {
post.name = r.post_report.original_post_name; post.name = r.post_report.original_post_name;
post.url = r.post_report.original_post_url; post.url = r.post_report.original_post_url;
post.body = r.post_report.original_post_body; post.body = r.post_report.original_post_body;
let pv: PostView = { const pv: PostView = {
post, post,
creator: r.post_creator, creator: r.post_creator,
community: r.community, community: r.community,
@ -54,6 +70,23 @@ export class PostReport extends Component<PostReportProps, any> {
allLanguages={[]} allLanguages={[]}
siteLanguages={[]} siteLanguages={[]}
hideImage 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> <div>
{i18n.t("reporter")}: <PersonListing person={r.creator} /> {i18n.t("reporter")}: <PersonListing person={r.creator} />
@ -82,26 +115,27 @@ export class PostReport extends Component<PostReportProps, any> {
data-tippy-content={tippyContent} data-tippy-content={tippyContent}
aria-label={tippyContent} aria-label={tippyContent}
> >
<Icon {this.state.loading ? (
icon="check" <Spinner />
classes={`icon-inline ${ ) : (
r.post_report.resolved ? "text-success" : "text-danger" <Icon
}`} icon="check"
/> classes={`icon-inline ${
r.post_report.resolved ? "text-success" : "text-danger"
}`}
/>
)}
</button> </button>
</div> </div>
); );
} }
handleResolveReport(i: PostReport) { handleResolveReport(i: PostReport) {
let auth = myAuth(); i.setState({ loading: true });
if (auth) { i.props.onResolveReport({
let form: ResolvePostReport = { report_id: i.props.report.post_report.id,
report_id: i.props.report.post_report.id, resolved: !i.props.report.post_report.resolved,
resolved: !i.props.report.post_report.resolved, auth: myAuthRequired(),
auth, });
};
WebSocketService.Instance.send(wsClient.resolvePostReport(form));
}
} }
} }

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,39 +1,29 @@
import { Component, linkEvent } from "inferno"; import { Component, InfernoNode, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess"; import { T } from "inferno-i18next-dess";
import { Prompt } from "inferno-router";
import { import {
CreatePrivateMessage, CreatePrivateMessage,
EditPrivateMessage, EditPrivateMessage,
Person, Person,
PrivateMessageResponse,
PrivateMessageView, PrivateMessageView,
UserOperation,
wsJsonToRes,
wsUserOp,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { Subscription } from "rxjs";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { WebSocketService } from "../../services";
import { import {
capitalizeFirstLetter, capitalizeFirstLetter,
isBrowser, myAuthRequired,
myAuth,
relTags, relTags,
setupTippy, setupTippy,
toast,
wsClient,
wsSubscribe,
} from "../../utils"; } from "../../utils";
import { Icon, Spinner } from "../common/icon"; import { Icon, Spinner } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea"; import { MarkdownTextArea } from "../common/markdown-textarea";
import NavigationPrompt from "../common/navigation-prompt";
import { PersonListing } from "../person/person-listing"; import { PersonListing } from "../person/person-listing";
interface PrivateMessageFormProps { interface PrivateMessageFormProps {
recipient: Person; recipient: Person;
privateMessageView?: PrivateMessageView; // If a pm is given, that means this is an edit privateMessageView?: PrivateMessageView; // If a pm is given, that means this is an edit
onCancel?(): any; onCancel?(): any;
onCreate?(message: PrivateMessageView): any; onCreate?(form: CreatePrivateMessage): void;
onEdit?(message: PrivateMessageView): any; onEdit?(form: EditPrivateMessage): void;
} }
interface PrivateMessageFormState { interface PrivateMessageFormState {
@ -41,168 +31,157 @@ interface PrivateMessageFormState {
loading: boolean; loading: boolean;
previewMode: boolean; previewMode: boolean;
showDisclaimer: boolean; showDisclaimer: boolean;
submitted: boolean;
} }
export class PrivateMessageForm extends Component< export class PrivateMessageForm extends Component<
PrivateMessageFormProps, PrivateMessageFormProps,
PrivateMessageFormState PrivateMessageFormState
> { > {
private subscription?: Subscription;
state: PrivateMessageFormState = { state: PrivateMessageFormState = {
loading: false, loading: false,
previewMode: false, previewMode: false,
showDisclaimer: false, showDisclaimer: false,
content: this.props.privateMessageView
? this.props.privateMessageView.private_message.content
: undefined,
submitted: false,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.handleContentChange = this.handleContentChange.bind(this); 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() { componentDidMount() {
setupTippy(); setupTippy();
} }
componentDidUpdate() { componentWillReceiveProps(
if (!this.state.loading && this.state.content) { nextProps: Readonly<{ children?: InfernoNode } & PrivateMessageFormProps>
window.onbeforeunload = () => true; ): void {
} else { if (this.props != nextProps) {
window.onbeforeunload = null; this.setState({ loading: false, content: undefined, previewMode: false });
}
}
componentWillUnmount() {
if (isBrowser()) {
this.subscription?.unsubscribe();
window.onbeforeunload = null;
} }
} }
// TODO
// <Prompt
// when={!this.state.loading && this.state.content}
// message={i18n.t("block_leaving")}
// />
render() { render() {
return ( return (
<div> <form onSubmit={linkEvent(this, this.handlePrivateMessageSubmit)}>
<Prompt <NavigationPrompt
when={!this.state.loading && this.state.content} when={
message={i18n.t("block_leaving")} !this.state.loading && !!this.state.content && !this.state.submitted
}
/> />
<form onSubmit={linkEvent(this, this.handlePrivateMessageSubmit)}> {!this.props.privateMessageView && (
{!this.props.privateMessageView && (
<div className="form-group row">
<label className="col-sm-2 col-form-label">
{capitalizeFirstLetter(i18n.t("to"))}
</label>
<div className="col-sm-10 form-control-plaintext">
<PersonListing person={this.props.recipient} />
</div>
</div>
)}
<div className="form-group row"> <div className="form-group row">
<label className="col-sm-2 col-form-label"> <label className="col-sm-2 col-form-label">
{i18n.t("message")} {capitalizeFirstLetter(i18n.t("to"))}
<button
className="btn btn-link text-warning d-inline-block"
onClick={linkEvent(this, this.handleShowDisclaimer)}
data-tippy-content={i18n.t("private_message_disclaimer")}
aria-label={i18n.t("private_message_disclaimer")}
>
<Icon icon="alert-triangle" classes="icon-inline" />
</button>
</label> </label>
<div className="col-sm-10">
<MarkdownTextArea <div className="col-sm-10 form-control-plaintext">
initialContent={this.state.content} <PersonListing person={this.props.recipient} />
onContentChange={this.handleContentChange}
allLanguages={[]}
siteLanguages={[]}
/>
</div> </div>
</div> </div>
)}
<div className="form-group row">
<label className="col-sm-2 col-form-label">
{i18n.t("message")}
<button
className="btn btn-link text-warning d-inline-block"
onClick={linkEvent(this, this.handleShowDisclaimer)}
data-tippy-content={i18n.t("private_message_disclaimer")}
aria-label={i18n.t("private_message_disclaimer")}
>
<Icon icon="alert-triangle" classes="icon-inline" />
</button>
</label>
<div className="col-sm-10">
<MarkdownTextArea
initialContent={this.state.content}
onContentChange={this.handleContentChange}
allLanguages={[]}
siteLanguages={[]}
hideNavigationWarnings
/>
</div>
</div>
{this.state.showDisclaimer && ( {this.state.showDisclaimer && (
<div className="form-group row">
<div className="offset-sm-2 col-sm-10">
<div className="alert alert-danger" role="alert">
<T i18nKey="private_message_disclaimer">
#
<a
className="alert-link"
rel={relTags}
href="https://element.io/get-started"
>
#
</a>
</T>
</div>
</div>
</div>
)}
<div className="form-group row"> <div className="form-group row">
<div className="offset-sm-2 col-sm-10"> <div className="offset-sm-2 col-sm-10">
<button <div className="alert alert-danger" role="alert">
type="submit" <T i18nKey="private_message_disclaimer">
className="btn btn-secondary mr-2" #
disabled={this.state.loading} <a
> className="alert-link"
{this.state.loading ? ( rel={relTags}
<Spinner /> href="https://element.io/get-started"
) : this.props.privateMessageView ? ( >
capitalizeFirstLetter(i18n.t("save")) #
) : ( </a>
capitalizeFirstLetter(i18n.t("send_message")) </T>
)} </div>
</button>
{this.props.privateMessageView && (
<button
type="button"
className="btn btn-secondary"
onClick={linkEvent(this, this.handleCancel)}
>
{i18n.t("cancel")}
</button>
)}
<ul className="d-inline-block float-right list-inline mb-1 text-muted font-weight-bold">
<li className="list-inline-item"></li>
</ul>
</div> </div>
</div> </div>
</form> )}
</div> <div className="form-group row">
<div className="offset-sm-2 col-sm-10">
<button
type="submit"
className="btn btn-secondary mr-2"
disabled={this.state.loading}
>
{this.state.loading ? (
<Spinner />
) : this.props.privateMessageView ? (
capitalizeFirstLetter(i18n.t("save"))
) : (
capitalizeFirstLetter(i18n.t("send_message"))
)}
</button>
{this.props.privateMessageView && (
<button
type="button"
className="btn btn-secondary"
onClick={linkEvent(this, this.handleCancel)}
>
{i18n.t("cancel")}
</button>
)}
<ul className="d-inline-block float-right list-inline mb-1 text-muted font-weight-bold">
<li className="list-inline-item"></li>
</ul>
</div>
</div>
</form>
); );
} }
handlePrivateMessageSubmit(i: PrivateMessageForm, event: any) { handlePrivateMessageSubmit(i: PrivateMessageForm, event: any) {
event.preventDefault(); event.preventDefault();
let pm = i.props.privateMessageView; i.setState({ loading: true, submitted: true });
let auth = myAuth(); const pm = i.props.privateMessageView;
let content = i.state.content; const auth = myAuthRequired();
if (auth && content) { const content = i.state.content ?? "";
if (pm) { if (pm) {
let form: EditPrivateMessage = { i.props.onEdit?.({
private_message_id: pm.private_message.id, private_message_id: pm.private_message.id,
content, content,
auth, auth,
}; });
WebSocketService.Instance.send(wsClient.editPrivateMessage(form)); } else {
} else { i.props.onCreate?.({
let form: CreatePrivateMessage = { content,
content, recipient_id: i.props.recipient.id,
recipient_id: i.props.recipient.id, auth,
auth, });
};
WebSocketService.Instance.send(wsClient.createPrivateMessage(form));
}
i.setState({ loading: true });
} }
} }
@ -222,25 +201,4 @@ export class PrivateMessageForm extends Component<
handleShowDisclaimer(i: PrivateMessageForm) { handleShowDisclaimer(i: PrivateMessageForm) {
i.setState({ showDisclaimer: !i.state.showDisclaimer }); i.setState({ showDisclaimer: !i.state.showDisclaimer });
} }
parseMessage(msg: any) {
let 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
) {
let data = wsJsonToRes<PrivateMessageResponse>(msg);
this.setState({ loading: false });
this.props.onEdit?.(data.private_message_view);
} else if (op == UserOperation.CreatePrivateMessage) {
let data = wsJsonToRes<PrivateMessageResponse>(msg);
this.props.onCreate?.(data.private_message_view);
}
}
} }

View file

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

View file

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

View file

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

View file

@ -34,12 +34,6 @@ function getHost() {
return isBrowser() ? getExternalHost() : getInternalHost(); return isBrowser() ? getExternalHost() : getInternalHost();
} }
function getWsHost() {
return isBrowser()
? window.lemmyConfig?.wsHost ?? getHost()
: process.env.LEMMY_UI_LEMMY_WS_HOST ?? getExternalHost();
}
function getBaseLocal(s = "") { function getBaseLocal(s = "") {
return `http${s}://${getHost()}`; return `http${s}://${getHost()}`;
} }
@ -47,18 +41,20 @@ function getBaseLocal(s = "") {
export function getHttpBaseInternal() { export function getHttpBaseInternal() {
return getBaseLocal(); // Don't use secure here return getBaseLocal(); // Don't use secure here
} }
export function getHttpBaseExternal() {
return `http${getSecure()}://${getExternalHost()}`;
}
export function getHttpBase() { export function getHttpBase() {
return getBaseLocal(getSecure()); return getBaseLocal(getSecure());
} }
export function getWsUri() {
return `ws${getSecure()}://${getWsHost()}/api/v3/ws`;
}
export function isHttps() { export function isHttps() {
return getSecure() === "s"; return getSecure() === "s";
} }
console.log(`httpbase: ${getHttpBase()}`); console.log(`httpbase: ${getHttpBase()}`);
console.log(`wsUri: ${getWsUri()}`);
console.log(`isHttps: ${isHttps()}`); console.log(`isHttps: ${isHttps()}`);
// This is for html tags, don't include port // 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 type { ParsedQs } from "qs";
import { RequestState, WrappedLemmyHttp } from "./services/HttpService";
import { ErrorPageData } from "./utils"; import { ErrorPageData } from "./utils";
/** /**
@ -7,7 +8,7 @@ import { ErrorPageData } from "./utils";
*/ */
export interface IsoData { export interface IsoData {
path: string; path: string;
routeData: any[]; routeData: RequestState<any>[];
site_res: GetSiteResponse; site_res: GetSiteResponse;
errorPageData?: ErrorPageData; errorPageData?: ErrorPageData;
} }
@ -28,7 +29,7 @@ declare global {
export interface InitialFetchRequest<T extends ParsedQs = ParsedQs> { export interface InitialFetchRequest<T extends ParsedQs = ParsedQs> {
auth?: string; auth?: string;
client: LemmyHttp; client: WrappedLemmyHttp;
path: string; path: string;
query: T; query: T;
site: GetSiteResponse; site: GetSiteResponse;
@ -69,6 +70,11 @@ export enum PurgeType {
Comment, Comment,
} }
export enum VoteType {
Upvote,
Downvote,
}
export interface CommentNodeI { export interface CommentNodeI {
comment_view: CommentView; comment_view: CommentView;
children: Array<CommentNodeI>; 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 { CreatePrivateMessage } from "./components/private_message/create-private-message";
import { Search } from "./components/search"; import { Search } from "./components/search";
import { InitialFetchRequest } from "./interfaces"; import { InitialFetchRequest } from "./interfaces";
import { RequestState } from "./services/HttpService";
interface IRoutePropsWithFetch extends IRouteProps { interface IRoutePropsWithFetch extends IRouteProps {
// TODO Make sure this one is good. // TODO Make sure this one is good.
fetchInitialData?(req: InitialFetchRequest): Promise<any>[]; fetchInitialData?(req: InitialFetchRequest): Promise<RequestState<any>>[];
} }
export const routes: IRoutePropsWithFetch[] = [ 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 IsomorphicCookie from "isomorphic-cookie";
import jwt_decode from "jwt-decode"; import jwt_decode from "jwt-decode";
import { LoginResponse, MyUserInfo } from "lemmy-js-client"; import { LoginResponse, MyUserInfo } from "lemmy-js-client";
import { BehaviorSubject } from "rxjs";
import { isHttps } from "../env"; import { isHttps } from "../env";
import { i18n } from "../i18next"; import { i18n } from "../i18next";
import { isAuthPath, isBrowser, toast } from "../utils"; import { isAuthPath, isBrowser, toast } from "../utils";
@ -19,27 +18,21 @@ interface JwtInfo {
} }
export class UserService { export class UserService {
private static _instance: UserService; static #instance: UserService;
public myUserInfo?: MyUserInfo; public myUserInfo?: MyUserInfo;
public jwtInfo?: JwtInfo; 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() { private constructor() {
this.setJwtInfo(); this.#setJwtInfo();
} }
public login(res: LoginResponse) { public login(res: LoginResponse) {
let expires = new Date(); const expires = new Date();
expires.setDate(expires.getDate() + 365); expires.setDate(expires.getDate() + 365);
if (res.jwt) { if (res.jwt) {
toast(i18n.t("logged_in")); toast(i18n.t("logged_in"));
IsomorphicCookie.save("jwt", res.jwt, { expires, secure: isHttps() }); IsomorphicCookie.save("jwt", res.jwt, { expires, secure: isHttps() });
this.setJwtInfo(); this.#setJwtInfo();
} }
} }
@ -55,12 +48,12 @@ export class UserService {
} }
} }
public auth(throwErr = true): string | undefined { public auth(throwErr = false): string | undefined {
let jwt = this.jwtInfo?.jwt; const jwt = this.jwtInfo?.jwt;
if (jwt) { if (jwt) {
return jwt; return jwt;
} else { } else {
let msg = "No JWT cookie found"; const msg = "No JWT cookie found";
if (throwErr && isBrowser()) { if (throwErr && isBrowser()) {
console.error(msg); console.error(msg);
toast(i18n.t("not_logged_in"), "danger"); toast(i18n.t("not_logged_in"), "danger");
@ -70,8 +63,8 @@ export class UserService {
} }
} }
private setJwtInfo() { #setJwtInfo() {
let jwt: string | undefined = IsomorphicCookie.load("jwt"); const jwt: string | undefined = IsomorphicCookie.load("jwt");
if (jwt) { if (jwt) {
this.jwtInfo = { jwt, claims: jwt_decode(jwt) }; this.jwtInfo = { jwt, claims: jwt_decode(jwt) };
@ -79,6 +72,6 @@ export class UserService {
} }
public static get Instance() { 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) {
let 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 { UserService } from "./UserService";
export { WebSocketService } from "./WebSocketService";

View file

@ -3,7 +3,9 @@ import emojiShortName from "emoji-short-name";
import { import {
BlockCommunityResponse, BlockCommunityResponse,
BlockPersonResponse, BlockPersonResponse,
CommentAggregates,
Comment as CommentI, Comment as CommentI,
CommentReplyView,
CommentReportView, CommentReportView,
CommentSortType, CommentSortType,
CommentView, CommentView,
@ -14,9 +16,9 @@ import {
GetSiteResponse, GetSiteResponse,
Language, Language,
LemmyHttp, LemmyHttp,
LemmyWebsocket,
MyUserInfo, MyUserInfo,
Person, Person,
PersonMentionView,
PersonView, PersonView,
PostReportView, PostReportView,
PostView, PostView,
@ -24,8 +26,8 @@ import {
PrivateMessageView, PrivateMessageView,
RegistrationApplicationView, RegistrationApplicationView,
Search, Search,
SearchType,
SortType, SortType,
UploadImageResponse,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { default as MarkdownIt } from "markdown-it"; import { default as MarkdownIt } from "markdown-it";
import markdown_it_container from "markdown-it-container"; 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 Renderer from "markdown-it/lib/renderer";
import Token from "markdown-it/lib/token"; import Token from "markdown-it/lib/token";
import moment from "moment"; import moment from "moment";
import { Subscription } from "rxjs";
import { delay, retryWhen, take } from "rxjs/operators";
import tippy from "tippy.js"; import tippy from "tippy.js";
import Toastify from "toastify-js"; import Toastify from "toastify-js";
import { getHttpBase } from "./env"; import { getHttpBase } from "./env";
import { i18n, languages } from "./i18next"; import { i18n, languages } from "./i18next";
import { CommentNodeI, DataType, IsoData } from "./interfaces"; import { CommentNodeI, DataType, IsoData, VoteType } from "./interfaces";
import { UserService, WebSocketService } from "./services"; import { HttpService, UserService } from "./services";
let Tribute: any; let Tribute: any;
if (isBrowser()) { if (isBrowser()) {
Tribute = require("tributejs"); Tribute = require("tributejs");
} }
export const wsClient = new LemmyWebsocket();
export const favIconUrl = "/static/assets/icons/favicon.svg"; export const favIconUrl = "/static/assets/icons/favicon.svg";
export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png"; export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
// TODO // TODO
@ -110,7 +108,7 @@ export interface ErrorPageData {
adminMatrixIds?: string[]; adminMatrixIds?: string[];
} }
let customEmojis: EmojiMartCategory[] = []; const customEmojis: EmojiMartCategory[] = [];
export let customEmojisLookup: Map<string, CustomEmojiView> = new Map< export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
string, string,
CustomEmojiView CustomEmojiView
@ -192,11 +190,11 @@ export function hotRankPost(post_view: PostView): number {
export function hotRank(score: number, timeStr: string): number { export function hotRank(score: number, timeStr: string): number {
// Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
let date: Date = new Date(timeStr + "Z"); // Add Z to convert from UTC date const date: Date = new Date(timeStr + "Z"); // Add Z to convert from UTC date
let now: Date = new Date(); const now: Date = new Date();
let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5; const hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
let rank = const rank =
(10000 * Math.log10(Math.max(1, 3 + Number(score)))) / (10000 * Math.log10(Math.max(1, 3 + Number(score)))) /
Math.pow(hoursElapsed + 2, 1.8); Math.pow(hoursElapsed + 2, 1.8);
@ -243,7 +241,7 @@ export function canMod(
.concat(mods?.map(m => m.moderator.id) ?? []) ?? []; .concat(mods?.map(m => m.moderator.id) ?? []) ?? [];
if (myUserInfo) { if (myUserInfo) {
let myIndex = adminsThenMods.findIndex( const myIndex = adminsThenMods.findIndex(
id => id == myUserInfo.local_user_view.person.id id => id == myUserInfo.local_user_view.person.id
); );
if (myIndex == -1) { if (myIndex == -1) {
@ -294,7 +292,7 @@ export function amCommunityCreator(
mods?: CommunityModeratorView[], mods?: CommunityModeratorView[],
myUserInfo = UserService.Instance.myUserInfo myUserInfo = UserService.Instance.myUserInfo
): boolean { ): boolean {
let myId = myUserInfo?.local_user_view.person.id; const myId = myUserInfo?.local_user_view.person.id;
// Don't allow mod actions on yourself // Don't allow mod actions on yourself
return myId == mods?.at(0)?.moderator.id && myId != creator_id; return myId == mods?.at(0)?.moderator.id && myId != creator_id;
} }
@ -304,7 +302,7 @@ export function amSiteCreator(
admins?: PersonView[], admins?: PersonView[],
myUserInfo = UserService.Instance.myUserInfo myUserInfo = UserService.Instance.myUserInfo
): boolean { ): boolean {
let myId = myUserInfo?.local_user_view.person.id; const myId = myUserInfo?.local_user_view.person.id;
return myId == admins?.at(0)?.person.id && myId != creator_id; return myId == admins?.at(0)?.person.id && myId != creator_id;
} }
@ -331,12 +329,12 @@ export function validURL(str: string) {
} }
export function communityRSSUrl(actorId: string, sort: string): string { export function communityRSSUrl(actorId: string, sort: string): string {
let url = new URL(actorId); const url = new URL(actorId);
return `${url.origin}/feeds${url.pathname}.xml?sort=${sort}`; return `${url.origin}/feeds${url.pathname}.xml?sort=${sort}`;
} }
export function validEmail(email: string) { export function validEmail(email: string) {
let re = const re =
/^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/; /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
return re.test(String(email).toLowerCase()); return re.test(String(email).toLowerCase());
} }
@ -346,8 +344,8 @@ export function capitalizeFirstLetter(str: string): string {
} }
export async function getSiteMetadata(url: string) { export async function getSiteMetadata(url: string) {
let form: GetSiteMetadata = { url }; const form: GetSiteMetadata = { url };
let client = new LemmyHttp(getHttpBase()); const client = new LemmyHttp(getHttpBase());
return client.getSiteMetadata(form); return client.getSiteMetadata(form);
} }
@ -404,8 +402,8 @@ export function getLanguages(
override?: string, override?: string,
myUserInfo = UserService.Instance.myUserInfo myUserInfo = UserService.Instance.myUserInfo
): string[] { ): string[] {
let myLang = myUserInfo?.local_user_view.local_user.interface_language; const myLang = myUserInfo?.local_user_view.local_user.interface_language;
let lang = override || myLang || "browser"; const lang = override || myLang || "browser";
if (lang == "browser" && isBrowser()) { if (lang == "browser" && isBrowser()) {
return getBrowserLanguages(); return getBrowserLanguages();
@ -416,10 +414,10 @@ export function getLanguages(
function getBrowserLanguages(): string[] { function getBrowserLanguages(): string[] {
// Intersect lemmy's langs, with the browser langs // Intersect lemmy's langs, with the browser langs
let langs = languages ? languages.map(l => l.code) : ["en"]; const langs = languages ? languages.map(l => l.code) : ["en"];
// NOTE, mobile browsers seem to be missing this list, so append en // NOTE, mobile browsers seem to be missing this list, so append en
let allowedLangs = navigator.languages const allowedLangs = navigator.languages
.concat("en") .concat("en")
.filter(v => langs.includes(v)); .filter(v => langs.includes(v));
return allowedLangs; return allowedLangs;
@ -441,11 +439,11 @@ export async function setTheme(theme: string, forceReload = false) {
theme = "darkly"; theme = "darkly";
} }
let themeList = await fetchThemeList(); const themeList = await fetchThemeList();
// Unload all the other themes // Unload all the other themes
for (var i = 0; i < themeList.length; i++) { for (var i = 0; i < themeList.length; i++) {
let styleSheet = document.getElementById(themeList[i]); const styleSheet = document.getElementById(themeList[i]);
if (styleSheet) { if (styleSheet) {
styleSheet.setAttribute("disabled", "disabled"); styleSheet.setAttribute("disabled", "disabled");
} }
@ -457,7 +455,7 @@ export async function setTheme(theme: string, forceReload = false) {
document.getElementById("default-dark")?.setAttribute("disabled", "disabled"); document.getElementById("default-dark")?.setAttribute("disabled", "disabled");
// Load the theme dynamically // Load the theme dynamically
let cssLoc = `/css/themes/${theme}.css`; const cssLoc = `/css/themes/${theme}.css`;
loadCss(theme, cssLoc); loadCss(theme, cssLoc);
document.getElementById(theme)?.removeAttribute("disabled"); document.getElementById(theme)?.removeAttribute("disabled");
@ -559,86 +557,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()) {
let htmlBody = info.body ? md.render(info.body) : "";
let backgroundColor = `var(--light)`;
let 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) {
let 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) {
let 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) {
let 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() { export function setupTribute() {
return new Tribute({ return new Tribute({
noMatchTemplate: function () { noMatchTemplate: function () {
@ -649,11 +567,11 @@ export function setupTribute() {
{ {
trigger: ":", trigger: ":",
menuItemTemplate: (item: any) => { menuItemTemplate: (item: any) => {
let shortName = `:${item.original.key}:`; const shortName = `:${item.original.key}:`;
return `${item.original.val} ${shortName}`; return `${item.original.val} ${shortName}`;
}, },
selectTemplate: (item: any) => { selectTemplate: (item: any) => {
let customEmoji = customEmojisLookup.get( const customEmoji = customEmojisLookup.get(
item.original.key item.original.key
)?.custom_emoji; )?.custom_emoji;
if (customEmoji == undefined) return `${item.original.val}`; if (customEmoji == undefined) return `${item.original.val}`;
@ -680,7 +598,7 @@ export function setupTribute() {
{ {
trigger: "@", trigger: "@",
selectTemplate: (item: any) => { selectTemplate: (item: any) => {
let it: PersonTribute = item.original; const it: PersonTribute = item.original;
return `[${it.key}](${it.view.person.actor_id})`; return `[${it.key}](${it.view.person.actor_id})`;
}, },
values: debounce(async (text: string, cb: any) => { values: debounce(async (text: string, cb: any) => {
@ -697,7 +615,7 @@ export function setupTribute() {
{ {
trigger: "!", trigger: "!",
selectTemplate: (item: any) => { selectTemplate: (item: any) => {
let it: CommunityTribute = item.original; const it: CommunityTribute = item.original;
return `[${it.key}](${it.view.community.actor_id})`; return `[${it.key}](${it.view.community.actor_id})`;
}, },
values: debounce(async (text: string, cb: any) => { values: debounce(async (text: string, cb: any) => {
@ -714,7 +632,10 @@ export function setupTribute() {
} }
function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) { function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
let groupedEmojis = groupBy(custom_emoji_views, x => x.custom_emoji.category); const groupedEmojis = groupBy(
custom_emoji_views,
x => x.custom_emoji.category
);
for (const [category, emojis] of Object.entries(groupedEmojis)) { for (const [category, emojis] of Object.entries(groupedEmojis)) {
customEmojis.push({ customEmojis.push({
id: category, id: category,
@ -739,7 +660,7 @@ export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
keywords: custom_emoji_view.keywords.map(x => x.keyword), keywords: custom_emoji_view.keywords.map(x => x.keyword),
skins: [{ src: custom_emoji_view.custom_emoji.image_url }], skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
}; };
let categoryIndex = customEmojis.findIndex( const categoryIndex = customEmojis.findIndex(
x => x.id == custom_emoji_view.custom_emoji.category x => x.id == custom_emoji_view.custom_emoji.category
); );
if (categoryIndex == -1) { if (categoryIndex == -1) {
@ -749,7 +670,7 @@ export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
emojis: [emoji], emojis: [emoji],
}); });
} else { } else {
let emojiIndex = customEmojis[categoryIndex].emojis.findIndex( const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
x => x.id == custom_emoji_view.custom_emoji.shortcode x => x.id == custom_emoji_view.custom_emoji.shortcode
); );
if (emojiIndex == -1) { if (emojiIndex == -1) {
@ -766,7 +687,7 @@ export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
export function removeFromEmojiDataModel(id: number) { export function removeFromEmojiDataModel(id: number) {
let view: CustomEmojiView | undefined; let view: CustomEmojiView | undefined;
for (let item of customEmojisLookup.values()) { for (const item of customEmojisLookup.values()) {
if (item.custom_emoji.id === id) { if (item.custom_emoji.id === id) {
view = item; view = item;
break; break;
@ -872,15 +793,12 @@ interface PersonTribute {
} }
async function personSearch(text: string): Promise<PersonTribute[]> { async function personSearch(text: string): Promise<PersonTribute[]> {
let users = (await fetchUsers(text)).users; const usersResponse = await fetchUsers(text);
let persons: PersonTribute[] = users.map(pv => {
let tribute: PersonTribute = { return usersResponse.map(pv => ({
key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`, key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`,
view: pv, view: pv,
}; }));
return tribute;
});
return persons;
} }
interface CommunityTribute { interface CommunityTribute {
@ -889,15 +807,12 @@ interface CommunityTribute {
} }
async function communitySearch(text: string): Promise<CommunityTribute[]> { async function communitySearch(text: string): Promise<CommunityTribute[]> {
let comms = (await fetchCommunities(text)).communities; const communitiesResponse = await fetchCommunities(text);
let communities: CommunityTribute[] = comms.map(cv => {
let tribute: CommunityTribute = { return communitiesResponse.map(cv => ({
key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`, key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`,
view: cv, view: cv,
}; }));
return tribute;
});
return communities;
} }
export function getRecipientIdFromProps(props: any): number { export function getRecipientIdFromProps(props: any): number {
@ -907,51 +822,137 @@ export function getRecipientIdFromProps(props: any): number {
} }
export function getIdFromProps(props: any): number | undefined { export function getIdFromProps(props: any): number | undefined {
let id = props.match.params.post_id; const id = props.match.params.post_id;
return id ? Number(id) : undefined; return id ? Number(id) : undefined;
} }
export function getCommentIdFromProps(props: any): number | undefined { export function getCommentIdFromProps(props: any): number | undefined {
let id = props.match.params.comment_id; const id = props.match.params.comment_id;
return id ? Number(id) : undefined; return id ? Number(id) : undefined;
} }
export function editCommentRes(data: CommentView, comments?: CommentView[]) { type ImmutableListKey =
let found = comments?.find(c => c.comment.id == data.comment.id); | "comment"
if (found) { | "comment_reply"
found.comment.content = data.comment.content; | "person_mention"
found.comment.distinguished = data.comment.distinguished; | "community"
found.comment.updated = data.comment.updated; | "private_message"
found.comment.removed = data.comment.removed; | "post"
found.comment.deleted = data.comment.deleted; | "post_report"
found.counts.upvotes = data.counts.upvotes; | "comment_report"
found.counts.downvotes = data.counts.downvotes; | "private_message_report"
found.counts.score = data.counts.score; | "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[]) { export function editComment(
let found = comments?.find(c => c.comment.id == data.comment.id); data: CommentView,
if (found) { comments: CommentView[]
found.saved = data.saved; ): 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( export function updatePersonBlock(
data: BlockPersonResponse, data: BlockPersonResponse,
myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
) { ) {
let mui = myUserInfo; if (myUserInfo) {
if (mui) {
if (data.blocked) { if (data.blocked) {
mui.person_blocks.push({ myUserInfo.person_blocks.push({
person: mui.local_user_view.person, person: myUserInfo.local_user_view.person,
target: data.person_view.person, target: data.person_view.person,
}); });
toast(`${i18n.t("blocked")} ${data.person_view.person.name}`); toast(`${i18n.t("blocked")} ${data.person_view.person.name}`);
} else { } else {
mui.person_blocks = mui.person_blocks.filter( myUserInfo.person_blocks = myUserInfo.person_blocks.filter(
i => i.target.id != data.person_view.person.id i => i.target.id !== data.person_view.person.id
); );
toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`); toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`);
} }
@ -962,128 +963,25 @@ export function updateCommunityBlock(
data: BlockCommunityResponse, data: BlockCommunityResponse,
myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
) { ) {
let mui = myUserInfo; if (myUserInfo) {
if (mui) {
if (data.blocked) { if (data.blocked) {
mui.community_blocks.push({ myUserInfo.community_blocks.push({
person: mui.local_user_view.person, person: myUserInfo.local_user_view.person,
community: data.community_view.community, community: data.community_view.community,
}); });
toast(`${i18n.t("blocked")} ${data.community_view.community.name}`); toast(`${i18n.t("blocked")} ${data.community_view.community.name}`);
} else { } else {
mui.community_blocks = mui.community_blocks.filter( myUserInfo.community_blocks = myUserInfo.community_blocks.filter(
i => i.community.id != data.community_view.community.id i => i.community.id !== data.community_view.community.id
); );
toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`); toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`);
} }
} }
} }
export function createCommentLikeRes(
data: CommentView,
comments?: CommentView[]
) {
let 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[]) {
let 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[]) {
let 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[]
) {
let 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[]
) {
let 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[]
) {
let 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[]
) {
let 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[] { export function commentsToFlatNodes(comments: CommentView[]): CommentNodeI[] {
let nodes: CommentNodeI[] = []; const nodes: CommentNodeI[] = [];
for (let comment of comments) { for (const comment of comments) {
nodes.push({ comment_view: comment, children: [], depth: 0 }); nodes.push({ comment_view: comment, children: [], depth: 0 });
} }
return nodes; return nodes;
@ -1111,15 +1009,15 @@ export function buildCommentsTree(
comments: CommentView[], comments: CommentView[],
parentComment: boolean parentComment: boolean
): CommentNodeI[] { ): CommentNodeI[] {
let map = new Map<number, CommentNodeI>(); const map = new Map<number, CommentNodeI>();
let depthOffset = !parentComment const depthOffset = !parentComment
? 0 ? 0
: getDepthFromComment(comments[0].comment) ?? 0; : getDepthFromComment(comments[0].comment) ?? 0;
for (let comment_view of comments) { for (const comment_view of comments) {
let depthI = getDepthFromComment(comment_view.comment) ?? 0; const depthI = getDepthFromComment(comment_view.comment) ?? 0;
let depth = depthI ? depthI - depthOffset : 0; const depth = depthI ? depthI - depthOffset : 0;
let node: CommentNodeI = { const node: CommentNodeI = {
comment_view, comment_view,
children: [], children: [],
depth, depth,
@ -1127,22 +1025,22 @@ export function buildCommentsTree(
map.set(comment_view.comment.id, { ...node }); map.set(comment_view.comment.id, { ...node });
} }
let tree: CommentNodeI[] = []; const tree: CommentNodeI[] = [];
// if its a parent comment fetch, then push the first comment to the top node. // if its a parent comment fetch, then push the first comment to the top node.
if (parentComment) { if (parentComment) {
let cNode = map.get(comments[0].comment.id); const cNode = map.get(comments[0].comment.id);
if (cNode) { if (cNode) {
tree.push(cNode); tree.push(cNode);
} }
} }
for (let comment_view of comments) { for (const comment_view of comments) {
let child = map.get(comment_view.comment.id); const child = map.get(comment_view.comment.id);
if (child) { if (child) {
let parent_id = getCommentParentId(comment_view.comment); const parent_id = getCommentParentId(comment_view.comment);
if (parent_id) { if (parent_id) {
let parent = map.get(parent_id); const parent = map.get(parent_id);
// Necessary because blocked comment might not exist // Necessary because blocked comment might not exist
if (parent) { if (parent) {
parent.children.push(child); parent.children.push(child);
@ -1159,7 +1057,7 @@ export function buildCommentsTree(
} }
export function getCommentParentId(comment?: CommentI): number | undefined { export function getCommentParentId(comment?: CommentI): number | undefined {
let split = comment?.path.split("."); const split = comment?.path.split(".");
// remove the 0 // remove the 0
split?.shift(); split?.shift();
@ -1169,25 +1067,26 @@ export function getCommentParentId(comment?: CommentI): number | undefined {
} }
export function getDepthFromComment(comment?: CommentI): number | undefined { export function getDepthFromComment(comment?: CommentI): number | undefined {
let len = comment?.path.split(".").length; const len = comment?.path.split(".").length;
return len ? len - 2 : undefined; return len ? len - 2 : undefined;
} }
// TODO make immutable
export function insertCommentIntoTree( export function insertCommentIntoTree(
tree: CommentNodeI[], tree: CommentNodeI[],
cv: CommentView, cv: CommentView,
parentComment: boolean parentComment: boolean
) { ) {
// Building a fake node to be used for later // Building a fake node to be used for later
let node: CommentNodeI = { const node: CommentNodeI = {
comment_view: cv, comment_view: cv,
children: [], children: [],
depth: 0, depth: 0,
}; };
let parentId = getCommentParentId(cv.comment); const parentId = getCommentParentId(cv.comment);
if (parentId) { if (parentId) {
let parent_comment = searchCommentTree(tree, parentId); const parent_comment = searchCommentTree(tree, parentId);
if (parent_comment) { if (parent_comment) {
node.depth = parent_comment.depth + 1; node.depth = parent_comment.depth + 1;
parent_comment.children.unshift(node); parent_comment.children.unshift(node);
@ -1201,13 +1100,13 @@ export function searchCommentTree(
tree: CommentNodeI[], tree: CommentNodeI[],
id: number id: number
): CommentNodeI | undefined { ): CommentNodeI | undefined {
for (let node of tree) { for (const node of tree) {
if (node.comment_view.comment.id === id) { if (node.comment_view.comment.id === id) {
return node; return node;
} }
for (const child of node.children) { for (const child of node.children) {
let res = searchCommentTree([child], id); const res = searchCommentTree([child], id);
if (res) { if (res) {
return res; return res;
@ -1232,7 +1131,7 @@ function hsl(num: number) {
} }
export function hostname(url: string): string { export function hostname(url: string): string {
let cUrl = new URL(url); const cUrl = new URL(url);
return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`; return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`;
} }
@ -1269,20 +1168,6 @@ export function setIsoData(context: any): IsoData {
} else return context.router.staticContext; } 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", { moment.updateLocale("en", {
relativeTime: { relativeTime: {
future: "in %s", future: "in %s",
@ -1305,14 +1190,14 @@ moment.updateLocale("en", {
}); });
export function saveScrollPosition(context: any) { export function saveScrollPosition(context: any) {
let path: string = context.router.route.location.pathname; const path: string = context.router.route.location.pathname;
let y = window.scrollY; const y = window.scrollY;
sessionStorage.setItem(`scrollPosition_${path}`, y.toString()); sessionStorage.setItem(`scrollPosition_${path}`, y.toString());
} }
export function restoreScrollPosition(context: any) { export function restoreScrollPosition(context: any) {
let path: string = context.router.route.location.pathname; const path: string = context.router.route.location.pathname;
let y = Number(sessionStorage.getItem(`scrollPosition_${path}`)); const y = Number(sessionStorage.getItem(`scrollPosition_${path}`));
window.scrollTo(0, y); window.scrollTo(0, y);
} }
@ -1346,32 +1231,30 @@ export function personToChoice(pvs: PersonView): Choice {
}; };
} }
export async function fetchCommunities(q: string) { function fetchSearchResults(q: string, type_: SearchType) {
let form: Search = { const form: Search = {
q, q,
type_: "Communities", type_,
sort: "TopAll", sort: "TopAll",
listing_type: "All", listing_type: "All",
page: 1, page: 1,
limit: fetchLimit, limit: fetchLimit,
auth: myAuth(false), auth: myAuth(),
}; };
let 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) { export async function fetchUsers(q: string) {
let form: Search = { const res = await fetchSearchResults(q, "Users");
q,
type_: "Users", return res.state === "success" ? res.data.users : [];
sort: "TopAll",
listing_type: "All",
page: 1,
limit: fetchLimit,
auth: myAuth(false),
};
let client = new LemmyHttp(getHttpBase());
return client.search(form);
} }
export function communitySelectName(cv: CommunityView): string { export function communitySelectName(cv: CommunityView): string {
@ -1391,7 +1274,7 @@ export function initializeSite(site?: GetSiteResponse) {
UserService.Instance.myUserInfo = site?.my_user; UserService.Instance.myUserInfo = site?.my_user;
i18n.changeLanguage(getLanguages()[0]); i18n.changeLanguage(getLanguages()[0]);
if (site) { if (site) {
setupEmojiDataModel(site.custom_emojis); setupEmojiDataModel(site.custom_emojis ?? []);
} }
setupMarkdown(); setupMarkdown();
} }
@ -1408,7 +1291,7 @@ export function numToSI(value: number): string {
} }
export function isBanned(ps: Person): boolean { export function isBanned(ps: Person): boolean {
let expires = ps.ban_expires; const expires = ps.ban_expires;
// Add Z to convert from UTC date // Add Z to convert from UTC date
// TODO this check probably isn't necessary anymore // TODO this check probably isn't necessary anymore
if (expires) { if (expires) {
@ -1422,8 +1305,12 @@ export function isBanned(ps: Person): boolean {
} }
} }
export function myAuth(throwErr = true): string | undefined { export function myAuth(): string | undefined {
return UserService.Instance.auth(throwErr); return UserService.Instance.auth();
}
export function myAuthRequired(): string {
return UserService.Instance.auth(true) ?? "";
} }
export function enableDownvotes(siteRes: GetSiteResponse): boolean { export function enableDownvotes(siteRes: GetSiteResponse): boolean {
@ -1478,8 +1365,8 @@ export function nsfwCheck(
pv: PostView, pv: PostView,
myUserInfo = UserService.Instance.myUserInfo myUserInfo = UserService.Instance.myUserInfo
): boolean { ): boolean {
let nsfw = pv.post.nsfw || pv.community.nsfw; const nsfw = pv.post.nsfw || pv.community.nsfw;
let myShowNsfw = myUserInfo?.local_user_view.local_user.show_nsfw ?? false; const myShowNsfw = myUserInfo?.local_user_view.local_user.show_nsfw ?? false;
return !nsfw || (nsfw && myShowNsfw); return !nsfw || (nsfw && myShowNsfw);
} }
@ -1503,10 +1390,10 @@ export function selectableLanguages(
showSite?: boolean, showSite?: boolean,
myUserInfo = UserService.Instance.myUserInfo myUserInfo = UserService.Instance.myUserInfo
): Language[] { ): Language[] {
let allLangIds = allLanguages.map(l => l.id); const allLangIds = allLanguages.map(l => l.id);
let myLangs = myUserInfo?.discussion_languages ?? allLangIds; let myLangs = myUserInfo?.discussion_languages ?? allLangIds;
myLangs = myLangs.length == 0 ? allLangIds : myLangs; myLangs = myLangs.length == 0 ? allLangIds : myLangs;
let siteLangs = siteLanguages.length == 0 ? allLangIds : siteLanguages; const siteLangs = siteLanguages.length == 0 ? allLangIds : siteLanguages;
if (showAll) { if (showAll) {
return allLanguages; return allLanguages;
@ -1521,12 +1408,6 @@ export function selectableLanguages(
} }
} }
export function uploadImage(image: File): Promise<UploadImageResponse> {
const client = new LemmyHttp(getHttpBase());
return client.uploadImage({ image });
}
interface EmojiMartCategory { interface EmojiMartCategory {
id: string; id: string;
name: string; name: string;
@ -1587,7 +1468,7 @@ export function getQueryString<T extends Record<string, string | undefined>>(
} }
export function isAuthPath(pathname: string) { 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 pathname
); );
} }
@ -1601,3 +1482,11 @@ export function share(shareData: ShareData) {
navigator.share(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;
}
}

View file

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

1397
yarn.lock

File diff suppressed because it is too large Load diff