From 5809419bcece68350d55e4fe95076edfcf917250 Mon Sep 17 00:00:00 2001 From: Misha Vyrtsev Date: Sun, 24 Mar 2019 21:53:55 +0300 Subject: [PATCH] Refactor frontend to typescript (#281) --- Dockerfile | 1 + FRONTEND.MAN.md | 27 + README.md | 40 +- compose-dev-frontend.yml | 6 +- web/.babelrc | 21 + web/.eslintrc.js | 65 +- web/app/@types/bem-react-helper/index.d.ts | 14 + web/app/common/accessibility.js | 13 - web/app/common/accessibility.ts | 13 + web/app/common/api.js | 180 -- web/app/common/api.ts | 246 ++ web/app/common/config-types.ts | 21 + web/app/common/{constants.js => constants.ts} | 33 +- web/app/common/cookies.js | 39 - web/app/common/cookies.ts | 49 + web/app/common/{copy.js => copy.ts} | 21 +- web/app/common/fetcher.js | 52 - web/app/common/fetcher.ts | 81 + .../{localStorage.js => local-storage.ts} | 2 +- web/app/common/polyfills.js | 19 - web/app/common/polyfills.ts | 27 + web/app/common/settings.js | 18 - web/app/common/settings.ts | 40 + web/app/common/static_store.ts | 30 + web/app/common/store.js | 123 - web/app/common/store.test.js | 13 - web/app/common/types.ts | 130 + web/app/common/user-info-settings.ts | 23 + .../auth-panel__readonly-label.scss | 8 + .../auth-panel/__tests__/auth-panel.test.js | 73 - .../__user-id/auth-panel__user-id.jsx | 8 - .../__user-id/auth-panel__user-id.tsx | 15 + .../components/auth-panel/__user-id/index.js | 6 - .../components/auth-panel/__user-id/index.ts | 3 + .../components/auth-panel/auth-panel.test.tsx | 107 + .../{auth-panel.jsx => auth-panel.tsx} | 117 +- .../auth-panel/{index.js => index.ts} | 7 +- .../components/avatar-icon/avatar-icon.jsx | 14 - .../components/avatar-icon/avatar-icon.tsx | 20 + web/app/components/avatar-icon/index.js | 7 - web/app/components/avatar-icon/index.ts | 4 + .../blocked-users/blocked-users.jsx | 84 - .../blocked-users/blocked-users.tsx | 103 + .../blocked-users/{index.js => index.ts} | 2 +- web/app/components/button/button.jsx | 74 - web/app/components/button/button.tsx | 96 + .../components/button/{index.js => index.ts} | 5 +- web/app/components/comment/comment.jsx | 757 ----- web/app/components/comment/comment.test.tsx | 216 ++ web/app/components/comment/comment.tsx | 721 +++++ .../components/comment/connected-comment.ts | 75 + web/app/components/comment/index.ts | 1 + .../comment/{index.js => styles.ts} | 6 +- web/app/components/countdown/index.tsx | 59 + .../dropdown/__item/dropdown__item.jsx | 8 - .../dropdown/__item/dropdown__item.tsx | 13 + web/app/components/dropdown/__item/index.js | 4 - web/app/components/dropdown/__item/index.ts | 3 + web/app/components/dropdown/dropdown.jsx | 87 - web/app/components/dropdown/dropdown.tsx | 101 + .../dropdown/{index.js => index.ts} | 3 +- web/app/components/input/index.ts | 1 + .../components/input/{input.jsx => input.tsx} | 136 +- .../components/input/{index.js => styles.ts} | 6 +- .../components/input/textarea-autosize.jsx | 34 - .../components/input/textarea-autosize.tsx | 54 + .../list-comments/{index.js => index.ts} | 2 +- .../list-comments/list-comments.jsx | 18 - .../list-comments/list-comments.tsx | 30 + .../preloader/{index.js => index.ts} | 0 .../preloader.test.jsx => preloader.test.tsx} | 8 +- .../{preloader.jsx => preloader.tsx} | 7 +- .../raw-content/{index.js => index.ts} | 0 .../components/root/{index.js => index.ts} | 2 +- web/app/components/root/root.jsx | 359 --- web/app/components/root/root.tsx | 319 +++ .../thread/collapsedCommentsMiddleware.js | 28 - .../components/thread/getCollapsedComments.js | 6 - web/app/components/thread/index.js | 8 - web/app/components/thread/index.ts | 1 + .../thread/saveCollapsedComments.js | 6 - web/app/components/thread/thread.actions.js | 6 - web/app/components/thread/thread.getters.js | 14 - .../components/thread/thread.getters.test.js | 31 - web/app/components/thread/thread.jsx | 64 - web/app/components/thread/thread.reducers.js | 22 - .../components/thread/thread.reducers.test.js | 18 - web/app/components/thread/thread.tsx | 53 + .../user-info/{index.js => index.ts} | 6 +- .../user-info/last-comments-list.jsx | 21 - .../user-info/last-comments-list.tsx | 31 + .../components/user-info/user-info.actions.js | 12 - .../components/user-info/user-info.getters.js | 2 - .../user-info/user-info.getters.test.js | 40 - web/app/components/user-info/user-info.jsx | 76 - .../user-info/user-info.reducers.js | 37 - .../user-info/user-info.reducers.test.js | 45 - web/app/components/user-info/user-info.tsx | 92 + web/app/components/with-theme/index.js | 1 - web/app/components/with-theme/index.ts | 15 + web/app/components/with-theme/with-theme.jsx | 16 - web/app/counter.js | 40 - web/app/counter.ts | 47 + web/app/deleteme.js | 44 - web/app/deleteme.ts | 52 + web/app/{embed.js => embed.ts} | 86 +- .../{last-comments.js => last-comments.tsx} | 30 +- web/app/remark.js | 79 - web/app/remark.tsx | 69 + web/app/store.js | 14 - web/app/store/actions.ts | 17 + web/app/store/comments/actions.ts | 110 + web/app/store/comments/getters.ts | 12 + web/app/store/comments/reducers.ts | 49 + web/app/store/comments/types.ts | 44 + web/app/store/comments/utils.ts | 145 + web/app/store/index.ts | 51 + web/app/store/post_info/actions.ts | 40 + web/app/store/post_info/reducers.ts | 24 + web/app/store/post_info/types.ts | 17 + web/app/store/reducers.ts | 18 + web/app/store/sort/actions.ts | 28 + web/app/store/sort/reducers.ts | 25 + web/app/store/sort/types.ts | 10 + web/app/store/theme/actions.ts | 10 + web/app/store/theme/reducers.ts | 16 + web/app/store/theme/types.ts | 10 + web/app/store/thread/actions.ts | 25 + web/app/store/thread/getters.ts | 16 + web/app/store/thread/reducers.test.ts | 34 + web/app/store/thread/reducers.ts | 32 + web/app/store/thread/types.ts | 15 + web/app/store/thread/utils.ts | 30 + web/app/store/user-info/actions.ts | 31 + web/app/store/user-info/reducers.ts | 22 + web/app/store/user-info/types.ts | 11 + web/app/store/user/actions.ts | 89 + web/app/store/user/reducers.test.ts | 87 + web/app/store/user/reducers.ts | 60 + web/app/store/user/types.ts | 46 + web/app/testUtils/index.js | 17 - web/app/testUtils/index.ts | 17 + web/app/utils/debounce.js | 8 - web/app/utils/debounce.ts | 12 + web/app/utils/debug-node.ts | 22 + web/app/utils/derequire.ts | 2 + web/app/utils/email.js | 35 - web/app/utils/email.ts | 43 + .../utils/{errorUtils.js => errorUtils.ts} | 31 +- web/app/utils/exclude.ts | 8 + web/app/utils/noop.js | 1 - web/app/utils/noop.ts | 1 + web/app/utils/ttl-to-time.ts | 22 + web/babelOptions.js | 14 - web/counter.ejs | 3 +- web/fileTransformer.js | 7 - web/iframe.html | 2 +- web/injectGlobalVariable.js | 1 - web/jest.config.js | 14 + web/package-lock.json | 2537 ++++++++++------- web/package.json | 82 +- web/tsconfig.json | 27 + web/webpack.config.js | 44 +- 163 files changed, 6232 insertions(+), 4084 deletions(-) create mode 100644 FRONTEND.MAN.md create mode 100644 web/.babelrc create mode 100644 web/app/@types/bem-react-helper/index.d.ts delete mode 100644 web/app/common/accessibility.js create mode 100644 web/app/common/accessibility.ts delete mode 100644 web/app/common/api.js create mode 100644 web/app/common/api.ts create mode 100644 web/app/common/config-types.ts rename web/app/common/{constants.js => constants.ts} (58%) delete mode 100644 web/app/common/cookies.js create mode 100644 web/app/common/cookies.ts rename web/app/common/{copy.js => copy.ts} (65%) delete mode 100644 web/app/common/fetcher.js create mode 100644 web/app/common/fetcher.ts rename web/app/common/{localStorage.js => local-storage.ts} (91%) delete mode 100644 web/app/common/polyfills.js create mode 100644 web/app/common/polyfills.ts delete mode 100644 web/app/common/settings.js create mode 100644 web/app/common/settings.ts create mode 100644 web/app/common/static_store.ts delete mode 100644 web/app/common/store.js delete mode 100644 web/app/common/store.test.js create mode 100644 web/app/common/types.ts create mode 100644 web/app/common/user-info-settings.ts create mode 100644 web/app/components/auth-panel/__readonly-label/auth-panel__readonly-label.scss delete mode 100644 web/app/components/auth-panel/__tests__/auth-panel.test.js delete mode 100644 web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx create mode 100644 web/app/components/auth-panel/__user-id/auth-panel__user-id.tsx delete mode 100644 web/app/components/auth-panel/__user-id/index.js create mode 100644 web/app/components/auth-panel/__user-id/index.ts create mode 100644 web/app/components/auth-panel/auth-panel.test.tsx rename web/app/components/auth-panel/{auth-panel.jsx => auth-panel.tsx} (58%) rename web/app/components/auth-panel/{index.js => index.ts} (81%) delete mode 100644 web/app/components/avatar-icon/avatar-icon.jsx create mode 100644 web/app/components/avatar-icon/avatar-icon.tsx delete mode 100644 web/app/components/avatar-icon/index.js create mode 100644 web/app/components/avatar-icon/index.ts delete mode 100644 web/app/components/blocked-users/blocked-users.jsx create mode 100644 web/app/components/blocked-users/blocked-users.tsx rename web/app/components/blocked-users/{index.js => index.ts} (90%) delete mode 100644 web/app/components/button/button.jsx create mode 100644 web/app/components/button/button.tsx rename web/app/components/button/{index.js => index.ts} (59%) delete mode 100644 web/app/components/comment/comment.jsx create mode 100644 web/app/components/comment/comment.test.tsx create mode 100644 web/app/components/comment/comment.tsx create mode 100644 web/app/components/comment/connected-comment.ts create mode 100644 web/app/components/comment/index.ts rename web/app/components/comment/{index.js => styles.ts} (93%) create mode 100644 web/app/components/countdown/index.tsx delete mode 100644 web/app/components/dropdown/__item/dropdown__item.jsx create mode 100644 web/app/components/dropdown/__item/dropdown__item.tsx delete mode 100644 web/app/components/dropdown/__item/index.js create mode 100644 web/app/components/dropdown/__item/index.ts delete mode 100644 web/app/components/dropdown/dropdown.jsx create mode 100644 web/app/components/dropdown/dropdown.tsx rename web/app/components/dropdown/{index.js => index.ts} (84%) create mode 100644 web/app/components/input/index.ts rename web/app/components/input/{input.jsx => input.tsx} (59%) rename web/app/components/input/{index.js => styles.ts} (85%) delete mode 100644 web/app/components/input/textarea-autosize.jsx create mode 100644 web/app/components/input/textarea-autosize.tsx rename web/app/components/list-comments/{index.js => index.ts} (62%) delete mode 100644 web/app/components/list-comments/list-comments.jsx create mode 100644 web/app/components/list-comments/list-comments.tsx rename web/app/components/preloader/{index.js => index.ts} (100%) rename web/app/components/preloader/{__tests__/preloader.test.jsx => preloader.test.tsx} (66%) rename web/app/components/preloader/{preloader.jsx => preloader.tsx} (65%) rename web/app/components/raw-content/{index.js => index.ts} (100%) rename web/app/components/root/{index.js => index.ts} (91%) delete mode 100644 web/app/components/root/root.jsx create mode 100644 web/app/components/root/root.tsx delete mode 100644 web/app/components/thread/collapsedCommentsMiddleware.js delete mode 100644 web/app/components/thread/getCollapsedComments.js delete mode 100644 web/app/components/thread/index.js create mode 100644 web/app/components/thread/index.ts delete mode 100644 web/app/components/thread/saveCollapsedComments.js delete mode 100644 web/app/components/thread/thread.actions.js delete mode 100644 web/app/components/thread/thread.getters.js delete mode 100644 web/app/components/thread/thread.getters.test.js delete mode 100644 web/app/components/thread/thread.jsx delete mode 100644 web/app/components/thread/thread.reducers.js delete mode 100644 web/app/components/thread/thread.reducers.test.js create mode 100644 web/app/components/thread/thread.tsx rename web/app/components/user-info/{index.js => index.ts} (52%) delete mode 100644 web/app/components/user-info/last-comments-list.jsx create mode 100644 web/app/components/user-info/last-comments-list.tsx delete mode 100644 web/app/components/user-info/user-info.actions.js delete mode 100644 web/app/components/user-info/user-info.getters.js delete mode 100644 web/app/components/user-info/user-info.getters.test.js delete mode 100644 web/app/components/user-info/user-info.jsx delete mode 100644 web/app/components/user-info/user-info.reducers.js delete mode 100644 web/app/components/user-info/user-info.reducers.test.js create mode 100644 web/app/components/user-info/user-info.tsx delete mode 100644 web/app/components/with-theme/index.js create mode 100644 web/app/components/with-theme/index.ts delete mode 100644 web/app/components/with-theme/with-theme.jsx delete mode 100644 web/app/counter.js create mode 100644 web/app/counter.ts delete mode 100644 web/app/deleteme.js create mode 100644 web/app/deleteme.ts rename web/app/{embed.js => embed.ts} (73%) rename web/app/{last-comments.js => last-comments.tsx} (57%) delete mode 100644 web/app/remark.js create mode 100644 web/app/remark.tsx delete mode 100644 web/app/store.js create mode 100644 web/app/store/actions.ts create mode 100644 web/app/store/comments/actions.ts create mode 100644 web/app/store/comments/getters.ts create mode 100644 web/app/store/comments/reducers.ts create mode 100644 web/app/store/comments/types.ts create mode 100644 web/app/store/comments/utils.ts create mode 100644 web/app/store/index.ts create mode 100644 web/app/store/post_info/actions.ts create mode 100644 web/app/store/post_info/reducers.ts create mode 100644 web/app/store/post_info/types.ts create mode 100644 web/app/store/reducers.ts create mode 100644 web/app/store/sort/actions.ts create mode 100644 web/app/store/sort/reducers.ts create mode 100644 web/app/store/sort/types.ts create mode 100644 web/app/store/theme/actions.ts create mode 100644 web/app/store/theme/reducers.ts create mode 100644 web/app/store/theme/types.ts create mode 100644 web/app/store/thread/actions.ts create mode 100644 web/app/store/thread/getters.ts create mode 100644 web/app/store/thread/reducers.test.ts create mode 100644 web/app/store/thread/reducers.ts create mode 100644 web/app/store/thread/types.ts create mode 100644 web/app/store/thread/utils.ts create mode 100644 web/app/store/user-info/actions.ts create mode 100644 web/app/store/user-info/reducers.ts create mode 100644 web/app/store/user-info/types.ts create mode 100644 web/app/store/user/actions.ts create mode 100644 web/app/store/user/reducers.test.ts create mode 100644 web/app/store/user/reducers.ts create mode 100644 web/app/store/user/types.ts delete mode 100644 web/app/testUtils/index.js create mode 100644 web/app/testUtils/index.ts delete mode 100644 web/app/utils/debounce.js create mode 100644 web/app/utils/debounce.ts create mode 100644 web/app/utils/debug-node.ts create mode 100644 web/app/utils/derequire.ts delete mode 100644 web/app/utils/email.js create mode 100644 web/app/utils/email.ts rename web/app/utils/{errorUtils.js => errorUtils.ts} (64%) create mode 100644 web/app/utils/exclude.ts delete mode 100644 web/app/utils/noop.js create mode 100644 web/app/utils/noop.ts create mode 100644 web/app/utils/ttl-to-time.ts delete mode 100644 web/babelOptions.js delete mode 100644 web/fileTransformer.js delete mode 100644 web/injectGlobalVariable.js create mode 100644 web/jest.config.js create mode 100644 web/tsconfig.json diff --git a/Dockerfile b/Dockerfile index 9e8729e3..05e7eff2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,7 @@ RUN \ FROM node:10.11-alpine as build-frontend-deps ARG CI +ENV HUSKY_SKIP_INSTALL=true RUN apk add --no-cache --update git ADD web/package.json /srv/web/package.json diff --git a/FRONTEND.MAN.md b/FRONTEND.MAN.md new file mode 100644 index 00000000..e90e536f --- /dev/null +++ b/FRONTEND.MAN.md @@ -0,0 +1,27 @@ +### Code Style + +* project uses typescript to statically analyze code +* project uses `eslint` to check frontend code. You can manually run `npm run lint` from `./web` directory to check style. +* git hooks (via husky) installed automatically on `npm install` and check and try to fix code style if possible, otherwise commit will be rejected +* if you want IDE integration, you need `eslint` plugin to be installed. + +### CSS Styles + +* although styles have `scss` extension, it is actually pack of post-css plugins, so syntax differs, for example in `calc` function. +* component styles use BEM notation (at least it should): `block__element_modifier`. Also there are `mix` classes: `block_modifier`. +* component base style resides in the component's root directory with name of component converted to kebab-case. For example `ListComments` style is located in `./web/app/components/list-comments/list-comments/scss` +* component's element style resides in its own subdirectory, with name consisting of full elements selector, for example `ListComments` `item` element is placed in `__item` directory under name `./list-comments__item.scss` +* each style should be `require`d in `index.ts` of component's root directory + +### Imports + +* imports for typescript, javascript files should be without extension: `./index`, not `./index.ts` +* if file resides in same directory or in subdirectory import should be relative: `./types/something` +* otherwise it should start from `@app` namespace: `@app/common/store` which mapped to `/web/app/common/store.ts` in webpack, tsconfig and jest + +### Testing + +* project uses `jest` as test harness. +* jest check files that match regex `\.test\.(j|t)s(x?)$`, i.e `comment.test.tsx`, `comment.test.js` +* you should run tests via `npm run test` from `./web` directory before pushing to master to avoid failed build +* example tests can be found in `./web/app/store/user/reducers.test.ts`, `./web/app/components/auth-panel/auth-panel.test.tsx` \ No newline at end of file diff --git a/README.md b/README.md index 3f3f5509..82e3f92d 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ For more details refer to [Yandex OAuth](https://tech.yandex.com/oauth/doc/dg/co Optionally, anonymous access can be turned on. In this case an extra `anonymous` provider will allow logins without any social login with any name satisfying 2 conditions: -- name should be at least 3 characters long +- name should be at least 3 characters long - name has to start from the letter and contains letters, numbers, underscores and spaces only. #### Initial import from Disqus @@ -465,6 +465,10 @@ npx cross-env REMARK_URL=http://127.0.0.1:8080 npm start Developer build running by `webpack-dev-server` supports devtools for [React](https://github.com/facebook/react-devtools) and [Redux](https://github.com/zalmoxisus/redux-devtools-extension). +#### Frontend guide + +Frontend guide can be found here: [./FRONTEND.MAN.md](./FRONTEND.MAN.md) + ## API ### Authorization @@ -501,6 +505,7 @@ type Comment struct { Votes map[string]bool `json:"votes"` // comment votes, read only Controversy float64 `json:"controversy,omitempty"` // comment controversy, read only Timestamp time.Time `json:"time"` // time stamp, read only + Edit *Edit `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in json response Pin bool `json:"pin"` // pinned status, read only Delete bool `json:"delete"` // delete status, read only PostTitle string `json:"title"` // post title @@ -510,6 +515,11 @@ type Locator struct { SiteID string `json:"site"` // site id URL string `json:"url"` // post url } + +type Edit struct { + Timestamp time.Time `json:"time" bson:"time"` + Summary string `json:"summary"` +} ``` * `POST /api/v1/preview` - preview comment in html. Body is `Comment` to render @@ -536,11 +546,11 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i * `PUT /api/v1/comment/{id}?site=site-id&url=post-url` - edit comment, allowed once in `EDIT_TIME` minutes since creation. Body is `EditRequest` json ```go - type EditRequest struct { - Text string `json:"text"` // updated text - Summary string `json:"summary"` // optional, summary of the edit - Delete bool `json:"delete"` // delete flag - }{} + type EditRequest struct { + Text string `json:"text"` // updated text + Summary string `json:"summary"` // optional, summary of the edit + Delete bool `json:"delete"` // delete flag + }{} ``` * `GET /api/v1/last/{max}?site=site-id` - get up to `{max}` last comments @@ -571,13 +581,17 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i * `GET /api/v1/config?site=site-id` - returns configuration (parameters) for given site ```go - type config struct { - Version string `json:"version"` - EditDuration int `json:"edit_duration"` // seconds - Admins []string `json:"admins"` - Auth []string `json:"auth_providers"` - LowScore int `json:"low_score"` - CriticalScore int `json:"critical_score"` + type Config struct { + Version string `json:"version"` + EditDuration int `json:"edit_duration"` + MaxCommentSize int `json:"max_comment_size"` + Admins []string `json:"admins"` + AdminEmail string `json:"admin_email"` + Auth []string `json:"auth_providers"` + LowScore int `json:"low_score"` + CriticalScore int `json:"critical_score"` + PositiveScore bool `json:"positive_score"` + ReadOnlyAge int `json:"readonly_age"` } ``` * `GET /api/v1/info?site=site-idd&url=post-ur` - returns `PostInfo` for site and url diff --git a/compose-dev-frontend.yml b/compose-dev-frontend.yml index f21c504a..bd79d7c1 100644 --- a/compose-dev-frontend.yml +++ b/compose-dev-frontend.yml @@ -6,7 +6,7 @@ version: '2' services: remark42: - build: + build: context: . dockerfile: Dockerfile args: @@ -36,5 +36,7 @@ services: - ADMIN_PASSWD=password - AUTH_DEV=true # activate local oauth "dev" - ADMIN_SHARED_ID=dev_user # set admin flag for default user on local ouath2 + - POSITIVE_SCORE=false # restricts comment's score to be only positive + - EDIT_TIME=5m # edit window volumes: - - ./var:/srv/var + - ./var:/srv/var diff --git a/web/.babelrc b/web/.babelrc new file mode 100644 index 00000000..7fb50e3e --- /dev/null +++ b/web/.babelrc @@ -0,0 +1,21 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "browsers": ["> 1%", "android >= 4.4.4", "ios >= 9", "IE >= 11"] + }, + "useBuiltIns": "usage" + } + ], + [ + "@babel/preset-react", + { + "pragma": "h", + "pragmaFrag": "div" + } + ] + ], + "plugins": ["@babel/plugin-syntax-dynamic-import", ["@babel/plugin-transform-react-jsx", { "pragma": "h" }]] +} diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 2915f941..d12ee394 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -1,7 +1,54 @@ module.exports = { parser: 'babel-eslint', - extends: ['eslint:recommended', 'plugin:jsx-a11y/recommended', 'plugin:prettier/recommended'], + extends: [ + 'eslint:recommended', + 'plugin:jsx-a11y/recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], plugins: ['react', 'jsx-a11y', 'prettier'], + overrides: [ + { + files: ['*.ts', '*.tsx'], + plugins: ['@typescript-eslint'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname, + }, + rules: { + // disabling because typescipt uses it's own lint (see next rule) + 'no-unused-vars': 0, + // allow Rust-like var starting with _underscore + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: /^_/ }], + // disabling because it's bad practice to mark accessibility in react classes + '@typescript-eslint/explicit-member-accessibility': 0, + // doesn't work in real world + '@typescript-eslint/no-non-null-assertion': 0, + // disabling because store actions use WATCH_ME_IM_SPECIAL case + '@typescript-eslint/class-name-casing': 0, + // disabling because server response contains snake case + '@typescript-eslint/camelcase': 0, + // disabling because it's standard behaviour that function is hoisted to top + '@typescript-eslint/no-use-before-define': 0, + // maybe good but I have just tired to type return types everywhere, especially with complex generic return types + '@typescript-eslint/explicit-function-return-type': 0, + }, + }, + { + files: ['*.test.ts', '*.test.tsx'], + rules: { + '@typescript-eslint/no-explicit-any': 0, + '@typescript-eslint/no-object-literal-type-assertion': 0, + }, + }, + { + files: ['*.test.ts', '*.test.tsx', '*.test.js', '*.test.jsx'], + rules: { + 'max-nested-callbacks': ['warn', { max: 10 }], + }, + }, + ], env: { browser: true, node: true, @@ -9,6 +56,8 @@ module.exports = { jest: true, }, parserOptions: { + ecmaVersion: 6, + sourceType: 'module', ecmaFeatures: { modules: true, jsx: true, @@ -16,13 +65,13 @@ module.exports = { }, globals: { remark_config: true, - b: true, }, rules: { + '@typescript-eslint/indent': 0, 'react/jsx-uses-react': 2, 'react/jsx-uses-vars': 2, 'no-cond-assign': 1, - 'no-empty': 0, + 'no-empty': ['error', { allowEmptyCatch: true }], 'no-console': 1, camelcase: 0, 'comma-style': 2, @@ -30,8 +79,8 @@ module.exports = { 'no-eval': 2, 'no-implied-eval': 2, 'no-new-func': 2, - 'guard-for-in': 0, - eqeqeq: 0, + 'guard-for-in': 2, + eqeqeq: 2, 'no-else-return': 2, 'no-redeclare': 2, 'no-dupe-keys': 2, @@ -42,17 +91,19 @@ module.exports = { 'no-delete-var': 2, 'no-undef-init': 2, 'no-shadow-restricted-names': 2, - 'handle-callback-err': 0, - 'no-lonely-if': 0, + 'handle-callback-err': 2, + 'no-lonely-if': 2, 'constructor-super': 2, 'no-this-before-super': 2, 'no-dupe-class-members': 2, 'no-const-assign': 2, 'prefer-spread': 2, + 'prefer-const': 2, 'no-useless-concat': 2, 'no-var': 2, 'object-shorthand': 2, 'prefer-arrow-callback': 2, 'prettier/prettier': 2, + '@typescript-eslint/no-var-requires': 0, }, }; diff --git a/web/app/@types/bem-react-helper/index.d.ts b/web/app/@types/bem-react-helper/index.d.ts new file mode 100644 index 00000000..dd8f1b81 --- /dev/null +++ b/web/app/@types/bem-react-helper/index.d.ts @@ -0,0 +1,14 @@ +declare module 'bem-react-helper' { + export interface Mods { + [key: string]: string | number | boolean | undefined | null; + } + export type Mix = string[] | string; + export default function b( + classname: string, + props?: { + mods?: Mods; + mix?: Mix; + }, + override_props?: Mods + ): string; +} diff --git a/web/app/common/accessibility.js b/web/app/common/accessibility.js deleted file mode 100644 index 35d7f1c7..00000000 --- a/web/app/common/accessibility.js +++ /dev/null @@ -1,13 +0,0 @@ -const handleBtnKeyPress = (event, handler) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - handler && handler(); - } -}; - -export const getHandleClickProps = handler => ({ - role: 'button', - onClick: handler, - onKeyPress: event => handleBtnKeyPress(event, handler), - ...(handler ? { tabIndex: 0 } : {}), -}); diff --git a/web/app/common/accessibility.ts b/web/app/common/accessibility.ts new file mode 100644 index 00000000..4a815df8 --- /dev/null +++ b/web/app/common/accessibility.ts @@ -0,0 +1,13 @@ +const handleBtnKeyPress = (event: KeyboardEvent, handler?: () => void) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handler && handler(); + } +}; + +export const getHandleClickProps = (handler?: () => void) => ({ + role: 'button', + onClick: handler, + onKeyPress: (event: KeyboardEvent) => handleBtnKeyPress(event, handler), + ...(handler ? { tabIndex: 0 } : {}), +}); diff --git a/web/app/common/api.js b/web/app/common/api.js deleted file mode 100644 index 9382d519..00000000 --- a/web/app/common/api.js +++ /dev/null @@ -1,180 +0,0 @@ -import { siteId, url } from './settings'; - -import fetcher from './fetcher'; - -// TODO: rename actions - -/* common */ - -export const logOut = () => fetcher.get({ url: `/auth/logout`, overriddenApiBase: '' }); - -export const getConfig = () => fetcher.get(`/config`); - -// TODO: looks like we can get url from settings here and below -export const getPostComments = ({ sort, url }) => fetcher.get(`/find?url=${url}&sort=${sort}&format=tree`); - -export const getLastComments = ({ siteId, max }) => fetcher.get(`/last/${max}?site=${siteId}`); - -export const getCommentsCount = ({ urls, siteId }) => - fetcher.post({ - url: `/counts?site=${siteId}`, - body: urls, - }); - -export const getComment = ({ id }) => fetcher.get(`/id/${id}?url=${url}`); - -export const getUserComments = ({ user, limit }) => fetcher.get(`/comments?user=${user}&limit=${limit}`); - -export const putCommentVote = ({ id, url, value }) => - fetcher.put({ - url: `/vote/${id}?url=${url}&vote=${value}`, - withCredentials: true, - }); - -export const addComment = ({ title, text, pid }) => - fetcher.post({ - url: '/comment', - body: { - title, - text, - locator: { - site: siteId, - url, - }, - ...(pid ? { pid } : {}), - }, - withCredentials: true, - }); - -export const updateComment = ({ text, id }) => - fetcher.put({ - url: `/comment/${id}?url=${url}`, - body: { - text, - }, - withCredentials: true, - }); - -export const removeMyComment = ({ id }) => - fetcher.put({ - url: `/comment/${id}?url=${url}`, - body: { - delete: true, - }, - withCredentials: true, - }); - -export const getPreview = ({ text }) => - fetcher.post({ - url: '/preview', - body: { - text, - }, - withCredentials: true, - }); - -export const getUser = () => - fetcher.get({ - url: '/user', - withCredentials: true, - }); - -/* GDPR */ - -export const deleteMe = () => - fetcher.post({ - url: `/deleteme?site=${siteId}`, - }); - -export const approveDeleteMe = token => - fetcher.get({ - url: `/admin/deleteme?token=${token}`, - }); - -/* admin */ -export const pinComment = ({ id, url }) => - fetcher.put({ - url: `/admin/pin/${id}?url=${url}&pin=1`, - withCredentials: true, - }); - -export const unpinComment = ({ id, url }) => - fetcher.put({ - url: `/admin/pin/${id}?url=${url}&pin=0`, - withCredentials: true, - }); - -export const setVerifyStatus = ({ id }) => - fetcher.put({ - url: `/admin/verify/${id}?verified=1`, - withCredentials: true, - }); - -export const removeVerifyStatus = ({ id }) => - fetcher.put({ - url: `/admin/verify/${id}?verified=0`, - withCredentials: true, - }); - -export const removeComment = ({ id }) => - fetcher.delete({ - url: `/admin/comment/${id}?url=${url}`, - withCredentials: true, - }); - -export const blockUser = ({ id, ttl }) => - fetcher.put({ - url: ttl === 'permanently' ? `/admin/user/${id}?block=1` : `/admin/user/${id}?block=1&ttl=${ttl}`, - withCredentials: true, - }); - -export const unblockUser = ({ id }) => - fetcher.put({ - url: `/admin/user/${id}?block=0`, - withCredentials: true, - }); - -export const getBlocked = () => - fetcher.get({ - url: '/admin/blocked', - withCredentials: true, - }); - -export const disableComments = () => - fetcher.put({ - url: `/admin/readonly?site=${siteId}&url=${url}&ro=1`, - withCredentials: true, - }); - -export const enableComments = () => - fetcher.put({ - url: `/admin/readonly?site=${siteId}&url=${url}&ro=0`, - withCredentials: true, - }); - -export default { - logOut, - getConfig, - getPostComments, - getLastComments, - getCommentsCount, - getComment, - getUserComments, - putCommentVote, - addComment, - updateComment, - removeMyComment, - getUser, - getPreview, - - pinComment, - unpinComment, - setVerifyStatus, - removeVerifyStatus, - removeComment, - blockUser, - unblockUser, - getBlocked, - disableComments, - enableComments, -}; diff --git a/web/app/common/api.ts b/web/app/common/api.ts new file mode 100644 index 00000000..2dafa831 --- /dev/null +++ b/web/app/common/api.ts @@ -0,0 +1,246 @@ +import { siteId, url } from './settings'; +import { BASE_URL } from './constants'; +import { Config, Comment, Tree, User, BlockedUser, Sorting, Provider, BlockTTL } from './types'; +import fetcher from './fetcher'; + +/* common */ + +export const logIn = (provider: Provider) => { + return new Promise((resolve, reject) => { + const newWindow = window.open( + `${BASE_URL}/auth/${provider}/login?from=${encodeURIComponent( + location.origin + location.pathname + '?selfClose' + )}&site=${siteId}` + ); + + let secondsPass = 0; + const checkMsDelay = 300; + const checkInterval = setInterval(() => { + let shouldProceed; + secondsPass += checkMsDelay; + try { + shouldProceed = (newWindow && newWindow.closed) || secondsPass > 30000; + } catch (e) {} + + if (shouldProceed) { + clearInterval(checkInterval); + + getUser() + .then(user => { + resolve(user); + }) + .catch(() => { + reject(new Error('User logIn Error')); + }); + } + }, checkMsDelay); + }); +}; + +export const logOut = (): Promise => + fetcher.get({ url: `/auth/logout`, overriddenApiBase: '', withCredentials: true }); + +export const getConfig = (): Promise => fetcher.get(`/config`); + +export const getPostComments = (sort: Sorting): Promise => + fetcher.get(`/find?site=${siteId}&url=${url}&sort=${sort}&format=tree`); + +export const getLastComments = (siteId: string, max: number): Promise => + fetcher.get(`/last/${max}?site=${siteId}`); + +export const getCommentsCount = (siteId: string, urls: string[]): Promise<{ url: string; count: number }[]> => + fetcher.post({ + url: `/counts?site=${siteId}`, + body: urls, + }); + +export const getComment = (id: Comment['id']): Promise => fetcher.get(`/id/${id}?url=${url}`); + +export const getUserComments = ( + userId: User['id'], + limit: number +): Promise<{ + comments: Comment[]; + count: number; +}> => fetcher.get(`/comments?user=${userId}&limit=${limit}`); + +export const putCommentVote = ({ id, value }: { id: Comment['id']; value: number }): Promise => + fetcher.put({ + url: `/vote/${id}?url=${url}&vote=${value}`, + withCredentials: true, + }); + +export const addComment = ({ + title, + text, + pid, +}: { + title: string; + text: string; + pid?: Comment['id']; +}): Promise => + fetcher.post({ + url: '/comment', + body: { + title, + text, + locator: { + site: siteId, + url, + }, + ...(pid ? { pid } : {}), + }, + withCredentials: true, + }); + +export const updateComment = ({ text, id }: { text: string; id: Comment['id'] }): Promise => + fetcher.put({ + url: `/comment/${id}?url=${url}`, + body: { + text, + }, + withCredentials: true, + }); + +export const getPreview = (text: string): Promise => + fetcher.post({ + url: '/preview', + body: { + text, + }, + withCredentials: true, + }); + +export const getUser = (): Promise => + fetcher + .get({ + url: '/user', + withCredentials: true, + }) + .catch(() => null); + +/* GDPR */ + +export const deleteMe = (): Promise<{ + user_id: string; + link: string; +}> => + fetcher.post({ + url: `/deleteme?site=${siteId}`, + }); + +export const approveDeleteMe = (token: string): Promise => + fetcher.get({ + url: `/admin/deleteme?token=${token}`, + }); + +/* admin */ +export const pinComment = (id: Comment['id']): Promise => + fetcher.put({ + url: `/admin/pin/${id}?url=${url}&pin=1`, + withCredentials: true, + }); + +export const unpinComment = (id: Comment['id']): Promise => + fetcher.put({ + url: `/admin/pin/${id}?url=${url}&pin=0`, + withCredentials: true, + }); + +export const setVerifiedStatus = (id: User['id']): Promise => + fetcher.put({ + url: `/admin/verify/${id}?verified=1`, + withCredentials: true, + }); + +export const removeVerifiedStatus = (id: User['id']): Promise => + fetcher.put({ + url: `/admin/verify/${id}?verified=0`, + withCredentials: true, + }); + +export const removeComment = (id: Comment['id']) => + fetcher.delete({ + url: `/admin/comment/${id}?url=${url}`, + withCredentials: true, + }); + +export const removeMyComment = (id: Comment['id']): Promise => + fetcher.put({ + url: `/comment/${id}?url=${url}`, + body: { + delete: true, + }, + withCredentials: true, + }); + +export const blockUser = ( + id: User['id'], + ttl: BlockTTL +): Promise<{ + block: boolean; + site_id: string; + user_id: string; +}> => + fetcher.put({ + url: ttl === 'permanently' ? `/admin/user/${id}?block=1` : `/admin/user/${id}?block=1&ttl=${ttl}`, + withCredentials: true, + }); + +export const unblockUser = ( + id: User['id'] +): Promise<{ + block: boolean; + site_id: string; + user_id: string; +}> => + fetcher.put({ + url: `/admin/user/${id}?block=0`, + withCredentials: true, + }); + +export const getBlocked = (): Promise => + fetcher.get({ + url: '/admin/blocked', + withCredentials: true, + }); + +export const disableComments = (): Promise => + fetcher.put({ + url: `/admin/readonly?site=${siteId}&url=${url}&ro=1`, + withCredentials: true, + }); + +export const enableComments = (): Promise => + fetcher.put({ + url: `/admin/readonly?site=${siteId}&url=${url}&ro=0`, + withCredentials: true, + }); + +export default { + logIn, + logOut, + getConfig, + getPostComments, + getLastComments, + getCommentsCount, + getComment, + getUserComments, + putCommentVote, + addComment, + updateComment, + removeMyComment, + getUser, + getPreview, + + pinComment, + unpinComment, + setVerifyStatus: setVerifiedStatus, + removeVerifyStatus: removeVerifiedStatus, + removeComment, + blockUser, + unblockUser, + getBlocked, + disableComments, + enableComments, +}; diff --git a/web/app/common/config-types.ts b/web/app/common/config-types.ts new file mode 100644 index 00000000..df727d3a --- /dev/null +++ b/web/app/common/config-types.ts @@ -0,0 +1,21 @@ +import { UserInfo, Theme } from './types'; + +export interface CounterConfig { + site_id: string; + url?: string; +} + +export type UserInfoConfig = UserInfo; + +export interface CommentsConfig { + site_id: string; + url?: string; + max_shown_comments?: number; + theme?: Theme; + page_title?: string; +} + +export interface LastCommentsConfig { + site_id: string; + max_last_comments: number; +} diff --git a/web/app/common/constants.js b/web/app/common/constants.ts similarity index 58% rename from web/app/common/constants.js rename to web/app/common/constants.ts index afa97c5f..20f0c861 100644 --- a/web/app/common/constants.js +++ b/web/app/common/constants.ts @@ -1,14 +1,19 @@ -export const BASE_URL = process.env.REMARK_URL; +import { Sorting, Provider, BlockingDuration, Theme } from './types'; + +export const BASE_URL: string = process.env.REMARK_URL!; export const API_BASE = '/api/v1'; -export const NODE_ID = process.env.REMARK_NODE; +export const NODE_ID: string = process.env.REMARK_NODE!; export const COUNTER_NODE_CLASSNAME = 'remark42__counter'; export const COMMENT_NODE_CLASSNAME_PREFIX = 'remark42__comment-'; export const LAST_COMMENTS_NODE_CLASSNAME = 'remark42__last-comments'; export const DEFAULT_LAST_COMMENTS_MAX = 15; export const DEFAULT_MAX_COMMENT_SIZE = 1000; export const MAX_SHOWN_ROOT_COMMENTS = 10; -export const DEFAULT_SORT = '-active'; -export const PROVIDER_NAMES = { + +export const DEFAULT_SORT: Sorting = '-active'; + +/* object of supported providers */ +export const PROVIDER_NAMES: { [P in Provider]: string } = { google: 'Google', facebook: 'Facebook', github: 'GitHub', @@ -16,38 +21,42 @@ export const PROVIDER_NAMES = { dev: 'Dev', }; +/** locastorage key for collapsed comments */ export const LS_COLLAPSE_KEY = '__remarkCollapsed'; +/** cookie key under which sort preference resides */ export const COOKIE_SORT_KEY = 'remarkSort'; -export const BLOCKING_DURATIONS = [ +export const BLOCKING_DURATIONS: BlockingDuration[] = [ { label: 'Permanently', value: 'permanently', }, { label: 'For a month', - value: `${30 * 60 * 24}m`, + value: '43200m', }, { label: 'For a week', - value: `${7 * 60 * 24}m`, + value: '10080m', }, { label: 'For a day', - value: `${60 * 24}m`, + value: '1440m', }, ]; -export const THEMES = ['light', 'dark']; +export const THEMES: Theme[] = ['light', 'dark']; + +export const IS_MOBILE = /Android|webOS|iPhone|iPad|iPod|Opera Mini|Windows Phone/i.test(navigator.userAgent); /** * Defines if browser storage features (cookies, localsrotage) * are available or blocked via browser preferences */ -export const IS_STORAGE_AVAILABLE = (() => { +export const IS_STORAGE_AVAILABLE: boolean = (() => { try { - localStorage.setItem('localstorage_availability_test', null); + localStorage.setItem('localstorage_availability_test', ''); localStorage.removeItem('localstorage_availability_test'); } catch (e) { return false; @@ -59,7 +68,7 @@ export const IS_STORAGE_AVAILABLE = (() => { * Defines whether iframe loaded in cross origin environment * Usefull for checking if some privacy restriction may be applied */ -export const IS_THIRD_PARTY = (() => { +export const IS_THIRD_PARTY: boolean = (() => { try { return window.parent.location.host !== window.location.host; } catch (e) { diff --git a/web/app/common/cookies.js b/web/app/common/cookies.js deleted file mode 100644 index 6e8ab4f1..00000000 --- a/web/app/common/cookies.js +++ /dev/null @@ -1,39 +0,0 @@ -// possible options: expires (in seconds), path, domain, secure -export function setCookie(name, value, options = {}) { - let expires = options.expires; - - if (typeof expires === 'number' && expires) { - const d = new Date(); - d.setTime(d.getTime() + expires * 1000); - expires = options.expires = d; - } - - if (expires && expires.toUTCString) { - options.expires = expires.toUTCString(); - } - - value = encodeURIComponent(value); - - let updatedCookie = `${name}=${value}`; - - for (let propName in options) { - updatedCookie += `; ${propName}`; - if (options[propName] !== true) { - updatedCookie += `=${options[propName]}`; - } - } - - document.cookie = updatedCookie; -} - -export function getCookie(name) { - const matches = document.cookie.match( - new RegExp(`(?:^|; )${name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1')}=([^;]*)`) - ); - - return matches ? decodeURIComponent(matches[1]) : undefined; -} - -export function deleteCookie(name) { - setCookie(name, '', { expires: -1 }); -} diff --git a/web/app/common/cookies.ts b/web/app/common/cookies.ts new file mode 100644 index 00000000..6acf336c --- /dev/null +++ b/web/app/common/cookies.ts @@ -0,0 +1,49 @@ +interface CookieOptions { + /** + * Either time in seconds, + * RFC-1123 formatted date string, + * or Date object + */ + expires?: number | string | Date; + path?: string; + domain?: string; + secure?: boolean; +} + +export function setCookie(name: string, value: string, options: CookieOptions = {}) { + if (options.expires) { + if (typeof options.expires === 'number') { + const d = new Date(); + d.setTime(d.getTime() + options.expires * 1000); + options.expires = d; + options.expires = options.expires.toUTCString(); + } else if (options.expires instanceof Date) { + options.expires = options.expires.toUTCString(); + } + } + + value = encodeURIComponent(value); + + let updatedCookie = `${name}=${value}`; + + for (const [key, value] of Object.entries(options)) { + updatedCookie += `; ${key}`; + if (value !== true) { + updatedCookie += `=${value}`; + } + } + + document.cookie = updatedCookie; +} + +export function getCookie(name: string) { + const matches = document.cookie.match( + new RegExp(`(?:^|; )${name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1')}=([^;]*)`) + ); + + return matches ? decodeURIComponent(matches[1]) : undefined; +} + +export function deleteCookie(name: string) { + setCookie(name, '', { expires: -1 }); +} diff --git a/web/app/common/copy.js b/web/app/common/copy.ts similarity index 65% rename from web/app/common/copy.js rename to web/app/common/copy.ts index 3a352488..48925a69 100644 --- a/web/app/common/copy.js +++ b/web/app/common/copy.ts @@ -1,10 +1,13 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + // based on https://github.com/sindresorhus/copy-text-to-clipboard, but improved to copy text styles too -module.exports = input => { +export default (input: string): boolean => { const el = document.createElement('div'); el.innerHTML = input; - el.style.contain = 'strict'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (el.style as any).contain = 'strict'; el.style.position = 'absolute'; el.style.left = '-9999px'; el.style.fontSize = '12pt'; // Prevent zooming on iOS @@ -12,15 +15,17 @@ module.exports = input => { document.body.appendChild(el); const currentSelection = document.getSelection(); - let originalRange = false; + if (!currentSelection) return true; + + let originalRange = null; if (currentSelection.rangeCount > 0) { originalRange = currentSelection.getRangeAt(0); } let range, selection; - if (document.body.createTextRange) { - range = document.body.createTextRange(); + if ((document.body as any).createTextRange) { + range = (document.body as any).createTextRange(); range.moveToElement(el); range.select(); } else if (window.getSelection) { @@ -40,15 +45,15 @@ module.exports = input => { success = document.execCommand('copy'); } catch (err) {} - if (!document.body.createTextRange && window.getSelection) { + if (!(document.body as any).createTextRange && window.getSelection) { window.getSelection().removeAllRanges(); } document.body.removeChild(el); if (originalRange) { - selection.removeAllRanges(); - selection.addRange(originalRange); + (selection as any).removeAllRanges(); + (selection as any).addRange(originalRange); } return success; diff --git a/web/app/common/fetcher.js b/web/app/common/fetcher.js deleted file mode 100644 index a19f8430..00000000 --- a/web/app/common/fetcher.js +++ /dev/null @@ -1,52 +0,0 @@ -import axios from 'axios'; - -import { BASE_URL, API_BASE } from './constants'; -import { siteId } from './settings'; -import store from './store'; - -const fetcher = {}; -const methods = ['get', 'post', 'put', 'patch', 'delete', 'head']; - -methods.forEach(method => { - fetcher[method] = data => { - const { url, body = {}, withCredentials = false, overriddenApiBase = API_BASE } = - typeof data === 'string' ? { url: data } : data; - const basename = `${BASE_URL}${overriddenApiBase}`; - - return new Promise((resolve, reject) => { - const headers = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - - const parameters = { - method, - headers, - withCredentials, - }; - - if (Object.keys(body).length) { - parameters.data = body; - } - - parameters.url = `${basename}${url}`; - - if (siteId && method !== 'post' && !parameters.url.includes('?site=') && !parameters.url.includes('&site=')) { - parameters.url += (parameters.url.includes('?') ? '&' : '?') + `site=${siteId}`; - } - - axios(parameters) - .then(res => { - const date = ('date' in res.headers && res.headers.date) || ''; - const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date); - const timeDiff = (new Date() - timestamp) / 1000; - store.set('serverClientTimeDiff', timeDiff); - - resolve(res.data); - }) - .catch(error => reject(error)); - }); - }; -}); - -export default fetcher; diff --git a/web/app/common/fetcher.ts b/web/app/common/fetcher.ts new file mode 100644 index 00000000..25ce3fba --- /dev/null +++ b/web/app/common/fetcher.ts @@ -0,0 +1,81 @@ +import { BASE_URL, API_BASE } from './constants'; +import { siteId } from './settings'; +import { StaticStore } from './static_store'; +import { getCookie } from './cookies'; + +export type FetcherMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'; +const methods: FetcherMethod[] = ['get', 'post', 'put', 'patch', 'delete', 'head']; + +type FetcherInit = + | string + | { + url: string; + body?: string | object | Blob | ArrayBuffer; + overriddenApiBase?: string; + withCredentials?: boolean; + }; + +type FetcherObject = { [K in FetcherMethod]: (data: FetcherInit) => Promise }; + +const fetcher = methods.reduce>((acc, method) => { + acc[method] = (data: FetcherInit): Promise => { + const { url, body = undefined, withCredentials = false, overriddenApiBase = API_BASE } = + typeof data === 'string' ? { url: data } : data; + const basename = `${BASE_URL}${overriddenApiBase}`; + + const headers = new Headers({ + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': getCookie('XSRF-TOKEN') || '', + }); + + let rurl = `${basename}${url}`; + + const parameters: RequestInit = { + method, + headers, + mode: 'cors', + credentials: withCredentials ? 'include' : 'omit', + }; + + if (body) { + if (typeof body === 'object') { + parameters.body = JSON.stringify(body); + } else { + parameters.body = body; + } + } + + if (siteId && method !== 'post' && !rurl.includes('?site=') && !rurl.includes('&site=')) { + rurl += (rurl.includes('?') ? '&' : '?') + `site=${siteId}`; + } + + return fetch(rurl, parameters).then(res => { + const date = (res.headers.has('date') && res.headers.get('date')) || ''; + const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date); + const timeDiff = (new Date().getTime() - timestamp) / 1000; + StaticStore.serverClientTimeDiff = timeDiff; + + if (res.status >= 400) { + return res.text().then(text => { + let err; + try { + err = JSON.parse(text); + } catch (e) { + throw text; + } + throw err; + }); + } + + if (res.headers.has('Content-Type') && res.headers.get('Content-Type')!.indexOf('application/json') === 0) { + return res.json(); + } + + return res.text(); + }); + }; + return acc; +}, {}) as FetcherObject; + +export default fetcher; diff --git a/web/app/common/localStorage.js b/web/app/common/local-storage.ts similarity index 91% rename from web/app/common/localStorage.js rename to web/app/common/local-storage.ts index 00187297..571ed9d7 100644 --- a/web/app/common/localStorage.js +++ b/web/app/common/local-storage.ts @@ -1,4 +1,4 @@ -import { IS_STORAGE_AVAILABLE } from 'common/constants'; +import { IS_STORAGE_AVAILABLE } from './constants'; const failMessage = 'remark42: localStorage access denied, check browser preferences'; diff --git a/web/app/common/polyfills.js b/web/app/common/polyfills.js deleted file mode 100644 index aad6a808..00000000 --- a/web/app/common/polyfills.js +++ /dev/null @@ -1,19 +0,0 @@ -import 'core-js/es7/promise'; -import 'focus-visible'; - -export default function loadPolyfills() { - const fillCoreJs = () => { - if ( - 'startsWith' in String.prototype && - 'endsWith' in String.prototype && - 'includes' in Array.prototype && - 'assign' in Object && - 'keys' in Object - ) - return Promise.resolve(); - - return import(/* webpackChunkName: "polyfills" */ 'core-js'); - }; - - return fillCoreJs(); -} diff --git a/web/app/common/polyfills.ts b/web/app/common/polyfills.ts new file mode 100644 index 00000000..8bb6b96b --- /dev/null +++ b/web/app/common/polyfills.ts @@ -0,0 +1,27 @@ +import 'core-js/es7/promise'; +import 'focus-visible'; + +export default async function loadPolyfills() { + const fillCoreJs = async () => { + if ( + 'startsWith' in String.prototype && + 'endsWith' in String.prototype && + 'includes' in Array.prototype && + 'assign' in Object && + 'keys' in Object + ) + return; + + await import(/* webpackChunkName: "polyfills" */ 'core-js').then(); + return; + }; + + const fillFetch = async () => { + if ('fetch' in window) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await import(/* webpackChunkName: "polyfills" */ 'whatwg-fetch' as any).then(); + }; + + await Promise.all([fillCoreJs(), fillFetch()]); + return; +} diff --git a/web/app/common/settings.js b/web/app/common/settings.js deleted file mode 100644 index cd9d9b97..00000000 --- a/web/app/common/settings.js +++ /dev/null @@ -1,18 +0,0 @@ -import { THEMES } from './constants'; - -const querySettings = - window.location.search - .substr(1) - .split('&') - .reduce((acc, param) => { - const pair = param.split('='); - acc[pair[0]] = decodeURIComponent(pair[1]); - return acc; - }, {}) || {}; - -export const siteId = querySettings['site_id']; -export const pageTitle = querySettings['page_title']; -export const url = querySettings['url']; -export const maxShownComments = querySettings['max_shown_comments']; -export const token = querySettings['token']; -export const theme = querySettings['theme'] || THEMES[0]; diff --git a/web/app/common/settings.ts b/web/app/common/settings.ts new file mode 100644 index 00000000..0816faf1 --- /dev/null +++ b/web/app/common/settings.ts @@ -0,0 +1,40 @@ +import { Theme } from './types'; +import { THEMES, MAX_SHOWN_ROOT_COMMENTS } from './constants'; + +export interface QuerySettingsType { + site_id?: string; + page_title?: string; + url?: string; + max_shown_comments?: number; + theme: Theme; + /* used in delete users data page */ + token?: string; +} + +export const querySettings: Partial = + window.location.search + .substr(1) + .split('&') + .reduce<{ [key: string]: string }>((acc, param) => { + const pair = param.split('='); + acc[pair[0]] = decodeURIComponent(pair[1]); + return acc; + }, {}) || {}; + +if (querySettings.max_shown_comments) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + querySettings.max_shown_comments = parseInt((querySettings.max_shown_comments as any) as string, 10); +} else { + querySettings.max_shown_comments = MAX_SHOWN_ROOT_COMMENTS; +} + +if (!querySettings.theme || THEMES.indexOf(querySettings.theme) === -1) { + querySettings.theme = THEMES[0]; +} + +export const siteId = querySettings.site_id; +export const pageTitle = querySettings.page_title; +export const url = querySettings.url; +export const maxShownComments = querySettings.max_shown_comments; +export const token = querySettings.token; +export const theme = querySettings.theme; diff --git a/web/app/common/static_store.ts b/web/app/common/static_store.ts new file mode 100644 index 00000000..ad317971 --- /dev/null +++ b/web/app/common/static_store.ts @@ -0,0 +1,30 @@ +import { Config } from './types'; +import { QuerySettingsType, querySettings } from './settings'; + +interface StaticStoreType { + config: Config; + query: QuerySettingsType; + /** used in fetcher, fer example to set comment edit temiout */ + serverClientTimeDiff?: number; +} + +/** + * Represent store of values that and will not change, or doesn't need reactivity + * + * Initialized once at webpack's entry points (i.e remark.tsx) + */ +export const StaticStore: StaticStoreType = { + config: { + version: '', + edit_duration: 5000, + max_comment_size: 5000, + admins: [], + admin_email: '', + auth_providers: [], + critical_score: 0, + low_score: 0, + positive_score: false, + readonly_age: 0, + }, + query: querySettings as QuerySettingsType, +}; diff --git a/web/app/common/store.js b/web/app/common/store.js deleted file mode 100644 index bb33e90c..00000000 --- a/web/app/common/store.js +++ /dev/null @@ -1,123 +0,0 @@ -let _instance; - -class Store { - constructor() { - if (_instance) return _instance; - - this.data = {}; - - this.listeners = {}; - - _instance = this; - - return _instance; - } - - onUpdate(key, cb) { - if (!this.listeners[key]) this.listeners[key] = []; - - this.listeners[key].push(cb); - } - - set(key, obj) { - this.data[key] = obj; - - if (this.listeners[key]) this.listeners[key].forEach(cb => cb(this.data[key])); - } - - get(key) { - return this.data[key]; - } - - addComment(comment) { - const newComment = { comment }; - - if (comment.pid) { - this.pasteReply(newComment); - } else { - this.pasteComment(newComment); - } - } - - pasteReply(newReply) { - let again = true; - - const concatReply = (root, reply) => { - root.replies = root.replies || []; - root.replies = [reply].concat(root.replies); - - again = false; - - return root; - }; - - const paste = (root, commentObj) => { - if (!again) return root; - - if (root.comment.id === commentObj.comment.pid) { - return concatReply(root, commentObj); - } - - if (root.replies) { - root.replies = root.replies.map(reply => { - if (reply.comment.id === commentObj.comment.pid) { - return concatReply(reply, commentObj); - } - return paste(reply, commentObj); - }); - } - - return root; - }; - - this.set('comments', this.data.comments.map(thread => paste(thread, newReply))); - } - - pasteComment(newComment) { - this.set('comments', [newComment].concat(this.data.comments)); - } - - replaceComment(newComment) { - let again = true; - - const replace = (thread, comment) => { - if (!again) return thread; - - if (thread.comment.id === comment.id) { - thread.comment = comment; - again = false; - return thread; - } - - if (thread.replies) { - thread.replies = thread.replies.map(reply => replace(reply, comment)); - } - - return thread; - }; - - this.set('comments', this.data.comments.map(thread => replace(thread, newComment))); - } - - getPinnedComments() { - const comments = this.data.comments || []; - - return comments.reduce((acc, thread) => acc.concat(findPinnedComments(thread)), []); - } -} - -function findPinnedComments(thread) { - let result = []; - - if (thread.comment.pin) { - result = result.concat(thread.comment); - } - - if (thread.replies) { - result = result.concat(thread.replies.reduce((acc, thread) => acc.concat(findPinnedComments(thread)), [])); - } - - return result; -} - -export default new Store(); diff --git a/web/app/common/store.test.js b/web/app/common/store.test.js deleted file mode 100644 index 65fd66c6..00000000 --- a/web/app/common/store.test.js +++ /dev/null @@ -1,13 +0,0 @@ -import store from './store'; - -describe('Store', () => { - it('should trigger listener on add comment', () => { - const listener = jest.fn(); - - store.set('comments', []); - store.onUpdate('comments', listener); - store.addComment({ id: `new` }); - - expect(listener).toBeCalledWith([{ comment: { id: 'new' } }]); - }); -}); diff --git a/web/app/common/types.ts b/web/app/common/types.ts new file mode 100644 index 00000000..49e01060 --- /dev/null +++ b/web/app/common/types.ts @@ -0,0 +1,130 @@ +export interface User { + name: string; + id: string; + picture: string; + ip: string; + admin: boolean; + block: boolean; + verified: boolean; +} + +/** data which is used on user-info page */ +export interface UserInfo { + id: User['id']; + name: string | ''; + isDefaultPicture: boolean; + picture: string; +} + +export interface BlockedUser { + id: string; + name: string; + time: string; +} + +export interface Locator { + /** site id */ + site: string; + /** post url */ + url: string; +} + +export interface Comment { + /** comment ID, read only */ + id: string; + /** parent ID */ + pid: string; + /** comment text, after md processing */ + text: string; + /** original comment text */ + orig?: string; + /** user info, read only */ + user: User; + /** post locator */ + locator: Locator; + /** comment score, read only */ + score: number; + /** comment votes, read only */ + votes: { [key: string]: boolean }; + /** comment controversy, read only */ + controversy?: number; + /** pointer to have empty default in json response */ + edit?: { + time: string; + summary: string; + }; + /** time stamp, read only */ + time: string; + /** pinned status, read only */ + pin?: boolean; + /** delete status, read only */ + delete?: boolean; + /** post title */ + title?: string; +} + +export interface CommentsResponse { + comments: Comment[]; + count: number; +} + +export interface Node { + comment: Comment; + replies?: Node[]; +} + +export interface PostInfo { + url: string; + count: number; + read_only?: boolean; + first_time?: string; + last_time?: string; +} + +export interface Tree { + comments: Node[]; + info: PostInfo; +} + +export interface Config { + version: string; + edit_duration: number; + max_comment_size: number; + admins: string[]; + admin_email: string; + auth_providers: Provider[]; + low_score: number; + critical_score: number; + positive_score: boolean; + readonly_age: number; +} + +export interface RemarkConfig { + site_id: string; + url: string; + /** used in last comments widget */ + max_last_comments?: number; +} + +export type Sorting = '-time' | '+time' | '-active' | '+active' | '-score' | '+score' | '-controversy' | '+controversy'; + +export type Provider = 'google' | 'facebook' | 'github' | 'yandex' | 'dev'; + +export type BlockTTL = 'permanently' | '43200m' | '10080m' | '1440m'; + +export interface BlockingDuration { + label: string; + value: BlockTTL; +} + +export type Theme = 'light' | 'dark'; + +/** + * Comment component's edit mode: + * whether it should have reply or edit Input shown + */ +export enum CommentMode { + None, + Reply, + Edit, +} diff --git a/web/app/common/user-info-settings.ts b/web/app/common/user-info-settings.ts new file mode 100644 index 00000000..fd33dfd4 --- /dev/null +++ b/web/app/common/user-info-settings.ts @@ -0,0 +1,23 @@ +import { UserInfo } from './types'; + +export const userInfo: Partial = + window.location.search + .substr(1) + .split('&') + .reduce<{ [key: string]: string }>((acc, param) => { + const pair = param.split('='); + acc[pair[0]] = decodeURIComponent(pair[1]); + return acc; + }, {}) || {}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +if (((userInfo.isDefaultPicture as any) as string) !== '1') { + userInfo.isDefaultPicture = false; +} else { + userInfo.isDefaultPicture = true; +} + +export const id = userInfo.id; +export const name = userInfo.name; +export const isDefaultPicture = userInfo.isDefaultPicture; +export const picture = userInfo.picture; diff --git a/web/app/components/auth-panel/__readonly-label/auth-panel__readonly-label.scss b/web/app/components/auth-panel/__readonly-label/auth-panel__readonly-label.scss new file mode 100644 index 00000000..f29ea688 --- /dev/null +++ b/web/app/components/auth-panel/__readonly-label/auth-panel__readonly-label.scss @@ -0,0 +1,8 @@ +.auth-panel__readonly-label { + margin-right: 0.3rem; +} + +.auth-panel__readonly-label::after { + content: '•'; + margin-left: 0.3rem; +} diff --git a/web/app/components/auth-panel/__tests__/auth-panel.test.js b/web/app/components/auth-panel/__tests__/auth-panel.test.js deleted file mode 100644 index 8e55f00a..00000000 --- a/web/app/components/auth-panel/__tests__/auth-panel.test.js +++ /dev/null @@ -1,73 +0,0 @@ -/** @jsx h */ -import { h, render } from 'preact'; -import AuthPanel from '../auth-panel'; -import { createDomContainer } from 'testUtils'; - -describe('', () => { - describe('For not authorized user', () => { - let container; - - createDomContainer(({ domContainer }) => { - container = domContainer; - }); - - it('should render login form with google and github provider', () => { - const element = ; - - render(element, container); - - const authPanelColumn = container.querySelectorAll('.auth-panel__column'); - - expect(authPanelColumn.length).toEqual(2); - - const authForm = authPanelColumn[0]; - - expect(authForm.textContent).toEqual(expect.stringContaining('Sign in to comment using')); - - const providerLinks = authForm.querySelectorAll('.auth-panel__pseudo-link'); - - expect(providerLinks[0].textContent).toEqual('Google'); - expect(providerLinks[1].textContent).toEqual('GitHub'); - }); - }); - describe('For authorized user', () => { - let container; - - createDomContainer(({ domContainer }) => { - container = domContainer; - }); - - it('should render info about current user', () => { - const element = ; - - render(element, container); - - const authPanelColumn = container.querySelectorAll('.auth-panel__column'); - - expect(authPanelColumn.length).toEqual(2); - - const userInfo = authPanelColumn[0]; - - expect(userInfo.textContent).toEqual(expect.stringContaining('You signed in as John')); - }); - }); - describe('For admin user', () => { - let container; - - createDomContainer(({ domContainer }) => { - container = domContainer; - }); - - it('should render admin action', () => { - const element = ( - - ); - - render(element, container); - - const adminAction = container.querySelector('.auth-panel__admin-action'); - - expect(adminAction.textContent).toEqual('Show blocked users'); - }); - }); -}); diff --git a/web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx b/web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx deleted file mode 100644 index d4e7a66c..00000000 --- a/web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx +++ /dev/null @@ -1,8 +0,0 @@ -/** @jsx h */ -import { h } from 'preact'; - -export default props => ( -
- {props.id} -
-); diff --git a/web/app/components/auth-panel/__user-id/auth-panel__user-id.tsx b/web/app/components/auth-panel/__user-id/auth-panel__user-id.tsx new file mode 100644 index 00000000..09f01e4c --- /dev/null +++ b/web/app/components/auth-panel/__user-id/auth-panel__user-id.tsx @@ -0,0 +1,15 @@ +/** @jsx h */ +import { h } from 'preact'; +import b from 'bem-react-helper'; +import { Theme } from '@app/common/types'; + +interface Props { + id: string; + theme: Theme; +} + +export const UserID = (props: Props) => ( +
+ {props.id} +
+); diff --git a/web/app/components/auth-panel/__user-id/index.js b/web/app/components/auth-panel/__user-id/index.js deleted file mode 100644 index a6c94313..00000000 --- a/web/app/components/auth-panel/__user-id/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import withTheme from 'components/with-theme'; -import UserId from './auth-panel__user-id'; - -export default withTheme(UserId); - -require('./auth-panel__user-id.scss'); diff --git a/web/app/components/auth-panel/__user-id/index.ts b/web/app/components/auth-panel/__user-id/index.ts new file mode 100644 index 00000000..e11d552b --- /dev/null +++ b/web/app/components/auth-panel/__user-id/index.ts @@ -0,0 +1,3 @@ +export { UserID } from './auth-panel__user-id'; + +require('./auth-panel__user-id.scss'); diff --git a/web/app/components/auth-panel/auth-panel.test.tsx b/web/app/components/auth-panel/auth-panel.test.tsx new file mode 100644 index 00000000..68ef25a2 --- /dev/null +++ b/web/app/components/auth-panel/auth-panel.test.tsx @@ -0,0 +1,107 @@ +/** @jsx h */ +import { h, render } from 'preact'; +import { Props, AuthPanel } from './auth-panel'; +import { createDomContainer } from '../../testUtils'; +import { User, PostInfo } from '../../common/types'; + +const DefaultProps: Partial = { + sort: '-score', + providers: [`google`, `github`], + postInfo: { + read_only: false, + url: 'https://example.com', + count: 3, + }, +}; + +describe('', () => { + describe('For not authorized user', () => { + let container: HTMLElement; + + createDomContainer(domContainer => { + container = domContainer; + }); + + it('should render login form with google and github provider', () => { + const element = ; + + render(element, container); + + const authPanelColumn = container.querySelectorAll('.auth-panel__column'); + + expect(authPanelColumn.length).toEqual(2); + + const authForm = authPanelColumn[0]; + + expect(authForm.textContent).toEqual(expect.stringContaining('Sign in to comment using')); + + const providerLinks = authForm.querySelectorAll('.auth-panel__pseudo-link'); + + expect(providerLinks[0].textContent).toEqual('Google'); + expect(providerLinks[1].textContent).toEqual('GitHub'); + }); + + it('should render login form with google and github provider for read-only post', () => { + const element = ( + + ); + + render(element, container); + + const authPanelColumn = container.querySelectorAll('.auth-panel__column'); + + expect(authPanelColumn.length).toEqual(2); + + const authForm = authPanelColumn[0]; + + expect(authForm.textContent).toEqual(expect.stringContaining('Sign in using Google or GitHub')); + + const providerLinks = authForm.querySelectorAll('.auth-panel__pseudo-link'); + + expect(providerLinks[0].textContent).toEqual('Google'); + expect(providerLinks[1].textContent).toEqual('GitHub'); + }); + }); + describe('For authorized user', () => { + let container: HTMLElement; + + createDomContainer(domContainer => { + container = domContainer; + }); + + it('should render info about current user', () => { + const element = ; + + render(element, container); + + const authPanelColumn = container.querySelectorAll('.auth-panel__column'); + + expect(authPanelColumn.length).toEqual(2); + + const userInfo = authPanelColumn[0]; + + expect(userInfo.textContent).toEqual(expect.stringContaining('You signed in as John')); + }); + }); + describe('For admin user', () => { + let container: HTMLElement; + + createDomContainer(domContainer => { + container = domContainer; + }); + + it('should render admin action', () => { + const element = ; + + render(element, container); + + const adminAction = container.querySelector('.auth-panel__admin-action')!; + + expect(adminAction.textContent).toEqual('Show blocked users'); + }); + }); +}); diff --git a/web/app/components/auth-panel/auth-panel.jsx b/web/app/components/auth-panel/auth-panel.tsx similarity index 58% rename from web/app/components/auth-panel/auth-panel.jsx rename to web/app/components/auth-panel/auth-panel.tsx index c9b0b207..3349afaf 100644 --- a/web/app/components/auth-panel/auth-panel.jsx +++ b/web/app/components/auth-panel/auth-panel.tsx @@ -1,26 +1,53 @@ /** @jsx h */ -import { h, Component } from 'preact'; +import { h, Component, RenderableProps } from 'preact'; +import b from 'bem-react-helper'; -import Dropdown, { DropdownItem } from 'components/dropdown'; -import Button from 'components/button'; -import { PROVIDER_NAMES, IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from 'common/constants'; -import { requestDeletion } from 'utils/email'; -import { getHandleClickProps } from 'common/accessibility'; +import { PROVIDER_NAMES, IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from '@app/common/constants'; +import { requestDeletion } from '@app/utils/email'; +import { getHandleClickProps } from '@app/common/accessibility'; +import { User, Provider, Sorting, Theme, PostInfo } from '@app/common/types'; -import UserId from './__user-id'; +import Dropdown, { DropdownItem } from '@app/components/dropdown'; +import { Button } from '@app/components/button'; +import { UserID } from './__user-id'; -export default class AuthPanel extends Component { - constructor(props) { +export interface Props { + user: User | null; + providers: Provider[]; + sort: Sorting; + isCommentsDisabled: boolean; + theme: Theme; + postInfo: PostInfo; + + onSortChange(s: Sorting): Promise; + onSignIn(p: Provider): Promise; + onSignOut(): Promise; + onCommentsEnable(): Promise; + onCommentsDisable(): Promise; + onBlockedUsersShow(): void; + onBlockedUsersHide(): void; +} + +interface State { + isBlockedVisible: boolean; +} + +export class AuthPanel extends Component { + constructor(props: Props) { super(props); + this.state = { + isBlockedVisible: false, + }; + this.toggleBlockedVisibility = this.toggleBlockedVisibility.bind(this); this.toggleCommentsAvailability = this.toggleCommentsAvailability.bind(this); this.onSortChange = this.onSortChange.bind(this); } - onSortChange(e) { + onSortChange(e: Event) { if (this.props.onSortChange) { - this.props.onSortChange(e.target.value); + this.props.onSortChange((e.target! as HTMLOptionElement).value as Sorting); } } @@ -34,44 +61,49 @@ export default class AuthPanel extends Component { toggleCommentsAvailability() { if (this.props.isCommentsDisabled) { - if (this.props.onCommentsEnable) { - this.props.onCommentsEnable(); - } + this.props.onCommentsEnable && this.props.onCommentsEnable(); } else { - if (this.props.onCommentsDisable) { - this.props.onCommentsDisable(); - } + this.props.onCommentsDisable && this.props.onCommentsDisable(); } } getUserTitle() { const { user } = this.props; - return {user.name}; + return {user!.name}; } - render(props, { isBlockedVisible }) { + render(props: RenderableProps, { isBlockedVisible }: State) { const { user, providers = [], sort, isCommentsDisabled } = props; - const sortArray = getSortArray(sort); + const loggedIn = !!user; + const signInMessage = props.postInfo.read_only ? 'Sign in using ' : 'Sign in to comment using '; - let loggedIn = !!user.id; return ( -
- {loggedIn && ( +
+ {user && (
You signed in as{' '} - - - + + + - {' '} -
@@ -79,7 +111,7 @@ export default class AuthPanel extends Component { {IS_STORAGE_AVAILABLE && !loggedIn && (
- Sign in to comment using{' '} + {signInMessage} {providers.map((provider, i) => { const comma = i === 0 ? '' : i === providers.length - 1 ? ' or ' : ', '; @@ -96,7 +128,6 @@ export default class AuthPanel extends Component { ); })} - {'.'}
)} @@ -118,34 +149,38 @@ export default class AuthPanel extends Component { )}
- {user.admin && ( + {user && user.admin && ( this.toggleBlockedVisibility())} role="link" > {isBlockedVisible ? 'Hide' : 'Show'} blocked users )} - {user.admin && ' • '} + {user && user.admin && ' • '} - {user.admin && ( + {user && user.admin && ( this.toggleCommentsAvailability())} role="link" > {isCommentsDisabled ? 'Enable' : 'Disable'} comments )} - {user.admin && ' • '} + {user && user.admin && ' • '} + + {!(user && user.admin) && props.postInfo.read_only && ( + Read-only + )} Sort by{' '} - {sortArray.find(x => x.selected).label} + {sortArray.find(x => 'selected' in x && x.selected!)!.label} - - {BLOCKING_DURATIONS.map(block => ( - - ))} - - - )} - - {!deleted && ( - - Delete - - )} - - )} -
-
- - {isReplying && mods.view !== 'user' && ( - - )} - - {isEditing && mods.view !== 'user' && ( - - )} - - ); - } -} - -function getTextSnippet(html) { - const LENGTH = 100; - const tmp = document.createElement('div'); - tmp.innerHTML = html.replace('

', ' '); - - const result = tmp.innerText || ''; - const snippet = result.substr(0, LENGTH); - - return snippet.length === LENGTH && result.length !== LENGTH ? `${snippet}...` : snippet; -} - -function formatTime(time) { - // 'ru-RU' adds a dot as a separator - const date = time.toLocaleDateString(['ru-RU'], { day: '2-digit', month: '2-digit', year: '2-digit' }); - - // do it manually because Intl API doesn't add leading zeros to hours; idk why - const hours = `0${time.getHours()}`.slice(-2); - const mins = `0${time.getMinutes()}`.slice(-2); - - return `${date} at ${hours}:${mins}`; -} diff --git a/web/app/components/comment/comment.test.tsx b/web/app/components/comment/comment.test.tsx new file mode 100644 index 00000000..254bb4ef --- /dev/null +++ b/web/app/components/comment/comment.test.tsx @@ -0,0 +1,216 @@ +/** @jsx h */ +import { h, render } from 'preact'; +import { Props, Comment } from './comment'; +import { createDomContainer } from '../../testUtils'; +import { User, Comment as CommentType, PostInfo } from '@app/common/types'; +import { delay } from '@app/store/comments/utils'; + +const DefaultProps: Partial = { + post_info: { + read_only: false, + } as PostInfo, + view: 'main', + data: { + text: 'test comment', + votes: {}, + user: { + id: 'someone', + picture: 'somepicture-url', + }, + locator: { + url: 'somelocatorurl', + site: 'remark', + }, + } as CommentType, + user: { + admin: false, + id: 'testuser', + } as User, +}; + +describe('', () => { + describe('voting', () => { + let container: HTMLElement; + + createDomContainer(domContainer => { + container = domContainer; + }); + + it('disabled on user info widget', () => { + const element = ; + render(element, container); + + const voteButtons = container.querySelectorAll('.comment__vote'); + expect(voteButtons.length).toStrictEqual(2); + + for (const b of voteButtons as any) { + expect(b.getAttribute('aria-disabled')).toStrictEqual('true'); + expect(b.getAttribute('title')).toStrictEqual("Voting allowed only on post's page"); + } + }); + + it('disabled on read only post', () => { + const element = ( + + ); + render(element, container); + + const voteButtons = container.querySelectorAll('.comment__vote'); + expect(voteButtons.length).toStrictEqual(2); + + for (const b of voteButtons as any) { + expect(b.getAttribute('aria-disabled')).toStrictEqual('true'); + expect(b.getAttribute('title')).toStrictEqual("Can't vote on read-only topics"); + } + }); + + it('disabled for deleted comment', () => { + const element = ( + // ahem + + ); + render(element, container); + + const voteButtons = container.querySelectorAll('.comment__vote'); + expect(voteButtons.length).toStrictEqual(2); + + for (const b of voteButtons as any) { + expect(b.getAttribute('aria-disabled')).toStrictEqual('true'); + expect(b.getAttribute('title')).toStrictEqual("Can't vote for deleted comment"); + } + }); + + it('disabled for guest', () => { + const element = ( + + ); + render(element, container); + + const voteButtons = container.querySelectorAll('.comment__vote'); + expect(voteButtons.length).toStrictEqual(2); + + for (const b of voteButtons as any) { + expect(b.getAttribute('aria-disabled')).toStrictEqual('true'); + expect(b.getAttribute('title')).toStrictEqual("Can't vote for your own comment"); + } + }); + + it('disabled for own comment', () => { + const element = ; + render(element, container); + + const voteButtons = container.querySelectorAll('.comment__vote'); + expect(voteButtons.length).toStrictEqual(2); + + for (const b of voteButtons as any) { + expect(b.getAttribute('aria-disabled')).toStrictEqual('true'); + expect(b.getAttribute('title')).toStrictEqual('Sign in to vote'); + } + }); + + it('disabled for already upvoted comment', async () => { + const voteSpy = jest.fn(async () => {}); + const element = ( + + ); + render(element, container); + + const voteButtons = container.querySelectorAll('.comment__vote'); + expect(voteButtons.length).toStrictEqual(2); + + expect(voteButtons[0].getAttribute('aria-disabled')).toStrictEqual('true'); + voteButtons[0].click(); + await delay(100); + expect(voteSpy).not.toBeCalled(); + + expect(voteButtons[1].getAttribute('aria-disabled')).toStrictEqual('false'); + voteButtons[1].click(); + await delay(100); + expect(voteSpy).toBeCalled(); + }, 30000); + + it('disabled for already downvoted comment', async () => { + const voteSpy = jest.fn(async () => {}); + const element = ( + + ); + render(element, container); + + const voteButtons = container.querySelectorAll('.comment__vote'); + expect(voteButtons.length).toStrictEqual(2); + + expect(voteButtons[1].getAttribute('aria-disabled')).toStrictEqual('true'); + voteButtons[1].click(); + await delay(100); + expect(voteSpy).not.toBeCalled(); + + expect(voteButtons[0].getAttribute('aria-disabled')).toStrictEqual('false'); + voteButtons[0].click(); + await delay(100); + expect(voteSpy).toBeCalled(); + }, 30000); + }); + + describe('admin controls', () => { + let container: HTMLElement; + + createDomContainer(domContainer => { + container = domContainer; + }); + + it('visible for admin', () => { + const element = ; + render(element, container); + + const controls = container.querySelector('.comment__controls'); + expect(controls).not.toBe(null); + }); + + it('not visible for regular user', () => { + const element = ; + render(element, container); + + const controls = container.querySelector('.comment__controls'); + expect(controls).toBe(null); + }); + + it('verification badge clickable for admin', () => { + const element = ; + render(element, container); + + const controls = container.querySelector('.comment__verification')!; + expect(controls.classList.contains('comment__verification_clickable')).toBe(true); + }); + + it('verification badge not clickable for regular user', () => { + const element = ( + + ); + render(element, container); + + const controls = container.querySelector('.comment__verification')!; + expect(controls.classList.contains('comment__verification_clickable')).toBe(false); + }); + }); +}); diff --git a/web/app/components/comment/comment.tsx b/web/app/components/comment/comment.tsx new file mode 100644 index 00000000..bdc51c01 --- /dev/null +++ b/web/app/components/comment/comment.tsx @@ -0,0 +1,721 @@ +/** @jsx h */ + +import './styles'; + +import { h, Component, RenderableProps } from 'preact'; +import b from 'bem-react-helper'; + +import { getHandleClickProps } from '@app/common/accessibility'; +import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX, BLOCKING_DURATIONS } from '@app/common/constants'; + +import { StaticStore } from '@app/common/static_store'; +import debounce from '@app/utils/debounce'; +import copy from '@app/common/copy'; +import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode } from '@app/common/types'; +import { extractErrorMessageFromResponse, FetcherResponse } from '@app/utils/errorUtils'; + +import { Input } from '@app/components/input'; +import { AvatarIcon } from '@app/components/avatar-icon'; +import Countdown from '../countdown'; + +export interface Props { + user: User | null; + data: CommentType; + repliesCount?: number; + post_info: PostInfo | null; + /** whether comment's user is banned */ + isUserBanned?: boolean; + isCommentsDisabled: boolean; + /** edit mode: is comment should have reply, or edit Input */ + editMode?: CommentMode; + /** + * "main" view used in main case, + * "pinned" view used in pinned block, + * "user" is for user comments widget, + * "preview" is for last comments page + */ + view: 'main' | 'pinned' | 'user' | 'preview'; + /** defines whether comment should have reply/edit actions */ + disabled?: boolean; + collapsed?: boolean; + theme: Theme; + level?: number; + mix?: string; + + // actions are optional, as component has read-only mode, such as in last comments + addComment?: (text: string, title: string, pid?: CommentType['id']) => Promise; + updateComment?: (id: CommentType['id'], text: string) => Promise; + removeComment?(id: CommentType['id']): Promise; + setReplyEditState?(id: CommentType['id'], mode: CommentMode): void; + getPreview?: (text: string) => Promise; + putCommentVote?(id: CommentType['id'], value: number): Promise; + collapseToggle?: (id: CommentType['id']) => void; + setPinState?(id: CommentType['id'], value: boolean): Promise; + blockUser?(id: User['id'], name: User['name'], ttl: BlockTTL): Promise; + unblockUser?(id: User['id']): Promise; + setVerifyStatus?(id: User['id'], value: boolean): Promise; +} + +export interface State { + isCopied: boolean; + editDeadline: Date | null; + voteErrorMessage: string | null; + /** + * delta of the score: + * default is 0. + * if user upvoted delta will be incremented + * if downvoted delta will be decremented + */ + scoreDelta: number; + /** + * score copied from props, that updates instantly, + * without server response + */ + cachedScore: number; +} + +export class Comment extends Component { + votingPromise: Promise; + /** comment text node. Used in comment text copying */ + textNode?: HTMLDivElement; + + constructor(props: Props) { + super(props); + + this.state = { + isCopied: false, + editDeadline: null, + voteErrorMessage: null, + scoreDelta: 0, + cachedScore: props.data.score, + }; + + this.votingPromise = Promise.resolve(); + + this.updateState(props); + + this.toggleEditing = this.toggleEditing.bind(this); + this.toggleReplying = this.toggleReplying.bind(this); + this.blockUser = debounce(this.blockUser, 100).bind(this); + } + + componentWillReceiveProps(nextProps: Props) { + this.updateState(nextProps); + } + + updateState(props: Props) { + let scoreDelta = 0; + if (props.user) { + if (props.data.votes[props.user.id] === true) { + ++scoreDelta; + } + if (props.data.votes[props.user.id] === false) { + --scoreDelta; + } + } + + this.setState({ + scoreDelta, + cachedScore: props.data.score, + }); + + if (props.user) { + const userId = props.user!.id; + + // set comment edit timer + if (userId === props.data.user.id) { + const editDuration = StaticStore.config.edit_duration; + const timeDiff = StaticStore.serverClientTimeDiff || 0; + let editDeadline: Date | null = new Date(new Date(props.data.time).getTime() + timeDiff + editDuration * 1000); + if (editDeadline < new Date()) editDeadline = null; + this.setState({ + editDeadline, + }); + } + } + } + + toggleReplying() { + const { editMode } = this.props; + if (editMode === CommentMode.Reply) { + this.props.setReplyEditState!(this.props.data.id, CommentMode.None); + } else { + this.props.setReplyEditState!(this.props.data.id, CommentMode.Reply); + } + } + + toggleEditing() { + const { editMode } = this.props; + if (editMode === CommentMode.Edit) { + this.props.setReplyEditState!(this.props.data.id, CommentMode.None); + } else { + this.props.setReplyEditState!(this.props.data.id, CommentMode.Edit); + } + } + + toggleUserInfoVisibility() { + if (window.parent) { + const { user } = this.props.data; + const data = JSON.stringify({ isUserInfoShown: true, user }); + window.parent.postMessage(data, '*'); + } + } + + setPin(value: boolean) { + const promptMessage = `Do you want to ${value ? 'pin' : 'unpin'} this comment?`; + + if (confirm(promptMessage)) { + this.props.setPinState!(this.props.data.id, value); + } + } + + setVerify(value: boolean) { + const userId = this.props.data.user.id; + const promptMessage = `Do you want to ${value ? 'verify' : 'unverify'} this user?`; + + if (confirm(promptMessage)) { + this.props.setVerifyStatus!(userId, value); + } + } + + onBlockUserClick(e: Event) { + // blur event will be triggered by the confirm pop-up which will start + // infinite loop of blur -> confirm -> blur -> ... + // so we trigger the blur event manually and have debounce mechanism to prevent it + if (e.type === 'change') { + (e.target as HTMLElement).blur(); + } + // we have to debounce the blockUser function calls otherwise it will be + // called 2 times (by change event and by blur event) + this.blockUser((e.target as HTMLOptionElement).value as BlockTTL); + } + + blockUser(ttl: BlockTTL) { + const { user } = this.props.data; + + const block_duration = BLOCKING_DURATIONS.find(el => el.value === ttl); + // blocking duration may be undefined if user hasn't selected anything + // and ttl equals "Blocking period" + if (!block_duration) return; + + const duration = block_duration.label; + if (confirm(`Do you want to block ${user.name} ${duration.toLowerCase()}?`)) { + this.props.blockUser!(user.id, user.name, ttl); + } + } + + onUnblockUserClick() { + const { user } = this.props.data; + + const promptMessage = `Do you want to unblock this user?`; + + if (confirm(promptMessage)) { + this.props.unblockUser!(user.id); + } + } + + deleteComment() { + if (confirm('Do you want to delete this comment?')) { + this.props.setReplyEditState!(this.props.data.id, CommentMode.None); + + this.props.removeComment!(this.props.data.id); + } + } + + handleVoteError(e: FetcherResponse, originalScore: number, originalDelta: number) { + this.setState({ + scoreDelta: originalDelta, + cachedScore: originalScore, + voteErrorMessage: extractErrorMessageFromResponse(e), + }); + } + + sendVotingRequest(votingValue: number, originalScore: number, originalDelta: number) { + this.votingPromise = this.votingPromise + .then(() => this.props.putCommentVote!(this.props.data.id, votingValue)) + .catch(e => this.handleVoteError(e, originalScore, originalDelta)); + } + + increaseScore() { + const { cachedScore, scoreDelta } = this.state; + + if (scoreDelta === 1) return; + + this.setState({ + scoreDelta: scoreDelta + 1, + cachedScore: cachedScore + 1, + voteErrorMessage: null, + }); + + this.sendVotingRequest(1, cachedScore, scoreDelta); + } + + decreaseScore() { + const { cachedScore, scoreDelta } = this.state; + + if (scoreDelta === -1) return; + + this.setState({ + scoreDelta: scoreDelta - 1, + cachedScore: cachedScore - 1, + voteErrorMessage: null, + }); + + this.sendVotingRequest(-1, cachedScore, scoreDelta); + } + + async addComment(text: string, title: string, pid?: CommentType['id']) { + await this.props.addComment!(text, title, pid); + + this.props.setReplyEditState!(this.props.data.id, CommentMode.None); + } + + async updateComment(id: CommentType['id'], text: string) { + await this.props.updateComment!(id, text); + + this.props.setReplyEditState!(this.props.data.id, CommentMode.None); + } + + scrollToParent(e: Event) { + const { + data: { pid }, + } = this.props; + + e.preventDefault(); + + const parentCommentNode = document.getElementById(`${COMMENT_NODE_CLASSNAME_PREFIX}${pid}`); + + if (parentCommentNode) { + parentCommentNode.scrollIntoView(); + } + } + + toggleCollapse() { + this.props.setReplyEditState!(this.props.data.id, CommentMode.None); + + this.props.collapseToggle!(this.props.data.id); + } + + copyComment({ username, time }: { username: string; time: string }) { + const text = this.textNode!.textContent || ''; + + copy(`${username} ${time}
${text.replace(/\n+/g, '
')}`); + + this.setState({ isCopied: true }, () => { + setTimeout(() => this.setState({ isCopied: false }), 3000); + }); + } + + /** + * Defines whether current client is admin + */ + isAdmin(): boolean { + return !!this.props.user && this.props.user.admin; + } + + /** + * Defines whether current client is not logged in + */ + isGuest(): boolean { + return !this.props.user; + } + + /** + * Defines whether comment made by logged in user + */ + isCurrentUser(): boolean { + if (this.isGuest()) { + return false; + } + return this.props.data.user.id === this.props.user!.id; + } + + /** + * returns reason for disabled downvoting + */ + getDownvoteDisabledReason(): string | null { + if (!(this.props.view === 'main' || this.props.view === 'pinned')) return "Voting allowed only on post's page"; + if (this.props.post_info!.read_only) return "Can't vote on read-only topics"; + if (this.props.data.delete) return "Can't vote for deleted comment"; + if (this.isCurrentUser()) return "Can't vote for your own comment"; + if (StaticStore.config.positive_score && this.props.data.score < 1) return 'Only positive score allowed'; + if (this.isGuest()) return 'Sign in to vote'; + return null; + } + + /** + * returns reason for disabled upvoting + */ + getUpvoteDisabledReason(): string | null { + if (!(this.props.view === 'main' || this.props.view === 'pinned')) return "Voting allowed only on post's page"; + if (this.props.post_info!.read_only) return "Can't vote on read-only topics"; + if (this.props.data.delete) return "Can't vote for deleted comment"; + if (this.isCurrentUser()) return "Can't vote for your own comment"; + if (this.isGuest()) return 'Sign in to vote'; + return null; + } + + render(props: RenderableProps, state: State) { + const isAdmin = this.isAdmin(); + const isGuest = this.isGuest(); + const isCurrentUser = this.isCurrentUser(); + + const isReplying = props.editMode === CommentMode.Reply; + const isEditing = props.editMode === CommentMode.Edit; + + const lowCommentScore = StaticStore.config.low_score; + const downvotingDisabledReason = this.getDownvoteDisabledReason(); + const isDownvotingDisabled = downvotingDisabledReason !== null; + const upvotingDisabledReason = this.getUpvoteDisabledReason(); + const isUpvotingDisabled = upvotingDisabledReason !== null; + const editable = props.repliesCount === 0 && state.editDeadline; + const scoreSignEnabled = !StaticStore.config.positive_score; + + /** + * CommentType adapted for rendering + */ + const o = { + ...props.data, + controversyText: `Controversy: ${(props.data.controversy || 0).toFixed(2)}`, + text: props.data.text.length + ? props.view === 'preview' + ? getTextSnippet(props.data.text) + : props.data.text + : this.props.isUserBanned + ? 'This user was blocked' + : props.data.delete + ? 'This comment was deleted' + : props.data.text, + time: formatTime(new Date(props.data.time)), + orig: isEditing + ? props.data.orig && + props.data.orig.replace(/&[#A-Za-z0-9]+;/gi, entity => { + const span = document.createElement('span'); + span.innerHTML = entity; + return span.innerText; + }) + : props.data.orig, + score: { + value: Math.abs(state.cachedScore), + sign: !scoreSignEnabled ? '' : state.cachedScore > 0 ? '+' : state.cachedScore < 0 ? '−' : null, + view: state.cachedScore > 0 ? 'positive' : state.cachedScore < 0 ? 'negative' : null, + }, + user: { + ...props.data.user, + picture: + props.data.user.picture.indexOf(API_BASE) === 0 + ? `${BASE_URL}${props.data.user.picture}` + : props.data.user.picture, + }, + }; + + const defaultMods = { + disabled: props.disabled, + pinned: props.data.pin, + // TODO: we also have critical_score, so we need to collapse comments with it in future + useless: + !!props.isUserBanned || + !!props.data.delete || + (props.view !== 'preview' && props.data.score < lowCommentScore && !props.data.pin && !props.disabled), + // TODO: add default view mod or don't? + guest: isGuest, + view: isAdmin ? 'admin' : props.view, + replying: props.view === 'main' && isReplying, + editing: props.view === 'main' && isEditing, + theme: props.view === 'preview' ? null : props.theme, + level: props.level, + }; + + if (props.view === 'preview') { + return ( +

+ ); + } + + return ( +
+ {props.view === 'user' && o.title && ( + + )} +
+
+ {props.view !== 'user' && } + + {props.view !== 'user' && ( + this.toggleUserInfoVisibility())} + className="comment__username" + title={o.user.id} + > + {o.user.name} + + )} + + {isAdmin && props.view !== 'user' && ( + this.setVerify(!o.user.verified))} + aria-label="Toggle verification" + title={o.user.verified ? 'Verified user' : 'Unverified user'} + className={b('comment__verification', {}, { active: o.user.verified, clickable: true })} + /> + )} + + {!isAdmin && !!o.user.verified && props.view !== 'user' && ( + + )} + + + {o.time} + + + {!!props.level && props.level > 0 && props.view === 'main' && ( + this.scrollToParent(e)} + > + {' '} + + )} + + {isAdmin && props.isUserBanned && props.view !== 'user' && Blocked} + + {isAdmin && !props.isUserBanned && props.data.delete && Deleted} + + {!props.disabled && props.view === 'main' && ( + this.toggleCollapse())} + className={b('comment__action', {}, { type: 'collapse', selected: props.collapsed })} + > + {props.collapsed ? '+' : '−'} + + )} + + + this.increaseScore())} + title={upvotingDisabledReason || undefined} + > + Vote up + + + + {o.score.sign} + {o.score.value} + + + this.decreaseScore())} + title={downvotingDisabledReason || undefined} + > + Vote down + + +
+ + {!!state.voteErrorMessage && ( +
+ Voting error: {state.voteErrorMessage} +
+ )} + + {(!props.collapsed || props.view === 'pinned') && ( +
(this.textNode = r)} + dangerouslySetInnerHTML={{ __html: o.text }} + /> + )} + + {(!props.collapsed || props.view === 'pinned') && ( +
+ {!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && ( + this.toggleReplying())} className="comment__action"> + {isReplying ? 'Cancel' : 'Reply'} + + )} + + {!props.data.delete && + !props.disabled && + !!o.orig && + isCurrentUser && + (editable || isEditing) && + props.view === 'main' && [ + this.toggleEditing())} + className="comment__action comment__action_type_edit" + > + {isEditing ? 'Cancel' : 'Edit'} + , + !isAdmin && ( + this.deleteComment())} + className="comment__action comment__action_type_delete" + > + Delete + + ), + state.editDeadline && ( + + this.setState({ + editDeadline: null, + }) + } + /> + ), + ]} + + {!props.data.delete && isAdmin && ( + + {!state.isCopied && ( + this.copyComment({ username: o.user.name, time: o.time }))} + className="comment__control" + > + Copy + + )} + + {state.isCopied && Copied!} + + {(props.view === 'main' || props.view === 'pinned') && ( + this.setPin(!props.data.pin))} className="comment__control"> + {props.data.pin ? 'Unpin' : 'Pin'} + + )} + + {props.isUserBanned && ( + this.onUnblockUserClick())} className="comment__control"> + Unblock + + )} + + {props.user!.id !== props.data.user.id && !props.isUserBanned && ( + + Block + + + )} + + {!props.data.delete && ( + this.deleteComment())} className="comment__control"> + Delete + + )} + + )} +
+ )} +
+ + {isReplying && props.view === 'main' && ( + this.addComment(text, title, o.id)} + onCancel={this.toggleReplying} + getPreview={this.props.getPreview!} + autofocus={true} + /> + )} + + {isEditing && props.view === 'main' && ( + this.updateComment(props.data.id, text)} + onCancel={this.toggleEditing} + getPreview={this.props.getPreview!} + errorMessage={state.editDeadline === null ? 'Editing time has expired.' : undefined} + autofocus={true} + /> + )} +
+ ); + } +} + +function getTextSnippet(html: string) { + const LENGTH = 100; + const tmp = document.createElement('div'); + tmp.innerHTML = html.replace('

', ' '); + + const result = tmp.innerText || ''; + const snippet = result.substr(0, LENGTH); + + return snippet.length === LENGTH && result.length !== LENGTH ? `${snippet}...` : snippet; +} + +function formatTime(time: Date) { + // 'ru-RU' adds a dot as a separator + const date = time.toLocaleDateString(['ru-RU'], { day: '2-digit', month: '2-digit', year: '2-digit' }); + + // do it manually because Intl API doesn't add leading zeros to hours; idk why + const hours = `0${time.getHours()}`.slice(-2); + const mins = `0${time.getMinutes()}`.slice(-2); + + return `${date} at ${hours}:${mins}`; +} diff --git a/web/app/components/comment/connected-comment.ts b/web/app/components/comment/connected-comment.ts new file mode 100644 index 00000000..cf84725b --- /dev/null +++ b/web/app/components/comment/connected-comment.ts @@ -0,0 +1,75 @@ +/* + * connected comment is not exported in index.ts to avoid leaking redux import into last-comments + * and should be importded explicitly + */ + +import { Comment as CommentType, User, BlockTTL, CommentMode } from '@app/common/types'; + +import { connect } from 'preact-redux'; + +import { StoreState, StoreDispatch } from '@app/store'; +import { + addComment, + removeComment, + updateComment, + setPinState, + putVote, + setCommentMode, +} from '@app/store/comments/actions'; +import { setCollapse } from '@app/store/thread/actions'; +import { blockUser, unblockUser, setVirifiedStatus } from '@app/store/user/actions'; + +import { Comment, Props } from './comment'; +import { getCommentMode } from '@app/store/comments/getters'; + +const mapProps = (state: StoreState, cprops: { data: CommentType }) => { + const props: Pick< + Props, + 'editMode' | 'user' | 'isUserBanned' | 'post_info' | 'isCommentsDisabled' | 'theme' | 'collapsed' + > = { + editMode: getCommentMode(state, cprops.data.id), + user: state.user, + isUserBanned: state.bannedUsers.find(u => u.id === cprops.data.user.id) !== undefined, + post_info: state.info, + isCommentsDisabled: state.info.read_only || false, + theme: state.theme, + collapsed: state.collapsedThreads[cprops.data.id] === true, + }; + return props; +}; + +const mapDispatchToProps = (dispatch: StoreDispatch) => { + const props: Pick< + Props, + | 'addComment' + | 'updateComment' + | 'removeComment' + | 'setReplyEditState' + | 'collapseToggle' + | 'setPinState' + | 'putCommentVote' + | 'blockUser' + | 'unblockUser' + | 'setVerifyStatus' + > = { + addComment: (text: string, title: string, pid?: CommentType['id']) => dispatch(addComment(text, title, pid)), + updateComment: (id: CommentType['id'], text: string) => dispatch(updateComment(id, text)), + removeComment: (id: CommentType['id']) => dispatch(removeComment(id)), + setReplyEditState: (id: CommentType['id'], mode: CommentMode) => dispatch(setCommentMode({ id, state: mode })), + collapseToggle: (id: CommentType['id']) => dispatch(setCollapse(id)), + setPinState: (id: CommentType['id'], value: boolean) => dispatch(setPinState(id, value)), + putCommentVote: (id: CommentType['id'], value: number) => dispatch(putVote(id, value)), + + blockUser: (id: User['id'], name: User['name'], ttl: BlockTTL) => dispatch(blockUser(id, name, ttl)), + unblockUser: (id: User['id']) => dispatch(unblockUser(id)), + setVerifyStatus: (id: User['id'], value: boolean) => dispatch(setVirifiedStatus(id, value)), + }; + + return props; +}; + +/** Comment component connected to redux */ +export const ConnectedComment = connect( + mapProps, + mapDispatchToProps +)(Comment); diff --git a/web/app/components/comment/index.ts b/web/app/components/comment/index.ts new file mode 100644 index 00000000..9b82506b --- /dev/null +++ b/web/app/components/comment/index.ts @@ -0,0 +1 @@ +export { Comment } from './comment'; diff --git a/web/app/components/comment/index.js b/web/app/components/comment/styles.ts similarity index 93% rename from web/app/components/comment/index.js rename to web/app/components/comment/styles.ts index deb63a1b..3fa68a40 100644 --- a/web/app/components/comment/index.js +++ b/web/app/components/comment/styles.ts @@ -1,8 +1,4 @@ -import 'components/raw-content'; -import withTheme from 'components/with-theme'; -import Comment from './comment'; - -export default withTheme(Comment); +import '@app/components/raw-content'; require('./comment.scss'); diff --git a/web/app/components/countdown/index.tsx b/web/app/components/countdown/index.tsx new file mode 100644 index 00000000..be17bda9 --- /dev/null +++ b/web/app/components/countdown/index.tsx @@ -0,0 +1,59 @@ +/** @jsx h */ +import { h, Component, RenderableProps } from 'preact'; +import { exclude } from '@app/utils/exclude'; + +type Props = { + time: Date; + onTimePassed?: () => void; +} & JSX.HTMLAttributes; + +interface State { + /** props.time converted to timestamp */ + time: number; +} + +/** Component which uses plain DOM mutation instead of rerendering react reactive reactivity */ +export default class Countdown extends Component { + elemRef?: HTMLSpanElement; + intervalID?: number; + constructor(props: Props) { + super(props); + this.state = { + time: props.time.getTime(), + }; + } + componentDidMount() { + this.start(); + } + componentWillReceiveProps(nextProps: Props) { + if (nextProps.time === this.props.time) return; + this.setState({ + time: nextProps.time.getTime(), + }); + this.start(); + } + shouldComponentUpdate() { + return false; + } + tick() { + if (this.elemRef) { + const value = Math.max(0, (this.state.time - new Date().getTime()) / 1000).toFixed(0); + this.elemRef!.innerText = value; + if (value === '0') { + this.props.onTimePassed && this.props.onTimePassed(); + window.clearInterval(this.intervalID); + this.intervalID = undefined; + } + } + } + start() { + if (this.intervalID) clearInterval(this.intervalID); + this.tick(); + this.intervalID = window.setInterval(() => { + this.tick(); + }, 1000); + } + render(props: RenderableProps) { + return (this.elemRef = ref)} />; + } +} diff --git a/web/app/components/dropdown/__item/dropdown__item.jsx b/web/app/components/dropdown/__item/dropdown__item.jsx deleted file mode 100644 index 84ec0795..00000000 --- a/web/app/components/dropdown/__item/dropdown__item.jsx +++ /dev/null @@ -1,8 +0,0 @@ -/** @jsx h */ -import { h } from 'preact'; - -export default function DropdownItem(props) { - const { children, separator = false } = props; - - return

{children}
; -} diff --git a/web/app/components/dropdown/__item/dropdown__item.tsx b/web/app/components/dropdown/__item/dropdown__item.tsx new file mode 100644 index 00000000..dcacf804 --- /dev/null +++ b/web/app/components/dropdown/__item/dropdown__item.tsx @@ -0,0 +1,13 @@ +/** @jsx h */ +import { h, RenderableProps } from 'preact'; +import b from 'bem-react-helper'; + +interface Props { + separator?: boolean; +} + +export default function DropdownItem(props: RenderableProps & JSX.HTMLAttributes & { separator?: boolean }) { + const { children, separator = false } = props; + + return
{children}
; +} diff --git a/web/app/components/dropdown/__item/index.js b/web/app/components/dropdown/__item/index.js deleted file mode 100644 index 80204192..00000000 --- a/web/app/components/dropdown/__item/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import withTheme from 'components/with-theme'; -import Dropdown__Item from './dropdown__item'; - -export default withTheme(Dropdown__Item); diff --git a/web/app/components/dropdown/__item/index.ts b/web/app/components/dropdown/__item/index.ts new file mode 100644 index 00000000..3fdf4de3 --- /dev/null +++ b/web/app/components/dropdown/__item/index.ts @@ -0,0 +1,3 @@ +import Dropdown__Item from './dropdown__item'; + +export default Dropdown__Item; diff --git a/web/app/components/dropdown/dropdown.jsx b/web/app/components/dropdown/dropdown.jsx deleted file mode 100644 index c28773f5..00000000 --- a/web/app/components/dropdown/dropdown.jsx +++ /dev/null @@ -1,87 +0,0 @@ -/** @jsx h */ -import { Component, h } from 'preact'; - -import Button from 'components/button'; - -export default class Dropdown extends Component { - constructor(props) { - super(props); - - this.state = { - isActive: props.isActive || false, - }; - - this.onTitleClick = this.onTitleClick.bind(this); - this.onOutsideClick = this.onOutsideClick.bind(this); - this.receiveMessage = this.receiveMessage.bind(this); - } - - onTitleClick() { - this.setState({ - isActive: !this.state.isActive, - }); - - if (this.props.onTitleClick) { - this.props.onTitleClick(); - } - } - - receiveMessage(e) { - try { - const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; - - if (data.clickOutside) { - if (this.state.isActive) { - this.setState({ - isActive: false, - }); - } - } - } catch (e) {} - } - - onOutsideClick(e) { - if (!this.rootNode.contains(e.target)) { - if (this.state.isActive) { - this.setState({ - isActive: false, - }); - } - } - } - - componentDidMount() { - document.addEventListener('click', this.onOutsideClick); - - window.addEventListener('message', this.receiveMessage); - } - - componentWillUnmount() { - document.removeEventListener('click', this.onOutsideClick); - - window.removeEventListener('message', this.receiveMessage); - } - - render(props, { isActive }) { - const { title, heading, children, mix, mods } = props; - - return ( -
(this.rootNode = r)}> - - -
- {heading &&
{heading}
} -
{children}
-
-
- ); - } -} diff --git a/web/app/components/dropdown/dropdown.tsx b/web/app/components/dropdown/dropdown.tsx new file mode 100644 index 00000000..18430436 --- /dev/null +++ b/web/app/components/dropdown/dropdown.tsx @@ -0,0 +1,101 @@ +/** @jsx h */ +import { Component, h, RenderableProps } from 'preact'; +import b from 'bem-react-helper'; + +import { Button } from '@app/components/button'; +import { Theme } from '@app/common/types'; + +interface Props { + title: string; + heading?: string; + isActive?: boolean; + onTitleClick?: () => void; + mix?: string; + theme: Theme; +} + +interface State { + isActive: boolean; +} + +export default class Dropdown extends Component { + rootNode?: HTMLDivElement; + + constructor(props: Props) { + super(props); + + this.state = { + isActive: props.isActive || false, + }; + } + + onTitleClick() { + this.setState({ + isActive: !this.state.isActive, + }); + + if (this.props.onTitleClick) { + this.props.onTitleClick(); + } + } + + receiveMessage(e: { data: string | object }) { + try { + const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; + + if (data.clickOutside) { + if (this.state.isActive) { + this.setState({ + isActive: false, + }); + } + } + } catch (e) {} + } + + onOutsideClick(e: MouseEvent) { + if (this.rootNode && !this.rootNode.contains(e.target as Node)) { + if (this.state.isActive) { + this.setState({ + isActive: false, + }); + } + } + } + + componentDidMount() { + document.addEventListener('click', e => this.onOutsideClick(e)); + + window.addEventListener('message', e => this.receiveMessage(e)); + } + + componentWillUnmount() { + document.removeEventListener('click', e => this.onOutsideClick(e)); + + window.removeEventListener('message', e => this.receiveMessage(e)); + } + + render(props: RenderableProps, { isActive }: State) { + const { title, heading, children, mix } = props; + + return ( +
(this.rootNode = r)}> + + +
+ {heading &&
{heading}
} +
{children}
+
+
+ ); + } +} diff --git a/web/app/components/dropdown/index.js b/web/app/components/dropdown/index.ts similarity index 84% rename from web/app/components/dropdown/index.js rename to web/app/components/dropdown/index.ts index 054b0b4b..ff172454 100644 --- a/web/app/components/dropdown/index.js +++ b/web/app/components/dropdown/index.ts @@ -1,7 +1,6 @@ -import withTheme from 'components/with-theme'; import Dropdown from './dropdown'; -export default withTheme(Dropdown); +export default Dropdown; export { default as DropdownItem } from './__item'; diff --git a/web/app/components/input/index.ts b/web/app/components/input/index.ts new file mode 100644 index 00000000..f376f8cd --- /dev/null +++ b/web/app/components/input/index.ts @@ -0,0 +1 @@ +export { Input } from './input'; diff --git a/web/app/components/input/input.jsx b/web/app/components/input/input.tsx similarity index 59% rename from web/app/components/input/input.jsx rename to web/app/components/input/input.tsx index e3b37234..daab9afd 100644 --- a/web/app/components/input/input.jsx +++ b/web/app/components/input/input.tsx @@ -1,30 +1,67 @@ /** @jsx h */ -import { h, Component } from 'preact'; -import { BASE_URL, API_BASE, DEFAULT_MAX_COMMENT_SIZE } from 'common/constants'; -import { siteId, url, pageTitle } from 'common/settings'; +/* styles imports */ +import '@app/components/raw-content'; +import './styles'; -import api from 'common/api'; -import store from 'common/store'; -import { extractErrorMessageFromResponse } from 'utils/errorUtils'; -import TextareaAutosize from 'components/input/textarea-autosize'; +import { h, Component, RenderableProps } from 'preact'; +import b, { Mix } from 'bem-react-helper'; + +import { User, Theme } from '@app/common/types'; +import { BASE_URL, API_BASE } from '@app/common/constants'; +import { StaticStore } from '@app/common/static_store'; +import { siteId, url, pageTitle } from '@app/common/settings'; +import { extractErrorMessageFromResponse } from '@app/utils/errorUtils'; + +import TextareaAutosize from './textarea-autosize'; const RSS_THREAD_URL = `${BASE_URL}${API_BASE}/rss/post?site=${siteId}&url=${url}`; const RSS_SITE_URL = `${BASE_URL}${API_BASE}/rss/site?site=${siteId}`; const RSS_REPLIES_URL = `${BASE_URL}${API_BASE}/rss/reply?site=${siteId}&user=`; -export default class Input extends Component { - constructor(props) { - super(props); +interface Props { + /** user id for rss link generation */ + userId?: User['id']; + errorMessage?: string; + value?: string; + mix?: Mix; + mode?: 'main' | 'edit' | 'reply'; + theme: Theme; + autofocus?: boolean; - const config = store.get('config') || {}; + onSubmit(text: string, pageTitle: string): Promise; + getPreview(text: string): Promise; + /** action on cancel. optional as root input has no cancel option */ + onCancel?: () => void; +} + +interface State { + preview: string | null; + isErrorShown: boolean; + errorMessage: string | null; + isDisabled: boolean; + maxLength: number; + text: string; +} + +const Labels = { + main: 'Send', + edit: 'Edit', + reply: 'Reply', +}; + +export class Input extends Component { + textAreaRef?: TextareaAutosize; + + constructor(props: Props) { + super(props); this.state = { preview: null, isErrorShown: false, errorMessage: null, isDisabled: false, - maxLength: config.max_comment_size || DEFAULT_MAX_COMMENT_SIZE, + maxLength: StaticStore.config.max_comment_size, text: props.value || '', }; @@ -34,42 +71,43 @@ export default class Input extends Component { this.onKeyDown = this.onKeyDown.bind(this); } - componentDidMount() { - store.onUpdate('config', config => { - this.setState({ maxLength: (config && config.max_comment_size) || DEFAULT_MAX_COMMENT_SIZE }); - }); + componentWillReceiveProps(nextProps: Props) { + if (nextProps.value !== this.props.value) { + this.setState({ text: nextProps.value || '' }); + this.props.autofocus && this.textAreaRef && this.textAreaRef.focus(); + } } - shouldComponentUpdate(nextProps, nextState) { + shouldComponentUpdate(nextProps: Props, nextState: State) { return ( - nextProps.id !== this.props.id || - nextProps.mods !== this.props.mods || - nextProps.pid !== this.props.pid || + nextProps.mode !== this.props.mode || + nextProps.theme !== this.props.theme || + nextProps.userId !== this.props.userId || nextProps.value !== this.props.value || nextProps.errorMessage !== this.props.errorMessage || nextState !== this.state ); } - onKeyDown(e) { + onKeyDown(e: KeyboardEvent) { // send on cmd+enter / ctrl+enter if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) { - this.send(); + this.send(e); } } - onInput(e) { + onInput(e: Event) { this.setState({ preview: null, isErrorShown: false, errorMessage: null, - text: e.target.value, + text: (e.target as HTMLInputElement).value, }); } - send(e) { + send(e: Event) { const text = this.state.text; - const { mods = {}, pid, id } = this.props; + const props = this.props; if (e) e.preventDefault(); @@ -82,18 +120,14 @@ export default class Input extends Component { this.setState({ isDisabled: true, isErrorShown: false }); - const request = - mods.mode === 'edit' - ? api.updateComment({ text, id }) - : api.addComment({ title: pageTitle || document.title, text, ...(pid ? { pid } : {}) }); - - request - .then(comment => { - this.props.onSubmit && this.props.onSubmit(comment); + props + .onSubmit(text, pageTitle || document.title) + .then(() => { this.setState({ preview: null, text: '' }); }) .catch(e => { - const errorMessage = extractErrorMessageFromResponse(e.response); + console.error(e); // eslint-disable-line no-console + const errorMessage = extractErrorMessageFromResponse(e); this.setState({ isErrorShown: true, errorMessage }); }) .finally(() => this.setState({ isDisabled: false })); @@ -106,23 +140,34 @@ export default class Input extends Component { this.setState({ isErrorShown: false, errorMessage: null }); - api - .getPreview({ text }) + this.props + .getPreview(text) .then(preview => this.setState({ preview })) .catch(() => { this.setState({ isErrorShown: true, errorMessage: null }); }); } - render(props, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text }) { + render(props: RenderableProps, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text }: State) { const charactersLeft = maxLength - text.length; - const { mods = {}, userId } = props; errorMessage = props.errorMessage || errorMessage; + const label = Labels[props.mode || 'main']; return ( -
+
(this.textAreaRef = ref)} className="input__field" placeholder="Your comment here" value={text} @@ -130,6 +175,7 @@ export default class Input extends Component { onInput={this.onInput} onKeyDown={this.onKeyDown} disabled={isDisabled} + autofocus={!!props.autofocus} /> {charactersLeft < 100 && {charactersLeft}} @@ -152,14 +198,14 @@ export default class Input extends Component { - {mods.type === 'main' && ( + {props.mode === 'main' && (
Styling with{' '} - + Markdown {' '} is supported @@ -173,7 +219,7 @@ export default class Input extends Component { Site {' '} or  - + Replies {' '} by RSS @@ -186,7 +232,7 @@ export default class Input extends Component { !!preview && (
diff --git a/web/app/components/input/index.js b/web/app/components/input/styles.ts similarity index 85% rename from web/app/components/input/index.js rename to web/app/components/input/styles.ts index 077bfe03..077e3817 100644 --- a/web/app/components/input/index.js +++ b/web/app/components/input/styles.ts @@ -1,8 +1,4 @@ -import 'components/raw-content'; -import withTheme from 'components/with-theme'; -import Input from './input'; - -export default withTheme(Input); +import '@app/components/raw-content'; require('./input.scss'); diff --git a/web/app/components/input/textarea-autosize.jsx b/web/app/components/input/textarea-autosize.jsx deleted file mode 100644 index df65978c..00000000 --- a/web/app/components/input/textarea-autosize.jsx +++ /dev/null @@ -1,34 +0,0 @@ -/** @jsx h */ -import { h, Component } from 'preact'; - -export default class TextareaAutosize extends Component { - constructor(props) { - super(props); - - this.onRef = this.onRef.bind(this); - } - componentDidMount() { - this.autoResize(); - } - componentDidUpdate(prevProps) { - if (prevProps.value !== this.props.value) { - this.autoResize(); - } - } - onRef(node) { - this.textareaRef = node; - } - autoResize() { - this.textareaRef.style.height = ''; - this.textareaRef.style.height = `${this.textareaRef.scrollHeight}px`; - } - render(props) { - return ( - // We set text as a child of textarea and not in value property for a reason. - // It's a workaround for the bug described here https://github.com/developit/preact/issues/326 - - ); - } -} diff --git a/web/app/components/input/textarea-autosize.tsx b/web/app/components/input/textarea-autosize.tsx new file mode 100644 index 00000000..51be6499 --- /dev/null +++ b/web/app/components/input/textarea-autosize.tsx @@ -0,0 +1,54 @@ +/** @jsx h */ +import { h, Component, RenderableProps } from 'preact'; + +type Props = JSX.HTMLAttributes & { + autofocus: boolean; +}; + +export default class TextareaAutosize extends Component { + textareaRef?: HTMLTextAreaElement; + + constructor(props: Props) { + super(props); + + this.onRef = this.onRef.bind(this); + } + componentDidMount() { + this.autoResize(); + + if (this.props.autofocus) this.focus(); + } + componentDidUpdate(prevProps: Props) { + if (prevProps.value !== this.props.value) { + this.autoResize(); + } + } + + focus(): void { + setTimeout(() => { + if (this.textareaRef) { + this.textareaRef.focus(); + this.textareaRef.selectionStart = this.textareaRef.selectionEnd = this.textareaRef.value.length; + } + }, 100); + } + + onRef(node: HTMLTextAreaElement) { + this.textareaRef = node; + } + autoResize() { + if (this.textareaRef) { + this.textareaRef.style.height = ''; + this.textareaRef.style.height = `${this.textareaRef.scrollHeight}px`; + } + } + render(props: RenderableProps) { + return ( + // We set text as a child of textarea and not in value property for a reason. + // It's a workaround for the bug described here https://github.com/developit/preact/issues/326 + + ); + } +} diff --git a/web/app/components/list-comments/index.js b/web/app/components/list-comments/index.ts similarity index 62% rename from web/app/components/list-comments/index.js rename to web/app/components/list-comments/index.ts index 4cae2ce1..7815fcc0 100644 --- a/web/app/components/list-comments/index.js +++ b/web/app/components/list-comments/index.ts @@ -1,4 +1,4 @@ -export { default } from './list-comments'; +export { ListComments } from './list-comments'; require('./list-comments.scss'); diff --git a/web/app/components/list-comments/list-comments.jsx b/web/app/components/list-comments/list-comments.jsx deleted file mode 100644 index 824f7f6e..00000000 --- a/web/app/components/list-comments/list-comments.jsx +++ /dev/null @@ -1,18 +0,0 @@ -/** @jsx h */ -import { h } from 'preact'; - -import { NODE_ID } from 'common/constants'; - -import Comment from 'components/comment'; - -const ListComments = ({ comments = [] }) => ( -
-
- {comments.map(comment => ( - - ))} -
-
-); - -export default ListComments; diff --git a/web/app/components/list-comments/list-comments.tsx b/web/app/components/list-comments/list-comments.tsx new file mode 100644 index 00000000..0dc98db9 --- /dev/null +++ b/web/app/components/list-comments/list-comments.tsx @@ -0,0 +1,30 @@ +/** @jsx h */ +import { h } from 'preact'; + +import { NODE_ID } from '@app/common/constants'; +import { Comment as CommentType } from '@app/common/types'; + +import { Comment } from '@app/components/comment'; + +interface Props { + comments: CommentType[]; +} + +export const ListComments = ({ comments = [] }: Props) => ( +
+
+ {comments.map(comment => ( + + ))} +
+
+); diff --git a/web/app/components/preloader/index.js b/web/app/components/preloader/index.ts similarity index 100% rename from web/app/components/preloader/index.js rename to web/app/components/preloader/index.ts diff --git a/web/app/components/preloader/__tests__/preloader.test.jsx b/web/app/components/preloader/preloader.test.tsx similarity index 66% rename from web/app/components/preloader/__tests__/preloader.test.jsx rename to web/app/components/preloader/preloader.test.tsx index e6a23108..68c2b7c0 100644 --- a/web/app/components/preloader/__tests__/preloader.test.jsx +++ b/web/app/components/preloader/preloader.test.tsx @@ -1,12 +1,12 @@ /** @jsx h */ import { h, render } from 'preact'; -import Preloader from '../preloader'; -import { createDomContainer } from 'testUtils'; +import Preloader from './preloader'; +import { createDomContainer } from '@app/testUtils'; describe(``, () => { - let container; + let container: HTMLElement; - createDomContainer(({ domContainer }) => { + createDomContainer(domContainer => { container = domContainer; }); diff --git a/web/app/components/preloader/preloader.jsx b/web/app/components/preloader/preloader.tsx similarity index 65% rename from web/app/components/preloader/preloader.jsx rename to web/app/components/preloader/preloader.tsx index 89b2ba61..b8709e38 100644 --- a/web/app/components/preloader/preloader.jsx +++ b/web/app/components/preloader/preloader.tsx @@ -1,7 +1,12 @@ /** @jsx h */ import { h } from 'preact'; +import b, { Mix } from 'bem-react-helper'; -const Preloader = props => ( +type Props = JSX.HTMLAttributes & { + mix?: Mix; +}; + +const Preloader = (props: Props) => (
diff --git a/web/app/components/raw-content/index.js b/web/app/components/raw-content/index.ts similarity index 100% rename from web/app/components/raw-content/index.js rename to web/app/components/raw-content/index.ts diff --git a/web/app/components/root/index.js b/web/app/components/root/index.ts similarity index 91% rename from web/app/components/root/index.js rename to web/app/components/root/index.ts index e4c3e46d..2db6192e 100644 --- a/web/app/components/root/index.js +++ b/web/app/components/root/index.ts @@ -1,4 +1,4 @@ -export { default } from './root'; +export { Root, ConnectedRoot } from './root'; require('./root.scss'); diff --git a/web/app/components/root/root.jsx b/web/app/components/root/root.jsx deleted file mode 100644 index 3bd917b9..00000000 --- a/web/app/components/root/root.jsx +++ /dev/null @@ -1,359 +0,0 @@ -/** @jsx h */ -import { h, Component } from 'preact'; -import api from 'common/api'; - -import { - BASE_URL, - NODE_ID, - COMMENT_NODE_CLASSNAME_PREFIX, - DEFAULT_SORT, - COOKIE_SORT_KEY, - MAX_SHOWN_ROOT_COMMENTS, - THEMES, -} from 'common/constants'; -import { getCookie, setCookie } from 'common/cookies'; -import { siteId, url, maxShownComments, theme } from 'common/settings'; -import store from 'common/store'; - -import AuthPanel from 'components/auth-panel'; -import BlockedUsers from 'components/blocked-users'; -import Comment from 'components/comment'; -import Input from 'components/input'; -import Preloader from 'components/preloader'; -import Thread from 'components/thread'; - -const IS_MOBILE = /Android|webOS|iPhone|iPad|iPod|Opera Mini|Windows Phone/i.test(navigator.userAgent); - -export default class Root extends Component { - constructor(props) { - super(props); - - let sort; - - try { - sort = getCookie(COOKIE_SORT_KEY) || DEFAULT_SORT; - } catch (e) { - sort = DEFAULT_SORT; - } - - this.state = { - isLoaded: false, - isCommentsListLoading: false, - user: {}, - theme: THEMES[0], - sort, - commentsShown: maxShownComments || MAX_SHOWN_ROOT_COMMENTS, - }; - - this.addComment = this.addComment.bind(this); - this.replaceComment = this.replaceComment.bind(this); - this.onSignIn = this.onSignIn.bind(this); - this.onSignOut = this.onSignOut.bind(this); - this.onBlockedUsersShow = this.onBlockedUsersShow.bind(this); - this.onBlockedUsersHide = this.onBlockedUsersHide.bind(this); - this.onCommentsDisable = this.onCommentsDisable.bind(this); - this.onCommentsEnable = this.onCommentsEnable.bind(this); - this.onSortChange = this.onSortChange.bind(this); - this.onUnblockSomeone = this.onUnblockSomeone.bind(this); - this.checkUrlHash = this.checkUrlHash.bind(this); - this.showMore = this.showMore.bind(this); - this.onThemeUpdate = this.onThemeUpdate.bind(this); - } - - componentWillMount() { - store.onUpdate('comments', comments => this.setState({ comments })); - store.onUpdate('info', info => this.setState({ info })); - - if (THEMES.includes(theme)) { - store.set('theme', theme); - this.setState({ theme }); - } else { - store.set('theme', THEMES[0]); - this.setState({ theme: THEMES[0] }); - } - - window.addEventListener('message', this.onThemeUpdate); - } - - componentDidMount() { - const { sort } = this.state; - - api.getConfig().then(config => { - store.set('config', config); - this.setState({ config }); - }); - - Promise.all([ - api - .getUser() - .then(data => store.set('user', data)) - .catch(() => store.set('user', {})), - api - .getPostComments({ sort, url }) - .then(({ comments = [], info = {} } = {}) => { - store.set('comments', comments); - store.set('info', info); - }) - .catch(() => store.set('comments', [])), - ]).finally(() => { - this.setState({ - isLoaded: true, - user: store.get('user'), - }); - - setTimeout(this.checkUrlHash); - window.addEventListener('hashchange', this.checkUrlHash); - }); - } - - checkUrlHash(e) { - const hash = e ? `#${e.newURL.split('#')[1]}` : window.location.hash; - - if (hash.indexOf(`#${COMMENT_NODE_CLASSNAME_PREFIX}`) === 0) { - if (e) e.preventDefault(); - - const comment = document.querySelector(hash); - - if (comment) { - setTimeout(() => { - window.parent.postMessage(JSON.stringify({ scrollTo: comment.getBoundingClientRect().top }), '*'); - }, 500); - } - } - } - - onThemeUpdate(event) { - try { - const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; - if (data.theme && THEMES.includes(data.theme)) { - store.set('theme', data.theme); - this.setState({ theme: data.theme }); - } - } catch (e) {} - } - - onSignOut() { - api.logOut().then(() => { - store.set('user', {}); - this.setState({ user: {} }); - }); - } - - onSignIn(provider) { - const newWindow = window.open( - `${BASE_URL}/auth/${provider}/login?from=${encodeURIComponent( - location.origin + location.pathname + '?selfClose' - )}&site=${siteId}` - ); - - let secondsPass = 0; - const checkMsDelay = 300; - const checkInterval = setInterval(() => { - let shouldProceed; - secondsPass += checkMsDelay; - try { - shouldProceed = newWindow.closed || secondsPass > 30000; - } catch (e) {} - - if (shouldProceed) { - clearInterval(checkInterval); - - api - .getUser() - .then(user => { - store.set('user', user); - this.setState({ user }); - }) - .catch(() => {}); // TODO: we need to handle it and write error to user - } - }, checkMsDelay); - } - - onBlockedUsersShow() { - api.getBlocked().then(bannedUsers => { - this.setState({ bannedUsers, isBlockedVisible: true }); - }); - } - - onCommentsEnable() { - api.enableComments(siteId, url).then(() => { - const info = store.get('info'); - info.read_only = false; - this.setState({ info }); - }); - } - - onCommentsDisable() { - api.disableComments(siteId, url).then(() => { - const info = store.get('info'); - info.read_only = true; - this.setState({ info }); - }); - } - - onBlockedUsersHide() { - const { wasSomeoneUnblocked, sort } = this.state; - - // if someone was unblocked let's reload comments - if (wasSomeoneUnblocked) { - api.getPostComments({ sort, url }).then(({ comments, info } = {}) => { - store.set('comments', comments); - store.set('info', info); - }); - } - - this.setState({ - isBlockedVisible: false, - wasSomeoneUnblocked: false, - }); - } - - onSortChange(sort) { - if (sort === this.state.sort) return; - - this.setState({ sort, isCommentsListLoading: true }); - - try { - setCookie(COOKIE_SORT_KEY, sort, { expires: 60 * 60 * 24 * 365 }); // save sorting for a year - } catch (e) { - // can't save; ignore it - } - - api - .getPostComments({ sort, url }) - .then(({ comments, info } = {}) => { - store.set('comments', comments); - store.set('info', info); - }) - .finally(() => { - this.setState({ isCommentsListLoading: false }); - }); - } - - onUnblockSomeone() { - this.setState({ wasSomeoneUnblocked: true }); - } - - addComment(comment) { - store.addComment(comment); - } - - replaceComment(comment) { - store.replaceComment(comment); - } - - showMore() { - this.setState({ - commentsShown: this.state.commentsShown + MAX_SHOWN_ROOT_COMMENTS, - }); - } - - render( - props, - { - config = {}, - comments = [], - info = {}, - user, - sort, - isLoaded, - isBlockedVisible, - isCommentsListLoading, - bannedUsers, - commentsShown, - theme, - } - ) { - if (!isLoaded) { - return ( -
-
- -
-
- ); - } - - // TODO: i think we should do it on backend - const pinnedComments = store.getPinnedComments(); - const isGuest = !Object.keys(user).length; - const isCommentsDisabled = info != null && info.read_only === true; - - return ( -
-
- - - {!isBlockedVisible && ( -
- {!isGuest && !isCommentsDisabled && ( - - )} - - {!!pinnedComments.length && ( -
- {pinnedComments.map(comment => ( - - ))} -
- )} - - {!!comments.length && !isCommentsListLoading && ( -
- {(IS_MOBILE ? comments.slice(0, commentsShown) : comments).map(thread => ( - - ))} - - {commentsShown < comments.length && IS_MOBILE && ( - - )} -
- )} - - {isCommentsListLoading && ( -
- -
- )} -
- )} - - {isBlockedVisible && ( -
- -
- )} - -

- Powered by{' '} - - Remark42 - -

-
-
- ); - } -} diff --git a/web/app/components/root/root.tsx b/web/app/components/root/root.tsx new file mode 100644 index 00000000..69729734 --- /dev/null +++ b/web/app/components/root/root.tsx @@ -0,0 +1,319 @@ +/** @jsx h */ +import { h, Component, RenderableProps } from 'preact'; +import { connect } from 'preact-redux'; +import b from 'bem-react-helper'; + +import { + User, + Node, + PostInfo, + BlockedUser, + Comment as CommentType, + Tree, + Sorting, + Theme, + Provider, + BlockTTL, +} from '@app/common/types'; +import { + NODE_ID, + COMMENT_NODE_CLASSNAME_PREFIX, + MAX_SHOWN_ROOT_COMMENTS, + THEMES, + IS_MOBILE, +} from '@app/common/constants'; +import { maxShownComments } from '@app/common/settings'; + +import { StaticStore } from '@app/common/static_store'; +import { StoreState, StoreDispatch } from '@app/store'; +import { + fetchUser, + logout, + logIn, + blockUser, + unblockUser, + fetchBlockedUsers, + setBlockedVisibleState, +} from '@app/store/user/actions'; +import { fetchComments } from '@app/store/comments/actions'; +import { setCommentsReadOnlyState } from '@app/store/post_info/actions'; +import { setTheme } from '@app/store/theme/actions'; +import { setSort } from '@app/store/sort/actions'; +import { addComment, updateComment } from '@app/store/comments/actions'; + +import { AuthPanel } from '@app/components/auth-panel'; +import BlockedUsers from '@app/components/blocked-users'; +import { ConnectedComment as Comment } from '@app/components/comment/connected-comment'; +import { Input } from '@app/components/input'; +import Preloader from '@app/components/preloader'; +import { Thread } from '@app/components/thread'; + +interface Props { + user: User | null; + sort: Sorting; + comments: Node[]; + pinnedComments: CommentType[]; + theme: Theme; + info: PostInfo; + bannedUsers: BlockedUser[]; + isBlockedVisible: boolean; + + fetchComments(sort: Sorting): Promise; + fetchUser(): Promise; + fetchBlockedUsers(): Promise; + logIn(): Promise; + logOut(): Promise; + setTheme: (theme: Theme) => void; + setBlockedVisible: (value: boolean) => boolean; + changeSort(sort: Sorting): Promise; + enableComments(): Promise; + disableComments(): Promise; + getPreview(text: string): Promise; + blockUser(id: User['id'], name: User['name'], ttl: BlockTTL): Promise; + unblockUser(id: User['id']): Promise; + addComment(text: string, title: string, pid?: CommentType['id']): Promise; + updateComment(id: string, text: string): Promise; +} + +interface State { + isLoaded: boolean; + isCommentsListLoading: boolean; + commentsShown: number; + wasSomeoneUnblocked: boolean; +} + +/** main component fr main comments widget */ +export class Root extends Component { + constructor(props: Props) { + super(props); + + this.state = { + isLoaded: false, + isCommentsListLoading: false, + commentsShown: maxShownComments, + wasSomeoneUnblocked: false, + }; + + this.onBlockedUsersShow = this.onBlockedUsersShow.bind(this); + this.onBlockedUsersHide = this.onBlockedUsersHide.bind(this); + this.onUnblockSomeone = this.onUnblockSomeone.bind(this); + this.showMore = this.showMore.bind(this); + } + + async componentWillMount() { + Promise.all([this.props.fetchUser(), this.props.fetchComments(this.props.sort)]).finally(() => { + this.setState({ + isLoaded: true, + }); + + setTimeout(this.checkUrlHash); + window.addEventListener('hashchange', this.checkUrlHash); + }); + + window.addEventListener('message', this.onMessage.bind(this)); + } + + checkUrlHash( + e: Event & { + newURL?: string; + } + ) { + const hash = e ? `#${e.newURL!.split('#')[1]}` : window.location.hash; + + if (hash.indexOf(`#${COMMENT_NODE_CLASSNAME_PREFIX}`) === 0) { + if (e) e.preventDefault(); + + const comment = document.querySelector(hash); + + if (comment) { + setTimeout(() => { + window.parent.postMessage(JSON.stringify({ scrollTo: comment.getBoundingClientRect().top }), '*'); + }, 500); + } + } + } + + onMessage(event: { data: string | object }) { + try { + const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + if (data.theme && THEMES.includes(data.theme)) { + this.props.setTheme(data.theme); + } + } catch (e) { + console.error(e); // eslint-disable-line no-console + } + } + + onBlockedUsersShow() { + this.props.fetchBlockedUsers().then(() => { + this.props.setBlockedVisible(true); + }); + } + + onBlockedUsersHide() { + // if someone was unblocked let's reload comments + if (this.state.wasSomeoneUnblocked) { + this.props.fetchComments(this.props.sort); + } + this.props.setBlockedVisible(false), + this.setState({ + wasSomeoneUnblocked: false, + }); + } + + async changeSort(sort: Sorting) { + if (sort === this.props.sort) return; + this.setState({ isCommentsListLoading: true }); + await this.props.changeSort(sort).catch(() => {}); + this.setState({ isCommentsListLoading: false }); + } + + onUnblockSomeone() { + this.setState({ wasSomeoneUnblocked: true }); + } + + showMore() { + this.setState({ + commentsShown: this.state.commentsShown + MAX_SHOWN_ROOT_COMMENTS, + }); + } + + render(props: RenderableProps, { isLoaded, isCommentsListLoading, commentsShown }: State) { + if (!isLoaded) { + return ( +
+
+ +
+
+ ); + } + + const isGuest = !props.user; + const isCommentsDisabled = !!props.info.read_only; + + return ( +
+
+ + + {!this.props.isBlockedVisible && ( +
+ {!isGuest && !isCommentsDisabled && ( + this.props.addComment(text, title)} + getPreview={this.props.getPreview} + /> + )} + + {this.props.pinnedComments.length > 0 && ( +
+ {this.props.pinnedComments.map(comment => ( + + ))} +
+ )} + + {!!this.props.comments.length && !isCommentsListLoading && ( +
+ {(IS_MOBILE ? this.props.comments.slice(0, commentsShown) : this.props.comments).map(thread => ( + + ))} + + {commentsShown < this.props.comments.length && IS_MOBILE && ( + + )} +
+ )} + + {isCommentsListLoading && ( +
+ +
+ )} +
+ )} + + {this.props.isBlockedVisible && ( +
+ +
+ )} + +

+ Powered by{' '} + + Remark42 + +

+
+
+ ); + } +} + +const mapDispatchToProps = (dispatch: StoreDispatch) => { + return { + fetchComments: (sort: Sorting) => dispatch(fetchComments(sort)), + fetchUser: () => dispatch(fetchUser()), + fetchBlockedUsers: () => dispatch(fetchBlockedUsers()), + setBlockedVisible: (value: boolean) => dispatch(setBlockedVisibleState(value)), + logIn: (provider: Provider) => dispatch(logIn(provider)), + logOut: () => dispatch(logout()), + setTheme: (theme: Theme) => dispatch(setTheme(theme)), + enableComments: () => dispatch(setCommentsReadOnlyState(false)), + disableComments: () => dispatch(setCommentsReadOnlyState(true)), + changeSort: (sort: Sorting) => dispatch(setSort(sort)), + blockUser: (id: User['id'], name: User['name'], ttl: BlockTTL) => dispatch(blockUser(id, name, ttl)), + unblockUser: (id: User['id']) => dispatch(unblockUser(id)), + addComment: (text: string, pageTitle: string, pid?: CommentType['id']) => + dispatch(addComment(text, pageTitle, pid)), + updateComment: (id: CommentType['id'], text: string) => dispatch(updateComment(id, text)), + }; +}; + +/** Root component connected to redux */ +export const ConnectedRoot = connect( + (state: StoreState) => ({ + user: state.user, + sort: state.sort, + isBlockedVisible: state.isBlockedVisible, + comments: state.comments, + pinnedComments: state.pinnedComments, + theme: state.theme, + info: state.info, + bannedUsers: state.bannedUsers, + }), + mapDispatchToProps +)(Root); diff --git a/web/app/components/thread/collapsedCommentsMiddleware.js b/web/app/components/thread/collapsedCommentsMiddleware.js deleted file mode 100644 index 337fc49e..00000000 --- a/web/app/components/thread/collapsedCommentsMiddleware.js +++ /dev/null @@ -1,28 +0,0 @@ -import { siteId, url } from 'common/settings'; -import { THREAD_SET_COLLAPSE } from './thread.actions'; -import getCollapsedComments from './getCollapsedComments'; -import saveCollapsedComments from './saveCollapsedComments'; - -const collapsedCommentsMiddleware = ({ getState }) => next => action => { - if (action.type === THREAD_SET_COLLAPSE) { - const state = getState(); - const currentCollapsed = state[action.comment.id]; - - if (action.collapsed !== currentCollapsed) { - const lsCollapsedID = `${siteId}_${url}_${action.comment.id}`; - let collapsedComments = getCollapsedComments(); - - if (action.collapsed) { - collapsedComments = [...new Set(collapsedComments.concat(lsCollapsedID))]; - } else { - collapsedComments = collapsedComments.filter(id => id !== lsCollapsedID); - } - - saveCollapsedComments(collapsedComments); - } - } - - next(action); -}; - -export default collapsedCommentsMiddleware; diff --git a/web/app/components/thread/getCollapsedComments.js b/web/app/components/thread/getCollapsedComments.js deleted file mode 100644 index a239b0fa..00000000 --- a/web/app/components/thread/getCollapsedComments.js +++ /dev/null @@ -1,6 +0,0 @@ -import { LS_COLLAPSE_KEY } from 'common/constants'; -import { getItem as localStorageGetItem } from 'common/localStorage'; - -const getCollapsedComments = () => JSON.parse(localStorageGetItem(LS_COLLAPSE_KEY) || '[]'); - -export default getCollapsedComments; diff --git a/web/app/components/thread/index.js b/web/app/components/thread/index.js deleted file mode 100644 index 6691e307..00000000 --- a/web/app/components/thread/index.js +++ /dev/null @@ -1,8 +0,0 @@ -import { collapsedThreads } from './thread.reducers'; -import collapsedCommentsMiddleware from './collapsedCommentsMiddleware'; - -export const threadReducers = { collapsedThreads }; - -export const threadMiddlewares = [collapsedCommentsMiddleware]; - -export { default } from './thread'; diff --git a/web/app/components/thread/index.ts b/web/app/components/thread/index.ts new file mode 100644 index 00000000..857d107f --- /dev/null +++ b/web/app/components/thread/index.ts @@ -0,0 +1 @@ +export { ConnectedThread as Thread } from './thread'; diff --git a/web/app/components/thread/saveCollapsedComments.js b/web/app/components/thread/saveCollapsedComments.js deleted file mode 100644 index 53b29b62..00000000 --- a/web/app/components/thread/saveCollapsedComments.js +++ /dev/null @@ -1,6 +0,0 @@ -import { LS_COLLAPSE_KEY } from 'common/constants'; -import { setItem as localStorageSetItem } from 'common/localStorage'; - -const saveCollapsedComments = comments => localStorageSetItem(LS_COLLAPSE_KEY, JSON.stringify(comments)); - -export default saveCollapsedComments; diff --git a/web/app/components/thread/thread.actions.js b/web/app/components/thread/thread.actions.js deleted file mode 100644 index a3693e7b..00000000 --- a/web/app/components/thread/thread.actions.js +++ /dev/null @@ -1,6 +0,0 @@ -export const THREAD_SET_COLLAPSE = 'THREAD/COLLAPSE_SET'; -export const setCollapse = (comment, collapsed) => ({ - type: THREAD_SET_COLLAPSE, - comment, - collapsed, -}); diff --git a/web/app/components/thread/thread.getters.js b/web/app/components/thread/thread.getters.js deleted file mode 100644 index fc534815..00000000 --- a/web/app/components/thread/thread.getters.js +++ /dev/null @@ -1,14 +0,0 @@ -import store from 'common/store'; - -export const getThreadIsCollapsed = (state, comment) => { - let collapsed = state.collapsedThreads[comment.id]; - - if (collapsed !== null && collapsed !== undefined) { - return collapsed; - } - - const config = store.get('config') || {}; - const score = comment.score || 0; - - return score <= config.critical_score; -}; diff --git a/web/app/components/thread/thread.getters.test.js b/web/app/components/thread/thread.getters.test.js deleted file mode 100644 index e8fc6791..00000000 --- a/web/app/components/thread/thread.getters.test.js +++ /dev/null @@ -1,31 +0,0 @@ -import store from 'common/store'; -import { getThreadIsCollapsed } from './thread.getters'; - -describe('collapsedThreads', () => { - const notScoredComment = { id: 1 }; - const goodComment = { id: 1, score: 3 }; - const badComment = { id: 1, score: -1 }; - - it('takes value from the state', () => { - const state = { collapsedThreads: { [notScoredComment.id]: true } }; - const collapsed = getThreadIsCollapsed(state, notScoredComment); - expect(collapsed).toEqual(true); - }); - - beforeAll(() => { - const config = { critical_score: 2 }; - store.set('config', config); - }); - - it('returns true when score is less then critical_score', () => { - const state = { collapsedThreads: {} }; - const collapsed = getThreadIsCollapsed(state, badComment); - expect(collapsed).toEqual(true); - }); - - it('returns true when score is less then critical_score', () => { - const state = { collapsedThreads: {} }; - const collapsed = getThreadIsCollapsed(state, goodComment); - expect(collapsed).toEqual(false); - }); -}); diff --git a/web/app/components/thread/thread.jsx b/web/app/components/thread/thread.jsx deleted file mode 100644 index af7240f9..00000000 --- a/web/app/components/thread/thread.jsx +++ /dev/null @@ -1,64 +0,0 @@ -/** @jsx h */ -import { h, Component } from 'preact'; -import { connect } from 'preact-redux'; - -import Comment from 'components/comment'; -import { setCollapse } from './thread.actions'; -import { getThreadIsCollapsed } from './thread.getters'; - -class Thread extends Component { - constructor(props) { - super(props); - this.onCollapseToggle = this.onCollapseToggle.bind(this); - } - - onCollapseToggle() { - this.props.setCollapse(this.props.data.comment, !this.props.collapsed); - } - - render(props) { - const { - collapsed, - data: { comment, replies = [] }, - mods = {}, - isCommentsDisabled, - } = props; - - return ( -
- - - {!collapsed && - !!replies.length && - replies.map(thread => ( - - ))} -
- ); - } -} - -const ConnectedThread = connect( - (state, props) => ({ collapsed: getThreadIsCollapsed(state, props.data.comment) }), - { setCollapse } -)(Thread); - -export default ConnectedThread; diff --git a/web/app/components/thread/thread.reducers.js b/web/app/components/thread/thread.reducers.js deleted file mode 100644 index ef5adf55..00000000 --- a/web/app/components/thread/thread.reducers.js +++ /dev/null @@ -1,22 +0,0 @@ -import { siteId, url } from 'common/settings'; -import { THREAD_SET_COLLAPSE } from './thread.actions'; -import getCollapsedComments from './getCollapsedComments'; - -const collapsedCommentIds = getCollapsedComments() - .map(comment => comment.split('_')) - .filter(components => components[0] === siteId && components[1] === url) - .map(component => component[2]); - -const initialState = collapsedCommentIds.reduce((acc, id) => ({ ...acc, [id]: true }), {}); - -export const collapsedThreads = (state = initialState, action) => { - switch (action.type) { - case THREAD_SET_COLLAPSE: - return { - ...state, - [action.comment.id]: action.collapsed, - }; - default: - return state; - } -}; diff --git a/web/app/components/thread/thread.reducers.test.js b/web/app/components/thread/thread.reducers.test.js deleted file mode 100644 index 954feaee..00000000 --- a/web/app/components/thread/thread.reducers.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import { setCollapse } from './thread.actions'; -import { collapsedThreads } from './thread.reducers'; - -describe('collapsedThreads', () => { - const comment = { id: 1 }; - - it('should set collapsed to true', () => { - const action = setCollapse(comment, true); - const newState = collapsedThreads({}, action); - expect(newState).toEqual({ [comment.id]: true }); - }); - - it('should set collapsed to false', () => { - const action = setCollapse(comment, false); - const newState = collapsedThreads({}, action); - expect(newState).toEqual({ [comment.id]: false }); - }); -}); diff --git a/web/app/components/thread/thread.tsx b/web/app/components/thread/thread.tsx new file mode 100644 index 00000000..888526da --- /dev/null +++ b/web/app/components/thread/thread.tsx @@ -0,0 +1,53 @@ +/** @jsx h */ +import { h, RenderableProps } from 'preact'; +import { connect } from 'preact-redux'; +import b from 'bem-react-helper'; + +import { ConnectedComment as Comment } from '@app/components/comment/connected-comment'; +import { Node } from '@app/common/types'; +import { getThreadIsCollapsed } from '@app/store/thread/getters'; +import { StoreState } from '@app/store'; + +interface Props { + collapsed: boolean; + data: Node; + isCommentsDisabled: boolean; + level: number; + mix?: string; + + getPreview(text: string): Promise; +} + +function Thread(props: RenderableProps) { + const { + collapsed, + data: { comment, replies = [] }, + level, + } = props; + + return ( +
+ + + {!collapsed && + !!replies.length && + replies.map(thread => ( + + ))} +
+ ); +} + +export const ConnectedThread = connect((state: StoreState, props: { data: Node }) => ({ + collapsed: getThreadIsCollapsed(state, props.data.comment), + isCommentsDisabled: !!state.info.read_only, +}))(Thread); diff --git a/web/app/components/user-info/index.js b/web/app/components/user-info/index.ts similarity index 52% rename from web/app/components/user-info/index.js rename to web/app/components/user-info/index.ts index 84ef920e..067cda79 100644 --- a/web/app/components/user-info/index.js +++ b/web/app/components/user-info/index.ts @@ -1,8 +1,4 @@ -import { userComments, isLoadingUserComments } from './user-info.reducers'; - -export const userInfoReducers = { userComments, isLoadingUserComments }; - -export { default } from './user-info'; +export { ConnectedUserInfo as UserInfo } from './user-info'; require('./user-info.scss'); diff --git a/web/app/components/user-info/last-comments-list.jsx b/web/app/components/user-info/last-comments-list.jsx deleted file mode 100644 index a46e48ae..00000000 --- a/web/app/components/user-info/last-comments-list.jsx +++ /dev/null @@ -1,21 +0,0 @@ -/** @jsx h */ -import { h } from 'preact'; - -import Comment from 'components/comment'; -import Preloader from 'components/preloader'; - -const LastCommentsList = ({ comments, isLoading, mods = {} }) => { - if (isLoading) { - return ; - } - - return ( -
- {comments.map(comment => ( - - ))} -
- ); -}; - -export default LastCommentsList; diff --git a/web/app/components/user-info/last-comments-list.tsx b/web/app/components/user-info/last-comments-list.tsx new file mode 100644 index 00000000..a0e89116 --- /dev/null +++ b/web/app/components/user-info/last-comments-list.tsx @@ -0,0 +1,31 @@ +/** @jsx h */ +import { h } from 'preact'; + +import { Comment as CommentType } from '@app/common/types'; + +import { Comment } from '../comment'; +import Preloader from '../preloader'; + +const LastCommentsList = ({ comments, isLoading }: { comments: CommentType[]; isLoading: boolean }) => { + if (isLoading) { + return ; + } + + return ( +
+ {comments.map(comment => ( + + ))} +
+ ); +}; + +export default LastCommentsList; diff --git a/web/app/components/user-info/user-info.actions.js b/web/app/components/user-info/user-info.actions.js deleted file mode 100644 index 403ca402..00000000 --- a/web/app/components/user-info/user-info.actions.js +++ /dev/null @@ -1,12 +0,0 @@ -export const USER_INFO_FETCH_COMMENTS = 'USER_INFO/FETCH_COMMENTS'; -export const fetchComments = userId => ({ - type: USER_INFO_FETCH_COMMENTS, - userId, -}); - -export const USER_INFO_COMPLETE_FETCH_COMMENTS = 'USER_INFO/COMPLETE_FETCH_COMMENTS'; -export const completeFetchComments = (userId, comments) => ({ - type: USER_INFO_COMPLETE_FETCH_COMMENTS, - userId, - comments, -}); diff --git a/web/app/components/user-info/user-info.getters.js b/web/app/components/user-info/user-info.getters.js deleted file mode 100644 index c5067547..00000000 --- a/web/app/components/user-info/user-info.getters.js +++ /dev/null @@ -1,2 +0,0 @@ -export const getUserComments = (state, userId) => state.userComments[userId] || null; -export const getIsLoadingUserComments = (state, userId) => state.isLoadingUserComments[userId] || false; diff --git a/web/app/components/user-info/user-info.getters.test.js b/web/app/components/user-info/user-info.getters.test.js deleted file mode 100644 index c59118ac..00000000 --- a/web/app/components/user-info/user-info.getters.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import { getUserComments, getIsLoadingUserComments } from './user-info.getters'; - -describe('getUserComments', () => { - const userId = 1; - const comments = [{ id: 1 }]; - - it('returns null when comments have not been loaded', () => { - const state = { userComments: {} }; - const userComments = getUserComments(state, userId); - expect(userComments).toEqual(null); - }); - - it('returns comments when comments have been loaded', () => { - const state = { userComments: { [userId]: comments } }; - const userComments = getUserComments(state, userId); - expect(userComments).toEqual(comments); - }); -}); - -describe('getIsLoadingUserComments', () => { - const userId = 1; - - it('returns false when state is empty', () => { - const state = { isLoadingUserComments: {} }; - const loading = getIsLoadingUserComments(state, userId); - expect(loading).toEqual(false); - }); - - it('returns false when comments are not loading', () => { - const state = { isLoadingUserComments: { [userId]: false } }; - const loading = getIsLoadingUserComments(state, userId); - expect(loading).toEqual(false); - }); - - it('returns true when comments are loading', () => { - const state = { isLoadingUserComments: { [userId]: true } }; - const loading = getIsLoadingUserComments(state, userId); - expect(loading).toEqual(true); - }); -}); diff --git a/web/app/components/user-info/user-info.jsx b/web/app/components/user-info/user-info.jsx deleted file mode 100644 index e3b1ce30..00000000 --- a/web/app/components/user-info/user-info.jsx +++ /dev/null @@ -1,76 +0,0 @@ -/** @jsx h */ -import { h, Component } from 'preact'; -import { connect } from 'preact-redux'; - -import store from 'common/store'; -import api from 'common/api'; -import LastCommentsList from './last-comments-list'; -import Avatar from 'components/avatar-icon'; -import { fetchComments, completeFetchComments } from './user-info.actions'; -import { getUserComments, getIsLoadingUserComments } from './user-info.getters'; - -class UserInfo extends Component { - componentWillMount() { - const { - user: { id }, - comments, - isLoading, - fetchComments, - completeFetchComments, - } = this.props; - - if (!comments && !isLoading) { - fetchComments(id); - - api - .getUser() - .then(data => store.set('user', data)) - .catch(() => store.set('user', {})); - - api - .getUserComments({ user: id, limit: 10 }) - .then(({ comments }) => completeFetchComments(id, comments)) - .catch(() => completeFetchComments(id, [])); - } - - document.addEventListener('keydown', this.globalOnKeyDown); - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.globalOnKeyDown); - } - - globalOnKeyDown(e) { - // ESCAPE key pressed - if (e.keyCode === 27) { - const data = JSON.stringify({ isUserInfoShown: false }); - window.parent.postMessage(data, '*'); - } - } - - render(props) { - const { - user: { name, id, isDefaultPicture, picture }, - comments = [], - isLoading, - } = props; - - return ( -
- -

Last comments by {name}

-

{id}

- - {!!comments && } -
- ); - } -} - -export default connect( - (state, props) => ({ - comments: getUserComments(state, props.user.id), - isLoading: getIsLoadingUserComments(state, props.user.id), - }), - { fetchComments, completeFetchComments } -)(UserInfo); diff --git a/web/app/components/user-info/user-info.reducers.js b/web/app/components/user-info/user-info.reducers.js deleted file mode 100644 index 23e58b7d..00000000 --- a/web/app/components/user-info/user-info.reducers.js +++ /dev/null @@ -1,37 +0,0 @@ -import { USER_INFO_FETCH_COMMENTS, USER_INFO_COMPLETE_FETCH_COMMENTS } from './user-info.actions'; - -export const userComments = (state = {}, action) => { - switch (action.type) { - case USER_INFO_FETCH_COMMENTS: { - return { - ...state, - [action.userId]: [], - }; - } - case USER_INFO_COMPLETE_FETCH_COMMENTS: - return { - ...state, - [action.userId]: action.comments, - }; - default: - return state; - } -}; - -export const isLoadingUserComments = (state = {}, action) => { - switch (action.type) { - case USER_INFO_FETCH_COMMENTS: { - return { - ...state, - [action.userId]: true, - }; - } - case USER_INFO_COMPLETE_FETCH_COMMENTS: - return { - ...state, - [action.userId]: false, - }; - default: - return state; - } -}; diff --git a/web/app/components/user-info/user-info.reducers.test.js b/web/app/components/user-info/user-info.reducers.test.js deleted file mode 100644 index 12d6c8b4..00000000 --- a/web/app/components/user-info/user-info.reducers.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import { fetchComments, completeFetchComments } from './user-info.actions'; -import { userComments, isLoadingUserComments } from './user-info.reducers'; - -const userId = 1; -const comments = [{ id: 1 }]; - -describe('userComments', () => { - it('should return {} by default', () => { - const action = { type: 'OTHER' }; - const newState = userComments({}, action); - expect(newState).toEqual({}); - }); - - it('should return [] on USER_INFO_FETCH_COMMENTS', () => { - const action = fetchComments(userId); - const newState = userComments({}, action); - expect(newState).toEqual({ [userId]: [] }); - }); - - it('should return comments on USER_INFO_COMPLETE_FETCH_COMMENTS', () => { - const action = completeFetchComments(userId, comments); - const newState = userComments({}, action); - expect(newState).toEqual({ [userId]: comments }); - }); -}); - -describe('isLoadingUserComments', () => { - it('should return {} by default', () => { - const action = { type: 'OTHER' }; - const newState = isLoadingUserComments({}, action); - expect(newState).toEqual({}); - }); - - it('should return [] on USER_INFO_FETCH_COMMENTS', () => { - const action = fetchComments(userId); - const newState = isLoadingUserComments({}, action); - expect(newState).toEqual({ [userId]: true }); - }); - - it('should return comments on USER_INFO_COMPLETE_FETCH_COMMENTS', () => { - const action = completeFetchComments(userId, comments); - const newState = isLoadingUserComments({}, action); - expect(newState).toEqual({ [userId]: false }); - }); -}); diff --git a/web/app/components/user-info/user-info.tsx b/web/app/components/user-info/user-info.tsx new file mode 100644 index 00000000..68295118 --- /dev/null +++ b/web/app/components/user-info/user-info.tsx @@ -0,0 +1,92 @@ +/** @jsx h */ +import { h, Component, RenderableProps } from 'preact'; +import b from 'bem-react-helper'; +import { connect } from 'preact-redux'; + +import { StoreState, StoreDispatch } from '@app/store'; +import { Comment } from '@app/common/types'; +import { fetchInfo } from '@app/store/user-info/actions'; +import { userInfo } from '@app/common/user-info-settings'; + +import LastCommentsList from './last-comments-list'; +import { AvatarIcon } from '../avatar-icon'; + +interface Props { + comments: Comment[] | null; + fetchInfo: () => Promise; +} + +interface State { + isLoading: boolean; + error: string | null; +} + +class UserInfo extends Component { + state = { isLoading: true, error: null }; + + componentWillMount(): void { + if (!this.props.comments && this.state.isLoading) { + this.props + .fetchInfo() + .then(() => { + this.setState({ isLoading: false }); + }) + .catch(() => { + this.setState({ isLoading: false, error: 'Something went wrong' }); + }); + } + + document.addEventListener('keydown', UserInfo.onKeyDown); + } + + componentWillUnmount(): void { + document.removeEventListener('keydown', UserInfo.onKeyDown); + } + + render(props: RenderableProps, state: State): JSX.Element | null { + const user = userInfo; + const { comments = [] } = props; + + // TODO: handle + if (!user) { + return null; + } + + return ( +
+ +

Last comments by {user.name}

+

{user.id}

+ + {!!comments && } +
+ ); + } + + /** + * Global on `keydown` handler which is set on component mount. + * Listens for user's `esc` key press + */ + static onKeyDown(e: KeyboardEvent): void { + // ESCAPE key pressed + if (e.keyCode === 27) { + const data = JSON.stringify({ isUserInfoShown: false }); + window.parent.postMessage(data, '*'); + } + } +} + +const mapDispatchToProps = (dispatch: StoreDispatch) => ({ + fetchInfo: () => dispatch(fetchInfo()), +}); + +export const ConnectedUserInfo = connect( + ( + state: StoreState + ): { + comments: Comment[] | null; + } => ({ + comments: state.userComments![userInfo.id!], + }), + mapDispatchToProps +)(UserInfo); diff --git a/web/app/components/with-theme/index.js b/web/app/components/with-theme/index.js deleted file mode 100644 index 38fb0793..00000000 --- a/web/app/components/with-theme/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './with-theme'; diff --git a/web/app/components/with-theme/index.ts b/web/app/components/with-theme/index.ts new file mode 100644 index 00000000..1cac69c8 --- /dev/null +++ b/web/app/components/with-theme/index.ts @@ -0,0 +1,15 @@ +/** @jsx h */ +import { AnyComponent } from 'preact'; +import { connect } from 'preact-redux'; +import { StoreState } from '@app/store'; +import { Theme } from '@app/common/types'; + +/** + * Connects redux theme property to component's + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function withTheme

(PlainComponent: AnyComponent) { + return connect((state: StoreState) => ({ theme: state.theme }))(PlainComponent); +} + +export default withTheme; diff --git a/web/app/components/with-theme/with-theme.jsx b/web/app/components/with-theme/with-theme.jsx deleted file mode 100644 index 0bfd461e..00000000 --- a/web/app/components/with-theme/with-theme.jsx +++ /dev/null @@ -1,16 +0,0 @@ -/** @jsx h */ -import { h } from 'preact'; -import store from 'common/store'; - -const withTheme = PlainComponent => { - const ThemedComponent = props => { - const { mods = {} } = props; - const theme = store.get('theme'); - - return ; - }; - - return ThemedComponent; -}; - -export default withTheme; diff --git a/web/app/counter.js b/web/app/counter.js deleted file mode 100644 index 19bd7c2d..00000000 --- a/web/app/counter.js +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable no-console */ -import { COUNTER_NODE_CLASSNAME } from './common/constants'; - -import api from 'common/api'; - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); -} else { - init(); -} - -function init() { - const nodes = [].slice.call(document.getElementsByClassName(COUNTER_NODE_CLASSNAME)); - - if (!nodes) { - console.error("Remark42: Can't find counter nodes."); - return; - } - - try { - remark_config = remark_config || {}; - } catch (e) { - console.error('Remark42: Config object is undefined.'); - return; - } - - if (!remark_config.site_id) { - console.error('Remark42: Site ID is undefined.'); - return; - } - - const map = nodes.reduce((acc, node) => { - acc[node.dataset.url || remark_config.url || window.location.href] = node; - return acc; - }, {}); - - api.getCommentsCount({ urls: Object.keys(map), siteId: remark_config.site_id }).then(res => { - res.forEach(item => (map[item.url].innerHTML = item.count)); - }); -} diff --git a/web/app/counter.ts b/web/app/counter.ts new file mode 100644 index 00000000..8e51de3f --- /dev/null +++ b/web/app/counter.ts @@ -0,0 +1,47 @@ +/* eslint-disable no-console */ +declare let remark_config: CounterConfig; + +import loadPolyfills from '@app/common/polyfills'; +import api from './common/api'; +import { COUNTER_NODE_CLASSNAME } from '@app/common/constants'; +import { CounterConfig } from '@app/common/config-types'; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} + +async function init(): Promise { + await loadPolyfills(); + + const nodes: HTMLElement[] = [].slice.call(document.getElementsByClassName(COUNTER_NODE_CLASSNAME)); + + if (!nodes) { + console.error("Remark42: Can't find counter nodes."); + return; + } + + try { + remark_config = remark_config || {}; + } catch (e) { + console.error('Remark42: Config object is undefined.'); + return; + } + + if (!remark_config.site_id) { + console.error('Remark42: Site ID is undefined.'); + return; + } + + const map = nodes.reduce<{ [key: string]: HTMLElement[] }>((acc, node) => { + const id = node.dataset.url || remark_config.url || window.location.href; + if (!acc[id]) acc[id] = []; + acc[id].push(node); + return acc; + }, {}); + + api.getCommentsCount(remark_config.site_id, Object.keys(map)).then(res => { + res.forEach(item => map[item.url].map(n => (n.innerHTML = item.count.toString(10)))); + }); +} diff --git a/web/app/deleteme.js b/web/app/deleteme.js deleted file mode 100644 index ee755cdb..00000000 --- a/web/app/deleteme.js +++ /dev/null @@ -1,44 +0,0 @@ -/* eslint-disable no-console */ -import { NODE_ID, BASE_URL } from 'common/constants'; -import { approveDeleteMe, getUser } from 'common/api'; -import { token } from 'common/settings'; - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); -} else { - init(); -} - -function init() { - const node = document.getElementById(NODE_ID); - - if (!node) { - console.error("Remark42: Can't find root node."); - return; - } - - getUser() - .then(user => { - if (!user.admin) { - handleNotAuthorizedError(node); - return; - } - - approveDeleteMe(token).then( - data => - (node.innerHTML = ` -

User deleted successfully

-
${JSON.stringify(data, null, 4)}
`), - err => - (node.innerHTML = ` -

Something went wrong

-
${err}
`) - ); - }) - .catch(() => handleNotAuthorizedError(node)); -} - -function handleNotAuthorizedError(node) { - node.innerHTML = `

You are not logged in

-

Sign in as admin to delete user information

`; -} diff --git a/web/app/deleteme.ts b/web/app/deleteme.ts new file mode 100644 index 00000000..afd56290 --- /dev/null +++ b/web/app/deleteme.ts @@ -0,0 +1,52 @@ +/* eslint-disable no-console */ +import loadPolyfills from '@app/common/polyfills'; +import { NODE_ID, BASE_URL } from '@app/common/constants'; +import { approveDeleteMe, getUser } from '@app/common/api'; +import { token } from '@app/common/settings'; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} + +async function init(): Promise { + await loadPolyfills(); + const node = document.getElementById(NODE_ID); + + if (!node) { + console.error("Remark42: Can't find root node."); + return; + } + + getUser() + .then(user => { + if (user && !user.admin) { + handleNotAuthorizedError(node); + return; + } + + approveDeleteMe(token!).then( + data => { + node.innerHTML = ` +

User deleted successfully

+
${JSON.stringify(data, null, 4)}
+ `; + }, + err => { + node.innerHTML = ` +

Something went wrong

+
${err}
+ `; + } + ); + }) + .catch(() => handleNotAuthorizedError(node)); +} + +function handleNotAuthorizedError(node: HTMLElement): void { + node.innerHTML = ` +

You are not logged in

+

Sign in as admin to delete user information

+ `; +} diff --git a/web/app/embed.js b/web/app/embed.ts similarity index 73% rename from web/app/embed.js rename to web/app/embed.ts index 146a63c2..1be4eab4 100644 --- a/web/app/embed.js +++ b/web/app/embed.ts @@ -1,5 +1,11 @@ -/* eslint-disable no-console */ -import { BASE_URL, NODE_ID, COMMENT_NODE_CLASSNAME_PREFIX } from 'common/constants'; +/* eslint-disable no-console, @typescript-eslint/no-explicit-any */ + +declare let remark_config: CommentsConfig; + +import loadPolyfills from '@app/common/polyfills'; +import { BASE_URL, NODE_ID, COMMENT_NODE_CLASSNAME_PREFIX } from '@app/common/constants'; +import { UserInfo, Theme } from '@app/common/types'; +import { CommentsConfig } from '@app/common/config-types'; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); @@ -7,7 +13,9 @@ if (document.readyState === 'loading') { init(); } -function init() { +async function init(): Promise { + await loadPolyfills(); + const node = document.getElementById(NODE_ID); if (!node) { @@ -29,11 +37,11 @@ function init() { remark_config.url = (remark_config.url || window.location.href).split('#')[0]; - window.REMARK42 = window.REMARK42 || {}; - window.REMARK42.changeTheme = changeTheme; + (window as any).REMARK42 = (window as any).REMARK42 || {}; + (window as any).REMARK42.changeTheme = changeTheme; const query = Object.keys(remark_config) - .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(remark_config[key])}`) + .map((key: any) => `${encodeURIComponent(key)}=${encodeURIComponent((remark_config as any)[key])}`) .join('&'); node.innerHTML = ` @@ -57,13 +65,31 @@ function init() { window.addEventListener('hashchange', postHashToIframe); document.addEventListener('click', postClickOutsideToIframe); - new MutationObserver(mutations => postTitleToIframe(mutations[0].target.textContent)).observe( - document.querySelector('title'), - { subtree: true, characterData: true, childList: true } - ); + const titleElement = document.querySelector('title'); + if (titleElement) { + new MutationObserver(mutations => postTitleToIframe(mutations[0].target.textContent!)).observe(titleElement, { + subtree: true, + characterData: true, + childList: true, + }); + } const remarkRootId = 'remark-km423lmfdslkm34'; - const userInfo = { + const userInfo: { + node: HTMLElement | null; + back: HTMLElement | null; + closeEl: HTMLElement | null; + iframe: HTMLIFrameElement | null; + style: HTMLStyleElement | null; + init: (user: UserInfo) => void; + close: () => void; + delay: number | null; + events: string[]; + onAnimationClose: () => void; + onKeyDown: (e: KeyboardEvent) => void; + animationStop: () => void; + remove: () => void; + } = { node: null, back: null, closeEl: null, @@ -164,9 +190,9 @@ function init() { document.body.appendChild(this.node); document.addEventListener('keydown', this.onKeyDown); setTimeout(() => { - this.back.setAttribute('data-animation', ''); - this.node.setAttribute('data-animation', ''); - this.iframe.focus(); + this.back!.setAttribute('data-animation', ''); + this.node!.setAttribute('data-animation', ''); + this.iframe!.focus(); }, 400); }, close() { @@ -182,16 +208,16 @@ function init() { delay: null, events: ['', 'webkit', 'moz', 'MS', 'o'].map(prefix => (prefix ? `${prefix}TransitionEnd` : 'transitionend')), onAnimationClose() { - const el = this.node; + const el = this.node!; if (!this.node) { return; } - this.delay = setTimeout(this.animationStop, 1000); + this.delay = window.setTimeout(this.animationStop, 1000); this.events.forEach(event => el.addEventListener(event, this.animationStop, false)); }, onKeyDown(e) { // ESCAPE key pressed - if (e.keyCode == 27) { + if (e.keyCode === 27) { userInfo.close(); } }, @@ -204,7 +230,7 @@ function init() { clearTimeout(t.delay); t.delay = null; } - t.events.forEach(event => t.node.removeEventListener(event, t.animationStop, false)); + t.events.forEach(event => t.node!.removeEventListener(event, t.animationStop, false)); return t.remove(); }, remove() { @@ -215,7 +241,7 @@ function init() { }, }; - function receiveMessages(event) { + function receiveMessages(event: { data?: string }): void { try { const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; if (data.remarkIframeHeight) { @@ -241,27 +267,31 @@ function init() { } catch (e) {} } - function postHashToIframe(e) { + function postHashToIframe( + e?: Event & { + newURL: string; + } + ): void { const hash = e ? `#${e.newURL.split('#')[1]}` : window.location.hash; if (hash.indexOf(`#${COMMENT_NODE_CLASSNAME_PREFIX}`) === 0) { if (e) e.preventDefault(); - iframe.contentWindow.postMessage(JSON.stringify({ hash }), '*'); + iframe.contentWindow!.postMessage(JSON.stringify({ hash }), '*'); } } - function postTitleToIframe(title) { - iframe.contentWindow.postMessage(JSON.stringify({ title }), '*'); + function postTitleToIframe(title: string): void { + iframe.contentWindow!.postMessage(JSON.stringify({ title }), '*'); } - function postClickOutsideToIframe(e) { - if (!iframe.contains(e.target)) { - iframe.contentWindow.postMessage(JSON.stringify({ clickOutside: true }), '*'); + function postClickOutsideToIframe(e: MouseEvent): void { + if (!iframe.contains(e.target as Node)) { + iframe.contentWindow!.postMessage(JSON.stringify({ clickOutside: true }), '*'); } } - function changeTheme(theme) { - iframe.contentWindow.postMessage(JSON.stringify({ theme }), '*'); + function changeTheme(theme: Theme): void { + iframe.contentWindow!.postMessage(JSON.stringify({ theme }), '*'); } } diff --git a/web/app/last-comments.js b/web/app/last-comments.tsx similarity index 57% rename from web/app/last-comments.js rename to web/app/last-comments.tsx index 1f0a1413..7ab46d87 100644 --- a/web/app/last-comments.js +++ b/web/app/last-comments.tsx @@ -1,13 +1,14 @@ -/* eslint-disable no-console */ +/* eslint-disable no-console, @typescript-eslint/camelcase */ /** @jsx h */ +declare let remark_config: LastCommentsConfig; + +import loadPolyfills from '@app/common/polyfills'; import { h, render } from 'preact'; import 'preact/debug'; - -import { BASE_URL, DEFAULT_LAST_COMMENTS_MAX, LAST_COMMENTS_NODE_CLASSNAME } from './common/constants'; - -import api from 'common/api'; - -import ListComments from 'components/list-comments'; +import { getLastComments } from './common/api'; +import { LastCommentsConfig } from '@app/common/config-types'; +import { BASE_URL, DEFAULT_LAST_COMMENTS_MAX, LAST_COMMENTS_NODE_CLASSNAME } from '@app/common/constants'; +import { ListComments } from '@app/components/list-comments'; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); @@ -15,7 +16,9 @@ if (document.readyState === 'loading') { init(); } -function init() { +async function init(): Promise { + await loadPolyfills(); + const nodes = document.getElementsByClassName(LAST_COMMENTS_NODE_CLASSNAME); if (!nodes) { @@ -36,13 +39,16 @@ function init() { } const styles = document.createElement('link'); - styles.href = `${BASE_URL}/web/remark.css`; + styles.href = `${BASE_URL}/web/last-comments.css`; styles.rel = 'stylesheet'; (document.head || document.body).appendChild(styles); - [].slice.call(nodes).forEach(node => { - const max = node.dataset.max || remark_config.max_last_comments || DEFAULT_LAST_COMMENTS_MAX; - api.getLastComments({ max, siteId: remark_config.site_id }).then(comments => { + ([].slice.call(nodes) as HTMLElement[]).forEach(node => { + const max = + (node.dataset.max && parseInt(node.dataset.max, 10)) || + remark_config.max_last_comments || + DEFAULT_LAST_COMMENTS_MAX; + getLastComments(remark_config.site_id!, max).then(comments => { try { render(, node); } catch (e) { diff --git a/web/app/remark.js b/web/app/remark.js deleted file mode 100644 index a4e7a751..00000000 --- a/web/app/remark.js +++ /dev/null @@ -1,79 +0,0 @@ -/* eslint-disable no-console */ -/** @jsx h */ -import loadPolyfills from 'common/polyfills'; - -import { h, render } from 'preact'; -import 'preact/debug'; -import { Provider } from 'preact-redux'; -import Root from './components/root'; -import UserInfo from 'components/user-info'; -import store from 'common/store'; -import reduxStore from './store'; - -// eslint-disable-next-line no-unused-vars -import ListComments from './components/list-comments'; // TODO: temp solution for extracting styles - -import { NODE_ID } from './common/constants'; - -loadPolyfills().then(() => { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -}); - -function init() { - const node = document.getElementById(NODE_ID); - - if (!node) { - console.error("Remark42: Can't find root node."); - return; - } - - const params = window.location.search - .replace(/^\?/, '') - .split('&') - .reduce((memo, value) => { - const vals = value.split('='); - if (vals.length === 2) { - memo[vals[0]] = vals[1]; - } - return memo; - }, {}); - - if (params.page === 'user-info') { - const user = { - id: params.id, - name: decodeURIComponent(params.name) || '', - isDefaultPicture: params.isDefaultPicture != 0, - picture: params.picture, - }; - store.set('user', {}); - store.set('target-user', user); - const onClose = () => { - if (window.parent) { - window.parent.postMessage(JSON.stringify({ isUserInfoShown: false }), '*'); - } - }; - render( -
-
- - - -
-
, - node.parentElement, - node - ); - } else { - render( - - - , - node.parentElement, - node - ); - } -} diff --git a/web/app/remark.tsx b/web/app/remark.tsx new file mode 100644 index 00000000..d6edf06c --- /dev/null +++ b/web/app/remark.tsx @@ -0,0 +1,69 @@ +/* eslint-disable no-console */ +/** @jsx h */ +import loadPolyfills from '@app/common/polyfills'; +import { h, render } from 'preact'; +import 'preact/debug'; + +import { Provider } from 'preact-redux'; +import { ConnectedRoot } from '@app/components/root'; +import { UserInfo } from '@app/components/user-info'; +import reduxStore from '@app/store'; + +// importing css +import '@app/components/list-comments'; + +import { NODE_ID } from '@app/common/constants'; +import { StaticStore } from '@app/common/static_store'; +import api from '@app/common/api'; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} + +async function init(): Promise { + await loadPolyfills(); + + const node = document.getElementById(NODE_ID); + + if (!node) { + console.error("Remark42: Can't find root node."); + return; + } + + const params = window.location.search + .replace(/^\?/, '') + .split('&') + .reduce<{ [key: string]: string }>((memo, value) => { + const vals = value.split('='); + if (vals.length === 2) { + memo[vals[0]] = vals[1]; + } + return memo; + }, {}); + + StaticStore.config = await api.getConfig(); + + if (params.page === 'user-info') { + render( +
+
+ + + +
+
, + node.parentElement!, + node + ); + } else { + render( + + + , + node.parentElement!, + node + ); + } +} diff --git a/web/app/store.js b/web/app/store.js deleted file mode 100644 index fd570b1c..00000000 --- a/web/app/store.js +++ /dev/null @@ -1,14 +0,0 @@ -import { createStore, applyMiddleware } from 'redux'; -import { combineReducers } from 'redux'; - -import { threadReducers, threadMiddlewares } from './components/thread'; -import { userInfoReducers } from './components/user-info'; - -const reducers = combineReducers({ - ...threadReducers, - ...userInfoReducers, -}); - -const middlewares = applyMiddleware(...threadMiddlewares); - -export default createStore(reducers, middlewares); diff --git a/web/app/store/actions.ts b/web/app/store/actions.ts new file mode 100644 index 00000000..f95b2821 --- /dev/null +++ b/web/app/store/actions.ts @@ -0,0 +1,17 @@ +import { COMMENTS_ACTIONS } from './comments/types'; +import { POST_INFO_ACTIONS } from './post_info/types'; +import { SORT_ACTIONS } from './sort/types'; +import { THEME_ACTIONS } from './theme/types'; +import { THREAD_ACTIONS } from './thread/types'; +import { USER_ACTIONS } from './user/types'; +import { USER_INFO_ACTIONS } from './user-info/types'; + +/** Merged store actions */ +export type ACTIONS = + | COMMENTS_ACTIONS + | POST_INFO_ACTIONS + | SORT_ACTIONS + | THEME_ACTIONS + | THREAD_ACTIONS + | USER_ACTIONS + | USER_INFO_ACTIONS; diff --git a/web/app/store/comments/actions.ts b/web/app/store/comments/actions.ts new file mode 100644 index 00000000..d939ee8d --- /dev/null +++ b/web/app/store/comments/actions.ts @@ -0,0 +1,110 @@ +import api from '@app/common/api'; +import { Tree, Comment, Sorting, CommentMode } from '@app/common/types'; + +import { StoreAction, StoreState } from '../index'; +import { POST_INFO_SET } from '../post_info/types'; +import { + getPinnedComments, + addComment as uAddComment, + replaceComment as uReplaceComment, + removeComment as uRemoveComment, + setCommentPin as uSetCommentPin, +} from './utils'; +import { COMMENTS_SET, PINNED_COMMENTS_SET, COMMENT_MODE_SET } from './types'; + +/** sets comments, and put pinned comments in cache */ +export const setComments = (comments: StoreState['comments']): StoreAction => dispatch => { + dispatch({ + type: COMMENTS_SET, + comments, + }); + dispatch({ + type: PINNED_COMMENTS_SET, + comments: getPinnedComments(comments), + }); +}; + +/** appends comment to tree */ +export const addComment = (text: string, title: string, pid?: Comment['id']): StoreAction> => async ( + dispatch, + getState +) => { + const comment = await api.addComment({ text, title, pid }); + const comments = getState().comments; + dispatch(setComments(uAddComment(comments, comment))); +}; + +/** edits comment in tree */ +export const updateComment = (id: Comment['id'], text: string): StoreAction> => async ( + dispatch, + getState +) => { + const comment = await api.updateComment({ id, text }); + const comments = getState().comments; + dispatch(setComments(uReplaceComment(comments, comment))); +}; + +/** edits comment in tree */ +export const putVote = (id: Comment['id'], value: number): StoreAction> => async (dispatch, getState) => { + await api.putCommentVote({ id, value }); + const updatedComment = await api.getComment(id); + const comments = getState().comments; + dispatch(setComments(uReplaceComment(comments, updatedComment))); +}; + +/** edits comment in tree */ +export const setPinState = (id: Comment['id'], value: boolean): StoreAction> => async ( + dispatch, + getState +) => { + if (value) { + await api.pinComment(id); + } else { + await api.unpinComment(id); + } + const comments = getState().comments; + dispatch(setComments(uSetCommentPin(comments, id, value))); +}; + +/** edits comment in tree */ +export const removeComment = (id: Comment['id']): StoreAction> => async (dispatch, getState) => { + const user = getState().user; + if (!user) return; + if (user.admin) { + await api.removeComment(id); + } else { + await api.removeMyComment(id); + } + const comments = getState().comments; + dispatch(setComments(uRemoveComment(comments, id))); +}; + +/** fetches comments from server */ +export const fetchComments = (sort: Sorting): StoreAction> => async dispatch => { + const data = await api.getPostComments(sort); + dispatch(setComments(data.comments)); + dispatch({ + type: POST_INFO_SET, + info: data.info, + }); + return data; +}; + +/** sets mode for comment, either reply or edit */ +export const setCommentMode = (mode: StoreState['activeComment']): StoreAction => dispatch => { + if (mode !== null && mode.state === CommentMode.None) { + mode = null; + } + dispatch({ + type: COMMENT_MODE_SET, + mode, + }); +}; + +/** unsets comment mode */ +export const unsetCommentMode = (): StoreAction => dispatch => { + dispatch({ + type: COMMENT_MODE_SET, + mode: null, + }); +}; diff --git a/web/app/store/comments/getters.ts b/web/app/store/comments/getters.ts new file mode 100644 index 00000000..b68eff78 --- /dev/null +++ b/web/app/store/comments/getters.ts @@ -0,0 +1,12 @@ +import { Comment, CommentMode } from '@app/common/types'; +import { StoreState } from '../index'; + +export const getCommentMode = (state: StoreState, id: Comment['id']): CommentMode => { + if (state.activeComment === null) { + return CommentMode.None; + } + if (state.activeComment.id !== id) { + return CommentMode.None; + } + return state.activeComment.state; +}; diff --git a/web/app/store/comments/reducers.ts b/web/app/store/comments/reducers.ts new file mode 100644 index 00000000..7f33d525 --- /dev/null +++ b/web/app/store/comments/reducers.ts @@ -0,0 +1,49 @@ +import { Node, Comment } from '@app/common/types'; + +import { StoreState } from '../index'; +import { + COMMENTS_SET, + COMMENTS_SET_ACTION, + PINNED_COMMENTS_SET_ACTION, + PINNED_COMMENTS_SET, + COMMENT_MODE_SET, + COMMENT_MODE_SET_ACTION, +} from './types'; + +export const comments = (state: StoreState['comments'] = [], action: COMMENTS_SET_ACTION): Node[] => { + switch (action.type) { + case COMMENTS_SET: { + return action.comments; + } + default: + return state; + } +}; + +export const activeComment = ( + state: StoreState['activeComment'] = null, + action: COMMENT_MODE_SET_ACTION +): StoreState['activeComment'] => { + switch (action.type) { + case COMMENT_MODE_SET: { + return action.mode; + } + default: + return state; + } +}; + +export const pinnedComments = ( + state: StoreState['pinnedComments'] = [], + action: PINNED_COMMENTS_SET_ACTION +): Comment[] => { + switch (action.type) { + case PINNED_COMMENTS_SET: { + return action.comments; + } + default: + return state; + } +}; + +export default { comments, activeComment, pinnedComments }; diff --git a/web/app/store/comments/types.ts b/web/app/store/comments/types.ts new file mode 100644 index 00000000..b92a9c0f --- /dev/null +++ b/web/app/store/comments/types.ts @@ -0,0 +1,44 @@ +import { Node } from '@app/common/types'; +import { StoreState } from '../index'; + +export const COMMENTS_SET = 'COMMENTS/SET'; + +export interface COMMENTS_SET_ACTION { + type: typeof COMMENTS_SET; + comments: StoreState['comments']; +} + +export const COMMENTS_APPEND = 'COMMENTS/APPEND'; + +export interface COMMENTS_APPEND_ACTION { + type: typeof COMMENTS_APPEND; + comments: Node; +} + +export const PINNED_COMMENTS_SET = 'PINNED_COMMENTS/SET'; + +export interface PINNED_COMMENTS_SET_ACTION { + type: typeof PINNED_COMMENTS_SET; + comments: StoreState['pinnedComments']; +} + +export const COMMENTS_FETCH_TREE = 'COMMENTS/FETCH_TREE'; + +export interface COMMENTS_FETCH_TREE_ACTION { + type: typeof COMMENTS_FETCH_TREE; + comments: Node[]; +} + +export const COMMENT_MODE_SET = 'COMMENT_MODE/SET'; + +export interface COMMENT_MODE_SET_ACTION { + type: typeof COMMENT_MODE_SET; + mode: StoreState['activeComment']; +} + +export type COMMENTS_ACTIONS = + | COMMENTS_SET_ACTION + | COMMENTS_APPEND_ACTION + | PINNED_COMMENTS_SET_ACTION + | COMMENTS_FETCH_TREE_ACTION + | COMMENT_MODE_SET_ACTION; diff --git a/web/app/store/comments/utils.ts b/web/app/store/comments/utils.ts new file mode 100644 index 00000000..8414a9e1 --- /dev/null +++ b/web/app/store/comments/utils.ts @@ -0,0 +1,145 @@ +import { Comment, Node, User } from '@app/common/types'; + +/** + * Traverses through tree and applies function to comment with given id. + * Note that function must not mutate comment, or rerender will not happen + */ +function mapTreeIfID(tree: Node[], id: Comment['id'], fn: (c: Node) => Node): Node[] { + // path of indexes to comment with given id + let path: number[] = []; + const subfn = (tree: Node[], level: number): boolean => { + for (let i = 0; i < tree.length; i++) { + path = path.slice(0, level); + path.push(i); + if (id === tree[i].comment.id) return true; + if (tree[i].replies) { + if (subfn(tree[i].replies!, level + 1)) return true; + } + } + return false; + }; + if (!subfn(tree, 0)) return tree; + + // dereferencing (cloning) node path to comment with id, + // so react will cause rerender + const treeClone = [...tree]; + let subtree = treeClone; + for (let i = 0; i < path.length; i++) { + const index = path[i]; + if (i === path.length - 1) { + subtree[index] = fn(subtree[index]); + break; + } + subtree[index] = { comment: subtree[index].comment, replies: [...subtree[index].replies!] }; + subtree = subtree[index].replies!; + } + + return treeClone; +} + +/** + * Traverses through tree and applies function to comment on which function passed. + * Note that function must not mutate comment + */ +function mapTree(tree: Node[], fn: (c: Comment) => Comment): Node[] { + return tree.map(node => { + const clone: Node = { + comment: fn(node.comment), + }; + if (node.replies) { + clone.replies = mapTree(node.replies, fn); + } + return clone; + }); +} + +export function findPinnedComments(thread: Node): Comment[] { + let result: Comment[] = []; + + if (thread.comment.pin) { + result = result.concat(thread.comment); + } + + if (thread.replies) { + result = result.concat( + thread.replies.reduce((acc: Comment[], thread: Node) => acc.concat(findPinnedComments(thread)), []) + ); + } + + return result; +} + +export function getPinnedComments(threads: Node[]): Comment[] { + return threads.reduce((acc: Comment[], thread: Node) => acc.concat(findPinnedComments(thread)), []); +} + +export function removeComment(comments: Node[], id: Comment['id']): Node[] { + return mapTreeIfID( + comments, + id, + (n): Node => ({ + comment: { + ...n.comment, + delete: true, + }, + replies: n.replies, + }) + ); +} + +export function setCommentPin(comments: Node[], id: Comment['id'], value: boolean): Node[] { + return mapTreeIfID( + comments, + id, + (n): Node => ({ + comment: { + ...n.comment, + pin: value, + }, + replies: n.replies, + }) + ); +} + +export function setUserVerified(comments: Node[], userId: User['id'], value: boolean): Node[] { + return mapTree(comments, comment => { + if (comment.user.id !== userId) return comment; + return { + ...comment, + user: { + ...comment.user, + verified: value, + }, + }; + }); +} + +function pasteReply(comments: Node[], reply: Comment): Node[] { + return mapTreeIfID( + comments, + reply.pid, + (n): Node => { + const nn = { ...n }; + if (!nn.replies) nn.replies = []; + nn.replies = [{ comment: reply }, ...nn.replies]; + return nn; + } + ); +} + +export function addComment(comments: Node[], comment: Comment): Node[] { + if (comment.pid !== '') { + return pasteReply(comments, comment); + } + return [{ comment }, ...comments]; +} + +export function replaceComment(comments: Node[], comment: Comment): Node[] { + return mapTreeIfID(comments, comment.id, n => ({ ...n, comment })); +} + +export function delay(ms: number = 100): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/web/app/store/index.ts b/web/app/store/index.ts new file mode 100644 index 00000000..f0c0dabe --- /dev/null +++ b/web/app/store/index.ts @@ -0,0 +1,51 @@ +import { createStore, applyMiddleware } from 'redux'; +import { combineReducers } from 'redux'; +import thunk, { ThunkAction, ThunkDispatch } from 'redux-thunk'; +import { Comment, User, PostInfo, Node, BlockedUser, Theme, Sorting, CommentMode } from '@app/common/types'; + +import storeReducers from './reducers'; +import { ACTIONS } from './actions'; + +export interface StoreState { + /** Comments sort */ + sort: Sorting; + /** Comments list */ + comments: Node[]; + /** List of pinned comments */ + pinnedComments: Comment[]; + /** Defines comment that is in reply or edit mode */ + activeComment: null | { id: Comment['id']; state: CommentMode }; + /** Logged in user */ + user: User | null; + /** Remark's styling theme */ + theme: Theme; + /** Current post information */ + info: PostInfo; + /** List of banned users */ + bannedUsers: BlockedUser[]; + /** Whether list of blocked users should be visible */ + isBlockedVisible: boolean; + /** Map of collapsed threads */ + collapsedThreads: { + [key: string]: boolean; + }; + /** used in user comments widget */ + userComments?: { + [key: string]: Comment[]; + }; +} + +const reducers = combineReducers(storeReducers); +const middleware = applyMiddleware(thunk); + +/** + * Thunk Action shortcut + */ +export type StoreAction = ThunkAction; + +/** + * Thunk Dispatch shortcut + */ +export type StoreDispatch = ThunkDispatch; + +export default createStore(reducers, middleware); diff --git a/web/app/store/post_info/actions.ts b/web/app/store/post_info/actions.ts new file mode 100644 index 00000000..d9b4c6c8 --- /dev/null +++ b/web/app/store/post_info/actions.ts @@ -0,0 +1,40 @@ +import { PostInfo } from '@app/common/types'; + +import { StoreAction } from '../index'; +import { POST_INFO_SET } from './types'; +import api from '@app/common/api'; +import { unsetCommentMode } from '../comments/actions'; + +export const setPostInfo = (info: PostInfo): StoreAction => dispatch => + dispatch({ + type: POST_INFO_SET, + info, + }); + +/** set state of post: readonly or not */ +export const setCommentsReadOnlyState = (state: boolean): StoreAction> => async ( + dispatch, + getState +) => { + await (!state ? api.enableComments() : api.disableComments()); + const storeState = getState(); + dispatch(unsetCommentMode()); + dispatch({ + type: POST_INFO_SET, + info: { ...storeState.info, read_only: state }, + }); + return state; +}; + +/** toggles state of post: readonly or not */ +export const toggleCommentsReadOnlyState = (): StoreAction> => async (dispatch, getState) => { + const storeState = getState(); + const state = !storeState.info.read_only!; + await (state ? api.enableComments() : api.disableComments()); + dispatch(unsetCommentMode()); + dispatch({ + type: POST_INFO_SET, + info: { ...storeState.info, read_only: !state }, + }); + return !state; +}; diff --git a/web/app/store/post_info/reducers.ts b/web/app/store/post_info/reducers.ts new file mode 100644 index 00000000..72f3befc --- /dev/null +++ b/web/app/store/post_info/reducers.ts @@ -0,0 +1,24 @@ +import { PostInfo } from '@app/common/types'; +import { POST_INFO_SET, POST_INFO_SET_ACTION } from './types'; + +/* eslint-disable @typescript-eslint/camelcase */ +const DefaultPostInfo: PostInfo = { + url: '', + count: 0, + read_only: false, + first_time: '', + last_time: '', +}; +/* eslint-enable @typescript-eslint/camelcase */ + +export const info = (state: PostInfo = DefaultPostInfo, action: POST_INFO_SET_ACTION): PostInfo => { + switch (action.type) { + case POST_INFO_SET: { + return action.info; + } + default: + return state; + } +}; + +export default { info }; diff --git a/web/app/store/post_info/types.ts b/web/app/store/post_info/types.ts new file mode 100644 index 00000000..f039951d --- /dev/null +++ b/web/app/store/post_info/types.ts @@ -0,0 +1,17 @@ +import { PostInfo } from '@app/common/types'; + +export const POST_INFO_SET = 'POST_INFO/SET'; + +export interface POST_INFO_SET_ACTION { + type: typeof POST_INFO_SET; + info: PostInfo; +} + +export const POST_INFO_SET_READONLY = 'COMMENTS/SET_READONLY'; + +export interface POST_INFO_SET_READONLY_ACTION { + type: typeof POST_INFO_SET_READONLY; + readonly: boolean; +} + +export type POST_INFO_ACTIONS = POST_INFO_SET_ACTION | POST_INFO_SET_READONLY_ACTION; diff --git a/web/app/store/reducers.ts b/web/app/store/reducers.ts new file mode 100644 index 00000000..bfaa5f44 --- /dev/null +++ b/web/app/store/reducers.ts @@ -0,0 +1,18 @@ +import comments from './comments/reducers'; +import postinfo from './post_info/reducers'; +import theme from './theme/reducers'; +import user from './user/reducers'; +import userInfo from './user-info/reducers'; +import thread from './thread/reducers'; +import sort from './sort/reducers'; + +/** Merged store reducers */ +export default { + ...sort, + ...comments, + ...postinfo, + ...theme, + ...user, + ...userInfo, + ...thread, +}; diff --git a/web/app/store/sort/actions.ts b/web/app/store/sort/actions.ts new file mode 100644 index 00000000..8bb08fd6 --- /dev/null +++ b/web/app/store/sort/actions.ts @@ -0,0 +1,28 @@ +import api from '@app/common/api'; +import { Sorting } from '@app/common/types'; +import { COOKIE_SORT_KEY } from '@app/common/constants'; +import { setCookie } from '@app/common/cookies'; + +import { StoreAction } from '../index'; +import { setPostInfo } from '../post_info/actions'; +import { setComments } from '../comments/actions'; +import { SORT_SET, SORT_SET_ACTION } from './types'; + +export const setSort = (sort: Sorting): StoreAction> => async dispatch => { + try { + setCookie(COOKIE_SORT_KEY, sort, { expires: 60 * 60 * 24 * 365 }); // save sorting for a year + } catch (e) { + // can't save; ignore it + } + + const info = await api.getPostComments(sort); + + const action: SORT_SET_ACTION = { + type: SORT_SET, + sort, + }; + + dispatch(action); + dispatch(setPostInfo(info.info)); + dispatch(setComments(info.comments)); +}; diff --git a/web/app/store/sort/reducers.ts b/web/app/store/sort/reducers.ts new file mode 100644 index 00000000..6438ab98 --- /dev/null +++ b/web/app/store/sort/reducers.ts @@ -0,0 +1,25 @@ +import { Sorting } from '@app/common/types'; +import { COOKIE_SORT_KEY, DEFAULT_SORT } from '@app/common/constants'; +import { getCookie } from '@app/common/cookies'; + +import { SORT_SET, SORT_SET_ACTION } from './types'; + +const getDefaultSort = (): Sorting => { + try { + return (getCookie(COOKIE_SORT_KEY) as Sorting) || DEFAULT_SORT; + } catch (e) { + return DEFAULT_SORT; + } +}; + +export const sort = (state: Sorting = getDefaultSort(), action: SORT_SET_ACTION): Sorting => { + switch (action.type) { + case SORT_SET: { + return action.sort; + } + default: + return state; + } +}; + +export default { sort }; diff --git a/web/app/store/sort/types.ts b/web/app/store/sort/types.ts new file mode 100644 index 00000000..b65c4ac6 --- /dev/null +++ b/web/app/store/sort/types.ts @@ -0,0 +1,10 @@ +import { Sorting } from '@app/common/types'; + +export const SORT_SET = 'SORT/SET'; + +export interface SORT_SET_ACTION { + type: typeof SORT_SET; + sort: Sorting; +} + +export type SORT_ACTIONS = SORT_SET_ACTION; diff --git a/web/app/store/theme/actions.ts b/web/app/store/theme/actions.ts new file mode 100644 index 00000000..555e25b3 --- /dev/null +++ b/web/app/store/theme/actions.ts @@ -0,0 +1,10 @@ +import { Theme } from '@app/common/types'; + +import { StoreAction } from '../'; +import { THEME_SET } from './types'; + +export const setTheme = (theme: Theme): StoreAction => dispatch => + dispatch({ + type: THEME_SET, + theme, + }); diff --git a/web/app/store/theme/reducers.ts b/web/app/store/theme/reducers.ts new file mode 100644 index 00000000..e300afca --- /dev/null +++ b/web/app/store/theme/reducers.ts @@ -0,0 +1,16 @@ +import { Theme } from '@app/common/types'; +import { StaticStore } from '@app/common/static_store'; + +import { THEME_SET_ACTION, THEME_SET } from './types'; + +export const theme = (state: Theme = StaticStore.query.theme, action: THEME_SET_ACTION): Theme => { + switch (action.type) { + case THEME_SET: { + return action.theme; + } + default: + return state; + } +}; + +export default { theme }; diff --git a/web/app/store/theme/types.ts b/web/app/store/theme/types.ts new file mode 100644 index 00000000..ea2449a1 --- /dev/null +++ b/web/app/store/theme/types.ts @@ -0,0 +1,10 @@ +import { Theme } from '@app/common/types'; + +export const THEME_SET = 'THEME/SET'; + +export interface THEME_SET_ACTION { + type: typeof THEME_SET; + theme: Theme; +} + +export type THEME_ACTIONS = THEME_SET_ACTION; diff --git a/web/app/store/thread/actions.ts b/web/app/store/thread/actions.ts new file mode 100644 index 00000000..9fab8c1c --- /dev/null +++ b/web/app/store/thread/actions.ts @@ -0,0 +1,25 @@ +import { Comment } from '@app/common/types'; +import { siteId, url } from '@app/common/settings'; + +import { StoreAction } from '../index'; +import { THREAD_SET_COLLAPSE } from './types'; +import { saveCollapsedComments } from './utils'; + +export const setCollapse = (id: Comment['id']): StoreAction => (dispatch, getState) => { + const collapsed = !getState().collapsedThreads[id]; + dispatch({ + type: THREAD_SET_COLLAPSE, + id, + collapsed, + }); + saveCollapsedComments( + siteId!, + url!, + Object.entries(getState().collapsedThreads).reduce((acc: string[], [key, value]) => { + if (value) { + acc.push(key); + } + return acc; + }, []) + ); +}; diff --git a/web/app/store/thread/getters.ts b/web/app/store/thread/getters.ts new file mode 100644 index 00000000..e1502d89 --- /dev/null +++ b/web/app/store/thread/getters.ts @@ -0,0 +1,16 @@ +import { Comment } from '@app/common/types'; +import { StaticStore } from '@app/common/static_store'; + +import { StoreState } from '../index'; + +export const getThreadIsCollapsed = (state: StoreState, comment: Comment): boolean => { + const collapsed = state.collapsedThreads[comment.id]; + + if (collapsed !== null && collapsed !== undefined) { + return collapsed; + } + + const score = comment.score || 0; + + return score <= StaticStore.config.critical_score; +}; diff --git a/web/app/store/thread/reducers.test.ts b/web/app/store/thread/reducers.test.ts new file mode 100644 index 00000000..b26e0926 --- /dev/null +++ b/web/app/store/thread/reducers.test.ts @@ -0,0 +1,34 @@ +import { Comment } from '@app/common/types'; + +import { setCollapse } from './actions'; +import { THREAD_SET_COLLAPSE } from './types'; + +describe('collapsedThreads', () => { + const comment = { id: 'some-id' } as Comment; + + it('should set collapsed to true', () => { + const state = { collapsedThreads: {} }; + + const dispatch = jest.fn(); + const getState = jest.fn(() => state) as any; + setCollapse(comment.id)(dispatch, getState, undefined); + expect(dispatch).toBeCalledWith({ + type: THREAD_SET_COLLAPSE, + id: 'some-id', + collapsed: true, + }); + }); + + it('should collapse toggled', () => { + const dispatch = jest.fn(); + const getState = jest.fn(); + getState.mockReturnValue({ collapsedThreads: { 'some-id': true } }); + + setCollapse(comment.id)(dispatch, getState, undefined); + expect(dispatch).toBeCalledWith({ + type: THREAD_SET_COLLAPSE, + id: 'some-id', + collapsed: false, + }); + }); +}); diff --git a/web/app/store/thread/reducers.ts b/web/app/store/thread/reducers.ts new file mode 100644 index 00000000..7718275b --- /dev/null +++ b/web/app/store/thread/reducers.ts @@ -0,0 +1,32 @@ +import { THREAD_GET_COLLAPSE_ACTION, THREAD_SET_COLLAPSE, THREAD_SET_COLLAPSE_ACTION } from './types'; +import { getCollapsedComments } from './utils'; +import { StoreState } from '../index'; + +const collapsedCommentIds = getCollapsedComments(); + +const initialState: StoreState['collapsedThreads'] = collapsedCommentIds.reduce( + (acc: { [key: string]: boolean }, id) => { + acc[id] = true; + return acc; + }, + {} +); + +export const collapsedThreads = ( + state: StoreState['collapsedThreads'] = initialState, + action: THREAD_GET_COLLAPSE_ACTION | THREAD_SET_COLLAPSE_ACTION +): { [key: string]: boolean } => { + switch (action.type) { + case THREAD_SET_COLLAPSE: + return { + ...state, + [action.id]: action.collapsed, + }; + default: + return state; + } +}; + +export default { + collapsedThreads, +}; diff --git a/web/app/store/thread/types.ts b/web/app/store/thread/types.ts new file mode 100644 index 00000000..c5afd40e --- /dev/null +++ b/web/app/store/thread/types.ts @@ -0,0 +1,15 @@ +import { Comment } from '@app/common/types'; + +export const THREAD_GET_COLLAPSE = 'THREAD/COLLAPSE_GET'; +export interface THREAD_GET_COLLAPSE_ACTION { + type: typeof THREAD_GET_COLLAPSE; +} + +export const THREAD_SET_COLLAPSE = 'THREAD/COLLAPSE_SET'; +export interface THREAD_SET_COLLAPSE_ACTION { + type: typeof THREAD_SET_COLLAPSE; + id: Comment['id']; + collapsed: boolean; +} + +export type THREAD_ACTIONS = THREAD_GET_COLLAPSE_ACTION | THREAD_SET_COLLAPSE_ACTION; diff --git a/web/app/store/thread/utils.ts b/web/app/store/thread/utils.ts new file mode 100644 index 00000000..a4b43e53 --- /dev/null +++ b/web/app/store/thread/utils.ts @@ -0,0 +1,30 @@ +import { siteId, url } from '@app/common/settings'; +import { LS_COLLAPSE_KEY } from '@app/common/constants'; +import { setItem as localStorageSetItem, getItem as localStorageGetItem } from '@app/common/local-storage'; +import { Comment } from '@app/common/types'; + +function getFromLocalStorage(): string[] { + return JSON.parse(localStorageGetItem(LS_COLLAPSE_KEY) || '[]'); +} + +/** + * returns list of serialized comments of type "site-id_url_comment-id" + */ +export const getCollapsedComments = (): string[] => + getFromLocalStorage().reduce((acc: string[], v: string) => { + const components = v.split('_'); + if (components[0] === siteId && components[1] === url) { + acc.push(components[2]); + } + return acc; + }, []); + +/** + * @param info list of string of type "site-id_url_comment-id + */ +export const saveCollapsedComments = (siteId: string, url: string, info: (Comment['id'])[]): void => { + const data = info.map(i => `${siteId}_${url}_${i}`); + const notForThisPost = getFromLocalStorage().filter(entry => entry.indexOf(`${siteId}_${url}`) === -1); + const all = new Set([...notForThisPost, ...data]); + localStorageSetItem(LS_COLLAPSE_KEY, JSON.stringify([...all])); +}; diff --git a/web/app/store/user-info/actions.ts b/web/app/store/user-info/actions.ts new file mode 100644 index 00000000..06224b10 --- /dev/null +++ b/web/app/store/user-info/actions.ts @@ -0,0 +1,31 @@ +import api from '@app/common/api'; +import { Comment, User } from '@app/common/types'; +import { userInfo } from '@app/common/user-info-settings'; + +import { StoreAction } from '../index'; +import { USER_INFO_SET } from './types'; + +export const getUserComments = (userId: User['id']): StoreAction => (_dispatch, getState) => + getState().userComments![userId] || null; + +export const getComments = (id: User['id']): StoreAction => (_dispatch, getState) => { + const comments = getState().userComments![id]; + if (comments) { + return comments; + } + return null; +}; + +export const fetchInfo = (): StoreAction> => async dispatch => { + if (!userInfo.id) { + return null; + } + // TODO: limit + const info = await api.getUserComments(userInfo.id, 10); + dispatch({ + type: USER_INFO_SET, + id: userInfo.id, + comments: info.comments, + }); + return info.comments; +}; diff --git a/web/app/store/user-info/reducers.ts b/web/app/store/user-info/reducers.ts new file mode 100644 index 00000000..fffd506d --- /dev/null +++ b/web/app/store/user-info/reducers.ts @@ -0,0 +1,22 @@ +import { Comment } from '@app/common/types'; + +import { StoreState } from '../index'; +import { USER_INFO_SET, USER_INFO_ACTIONS } from './types'; + +export const userComments = ( + state: StoreState['userComments'] = {}, + action: USER_INFO_ACTIONS +): { [key: string]: Comment[] } => { + switch (action.type) { + case USER_INFO_SET: { + return { + ...state, + [action.id]: action.comments, + }; + } + default: + return state; + } +}; + +export default { userComments }; diff --git a/web/app/store/user-info/types.ts b/web/app/store/user-info/types.ts new file mode 100644 index 00000000..ca67426d --- /dev/null +++ b/web/app/store/user-info/types.ts @@ -0,0 +1,11 @@ +import { Comment, User } from '@app/common/types'; + +export const USER_INFO_SET = 'USER-INFO/SET'; + +export interface USER_INFO_SET_ACTION { + type: typeof USER_INFO_SET; + id: User['id']; + comments: Comment[]; +} + +export type USER_INFO_ACTIONS = USER_INFO_SET_ACTION; diff --git a/web/app/store/user/actions.ts b/web/app/store/user/actions.ts new file mode 100644 index 00000000..1886e891 --- /dev/null +++ b/web/app/store/user/actions.ts @@ -0,0 +1,89 @@ +import api from '@app/common/api'; +import { User, BlockedUser, Provider, BlockTTL } from '@app/common/types'; +import { ttlToTime } from '@app/utils/ttl-to-time'; + +import { StoreAction } from '../index'; +import { USER_BAN, USER_SET, USER_UNBAN, BLOCKED_VISIBLE_SET, USER_BANLIST_SET } from './types'; +import { setComments, unsetCommentMode } from '../comments/actions'; +import { setUserVerified as uSetUserVerified } from '../comments/utils'; + +export const fetchUser = (): StoreAction> => async dispatch => { + const user = await api.getUser(); + dispatch({ + type: USER_SET, + user, + }); + return user; +}; + +export const logIn = (provider: Provider): StoreAction> => async dispatch => { + const user = await api.logIn(provider); + dispatch({ + type: USER_SET, + user, + }); + return user; +}; + +export const logout = (): StoreAction> => async dispatch => { + await api.logOut(); + dispatch(unsetCommentMode()); + dispatch({ + type: USER_SET, + user: null, + }); +}; + +export const fetchBlockedUsers = (): StoreAction> => async dispatch => { + const list = await api.getBlocked(); + dispatch({ + type: USER_BANLIST_SET, + list, + }); + return list; +}; + +export const blockUser = ( + id: User['id'], + name: string, + ttl: BlockTTL +): StoreAction> => async dispatch => { + await api.blockUser(id, ttl); + dispatch({ + type: USER_BAN, + user: { + id, + name, + time: ttlToTime(ttl), + }, + }); +}; + +export const unblockUser = (id: User['id']): StoreAction> => async dispatch => { + await api.unblockUser(id); + dispatch({ + type: USER_UNBAN, + id, + }); +}; + +export const setVirifiedStatus = (id: User['id'], status: boolean): StoreAction> => async ( + dispatch, + getState +) => { + if (status) { + await api.setVerifyStatus(id); + } else { + await api.removeVerifyStatus(id); + } + const comments = getState().comments; + dispatch(setComments(uSetUserVerified(comments, id, status))); +}; + +export const setBlockedVisibleState = (state: boolean): StoreAction => dispatch => { + dispatch({ + type: BLOCKED_VISIBLE_SET, + state, + }); + return state; +}; diff --git a/web/app/store/user/reducers.test.ts b/web/app/store/user/reducers.test.ts new file mode 100644 index 00000000..2df63536 --- /dev/null +++ b/web/app/store/user/reducers.test.ts @@ -0,0 +1,87 @@ +import api from '@app/common/api'; +import { User } from '@app/common/types'; + +import { fetchUser, logIn, logout } from './actions'; +import { user } from './reducers'; +import { USER_SET } from './types'; + +jest.mock('@app/common/api'); + +afterEach(() => { + jest.resetModules(); +}); + +describe('user', () => { + it('should return null by default', () => { + const action = { type: 'OTHER' }; + const newState = user(null, action as any); + expect(newState).toEqual(null); + }); + + it('should set state of user on fetchUser', async () => { + (api.getUser as any).mockImplementation( + async (): Promise => + ({ + id: 'john', + name: 'John', + admin: true, + } as User) + ); + const dispatch = jest.fn(); + const getState = jest.fn(); + await fetchUser()(dispatch, getState, undefined); + expect(dispatch).toBeCalledWith({ + type: USER_SET, + user: { + id: 'john', + name: 'John', + admin: true, + }, + }); + }); + + it('should set state of user on logIn', async () => { + (api.logIn as any).mockImplementation( + async (): Promise => + ({ + id: 'john', + name: 'John', + admin: true, + } as User) + ); + const dispatch = jest.fn(); + const getState = jest.fn(); + await logIn('google')(dispatch, getState, undefined); + expect(dispatch).toBeCalledWith({ + type: USER_SET, + user: { + id: 'john', + name: 'John', + admin: true, + }, + }); + }); + + it('should NOT set state of user on failed logIn', async () => { + (api.logIn as any).mockImplementation( + async (): Promise => { + throw new Error('Unauthorized'); + } + ); + const dispatch = jest.fn(); + const getState = jest.fn(); + await logIn('google')(dispatch, getState, undefined).catch(() => {}); + expect(dispatch).not.toBeCalled(); + }); + + it('should unset user on logOut', async () => { + (api.logOut as any).mockImplementation(async (): Promise => {}); + const dispatch = jest.fn(); + const getState = jest.fn(); + await logout()(dispatch, getState, undefined); + expect(dispatch).toBeCalledWith({ + type: USER_SET, + user: null, + }); + }); +}); diff --git a/web/app/store/user/reducers.ts b/web/app/store/user/reducers.ts new file mode 100644 index 00000000..6e7acfa6 --- /dev/null +++ b/web/app/store/user/reducers.ts @@ -0,0 +1,60 @@ +import { User, BlockedUser } from '@app/common/types'; + +import { StoreState } from '../index'; +import { + USER_SET, + USER_BAN, + USER_UNBAN, + USER_ACTIONS, + BLOCKED_VISIBLE_SET_ACTION, + BLOCKED_VISIBLE_SET, + USER_BANLIST_SET, +} from './types'; + +export const user = (state: StoreState['user'] = null, action: USER_ACTIONS): User | null => { + switch (action.type) { + case USER_SET: { + return action.user; + } + default: + return state; + } +}; + +export const bannedUsers = (state: StoreState['bannedUsers'] = [], action: USER_ACTIONS): BlockedUser[] => { + switch (action.type) { + case USER_BANLIST_SET: { + return action.list; + } + case USER_BAN: { + if (state.find(u => u.id === action.user.id) !== undefined) { + return state; + } + return [action.user, ...state]; + } + case USER_UNBAN: { + const index = state.findIndex(u => u.id === action.id); + if (index === -1) { + return state; + } + return [...state.slice(0, index), ...state.slice(index + 1)]; + } + default: + return state; + } +}; + +export const isBlockedVisible = ( + state: StoreState['isBlockedVisible'] = false, + action: BLOCKED_VISIBLE_SET_ACTION +): boolean => { + switch (action.type) { + case BLOCKED_VISIBLE_SET: { + return action.state; + } + default: + return state; + } +}; + +export default { user, bannedUsers, isBlockedVisible }; diff --git a/web/app/store/user/types.ts b/web/app/store/user/types.ts new file mode 100644 index 00000000..bd28f5ff --- /dev/null +++ b/web/app/store/user/types.ts @@ -0,0 +1,46 @@ +import { User, BlockedUser } from '@app/common/types'; + +export const USER_SET = 'USER/SET'; + +export interface USER_SET_ACTION { + type: typeof USER_SET; + user: User | null; +} + +/** + * Set list of banned users + */ +export const USER_BANLIST_SET = 'USER/BANLIST_SET'; + +export interface USER_BANLIST_SET_ACTION { + type: typeof USER_BANLIST_SET; + list: BlockedUser[]; +} + +export const USER_BAN = 'USER/BAN'; + +export interface USER_BAN_ACTION { + type: typeof USER_BAN; + user: BlockedUser; +} + +export const USER_UNBAN = 'USER/UNBAN'; + +export interface USER_UNBAN_ACTION { + type: typeof USER_UNBAN; + id: User['id']; +} + +export const BLOCKED_VISIBLE_SET = 'BLOCKED_VISIBLE/SET'; + +export interface BLOCKED_VISIBLE_SET_ACTION { + type: typeof BLOCKED_VISIBLE_SET; + state: boolean; +} + +export type USER_ACTIONS = + | USER_SET_ACTION + | USER_BANLIST_SET_ACTION + | USER_BAN_ACTION + | USER_UNBAN_ACTION + | BLOCKED_VISIBLE_SET_ACTION; diff --git a/web/app/testUtils/index.js b/web/app/testUtils/index.js deleted file mode 100644 index e652394e..00000000 --- a/web/app/testUtils/index.js +++ /dev/null @@ -1,17 +0,0 @@ -export function createDomContainer(setup) { - let domContainer = null; - beforeAll(() => { - domContainer = document.createElement('div'); - (document.body || document.documentElement).appendChild(domContainer); - setup({ domContainer }); - }); - - beforeEach(() => { - domContainer.innerHTML = ''; - }); - - afterAll(() => { - domContainer.parentNode.removeChild(domContainer); - domContainer = null; - }); -} diff --git a/web/app/testUtils/index.ts b/web/app/testUtils/index.ts new file mode 100644 index 00000000..5c9b588b --- /dev/null +++ b/web/app/testUtils/index.ts @@ -0,0 +1,17 @@ +export function createDomContainer(setup: (domContainer: HTMLElement) => void): void { + let domContainer: HTMLElement | null = null; + beforeAll(() => { + domContainer = document.createElement('div'); + (document.body || document.documentElement).appendChild(domContainer); + setup(domContainer); + }); + + beforeEach(() => { + domContainer!.innerHTML = ''; + }); + + afterAll(() => { + domContainer!.parentNode!.removeChild(domContainer!); + domContainer = null; + }); +} diff --git a/web/app/utils/debounce.js b/web/app/utils/debounce.js deleted file mode 100644 index 06f5b9f0..00000000 --- a/web/app/utils/debounce.js +++ /dev/null @@ -1,8 +0,0 @@ -export default function debounce(func, wait) { - let timeout; - return function() { - const laterCall = () => func.apply(this, arguments); - clearTimeout(timeout); - timeout = setTimeout(laterCall, wait); - }; -} diff --git a/web/app/utils/debounce.ts b/web/app/utils/debounce.ts new file mode 100644 index 00000000..078b681f --- /dev/null +++ b/web/app/utils/debounce.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// type signature is wrong, but there is no way +// in typescript to augment function return type currently +export default function debounce void>(func: F, wait: number): F { + let timeout: number | undefined; + return function(this: any): void { + const laterCall = (): void => func.apply(this, arguments as any); + window.clearTimeout(timeout); + timeout = window.setTimeout(laterCall, wait); + } as any; +} diff --git a/web/app/utils/debug-node.ts b/web/app/utils/debug-node.ts new file mode 100644 index 00000000..3339d240 --- /dev/null +++ b/web/app/utils/debug-node.ts @@ -0,0 +1,22 @@ +import { Node } from '@app/common/types'; + +/** + * Function to debug node tree. + */ +export function debugNode(n: Node): Node { + const d = (n: Node, level: number): void => { + // eslint-disable-next-line no-console + console.log( + `${' '.repeat(level)}${n.comment.text.trim()} | id: ${n.comment.id} | delete: ${n.comment.delete} | pin: ${ + n.comment.pin + }` + ); + if (n.replies) { + for (const node of n.replies) { + d(node, level + 1); + } + } + }; + d(n, 0); + return n; +} diff --git a/web/app/utils/derequire.ts b/web/app/utils/derequire.ts new file mode 100644 index 00000000..780ac31b --- /dev/null +++ b/web/app/utils/derequire.ts @@ -0,0 +1,2 @@ +/** type that makes certain properties of type optional */ +export type Derequire = Pick> & Partial>; diff --git a/web/app/utils/email.js b/web/app/utils/email.js deleted file mode 100644 index c94e3913..00000000 --- a/web/app/utils/email.js +++ /dev/null @@ -1,35 +0,0 @@ -import store from '../common/store'; -import { siteId } from '../common/settings'; -import { deleteMe } from '../common/api'; - -// The right line breaks code in the body of inline email -// should be not just %0A, but %0D%0A -// see: https://www.ietf.org/rfc/rfc2368.txt -const LINE_BREAK_CODE = '%0D%0A'; - -export function getDeleteInformationMessage(userId, siteId, link) { - const subject = encodeURIComponent("Request to delete user's information"); - const message = encodeURIComponent(`Request to delete all information about ${userId} from remark42 on ${siteId} - -[you can provide the reason for removal request, optional] - -=== DO NOT REMOVE THE TEXT BELOW THIS LINE === - -site: ${siteId} -user: ${userId} -link: ${link} -`).replace('%0A', LINE_BREAK_CODE); - - return { - subject, - message, - }; -} - -export function requestDeletion() { - return deleteMe().then(data => { - const email = store.get('config').admin_email; - const { subject, message } = getDeleteInformationMessage(data.user_id, siteId, data.link); - window.location = `mailto:${email}?subject=${subject}&body=${message}`; - }); -} diff --git a/web/app/utils/email.ts b/web/app/utils/email.ts new file mode 100644 index 00000000..f300d850 --- /dev/null +++ b/web/app/utils/email.ts @@ -0,0 +1,43 @@ +import { siteId } from '@app/common/settings'; +import { deleteMe } from '@app/common/api'; +import { StaticStore } from '@app/common/static_store'; + +/** + * The right line breaks code in the body of inline email + * should be not just %0A, but %0D%0A + * see: https://www.ietf.org/rfc/rfc2368.txt + */ +const LINE_BREAK_CODE = '%0D%0A'; + +export function getDeleteInformationMessage( + userId: string, + siteId: string, + link: string +): { + subject: string; + message: string; +} { + const subject = encodeURIComponent("Request to delete user's information"); + const message = encodeURIComponent(`Request to delete all information about ${userId} from remark42 on ${siteId} + +[you can provide the reason for removal request, optional] + +=== DO NOT REMOVE THE TEXT BELOW THIS LINE === + +site: ${siteId} +user: ${userId} +link: ${link} +`).replace('%0A', LINE_BREAK_CODE); + + return { + subject, + message, + }; +} + +export async function requestDeletion(): Promise { + const data = await deleteMe(); + const email = StaticStore.config!.admin_email; + const { subject, message } = getDeleteInformationMessage(data.user_id, siteId!, data.link); + window.location.href = `mailto:${email}?subject=${subject}&body=${message}`; +} diff --git a/web/app/utils/errorUtils.js b/web/app/utils/errorUtils.ts similarity index 64% rename from web/app/utils/errorUtils.js rename to web/app/utils/errorUtils.ts index 08f8c0ba..400612fe 100644 --- a/web/app/utils/errorUtils.js +++ b/web/app/utils/errorUtils.ts @@ -20,25 +20,30 @@ const errorMessageForCodes = new Map([ [18, 'Requested file cannot be found.'], ]); -export function extractErrorMessageFromResponse(response) { - const defatulErrorMessage = 'Something went wrong. Please try again a bit later.'; - if (!(response && response.data)) { - return defatulErrorMessage; +export type FetcherResponse = + | string + | { + code?: number; + details?: string; + }; + +export function extractErrorMessageFromResponse(response: FetcherResponse): string { + const defaultErrorMessage = 'Something went wrong. Please try again a bit later.'; + if (!response) { + return defaultErrorMessage; } - const responseData = response.data; - - if (typeof responseData.code === 'number' && errorMessageForCodes.has(responseData.code)) { - return errorMessageForCodes.get(responseData.code); + if (typeof response === 'string') { + return response; } - if (typeof responseData.details === 'string') { - return responseData.details; + if (typeof response.code === 'number' && errorMessageForCodes.has(response.code)) { + return errorMessageForCodes.get(response.code)!; } - if (typeof responseData === 'string') { - return responseData; + if (typeof response.details === 'string') { + return response.details; } - return defatulErrorMessage; + return defaultErrorMessage; } diff --git a/web/app/utils/exclude.ts b/web/app/utils/exclude.ts new file mode 100644 index 00000000..669a907c --- /dev/null +++ b/web/app/utils/exclude.ts @@ -0,0 +1,8 @@ +/** return shallow clone of object without given props */ +export function exclude(o: T, ...exclude: K[]): Pick> { + const clone = { ...o }; + for (const item of exclude) { + delete clone[item]; + } + return clone; +} diff --git a/web/app/utils/noop.js b/web/app/utils/noop.js deleted file mode 100644 index ca6a7447..00000000 --- a/web/app/utils/noop.js +++ /dev/null @@ -1 +0,0 @@ -export default function noop() {} diff --git a/web/app/utils/noop.ts b/web/app/utils/noop.ts new file mode 100644 index 00000000..5d24cb3f --- /dev/null +++ b/web/app/utils/noop.ts @@ -0,0 +1 @@ +export default function noop(): void {} diff --git a/web/app/utils/ttl-to-time.ts b/web/app/utils/ttl-to-time.ts new file mode 100644 index 00000000..508bd266 --- /dev/null +++ b/web/app/utils/ttl-to-time.ts @@ -0,0 +1,22 @@ +import { BlockTTL } from '@app/common/types'; + +export function ttlToTime(ttl: BlockTTL): string { + const now = new Date(); + if (ttl === 'permanently') { + now.setFullYear(now.getFullYear() + 100); + return now.toISOString(); + } + if (ttl === '43200m') { + now.setMonth(now.getMonth() + 1); + return now.toISOString(); + } + if (ttl === '10080m') { + now.setDate(now.getDate() + 7); + return now.toISOString(); + } + if (ttl === '1440m') { + now.setDate(now.getDate() + 1); + return now.toISOString(); + } + throw new Error('unknown block ttl'); +} diff --git a/web/babelOptions.js b/web/babelOptions.js deleted file mode 100644 index 4d1ec697..00000000 --- a/web/babelOptions.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - presets: [ - [ - '@babel/preset-env', - { - targets: { - browsers: ['> 1%', 'android >= 4.4.4', 'ios >= 9', 'IE >= 11'], - }, - useBuiltIns: 'usage', - }, - ], - ], - plugins: ['@babel/plugin-syntax-dynamic-import', ['@babel/plugin-transform-react-jsx', { pragma: 'h' }]], -}; diff --git a/web/counter.ejs b/web/counter.ejs index 86094c56..7c64f9cd 100644 --- a/web/counter.ejs +++ b/web/counter.ejs @@ -21,7 +21,7 @@

First counter with url from data-attribute: - + (note)

@@ -39,6 +39,7 @@