Refactor frontend to typescript (#281)
This commit is contained in:
committed by
Aleksei Gurianov
parent
83de28c2da
commit
5809419bce
@@ -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
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }]]
|
||||
}
|
||||
+58
-7
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
+14
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<User | null>((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<void> =>
|
||||
fetcher.get({ url: `/auth/logout`, overriddenApiBase: '', withCredentials: true });
|
||||
|
||||
export const getConfig = (): Promise<Config> => fetcher.get(`/config`);
|
||||
|
||||
export const getPostComments = (sort: Sorting): Promise<Tree> =>
|
||||
fetcher.get(`/find?site=${siteId}&url=${url}&sort=${sort}&format=tree`);
|
||||
|
||||
export const getLastComments = (siteId: string, max: number): Promise<Comment[]> =>
|
||||
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<Comment> => 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<void> =>
|
||||
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<Comment> =>
|
||||
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<Comment> =>
|
||||
fetcher.put({
|
||||
url: `/comment/${id}?url=${url}`,
|
||||
body: {
|
||||
text,
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const getPreview = (text: string): Promise<string> =>
|
||||
fetcher.post({
|
||||
url: '/preview',
|
||||
body: {
|
||||
text,
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const getUser = (): Promise<User | null> =>
|
||||
fetcher
|
||||
.get<User | null>({
|
||||
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<void> =>
|
||||
fetcher.get({
|
||||
url: `/admin/deleteme?token=${token}`,
|
||||
});
|
||||
|
||||
/* admin */
|
||||
export const pinComment = (id: Comment['id']): Promise<void> =>
|
||||
fetcher.put({
|
||||
url: `/admin/pin/${id}?url=${url}&pin=1`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const unpinComment = (id: Comment['id']): Promise<void> =>
|
||||
fetcher.put({
|
||||
url: `/admin/pin/${id}?url=${url}&pin=0`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const setVerifiedStatus = (id: User['id']): Promise<void> =>
|
||||
fetcher.put({
|
||||
url: `/admin/verify/${id}?verified=1`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const removeVerifiedStatus = (id: User['id']): Promise<void> =>
|
||||
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<void> =>
|
||||
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<BlockedUser[]> =>
|
||||
fetcher.get({
|
||||
url: '/admin/blocked',
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const disableComments = (): Promise<void> =>
|
||||
fetcher.put({
|
||||
url: `/admin/readonly?site=${siteId}&url=${url}&ro=1`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const enableComments = (): Promise<void> =>
|
||||
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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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]: <T = unknown>(data: FetcherInit) => Promise<T> };
|
||||
|
||||
const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
|
||||
acc[method] = <T = unknown>(data: FetcherInit): Promise<T> => {
|
||||
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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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<QuerySettingsType> =
|
||||
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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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' } }]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { UserInfo } from './types';
|
||||
|
||||
export const userInfo: Partial<UserInfo> =
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
.auth-panel__readonly-label {
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.auth-panel__readonly-label::after {
|
||||
content: '•';
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/** @jsx h */
|
||||
import { h, render } from 'preact';
|
||||
import AuthPanel from '../auth-panel';
|
||||
import { createDomContainer } from 'testUtils';
|
||||
|
||||
describe('<AuthPanel />', () => {
|
||||
describe('For not authorized user', () => {
|
||||
let container;
|
||||
|
||||
createDomContainer(({ domContainer }) => {
|
||||
container = domContainer;
|
||||
});
|
||||
|
||||
it('should render login form with google and github provider', () => {
|
||||
const element = <AuthPanel user={{}} sort="-score" providers={[`google`, `github`]} />;
|
||||
|
||||
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 = <AuthPanel user={{ id: `test`, name: 'John' }} sort="-score" providers={[`google`, `github`]} />;
|
||||
|
||||
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 = (
|
||||
<AuthPanel user={{ id: `test`, admin: true, name: 'John' }} sort="-score" providers={[`google`, `github`]} />
|
||||
);
|
||||
|
||||
render(element, container);
|
||||
|
||||
const adminAction = container.querySelector('.auth-panel__admin-action');
|
||||
|
||||
expect(adminAction.textContent).toEqual('Show blocked users');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @jsx h */
|
||||
import { h } from 'preact';
|
||||
|
||||
export default props => (
|
||||
<div className={b('auth-panel__user-id', props)} title={props.id}>
|
||||
{props.id}
|
||||
</div>
|
||||
);
|
||||
@@ -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) => (
|
||||
<div className={b('auth-panel__user-id', {}, { theme: props.theme })} title={props.id}>
|
||||
{props.id}
|
||||
</div>
|
||||
);
|
||||
@@ -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');
|
||||
@@ -0,0 +1,3 @@
|
||||
export { UserID } from './auth-panel__user-id';
|
||||
|
||||
require('./auth-panel__user-id.scss');
|
||||
@@ -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<Props> = {
|
||||
sort: '-score',
|
||||
providers: [`google`, `github`],
|
||||
postInfo: {
|
||||
read_only: false,
|
||||
url: 'https://example.com',
|
||||
count: 3,
|
||||
},
|
||||
};
|
||||
|
||||
describe('<AuthPanel />', () => {
|
||||
describe('For not authorized user', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
createDomContainer(domContainer => {
|
||||
container = domContainer;
|
||||
});
|
||||
|
||||
it('should render login form with google and github provider', () => {
|
||||
const element = <AuthPanel {...DefaultProps as Props} user={null} />;
|
||||
|
||||
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 = (
|
||||
<AuthPanel
|
||||
{...DefaultProps as Props}
|
||||
user={null}
|
||||
postInfo={{ ...DefaultProps.postInfo, read_only: true } as PostInfo}
|
||||
/>
|
||||
);
|
||||
|
||||
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 = <AuthPanel {...DefaultProps as Props} user={{ id: `john`, name: 'John' } as User} />;
|
||||
|
||||
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 = <AuthPanel {...DefaultProps as Props} user={{ id: `test`, admin: true, name: 'John' } as User} />;
|
||||
|
||||
render(element, container);
|
||||
|
||||
const adminAction = container.querySelector('.auth-panel__admin-action')!;
|
||||
|
||||
expect(adminAction.textContent).toEqual('Show blocked users');
|
||||
});
|
||||
});
|
||||
});
|
||||
+78
-39
@@ -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<void>;
|
||||
onSignIn(p: Provider): Promise<User | null>;
|
||||
onSignOut(): Promise<void>;
|
||||
onCommentsEnable(): Promise<boolean>;
|
||||
onCommentsDisable(): Promise<boolean>;
|
||||
onBlockedUsersShow(): void;
|
||||
onBlockedUsersHide(): void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isBlockedVisible: boolean;
|
||||
}
|
||||
|
||||
export class AuthPanel extends Component<Props, State> {
|
||||
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 <span className="auth-panel__username">{user.name}</span>;
|
||||
return <span className="auth-panel__username">{user!.name}</span>;
|
||||
}
|
||||
|
||||
render(props, { isBlockedVisible }) {
|
||||
render(props: RenderableProps<Props>, { 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 (
|
||||
<div className={b('auth-panel', props, { loggedIn })}>
|
||||
{loggedIn && (
|
||||
<div className={b('auth-panel', {}, { theme: props.theme, loggedIn })}>
|
||||
{user && (
|
||||
<div className="auth-panel__column">
|
||||
You signed in as{' '}
|
||||
<Dropdown title={user.name}>
|
||||
<DropdownItem separator>
|
||||
<UserId id={user.id} />
|
||||
<Dropdown title={user.name} theme={this.props.theme}>
|
||||
<DropdownItem separator={true}>
|
||||
<UserID id={user.id} theme={this.props.theme} />
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem>
|
||||
<Button mods={{ kind: 'link' }} onClick={() => requestDeletion().then(props.onSignOut)}>
|
||||
<Button
|
||||
kind="link"
|
||||
theme={this.props.theme}
|
||||
onClick={() => requestDeletion().then(() => props.onSignOut())}
|
||||
>
|
||||
Request my data removal
|
||||
</Button>
|
||||
</DropdownItem>
|
||||
</Dropdown>{' '}
|
||||
<Button className="auth-panel__sign-out" mods={{ kind: 'link' }} onClick={props.onSignOut}>
|
||||
<Button
|
||||
className="auth-panel__sign-out"
|
||||
kind="link"
|
||||
theme={this.props.theme}
|
||||
onClick={() => props.onSignOut()}
|
||||
>
|
||||
Sign out?
|
||||
</Button>
|
||||
</div>
|
||||
@@ -79,7 +111,7 @@ export default class AuthPanel extends Component {
|
||||
|
||||
{IS_STORAGE_AVAILABLE && !loggedIn && (
|
||||
<div className="auth-panel__column">
|
||||
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 {
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{'.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -118,34 +149,38 @@ export default class AuthPanel extends Component {
|
||||
)}
|
||||
|
||||
<div className="auth-panel__column">
|
||||
{user.admin && (
|
||||
{user && user.admin && (
|
||||
<span
|
||||
className="auth-panel__pseudo-link auth-panel__admin-action"
|
||||
{...getHandleClickProps(this.toggleBlockedVisibility)}
|
||||
{...getHandleClickProps(() => this.toggleBlockedVisibility())}
|
||||
role="link"
|
||||
>
|
||||
{isBlockedVisible ? 'Hide' : 'Show'} blocked users
|
||||
</span>
|
||||
)}
|
||||
|
||||
{user.admin && ' • '}
|
||||
{user && user.admin && ' • '}
|
||||
|
||||
{user.admin && (
|
||||
{user && user.admin && (
|
||||
<span
|
||||
className="auth-panel__pseudo-link auth-panel__admin-action"
|
||||
{...getHandleClickProps(this.toggleCommentsAvailability)}
|
||||
{...getHandleClickProps(() => this.toggleCommentsAvailability())}
|
||||
role="link"
|
||||
>
|
||||
{isCommentsDisabled ? 'Enable' : 'Disable'} comments
|
||||
</span>
|
||||
)}
|
||||
|
||||
{user.admin && ' • '}
|
||||
{user && user.admin && ' • '}
|
||||
|
||||
{!(user && user.admin) && props.postInfo.read_only && (
|
||||
<span className="auth-panel__readonly-label">Read-only</span>
|
||||
)}
|
||||
|
||||
<span className="auth-panel__sort">
|
||||
Sort by{' '}
|
||||
<span className="auth-panel__select-label">
|
||||
{sortArray.find(x => x.selected).label}
|
||||
{sortArray.find(x => 'selected' in x && x.selected!)!.label}
|
||||
<select className="auth-panel__select" onChange={this.onSortChange} onBlur={this.onSortChange}>
|
||||
{sortArray.map(sort => (
|
||||
<option value={sort.value} selected={sort.selected}>
|
||||
@@ -161,8 +196,12 @@ export default class AuthPanel extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
function getSortArray(currentSort) {
|
||||
const sortArray = [
|
||||
function getSortArray(currentSort: Sorting) {
|
||||
const sortArray: {
|
||||
value: Sorting;
|
||||
label: string;
|
||||
selected?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
value: '-score',
|
||||
label: 'Best',
|
||||
@@ -1,10 +1,9 @@
|
||||
import withTheme from 'components/with-theme';
|
||||
import AuthPanel from './auth-panel';
|
||||
|
||||
export default withTheme(AuthPanel);
|
||||
export { AuthPanel } from './auth-panel';
|
||||
|
||||
require('./auth-panel.scss');
|
||||
|
||||
require('./__readonly-label/auth-panel__readonly-label.scss');
|
||||
|
||||
require('./__column/auth-panel__column.scss');
|
||||
require('./__pseudo-link/auth-panel__pseudo-link.scss');
|
||||
require('./__select/auth-panel__select.scss');
|
||||
@@ -1,14 +0,0 @@
|
||||
/** @jsx h */
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function AvatarIcon(props) {
|
||||
const { picture } = props;
|
||||
|
||||
return (
|
||||
<img
|
||||
className={b('avatar-icon', props, { default: !picture })}
|
||||
src={picture || require('./avatar-icon.svg')}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** @jsx h */
|
||||
import { h } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
import { Theme } from '@app/common/types';
|
||||
|
||||
interface Props {
|
||||
picture?: string;
|
||||
mix?: string;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
export function AvatarIcon(props: Props & JSX.HTMLAttributes) {
|
||||
return (
|
||||
<img
|
||||
className={b('avatar-icon', { mix: props.mix }, { theme: props.theme, default: !props.picture })}
|
||||
src={props.picture || require('./avatar-icon.svg')}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import withTheme from 'components/with-theme';
|
||||
import AvatarIcon from './avatar-icon';
|
||||
|
||||
export default withTheme(AvatarIcon);
|
||||
|
||||
require('./avatar-icon.scss');
|
||||
require('./_default/avatar-icon_default.scss');
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AvatarIcon } from './avatar-icon';
|
||||
|
||||
require('./avatar-icon.scss');
|
||||
require('./_default/avatar-icon_default.scss');
|
||||
@@ -1,84 +0,0 @@
|
||||
/** @jsx h */
|
||||
import { h, Component } from 'preact';
|
||||
|
||||
import api from 'common/api';
|
||||
import { getHandleClickProps } from 'common/accessibility';
|
||||
|
||||
export default class BlockedUsers extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
unblockedUsers: [],
|
||||
};
|
||||
}
|
||||
|
||||
block(user) {
|
||||
if (confirm('Do you want to block this user?')) {
|
||||
api.blockUser({ id: user.id }).then(() => {
|
||||
this.setState({ unblockedUsers: this.state.unblockedUsers.filter(x => x !== user.id) });
|
||||
|
||||
if (this.props.onBlock) this.props.onBlock(user.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
unblock(user) {
|
||||
if (confirm('Do you want to unblock this user?')) {
|
||||
api.unblockUser({ id: user.id }).then(() => {
|
||||
this.setState({ unblockedUsers: this.state.unblockedUsers.concat([user.id]) });
|
||||
|
||||
if (this.props.onUnblock) this.props.onUnblock(user.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render(props, { unblockedUsers }) {
|
||||
const { users } = props;
|
||||
|
||||
return (
|
||||
<div className={b('blocked-users', props)} role="region" aria-label="Blocked users">
|
||||
{!users.length && <p>There are no blocked users.</p>}
|
||||
|
||||
{!!users.length && <p>List of blocked users:</p>}
|
||||
|
||||
{!!users.length && (
|
||||
<ul className="blocked-users__list">
|
||||
{users.map(user => {
|
||||
const isUserUnblocked = unblockedUsers.includes(user.id);
|
||||
|
||||
return (
|
||||
<li className={b('blocked-users__list-item', {}, { view: isUserUnblocked ? 'invisible' : null })}>
|
||||
<span className="blocked-users__username">{user.name}</span>{' '}
|
||||
<span className="blocked-users__user-id">({user.id})</span>
|
||||
<span className="blocked-users__user-block-ttl"> until {formatTime(new Date(user.time))}</span>
|
||||
{isUserUnblocked && (
|
||||
<span {...getHandleClickProps(() => this.block(user))} className="blocked-users__action">
|
||||
block
|
||||
</span>
|
||||
)}
|
||||
{!isUserUnblocked && (
|
||||
<span {...getHandleClickProps(() => this.unblock(user))} className="blocked-users__action">
|
||||
unblock
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** @jsx h */
|
||||
import { h, Component, RenderableProps } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
|
||||
import { User, BlockedUser, Theme, BlockTTL } from '@app/common/types';
|
||||
import { getHandleClickProps } from '@app/common/accessibility';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
users: BlockedUser[];
|
||||
blockUser(id: User['id'], name: string, ttl: BlockTTL): Promise<void>;
|
||||
unblockUser(id: User['id']): Promise<void>;
|
||||
onUnblockSomeone(): void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
/**
|
||||
* cached copy so we can
|
||||
* reapply block on unblocked user
|
||||
*/
|
||||
users: BlockedUser[];
|
||||
unblockedUsers: (User['id'])[];
|
||||
}
|
||||
|
||||
export default class BlockedUsers extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
users: props.users.slice(),
|
||||
unblockedUsers: [],
|
||||
};
|
||||
}
|
||||
|
||||
block(user: BlockedUser) {
|
||||
if (confirm(`Do you want to block ${user.name}?`)) {
|
||||
this.setState({
|
||||
unblockedUsers: this.state.unblockedUsers.filter(x => x !== user.id),
|
||||
});
|
||||
this.props.blockUser(user.id, user.name, 'permanently');
|
||||
}
|
||||
}
|
||||
|
||||
unblock(user: BlockedUser) {
|
||||
if (confirm(`Do you want to unblock ${user.name}?`)) {
|
||||
this.setState({ unblockedUsers: this.state.unblockedUsers.concat([user.id]) });
|
||||
this.props.unblockUser(user.id);
|
||||
this.props.onUnblockSomeone();
|
||||
}
|
||||
}
|
||||
|
||||
render({ theme }: RenderableProps<Props>, { users, unblockedUsers }: State) {
|
||||
return (
|
||||
<div className={b('blocked-users', {}, { theme })} role="region" aria-label="Blocked users">
|
||||
{!users.length && <p>There are no blocked users.</p>}
|
||||
|
||||
{!!users.length && <p>List of blocked users:</p>}
|
||||
|
||||
{!!users.length && (
|
||||
<ul className="blocked-users__list">
|
||||
{users.map(user => {
|
||||
const isUserUnblocked = unblockedUsers.includes(user.id);
|
||||
|
||||
return (
|
||||
<li className={b('blocked-users__list-item', {}, { view: isUserUnblocked ? 'invisible' : null })}>
|
||||
<span className="blocked-users__username">{user.name}</span>{' '}
|
||||
<span className="blocked-users__user-id">({user.id})</span>
|
||||
<span className="blocked-users__user-block-ttl"> {formatTime(new Date(user.time))}</span>
|
||||
{isUserUnblocked && (
|
||||
<span {...getHandleClickProps(() => this.block(user))} className="blocked-users__action">
|
||||
block
|
||||
</span>
|
||||
)}
|
||||
{!isUserUnblocked && (
|
||||
<span {...getHandleClickProps(() => this.unblock(user))} className="blocked-users__action">
|
||||
unblock
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
function formatTime(time: Date): string {
|
||||
// let's assume that if block ttl is more than 50 years then user blocked permanently
|
||||
if (time.getFullYear() - currentYear >= 50) return 'permanently';
|
||||
|
||||
// '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 `until ${date} at ${hours}:${mins}`;
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import withTheme from 'components/with-theme';
|
||||
import withTheme from '../../components/with-theme';
|
||||
import BlockedUsers from './blocked-users';
|
||||
|
||||
export default withTheme(BlockedUsers);
|
||||
@@ -1,74 +0,0 @@
|
||||
/** @jsx h */
|
||||
import { Component, h } from 'preact';
|
||||
import noop from '../../utils/noop';
|
||||
|
||||
export default class Button extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isClicked: false,
|
||||
isFocused: false,
|
||||
};
|
||||
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
this.onFocus = this.onFocus.bind(this);
|
||||
this.onBlur = this.onBlur.bind(this);
|
||||
}
|
||||
|
||||
onMouseDown() {
|
||||
this.setState({
|
||||
isClicked: true,
|
||||
});
|
||||
}
|
||||
|
||||
onClick(e) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
|
||||
onBlur(e) {
|
||||
this.setState({
|
||||
isClicked: false,
|
||||
isFocused: false,
|
||||
});
|
||||
|
||||
this.props.onBlur(e);
|
||||
}
|
||||
|
||||
onFocus(e) {
|
||||
this.setState({
|
||||
isFocused: true,
|
||||
});
|
||||
|
||||
this.props.onFocus(e);
|
||||
}
|
||||
|
||||
render(props, state) {
|
||||
const { children } = props;
|
||||
const { isClicked, isFocused } = state;
|
||||
|
||||
const localProps = { ...props };
|
||||
delete localProps.children;
|
||||
delete localProps.mix;
|
||||
delete localProps.mods;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...localProps}
|
||||
className={b('button', props, { clicked: isClicked, focused: isFocused })}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Button.defaultProps = {
|
||||
type: 'button',
|
||||
onClick: noop,
|
||||
onBlur: noop,
|
||||
onFocus: noop,
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/** @jsx h */
|
||||
import { Component, h, RenderableProps } from 'preact';
|
||||
import b from 'bem-react-helper';
|
||||
|
||||
import noop from '@app/utils/noop';
|
||||
import { Theme } from '@app/common/types';
|
||||
|
||||
interface Props {
|
||||
type?: string;
|
||||
kind?: string;
|
||||
theme: Theme;
|
||||
mix?: string;
|
||||
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
onFocus?: (e: FocusEvent) => void;
|
||||
onBlur?: (e: FocusEvent) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isClicked: boolean;
|
||||
isFocused: boolean;
|
||||
}
|
||||
|
||||
export class Button extends Component<JSX.HTMLAttributes & Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isClicked: false,
|
||||
isFocused: false,
|
||||
};
|
||||
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
this.onFocus = this.onFocus.bind(this);
|
||||
this.onBlur = this.onBlur.bind(this);
|
||||
}
|
||||
|
||||
onMouseDown() {
|
||||
this.setState({
|
||||
isClicked: true,
|
||||
});
|
||||
}
|
||||
|
||||
onClick(e: MouseEvent) {
|
||||
this.props.onClick!(e);
|
||||
}
|
||||
|
||||
onBlur(e: FocusEvent) {
|
||||
this.setState({
|
||||
isClicked: false,
|
||||
isFocused: false,
|
||||
});
|
||||
|
||||
this.props.onBlur!(e);
|
||||
}
|
||||
|
||||
onFocus(e: FocusEvent) {
|
||||
this.setState({
|
||||
isFocused: true,
|
||||
});
|
||||
|
||||
this.props.onFocus!(e);
|
||||
}
|
||||
|
||||
render(props: RenderableProps<Props>, state: State) {
|
||||
const { children } = props;
|
||||
const { isClicked, isFocused } = state;
|
||||
|
||||
const localProps = { ...props };
|
||||
delete localProps.children;
|
||||
delete localProps.mix;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...localProps}
|
||||
className={b(
|
||||
'button',
|
||||
{ mix: props.mix },
|
||||
{ theme: props.theme, type: props.type, kind: props.kind, clicked: isClicked, focused: isFocused }
|
||||
)}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Button.defaultProps = {
|
||||
type: 'button',
|
||||
onClick: noop,
|
||||
onBlur: noop,
|
||||
onFocus: noop,
|
||||
};
|
||||
@@ -1,7 +1,4 @@
|
||||
import withTheme from 'components/with-theme';
|
||||
import Button from './button';
|
||||
|
||||
export default withTheme(Button);
|
||||
export { Button } from './button';
|
||||
|
||||
require('./button.scss');
|
||||
|
||||
@@ -1,757 +0,0 @@
|
||||
/** @jsx h */
|
||||
import { h, Component } from 'preact';
|
||||
|
||||
import api from 'common/api';
|
||||
import { getHandleClickProps } from 'common/accessibility';
|
||||
import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX, BLOCKING_DURATIONS } from 'common/constants';
|
||||
import { url } from 'common/settings';
|
||||
import store from 'common/store';
|
||||
import copy from 'common/copy';
|
||||
import debounce from 'utils/debounce';
|
||||
import { extractErrorMessageFromResponse } from 'utils/errorUtils';
|
||||
|
||||
import Input from 'components/input';
|
||||
|
||||
import Avatar from 'components/avatar-icon';
|
||||
|
||||
export default class Comment extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isCopied: false,
|
||||
isReplying: false,
|
||||
isEditing: false,
|
||||
isUserVerified: false,
|
||||
editTimeLeft: null,
|
||||
voteErrorMessage: null,
|
||||
};
|
||||
|
||||
this.votingPromise = Promise.resolve();
|
||||
|
||||
this.updateState(props);
|
||||
|
||||
this.copyComment = this.copyComment.bind(this);
|
||||
this.decreaseScore = this.decreaseScore.bind(this);
|
||||
this.increaseScore = this.increaseScore.bind(this);
|
||||
this.toggleEditing = this.toggleEditing.bind(this);
|
||||
this.toggleReplying = this.toggleReplying.bind(this);
|
||||
this.toggleCollapse = this.toggleCollapse.bind(this);
|
||||
this.toggleUserInfoVisibility = this.toggleUserInfoVisibility.bind(this);
|
||||
this.scrollToParent = this.scrollToParent.bind(this);
|
||||
this.onEdit = this.onEdit.bind(this);
|
||||
this.onReply = this.onReply.bind(this);
|
||||
this.onDeleteClick = this.onDeleteClick.bind(this);
|
||||
this.onOwnCommentDeleteClick = this.onOwnCommentDeleteClick.bind(this);
|
||||
this.onBlockUserClick = this.onBlockUserClick.bind(this);
|
||||
this.blockUser = debounce(this.blockUser, 100).bind(this);
|
||||
this.onUnblockUserClick = this.onUnblockUserClick.bind(this);
|
||||
this.isAdmin = this.isAdmin.bind(this);
|
||||
this.isCurrentUser = this.isCurrentUser.bind(this);
|
||||
this.isGuest = this.isGuest.bind(this);
|
||||
this.getUpvoteDisabledReason = this.getUpvoteDisabledReason.bind(this);
|
||||
this.getDownvoteDisabledReason = this.getDownvoteDisabledReason.bind(this);
|
||||
this.handleVoteError = this.handleVoteError.bind(this);
|
||||
this.sendVotingRequest = this.sendVotingRequest.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.updateState(nextProps);
|
||||
}
|
||||
|
||||
updateState(props) {
|
||||
const {
|
||||
data,
|
||||
data: {
|
||||
user: { block, id: commentUserId },
|
||||
pin,
|
||||
},
|
||||
mods: { guest } = {},
|
||||
} = props;
|
||||
|
||||
const votes = (data && data.votes) || [];
|
||||
const score = (data && data.score) || 0;
|
||||
|
||||
if (this.editTimerInterval) {
|
||||
clearInterval(this.editTimerInterval);
|
||||
this.editTimerInterval = null;
|
||||
}
|
||||
|
||||
if (guest) {
|
||||
this.setState({
|
||||
guest,
|
||||
score,
|
||||
deleted: data ? data.delete : false,
|
||||
});
|
||||
} else {
|
||||
const userId = store.get('user').id;
|
||||
|
||||
this.setState({
|
||||
guest,
|
||||
score,
|
||||
pinned: !!pin,
|
||||
deleted: data ? data.delete : false,
|
||||
userBlocked: !!block,
|
||||
scoreIncreased: userId in votes && votes[userId],
|
||||
scoreDecreased: userId in votes && !votes[userId],
|
||||
});
|
||||
|
||||
if (userId === commentUserId) {
|
||||
const editDuration = store.get('config') && store.get('config').edit_duration;
|
||||
const timeDiff = store.get('serverClientTimeDiff') || 0;
|
||||
const getEditTimeLeft = () => Math.floor(editDuration - ((new Date() - new Date(data.time)) / 1000 - timeDiff));
|
||||
|
||||
if (getEditTimeLeft() > 0) {
|
||||
this.editTimerInterval = setInterval(() => {
|
||||
const editTimeLeft = getEditTimeLeft();
|
||||
|
||||
if (editTimeLeft < 0) {
|
||||
clearInterval(this.editTimerInterval);
|
||||
this.editTimerInterval = null;
|
||||
this.setState({ editTimeLeft: null });
|
||||
} else {
|
||||
this.setState({ editTimeLeft });
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleReplying() {
|
||||
const { isReplying } = this.state;
|
||||
const onPrevInputToggleCb = store.get('onPrevInputToggleCb');
|
||||
|
||||
this.setState({ isEditing: false }, () => this.setState({ isReplying: !isReplying }));
|
||||
|
||||
if (onPrevInputToggleCb) onPrevInputToggleCb();
|
||||
|
||||
if (!isReplying) {
|
||||
store.set('onPrevInputToggleCb', () => this.setState({ isReplying: false }));
|
||||
} else {
|
||||
store.set('onPrevInputToggleCb', null);
|
||||
}
|
||||
}
|
||||
|
||||
toggleEditing() {
|
||||
const { isEditing } = this.state;
|
||||
const onPrevInputToggleCb = store.get('onPrevInputToggleCb');
|
||||
|
||||
this.setState({ isReplying: false }, () => this.setState({ isEditing: !isEditing }));
|
||||
|
||||
if (onPrevInputToggleCb) onPrevInputToggleCb();
|
||||
|
||||
if (!isEditing) {
|
||||
store.set('onPrevInputToggleCb', () => this.setState({ isEditing: false }));
|
||||
} else {
|
||||
store.set('onPrevInputToggleCb', null);
|
||||
}
|
||||
}
|
||||
|
||||
toggleUserInfoVisibility() {
|
||||
if (window.parent) {
|
||||
const { user } = this.props.data;
|
||||
const data = JSON.stringify({ isUserInfoShown: true, user });
|
||||
window.parent.postMessage(data, '*');
|
||||
}
|
||||
}
|
||||
|
||||
togglePin(isPinned) {
|
||||
const { id } = this.props.data;
|
||||
const promptMessage = `Do you want to ${isPinned ? 'unpin' : 'pin'} this user?`;
|
||||
|
||||
if (confirm(promptMessage)) {
|
||||
this.setState({ pinned: !isPinned });
|
||||
|
||||
(isPinned ? api.unpinComment : api.pinComment)({ id, url }).then(() => {
|
||||
api.getComment({ id }).then(comment => store.replaceComment(comment));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleVerify(isVerified) {
|
||||
const {
|
||||
id,
|
||||
user: { id: userId },
|
||||
} = this.props.data;
|
||||
const promptMessage = `Do you want to ${isVerified ? 'unverify' : 'verify'} this user?`;
|
||||
|
||||
if (confirm(promptMessage)) {
|
||||
this.setState({ isUserVerified: !isVerified });
|
||||
|
||||
(isVerified ? api.removeVerifyStatus : api.setVerifyStatus)({ id: userId }).then(() => {
|
||||
api.getComment({ id }).then(comment => store.replaceComment(comment));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onBlockUserClick(e) {
|
||||
// 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.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.value);
|
||||
}
|
||||
|
||||
blockUser(ttl) {
|
||||
const {
|
||||
id,
|
||||
user: { id: userId, name: userName },
|
||||
} = this.props.data;
|
||||
|
||||
const duration = BLOCKING_DURATIONS.find(el => el.value === ttl).label;
|
||||
const promptMessage =
|
||||
ttl === 'permanently'
|
||||
? `Do you want to permanently block user "${userName}"?`
|
||||
: `Do you want to block user "${userName}" (${duration.toLowerCase()})?`;
|
||||
if (confirm(promptMessage)) {
|
||||
this.setState({ userBlocked: true });
|
||||
|
||||
api
|
||||
.blockUser({ id: userId, ttl })
|
||||
.then(api.getComment({ id }))
|
||||
.then(comment => store.replaceComment(comment));
|
||||
}
|
||||
}
|
||||
|
||||
onUnblockUserClick() {
|
||||
const {
|
||||
id,
|
||||
user: { id: userId },
|
||||
} = this.props.data;
|
||||
|
||||
const promptMessage = `Do you want to unblock this user?`;
|
||||
|
||||
if (confirm(promptMessage)) {
|
||||
this.setState({ userBlocked: false });
|
||||
|
||||
api
|
||||
.unblockUser({ id: userId })
|
||||
.then(api.getComment({ id }))
|
||||
.then(comment => store.replaceComment(comment));
|
||||
}
|
||||
}
|
||||
|
||||
onDeleteClick() {
|
||||
const { id } = this.props.data;
|
||||
|
||||
if (confirm('Do you want to delete this comment?')) {
|
||||
this.setState({
|
||||
deleted: true,
|
||||
isEditing: false,
|
||||
isReplying: false,
|
||||
});
|
||||
|
||||
api.removeComment({ id }).then(() => {
|
||||
api.getComment({ id }).then(comment => store.replaceComment(comment));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onOwnCommentDeleteClick() {
|
||||
const { id } = this.props.data;
|
||||
|
||||
if (confirm('Do you want to delete this comment?')) {
|
||||
this.setState({
|
||||
deleted: true,
|
||||
isEditing: false,
|
||||
isReplying: false,
|
||||
});
|
||||
|
||||
api.removeMyComment({ id }).then(() => {
|
||||
api.getComment({ id }).then(comment => store.replaceComment(comment));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleVoteError(e, originalVotingState) {
|
||||
this.setState({
|
||||
...originalVotingState,
|
||||
voteErrorMessage: extractErrorMessageFromResponse(e.response),
|
||||
});
|
||||
}
|
||||
|
||||
sendVotingRequest(id, votingValue, originalVotingState) {
|
||||
this.votingPromise = this.votingPromise
|
||||
.then(() => {
|
||||
return api.putCommentVote({ id, url, value: votingValue }).then(() => {
|
||||
api.getComment({ id }).then(comment => store.replaceComment(comment));
|
||||
});
|
||||
})
|
||||
.catch(e => this.handleVoteError(e, originalVotingState));
|
||||
}
|
||||
|
||||
increaseScore() {
|
||||
const { score, scoreIncreased, scoreDecreased } = this.state;
|
||||
const { id } = this.props.data;
|
||||
|
||||
if (scoreIncreased) return;
|
||||
|
||||
this.setState({
|
||||
scoreIncreased: !scoreDecreased,
|
||||
scoreDecreased: false,
|
||||
score: score + 1,
|
||||
voteErrorMessage: null,
|
||||
});
|
||||
|
||||
this.sendVotingRequest(id, 1, { score, scoreIncreased, scoreDecreased });
|
||||
}
|
||||
|
||||
decreaseScore() {
|
||||
const { score, scoreIncreased, scoreDecreased } = this.state;
|
||||
const { id } = this.props.data;
|
||||
|
||||
if (scoreDecreased) return;
|
||||
|
||||
this.setState({
|
||||
scoreDecreased: !scoreIncreased,
|
||||
scoreIncreased: false,
|
||||
score: score - 1,
|
||||
voteErrorMessage: null,
|
||||
});
|
||||
|
||||
this.sendVotingRequest(id, -1, { score, scoreIncreased, scoreDecreased });
|
||||
}
|
||||
|
||||
onReply(...rest) {
|
||||
this.props.onReply(...rest);
|
||||
|
||||
this.setState({
|
||||
isReplying: false,
|
||||
});
|
||||
}
|
||||
|
||||
onEdit(...rest) {
|
||||
this.props.onEdit(...rest);
|
||||
|
||||
this.setState({
|
||||
isEditing: false,
|
||||
});
|
||||
}
|
||||
|
||||
scrollToParent(e) {
|
||||
const {
|
||||
data: { pid },
|
||||
} = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const parentCommentNode = document.getElementById(`${COMMENT_NODE_CLASSNAME_PREFIX}${pid}`);
|
||||
|
||||
if (parentCommentNode) {
|
||||
parentCommentNode.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
toggleCollapse() {
|
||||
this.setState({
|
||||
isEditing: false,
|
||||
isReplying: false,
|
||||
});
|
||||
|
||||
if (this.props.onCollapseToggle) {
|
||||
this.props.onCollapseToggle();
|
||||
}
|
||||
}
|
||||
|
||||
copyComment({ username, time }) {
|
||||
const text = this.textNode.textContent;
|
||||
|
||||
copy(`<b>${username}</b> ${time}<br>${text.replace(/\n+/g, '<br>')}`);
|
||||
|
||||
this.setState({ isCopied: true }, () => {
|
||||
setTimeout(() => this.setState({ isCopied: false }), 3000);
|
||||
});
|
||||
}
|
||||
|
||||
isAdmin() {
|
||||
return !this.state.guest && store.get('user').admin;
|
||||
}
|
||||
|
||||
isGuest() {
|
||||
return this.state.guest || !Object.keys(store.get('user')).length;
|
||||
}
|
||||
|
||||
isCurrentUser() {
|
||||
return (
|
||||
(this.props.data && this.props.data.user && this.props.data.user.id) ===
|
||||
(store.get('user') && store.get('user').id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* returns reason for disabled voting
|
||||
*
|
||||
* @return {(string|null)}
|
||||
*/
|
||||
getDownvoteDisabledReason() {
|
||||
if (this.props.mods && this.props.mods.view === 'user') return 'Voting disabled in last comments';
|
||||
if (this.isGuest()) return 'Only authorized users are allowed to vote';
|
||||
const info = store.get('info');
|
||||
if (info && info.read_only) return "You can't vote on read-only topics";
|
||||
if (this.isCurrentUser()) return "You can't vote for your own comment";
|
||||
const config = store.get('config') || {};
|
||||
if (config.positive_score && this.state.score < 1) return 'Only positive score allowed';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns reason for disabled voting
|
||||
*
|
||||
* @return {(string|null)}
|
||||
*/
|
||||
getUpvoteDisabledReason() {
|
||||
if (this.props.mods && this.props.mods.view === 'user') return 'Voting disabled in last comments';
|
||||
if (this.isGuest()) return 'Only authorized users are allowed to vote';
|
||||
const info = store.get('info');
|
||||
if (info && info.read_only) return "You can't vote on read-only topics";
|
||||
if (this.isCurrentUser()) return "You can't vote for your own comment";
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
props,
|
||||
{
|
||||
userBlocked,
|
||||
pinned,
|
||||
score,
|
||||
scoreIncreased,
|
||||
scoreDecreased,
|
||||
deleted,
|
||||
isCopied,
|
||||
isReplying,
|
||||
isEditing,
|
||||
isUserVerified,
|
||||
editTimeLeft,
|
||||
voteErrorMessage,
|
||||
}
|
||||
) {
|
||||
const { data, mods = {}, isCommentsDisabled } = props;
|
||||
const isAdmin = this.isAdmin();
|
||||
const isGuest = this.isGuest();
|
||||
const isCurrentUser = this.isCurrentUser();
|
||||
const config = store.get('config') || {};
|
||||
|
||||
const lowCommentScore = config.low_score;
|
||||
const downvotingDisabledReason = this.getDownvoteDisabledReason();
|
||||
const isDownvotingDisabled = downvotingDisabledReason !== null;
|
||||
const upvotingDisabledReason = this.getUpvoteDisabledReason();
|
||||
const isUpvotingDisabled = upvotingDisabledReason !== null;
|
||||
const editable = data.repliesCount === 0 && !!editTimeLeft;
|
||||
const scoreSignEnabled = !config.positive_score;
|
||||
|
||||
const o = {
|
||||
...data,
|
||||
controversyText: `Controversy: ${(data.controversy || 0).toFixed(2)}`,
|
||||
text: data.text.length
|
||||
? mods.view === 'preview'
|
||||
? getTextSnippet(data.text)
|
||||
: data.text
|
||||
: userBlocked
|
||||
? 'This user was blocked'
|
||||
: deleted
|
||||
? 'This comment was deleted'
|
||||
: data.text,
|
||||
time: formatTime(new Date(data.time)),
|
||||
orig: isEditing
|
||||
? data.orig &&
|
||||
data.orig.replace(/&[#A-Za-z0-9]+;/gi, entity => {
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = entity;
|
||||
return span.innerText;
|
||||
})
|
||||
: data.orig,
|
||||
score: {
|
||||
value: Math.abs(score),
|
||||
sign: !scoreSignEnabled ? '' : score > 0 ? '+' : score < 0 ? '−' : null,
|
||||
view: score > 0 ? 'positive' : score < 0 ? 'negative' : null,
|
||||
},
|
||||
user: {
|
||||
...data.user,
|
||||
picture: data.user.picture.indexOf(API_BASE) === 0 ? `${BASE_URL}${data.user.picture}` : data.user.picture,
|
||||
verified: data.user.verified || isUserVerified,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultMods = {
|
||||
pinned,
|
||||
// TODO: we also have critical_score, so we need to collapse comments with it in future
|
||||
useless: userBlocked || deleted || (score <= lowCommentScore && !mods.pinned && !mods.disabled),
|
||||
// TODO: add default view mod or don't?
|
||||
view: o.user.admin ? 'admin' : null,
|
||||
replying: isReplying,
|
||||
editing: isEditing,
|
||||
};
|
||||
|
||||
if (mods.view === 'preview') {
|
||||
return (
|
||||
<article className={b('comment', props, defaultMods)}>
|
||||
<div className="comment__body">
|
||||
{!!o.title && (
|
||||
<div className="comment__title">
|
||||
<a className="comment__title-link" href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
|
||||
{o.title}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="comment__info">
|
||||
{!!o.title && o.user.name}
|
||||
|
||||
{!o.title && (
|
||||
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__username">
|
||||
{o.user.name}
|
||||
</a>
|
||||
)}
|
||||
</div>{' '}
|
||||
<div
|
||||
className={b('comment__text', { mix: b('raw-content', {}, { theme: mods.theme }) })}
|
||||
dangerouslySetInnerHTML={{ __html: o.text }}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={b('comment', props, defaultMods)}
|
||||
id={mods.disabled ? null : `${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}
|
||||
>
|
||||
{mods.view === 'user' && o.title && (
|
||||
<div className="comment__title">
|
||||
<a className="comment__title-link" href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
|
||||
{o.title}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="comment__body">
|
||||
<div className="comment__info">
|
||||
{mods.view !== 'user' && <Avatar picture={o.user.picture} />}
|
||||
|
||||
{mods.view !== 'user' && (
|
||||
<span
|
||||
{...getHandleClickProps(this.toggleUserInfoVisibility)}
|
||||
className="comment__username"
|
||||
title={o.user.id}
|
||||
>
|
||||
{o.user.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isAdmin && mods.view !== 'user' && (
|
||||
<span
|
||||
{...getHandleClickProps(() => this.toggleVerify(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 && mods.view !== 'user' && (
|
||||
<span title="Verified user" className={b('comment__verification', {}, { active: true })} />
|
||||
)}
|
||||
|
||||
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__time">
|
||||
{o.time}
|
||||
</a>
|
||||
|
||||
{mods.level > 0 && mods.view !== 'user' && (
|
||||
<a
|
||||
className="comment__link-to-parent"
|
||||
href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.pid}`}
|
||||
aria-label="Go to parent comment"
|
||||
title="Go to parent comment"
|
||||
onClick={this.scrollToParent}
|
||||
>
|
||||
{' '}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{isAdmin && userBlocked && mods.view !== 'user' && <span className="comment__status">Blocked</span>}
|
||||
|
||||
{isAdmin && !userBlocked && deleted && <span className="comment__status">Deleted</span>}
|
||||
|
||||
{!mods.disabled && mods.view !== 'user' && (
|
||||
<span
|
||||
{...getHandleClickProps(this.toggleCollapse)}
|
||||
className={b('comment__action', {}, { type: 'collapse', selected: mods.collapsed })}
|
||||
>
|
||||
{mods.collapsed ? '+' : '−'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={b('comment__score', {}, { view: o.score.view })}>
|
||||
<span
|
||||
className={b(
|
||||
'comment__vote',
|
||||
{},
|
||||
{ type: 'up', selected: scoreIncreased, disabled: isUpvotingDisabled }
|
||||
)}
|
||||
aria-disabled={isUpvotingDisabled ? 'true' : 'false'}
|
||||
{...getHandleClickProps(isUpvotingDisabled ? null : this.increaseScore)}
|
||||
title={upvotingDisabledReason}
|
||||
>
|
||||
Vote up
|
||||
</span>
|
||||
|
||||
<span className="comment__score-value" title={o.controversyText}>
|
||||
{o.score.sign}
|
||||
{o.score.value}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={b(
|
||||
'comment__vote',
|
||||
{},
|
||||
{ type: 'down', selected: scoreDecreased, disabled: isDownvotingDisabled }
|
||||
)}
|
||||
aria-disabled={isDownvotingDisabled ? 'true' : 'false'}
|
||||
{...getHandleClickProps(isDownvotingDisabled ? null : this.decreaseScore)}
|
||||
title={downvotingDisabledReason}
|
||||
>
|
||||
Vote down
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!!voteErrorMessage && (
|
||||
<div className="voting__error" role="alert">
|
||||
Voting error: {voteErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={b('comment__text', { mix: b('raw-content', {}, { theme: mods.theme }) })}
|
||||
ref={r => (this.textNode = r)}
|
||||
dangerouslySetInnerHTML={{ __html: o.text }}
|
||||
/>
|
||||
|
||||
<div className="comment__actions">
|
||||
{!deleted && !isCommentsDisabled && !mods.disabled && !isGuest && mods.view !== 'user' && (
|
||||
<span {...getHandleClickProps(this.toggleReplying)} className="comment__action">
|
||||
{isReplying ? 'Cancel' : 'Reply'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!deleted &&
|
||||
!mods.disabled &&
|
||||
!!o.orig &&
|
||||
isCurrentUser &&
|
||||
(editable || isEditing) &&
|
||||
mods.view !== 'user' && [
|
||||
<span
|
||||
{...getHandleClickProps(this.toggleEditing)}
|
||||
className="comment__action comment__action_type_edit"
|
||||
>
|
||||
{isEditing ? 'Cancel' : 'Edit'}
|
||||
</span>,
|
||||
!isAdmin && (
|
||||
<span
|
||||
{...getHandleClickProps(this.onOwnCommentDeleteClick)}
|
||||
className="comment__action comment__action_type_delete"
|
||||
>
|
||||
Delete
|
||||
</span>
|
||||
),
|
||||
<span className="comment__edit-timer">{editTimeLeft && `${editTimeLeft}`}</span>,
|
||||
]}
|
||||
|
||||
{!deleted && isAdmin && (
|
||||
<span className="comment__controls">
|
||||
{!isCopied && (
|
||||
<span
|
||||
{...getHandleClickProps(() => this.copyComment({ username: o.user.name, time: o.time }))}
|
||||
className="comment__control"
|
||||
>
|
||||
Copy
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isCopied && <span className="comment__control comment__control_view_inactive">Copied!</span>}
|
||||
|
||||
{mods.view !== 'user' && (
|
||||
<span {...getHandleClickProps(() => this.togglePin(pinned))} className="comment__control">
|
||||
{pinned ? 'Unpin' : 'Pin'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{userBlocked && (
|
||||
<span {...getHandleClickProps(() => this.onUnblockUserClick())} className="comment__control">
|
||||
Unblock
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!userBlocked && (
|
||||
<span className="comment__control comment__control_select-label">
|
||||
Block
|
||||
<select
|
||||
className="comment__control_select"
|
||||
onBlur={this.onBlockUserClick}
|
||||
onChange={this.onBlockUserClick}
|
||||
>
|
||||
<option disabled selected value>
|
||||
{' '}
|
||||
Blocking period{' '}
|
||||
</option>
|
||||
{BLOCKING_DURATIONS.map(block => (
|
||||
<option value={block.value}>{block.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!deleted && (
|
||||
<span {...getHandleClickProps(this.onDeleteClick)} className="comment__control">
|
||||
Delete
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isReplying && mods.view !== 'user' && (
|
||||
<Input mix="comment__input" onSubmit={this.onReply} onCancel={this.toggleReplying} pid={o.id} />
|
||||
)}
|
||||
|
||||
{isEditing && mods.view !== 'user' && (
|
||||
<Input
|
||||
mix="comment__input"
|
||||
mods={{ mode: 'edit' }}
|
||||
onSubmit={this.onEdit}
|
||||
onCancel={this.toggleEditing}
|
||||
id={o.id}
|
||||
value={o.orig}
|
||||
errorMessage={!editTimeLeft && 'Editing time has expired.'}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getTextSnippet(html) {
|
||||
const LENGTH = 100;
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html.replace('</p><p>', ' ');
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -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<Props> = {
|
||||
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('<Comment />', () => {
|
||||
describe('voting', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
createDomContainer(domContainer => {
|
||||
container = domContainer;
|
||||
});
|
||||
|
||||
it('disabled on user info widget', () => {
|
||||
const element = <Comment {...{ ...DefaultProps, view: 'user' } as Props} />;
|
||||
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 = (
|
||||
<Comment {...{ ...DefaultProps, post_info: { ...DefaultProps.post_info, read_only: true } } as Props} />
|
||||
);
|
||||
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
|
||||
<Comment {...{ ...DefaultProps, data: { ...DefaultProps.data, delete: true } } as Props} />
|
||||
);
|
||||
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 = (
|
||||
<Comment
|
||||
{...{
|
||||
...DefaultProps,
|
||||
user: {
|
||||
id: 'someone',
|
||||
picture: 'somepicture-url',
|
||||
},
|
||||
} as Props}
|
||||
/>
|
||||
);
|
||||
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 = <Comment {...{ ...DefaultProps, user: null } as Props} />;
|
||||
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 = (
|
||||
<Comment
|
||||
{...DefaultProps as Props}
|
||||
data={{ ...DefaultProps.data, votes: { [DefaultProps.user!.id]: true } } as Props['data']}
|
||||
putCommentVote={voteSpy}
|
||||
/>
|
||||
);
|
||||
render(element, container);
|
||||
|
||||
const voteButtons = container.querySelectorAll<HTMLSpanElement>('.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 = (
|
||||
<Comment
|
||||
{...DefaultProps as Props}
|
||||
data={{ ...DefaultProps.data, votes: { [DefaultProps.user!.id]: false } } as Props['data']}
|
||||
putCommentVote={voteSpy}
|
||||
/>
|
||||
);
|
||||
render(element, container);
|
||||
|
||||
const voteButtons = container.querySelectorAll<HTMLSpanElement>('.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 = <Comment {...{ ...DefaultProps, user: { ...DefaultProps.user, admin: true } } as Props} />;
|
||||
render(element, container);
|
||||
|
||||
const controls = container.querySelector('.comment__controls');
|
||||
expect(controls).not.toBe(null);
|
||||
});
|
||||
|
||||
it('not visible for regular user', () => {
|
||||
const element = <Comment {...{ ...DefaultProps, user: { ...DefaultProps.user, admin: false } } as Props} />;
|
||||
render(element, container);
|
||||
|
||||
const controls = container.querySelector('.comment__controls');
|
||||
expect(controls).toBe(null);
|
||||
});
|
||||
|
||||
it('verification badge clickable for admin', () => {
|
||||
const element = <Comment {...{ ...DefaultProps, user: { ...DefaultProps.user, admin: true } } as Props} />;
|
||||
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 = (
|
||||
<Comment
|
||||
{...{
|
||||
...DefaultProps,
|
||||
data: { ...DefaultProps.data, user: { ...DefaultProps.data!.user, verified: true } },
|
||||
} as Props}
|
||||
/>
|
||||
);
|
||||
render(element, container);
|
||||
|
||||
const controls = container.querySelector('.comment__verification')!;
|
||||
expect(controls.classList.contains('comment__verification_clickable')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
updateComment?: (id: CommentType['id'], text: string) => Promise<void>;
|
||||
removeComment?(id: CommentType['id']): Promise<void>;
|
||||
setReplyEditState?(id: CommentType['id'], mode: CommentMode): void;
|
||||
getPreview?: (text: string) => Promise<string>;
|
||||
putCommentVote?(id: CommentType['id'], value: number): Promise<void>;
|
||||
collapseToggle?: (id: CommentType['id']) => void;
|
||||
setPinState?(id: CommentType['id'], value: boolean): Promise<void>;
|
||||
blockUser?(id: User['id'], name: User['name'], ttl: BlockTTL): Promise<void>;
|
||||
unblockUser?(id: User['id']): Promise<void>;
|
||||
setVerifyStatus?(id: User['id'], value: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
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<Props, State> {
|
||||
votingPromise: Promise<unknown>;
|
||||
/** 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(`<b>${username}</b> ${time}<br>${text.replace(/\n+/g, '<br>')}`);
|
||||
|
||||
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<Props>, 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 (
|
||||
<article className={b('comment', { mix: props.mix }, defaultMods)}>
|
||||
<div className="comment__body">
|
||||
{!!o.title && (
|
||||
<div className="comment__title">
|
||||
<a className="comment__title-link" href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
|
||||
{o.title}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="comment__info">
|
||||
{!!o.title && o.user.name}
|
||||
|
||||
{!o.title && (
|
||||
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__username">
|
||||
{o.user.name}
|
||||
</a>
|
||||
)}
|
||||
</div>{' '}
|
||||
<div
|
||||
className={b('comment__text', { mix: b('raw-content', {}, { theme: props.theme }) })}
|
||||
dangerouslySetInnerHTML={{ __html: o.text }}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={b('comment', { mix: this.props.mix }, defaultMods)}
|
||||
id={props.disabled ? undefined : `${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}
|
||||
>
|
||||
{props.view === 'user' && o.title && (
|
||||
<div className="comment__title">
|
||||
<a className="comment__title-link" href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
|
||||
{o.title}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="comment__body">
|
||||
<div className="comment__info">
|
||||
{props.view !== 'user' && <AvatarIcon theme={this.props.theme} picture={o.user.picture} />}
|
||||
|
||||
{props.view !== 'user' && (
|
||||
<span
|
||||
{...getHandleClickProps(() => this.toggleUserInfoVisibility())}
|
||||
className="comment__username"
|
||||
title={o.user.id}
|
||||
>
|
||||
{o.user.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isAdmin && props.view !== 'user' && (
|
||||
<span
|
||||
{...getHandleClickProps(() => 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' && (
|
||||
<span title="Verified user" className={b('comment__verification', {}, { active: true })} />
|
||||
)}
|
||||
|
||||
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__time">
|
||||
{o.time}
|
||||
</a>
|
||||
|
||||
{!!props.level && props.level > 0 && props.view === 'main' && (
|
||||
<a
|
||||
className="comment__link-to-parent"
|
||||
href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.pid}`}
|
||||
aria-label="Go to parent comment"
|
||||
title="Go to parent comment"
|
||||
onClick={e => this.scrollToParent(e)}
|
||||
>
|
||||
{' '}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{isAdmin && props.isUserBanned && props.view !== 'user' && <span className="comment__status">Blocked</span>}
|
||||
|
||||
{isAdmin && !props.isUserBanned && props.data.delete && <span className="comment__status">Deleted</span>}
|
||||
|
||||
{!props.disabled && props.view === 'main' && (
|
||||
<span
|
||||
{...getHandleClickProps(() => this.toggleCollapse())}
|
||||
className={b('comment__action', {}, { type: 'collapse', selected: props.collapsed })}
|
||||
>
|
||||
{props.collapsed ? '+' : '−'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={b('comment__score', {}, { view: o.score.view })}>
|
||||
<span
|
||||
className={b(
|
||||
'comment__vote',
|
||||
{},
|
||||
{ type: 'up', selected: state.scoreDelta === 1, disabled: isUpvotingDisabled }
|
||||
)}
|
||||
aria-disabled={state.scoreDelta === 1 || isUpvotingDisabled ? 'true' : 'false'}
|
||||
{...getHandleClickProps(isUpvotingDisabled ? undefined : () => this.increaseScore())}
|
||||
title={upvotingDisabledReason || undefined}
|
||||
>
|
||||
Vote up
|
||||
</span>
|
||||
|
||||
<span className="comment__score-value" title={o.controversyText}>
|
||||
{o.score.sign}
|
||||
{o.score.value}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={b(
|
||||
'comment__vote',
|
||||
{},
|
||||
{ type: 'down', selected: state.scoreDelta === -1, disabled: isDownvotingDisabled }
|
||||
)}
|
||||
aria-disabled={state.scoreDelta === -1 || isUpvotingDisabled ? 'true' : 'false'}
|
||||
{...getHandleClickProps(isDownvotingDisabled ? undefined : () => this.decreaseScore())}
|
||||
title={downvotingDisabledReason || undefined}
|
||||
>
|
||||
Vote down
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!!state.voteErrorMessage && (
|
||||
<div className="voting__error" role="alert">
|
||||
Voting error: {state.voteErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!props.collapsed || props.view === 'pinned') && (
|
||||
<div
|
||||
className={b('comment__text', { mix: b('raw-content', {}, { theme: props.theme }) })}
|
||||
ref={r => (this.textNode = r)}
|
||||
dangerouslySetInnerHTML={{ __html: o.text }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!props.collapsed || props.view === 'pinned') && (
|
||||
<div className="comment__actions">
|
||||
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && (
|
||||
<span {...getHandleClickProps(() => this.toggleReplying())} className="comment__action">
|
||||
{isReplying ? 'Cancel' : 'Reply'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!props.data.delete &&
|
||||
!props.disabled &&
|
||||
!!o.orig &&
|
||||
isCurrentUser &&
|
||||
(editable || isEditing) &&
|
||||
props.view === 'main' && [
|
||||
<span
|
||||
{...getHandleClickProps(() => this.toggleEditing())}
|
||||
className="comment__action comment__action_type_edit"
|
||||
>
|
||||
{isEditing ? 'Cancel' : 'Edit'}
|
||||
</span>,
|
||||
!isAdmin && (
|
||||
<span
|
||||
{...getHandleClickProps(() => this.deleteComment())}
|
||||
className="comment__action comment__action_type_delete"
|
||||
>
|
||||
Delete
|
||||
</span>
|
||||
),
|
||||
state.editDeadline && (
|
||||
<Countdown
|
||||
className="comment__edit-timer"
|
||||
time={state.editDeadline}
|
||||
onTimePassed={() =>
|
||||
this.setState({
|
||||
editDeadline: null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
]}
|
||||
|
||||
{!props.data.delete && isAdmin && (
|
||||
<span className="comment__controls">
|
||||
{!state.isCopied && (
|
||||
<span
|
||||
{...getHandleClickProps(() => this.copyComment({ username: o.user.name, time: o.time }))}
|
||||
className="comment__control"
|
||||
>
|
||||
Copy
|
||||
</span>
|
||||
)}
|
||||
|
||||
{state.isCopied && <span className="comment__control comment__control_view_inactive">Copied!</span>}
|
||||
|
||||
{(props.view === 'main' || props.view === 'pinned') && (
|
||||
<span {...getHandleClickProps(() => this.setPin(!props.data.pin))} className="comment__control">
|
||||
{props.data.pin ? 'Unpin' : 'Pin'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{props.isUserBanned && (
|
||||
<span {...getHandleClickProps(() => this.onUnblockUserClick())} className="comment__control">
|
||||
Unblock
|
||||
</span>
|
||||
)}
|
||||
|
||||
{props.user!.id !== props.data.user.id && !props.isUserBanned && (
|
||||
<span className="comment__control comment__control_select-label">
|
||||
Block
|
||||
<select
|
||||
className="comment__control_select"
|
||||
onBlur={e => this.onBlockUserClick(e)}
|
||||
onChange={e => this.onBlockUserClick(e)}
|
||||
>
|
||||
<option disabled selected value={undefined}>
|
||||
{' '}
|
||||
Blocking period{' '}
|
||||
</option>
|
||||
{BLOCKING_DURATIONS.map(block => (
|
||||
<option value={block.value}>{block.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!props.data.delete && (
|
||||
<span {...getHandleClickProps(() => this.deleteComment())} className="comment__control">
|
||||
Delete
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isReplying && props.view === 'main' && (
|
||||
<Input
|
||||
theme={props.theme}
|
||||
value=""
|
||||
mode="reply"
|
||||
mix="comment__input"
|
||||
onSubmit={(text, title) => this.addComment(text, title, o.id)}
|
||||
onCancel={this.toggleReplying}
|
||||
getPreview={this.props.getPreview!}
|
||||
autofocus={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEditing && props.view === 'main' && (
|
||||
<Input
|
||||
theme={props.theme}
|
||||
value={o.orig}
|
||||
mode="edit"
|
||||
mix="comment__input"
|
||||
onSubmit={(text, _title) => this.updateComment(props.data.id, text)}
|
||||
onCancel={this.toggleEditing}
|
||||
getPreview={this.props.getPreview!}
|
||||
errorMessage={state.editDeadline === null ? 'Editing time has expired.' : undefined}
|
||||
autofocus={true}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getTextSnippet(html: string) {
|
||||
const LENGTH = 100;
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html.replace('</p><p>', ' ');
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export { Comment } from './comment';
|
||||
@@ -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');
|
||||
|
||||
@@ -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<Props, State> {
|
||||
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<Props>) {
|
||||
return <span {...exclude(props, 'time', 'onTimePassed')} ref={ref => (this.elemRef = ref)} />;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @jsx h */
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function DropdownItem(props) {
|
||||
const { children, separator = false } = props;
|
||||
|
||||
return <div className={b('dropdown__item', props, { separator })}>{children}</div>;
|
||||
}
|
||||
@@ -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<Props> & JSX.HTMLAttributes & { separator?: boolean }) {
|
||||
const { children, separator = false } = props;
|
||||
|
||||
return <div className={b('dropdown__item', {}, { separator })}>{children}</div>;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import withTheme from 'components/with-theme';
|
||||
import Dropdown__Item from './dropdown__item';
|
||||
|
||||
export default withTheme(Dropdown__Item);
|
||||
@@ -0,0 +1,3 @@
|
||||
import Dropdown__Item from './dropdown__item';
|
||||
|
||||
export default Dropdown__Item;
|
||||
@@ -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 (
|
||||
<div className={b('dropdown', { mix, mods }, { active: isActive })} ref={r => (this.rootNode = r)}>
|
||||
<Button
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isActive && 'true'}
|
||||
mix="dropdown__title"
|
||||
type="button"
|
||||
onClick={this.onTitleClick}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
|
||||
<div className="dropdown__content" tabindex="-1" role="listbox">
|
||||
{heading && <div className="dropdown__heading">{heading}</div>}
|
||||
<div className="dropdown__items">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Props, State> {
|
||||
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<Props>, { isActive }: State) {
|
||||
const { title, heading, children, mix } = props;
|
||||
|
||||
return (
|
||||
<div className={b('dropdown', { mix }, { theme: props.theme, active: isActive })} ref={r => (this.rootNode = r)}>
|
||||
<Button
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isActive && 'true'}
|
||||
mix="dropdown__title"
|
||||
type="button"
|
||||
onClick={() => this.onTitleClick()}
|
||||
theme="light"
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
|
||||
<div className="dropdown__content" tabIndex={-1} role="listbox">
|
||||
{heading && <div className="dropdown__heading">{heading}</div>}
|
||||
<div className="dropdown__items">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { Input } from './input';
|
||||
@@ -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<void>;
|
||||
getPreview(text: string): Promise<string>;
|
||||
/** 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<Props, State> {
|
||||
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<Props>, { 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 (
|
||||
<form className={b('input', props)} onSubmit={this.send} aria-label="New comment">
|
||||
<form
|
||||
className={b('input', {
|
||||
mods: {
|
||||
theme: props.theme || 'light',
|
||||
type: props.mode || 'reply',
|
||||
},
|
||||
mix: props.mix,
|
||||
})}
|
||||
onSubmit={this.send}
|
||||
aria-label="New comment"
|
||||
>
|
||||
<div className="input__field-wrapper">
|
||||
<TextareaAutosize
|
||||
ref={ref => (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 && <span className="input__counter">{charactersLeft}</span>}
|
||||
@@ -152,14 +198,14 @@ export default class Input extends Component {
|
||||
</button>
|
||||
|
||||
<button className={b('input__button', {}, { type: 'send' })} type="submit" disabled={isDisabled}>
|
||||
Send
|
||||
{label}
|
||||
</button>
|
||||
|
||||
{mods.type === 'main' && (
|
||||
{props.mode === 'main' && (
|
||||
<div className="input__rss">
|
||||
<div class="input__markdown">
|
||||
Styling with{' '}
|
||||
<a className="input__markdown-link" target="_blank" href="markdown-help.html" target="_blank">
|
||||
<a className="input__markdown-link" target="_blank" href="markdown-help.html">
|
||||
Markdown
|
||||
</a>{' '}
|
||||
is supported
|
||||
@@ -173,7 +219,7 @@ export default class Input extends Component {
|
||||
Site
|
||||
</a>{' '}
|
||||
or
|
||||
<a className="input__rss-link" href={RSS_REPLIES_URL + userId} target="_blank">
|
||||
<a className="input__rss-link" href={RSS_REPLIES_URL + props.userId} target="_blank">
|
||||
Replies
|
||||
</a>{' '}
|
||||
by RSS
|
||||
@@ -186,7 +232,7 @@ export default class Input extends Component {
|
||||
!!preview && (
|
||||
<div className="input__preview-wrapper">
|
||||
<div
|
||||
className={b('input__preview', { mix: b('raw-content', {}, { theme: mods.theme }) })}
|
||||
className={b('input__preview', { mix: b('raw-content', {}, { theme: props.theme }) })}
|
||||
dangerouslySetInnerHTML={{ __html: preview }}
|
||||
/>
|
||||
</div>
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
<textarea {...props} ref={this.onRef}>
|
||||
{props.value}
|
||||
</textarea>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/** @jsx h */
|
||||
import { h, Component, RenderableProps } from 'preact';
|
||||
|
||||
type Props = JSX.HTMLAttributes & {
|
||||
autofocus: boolean;
|
||||
};
|
||||
|
||||
export default class TextareaAutosize extends Component<Props> {
|
||||
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<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
|
||||
<textarea {...props} ref={this.onRef}>
|
||||
{props.value}
|
||||
</textarea>
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export { default } from './list-comments';
|
||||
export { ListComments } from './list-comments';
|
||||
|
||||
require('./list-comments.scss');
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/** @jsx h */
|
||||
import { h } from 'preact';
|
||||
|
||||
import { NODE_ID } from 'common/constants';
|
||||
|
||||
import Comment from 'components/comment';
|
||||
|
||||
const ListComments = ({ comments = [] }) => (
|
||||
<div id={NODE_ID}>
|
||||
<div className="list-comments">
|
||||
{comments.map(comment => (
|
||||
<Comment data={comment} mods={{ level: 0, guest: true, view: 'preview' }} mix="list-comments__item" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ListComments;
|
||||
@@ -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) => (
|
||||
<div id={NODE_ID}>
|
||||
<div className="list-comments">
|
||||
{comments.map(comment => (
|
||||
<Comment
|
||||
data={comment}
|
||||
level={0}
|
||||
view="preview"
|
||||
mix="list-comments__item"
|
||||
user={null}
|
||||
theme="light"
|
||||
isCommentsDisabled={false}
|
||||
post_info={null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
+4
-4
@@ -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(`<Preloader />`, () => {
|
||||
let container;
|
||||
let container: HTMLElement;
|
||||
|
||||
createDomContainer(({ domContainer }) => {
|
||||
createDomContainer(domContainer => {
|
||||
container = domContainer;
|
||||
});
|
||||
|
||||
+6
-1
@@ -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) => (
|
||||
<div className={b('preloader', props)}>
|
||||
<div className="preloader__bounce" />
|
||||
<div className="preloader__bounce" />
|
||||
@@ -1,4 +1,4 @@
|
||||
export { default } from './root';
|
||||
export { Root, ConnectedRoot } from './root';
|
||||
|
||||
require('./root.scss');
|
||||
|
||||
@@ -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 (
|
||||
<div id={NODE_ID}>
|
||||
<div className={b('root', props, { theme })}>
|
||||
<Preloader mix="root__preloader" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div id={NODE_ID}>
|
||||
<div className={b('root', props, { theme })}>
|
||||
<AuthPanel
|
||||
user={user}
|
||||
sort={sort}
|
||||
providers={config.auth_providers}
|
||||
isCommentsDisabled={isCommentsDisabled}
|
||||
onSignIn={this.onSignIn}
|
||||
onSignOut={this.onSignOut}
|
||||
onBlockedUsersShow={this.onBlockedUsersShow}
|
||||
onBlockedUsersHide={this.onBlockedUsersHide}
|
||||
onCommentsEnable={this.onCommentsEnable}
|
||||
onCommentsDisable={this.onCommentsDisable}
|
||||
onSortChange={this.onSortChange}
|
||||
/>
|
||||
|
||||
{!isBlockedVisible && (
|
||||
<div className="root__main">
|
||||
{!isGuest && !isCommentsDisabled && (
|
||||
<Input mix="root__input" mods={{ type: 'main' }} onSubmit={this.addComment} userId={user.id} />
|
||||
)}
|
||||
|
||||
{!!pinnedComments.length && (
|
||||
<div className="root__pinned-comments" role="region" aria-label="Pinned comments">
|
||||
{pinnedComments.map(comment => (
|
||||
<Comment data={comment} mods={{ level: 0, disabled: true }} mix="root__pinned-comment" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!comments.length && !isCommentsListLoading && (
|
||||
<div className="root__threads" role="list">
|
||||
{(IS_MOBILE ? comments.slice(0, commentsShown) : comments).map(thread => (
|
||||
<Thread
|
||||
key={thread.comment.id}
|
||||
mix="root__thread"
|
||||
mods={{ level: 0 }}
|
||||
data={thread}
|
||||
isCommentsDisabled={isCommentsDisabled}
|
||||
onReply={this.addComment}
|
||||
onEdit={this.replaceComment}
|
||||
/>
|
||||
))}
|
||||
|
||||
{commentsShown < comments.length && IS_MOBILE && (
|
||||
<button className="root__show-more" onClick={this.showMore}>
|
||||
Show more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCommentsListLoading && (
|
||||
<div className="root__threads" role="list">
|
||||
<Preloader mix="root__preloader" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBlockedVisible && (
|
||||
<div className="root__main">
|
||||
<BlockedUsers users={bannedUsers} onUnblock={this.onUnblockSomeone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="root__copyright" role="contentinfo">
|
||||
Powered by{' '}
|
||||
<a href="https://remark42.com/" className="root__copyright-link">
|
||||
Remark42
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Tree>;
|
||||
fetchUser(): Promise<User | null>;
|
||||
fetchBlockedUsers(): Promise<BlockedUser[]>;
|
||||
logIn(): Promise<User | null>;
|
||||
logOut(): Promise<void>;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setBlockedVisible: (value: boolean) => boolean;
|
||||
changeSort(sort: Sorting): Promise<void>;
|
||||
enableComments(): Promise<boolean>;
|
||||
disableComments(): Promise<boolean>;
|
||||
getPreview(text: string): Promise<string>;
|
||||
blockUser(id: User['id'], name: User['name'], ttl: BlockTTL): Promise<void>;
|
||||
unblockUser(id: User['id']): Promise<void>;
|
||||
addComment(text: string, title: string, pid?: CommentType['id']): Promise<void>;
|
||||
updateComment(id: string, text: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isLoaded: boolean;
|
||||
isCommentsListLoading: boolean;
|
||||
commentsShown: number;
|
||||
wasSomeoneUnblocked: boolean;
|
||||
}
|
||||
|
||||
/** main component fr main comments widget */
|
||||
export class Root extends Component<Props, State> {
|
||||
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<Props>, { isLoaded, isCommentsListLoading, commentsShown }: State) {
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div id={NODE_ID}>
|
||||
<div className={b('root', {}, { theme: props.theme })}>
|
||||
<Preloader mix="root__preloader" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isGuest = !props.user;
|
||||
const isCommentsDisabled = !!props.info.read_only;
|
||||
|
||||
return (
|
||||
<div id={NODE_ID}>
|
||||
<div className={b('root', {}, { theme: props.theme })}>
|
||||
<AuthPanel
|
||||
theme={this.props.theme}
|
||||
user={this.props.user}
|
||||
sort={this.props.sort}
|
||||
providers={StaticStore.config.auth_providers}
|
||||
isCommentsDisabled={isCommentsDisabled}
|
||||
postInfo={this.props.info}
|
||||
onSignIn={this.props.logIn}
|
||||
onSignOut={this.props.logOut}
|
||||
onBlockedUsersShow={this.onBlockedUsersShow}
|
||||
onBlockedUsersHide={this.onBlockedUsersHide}
|
||||
onCommentsEnable={this.props.enableComments}
|
||||
onCommentsDisable={this.props.disableComments}
|
||||
onSortChange={this.props.changeSort}
|
||||
/>
|
||||
|
||||
{!this.props.isBlockedVisible && (
|
||||
<div className="root__main">
|
||||
{!isGuest && !isCommentsDisabled && (
|
||||
<Input
|
||||
theme={props.theme}
|
||||
mix="root__input"
|
||||
mode="main"
|
||||
userId={this.props.user!.id}
|
||||
onSubmit={(text, title) => this.props.addComment(text, title)}
|
||||
getPreview={this.props.getPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.props.pinnedComments.length > 0 && (
|
||||
<div className="root__pinned-comments" role="region" aria-label="Pinned comments">
|
||||
{this.props.pinnedComments.map(comment => (
|
||||
<Comment view="pinned" data={comment} level={0} disabled={true} mix="root__pinned-comment" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!this.props.comments.length && !isCommentsListLoading && (
|
||||
<div className="root__threads" role="list">
|
||||
{(IS_MOBILE ? this.props.comments.slice(0, commentsShown) : this.props.comments).map(thread => (
|
||||
<Thread
|
||||
key={thread.comment.id}
|
||||
mix="root__thread"
|
||||
level={0}
|
||||
data={thread}
|
||||
getPreview={this.props.getPreview}
|
||||
/>
|
||||
))}
|
||||
|
||||
{commentsShown < this.props.comments.length && IS_MOBILE && (
|
||||
<button className="root__show-more" onClick={this.showMore}>
|
||||
Show more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCommentsListLoading && (
|
||||
<div className="root__threads" role="list">
|
||||
<Preloader mix="root__preloader" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.props.isBlockedVisible && (
|
||||
<div className="root__main">
|
||||
<BlockedUsers
|
||||
users={this.props.bannedUsers}
|
||||
blockUser={this.props.blockUser}
|
||||
unblockUser={this.props.unblockUser}
|
||||
onUnblockSomeone={this.onUnblockSomeone}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="root__copyright" role="contentinfo">
|
||||
Powered by{' '}
|
||||
<a href="https://remark42.com/" className="root__copyright-link">
|
||||
Remark42
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
export { ConnectedThread as Thread } from './thread';
|
||||
@@ -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;
|
||||
@@ -1,6 +0,0 @@
|
||||
export const THREAD_SET_COLLAPSE = 'THREAD/COLLAPSE_SET';
|
||||
export const setCollapse = (comment, collapsed) => ({
|
||||
type: THREAD_SET_COLLAPSE,
|
||||
comment,
|
||||
collapsed,
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
className={b('thread', props)}
|
||||
role={['listitem'].concat(!collapsed && replies.length ? 'list' : [])}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<Comment
|
||||
data={{ ...comment, repliesCount: replies.length }}
|
||||
isCommentsDisabled={isCommentsDisabled}
|
||||
mods={{ level: mods.level, collapsed }}
|
||||
onReply={props.onReply}
|
||||
onEdit={props.onEdit}
|
||||
onCollapseToggle={this.onCollapseToggle}
|
||||
/>
|
||||
|
||||
{!collapsed &&
|
||||
!!replies.length &&
|
||||
replies.map(thread => (
|
||||
<ConnectedThread
|
||||
key={thread.comment.id}
|
||||
data={thread}
|
||||
isCommentsDisabled={isCommentsDisabled}
|
||||
mods={{ level: mods.level < 5 ? mods.level + 1 : mods.level }}
|
||||
onReply={props.onReply}
|
||||
onEdit={props.onEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ConnectedThread = connect(
|
||||
(state, props) => ({ collapsed: getThreadIsCollapsed(state, props.data.comment) }),
|
||||
{ setCollapse }
|
||||
)(Thread);
|
||||
|
||||
export default ConnectedThread;
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
function Thread(props: RenderableProps<Props>) {
|
||||
const {
|
||||
collapsed,
|
||||
data: { comment, replies = [] },
|
||||
level,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={b('thread', props, { level: props.level })}
|
||||
role={['listitem'].concat(!collapsed && replies.length ? 'list' : []).join(' ')}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<Comment view="main" data={comment} repliesCount={replies.length} level={level} getPreview={props.getPreview} />
|
||||
|
||||
{!collapsed &&
|
||||
!!replies.length &&
|
||||
replies.map(thread => (
|
||||
<ConnectedThread
|
||||
key={thread.comment.id}
|
||||
data={thread}
|
||||
level={Math.min(level + 1, 5)}
|
||||
getPreview={props.getPreview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ConnectedThread = connect((state: StoreState, props: { data: Node }) => ({
|
||||
collapsed: getThreadIsCollapsed(state, props.data.comment),
|
||||
isCommentsDisabled: !!state.info.read_only,
|
||||
}))(Thread);
|
||||
@@ -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');
|
||||
|
||||
@@ -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 <Preloader mix="user-info__preloader" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{comments.map(comment => (
|
||||
<Comment data={comment} mods={{ level: 0, view: 'user', theme: mods.theme }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LastCommentsList;
|
||||
@@ -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 <Preloader mix="user-info__preloader" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{comments.map(comment => (
|
||||
<Comment
|
||||
data={comment}
|
||||
level={0}
|
||||
view="user"
|
||||
user={null}
|
||||
isCommentsDisabled={false}
|
||||
theme="light"
|
||||
post_info={null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LastCommentsList;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export const getUserComments = (state, userId) => state.userComments[userId] || null;
|
||||
export const getIsLoadingUserComments = (state, userId) => state.isLoadingUserComments[userId] || false;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className={b('user-info', props)}>
|
||||
<Avatar mods={{ theme: 'light' }} mix="user-info__avatar" picture={isDefaultPicture ? null : picture} />
|
||||
<p className="user-info__title">Last comments by {name}</p>
|
||||
<p className="user-info__id">{id}</p>
|
||||
|
||||
{!!comments && <LastCommentsList mods={{ theme: 'light' }} isLoading={isLoading} comments={comments} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state, props) => ({
|
||||
comments: getUserComments(state, props.user.id),
|
||||
isLoading: getIsLoadingUserComments(state, props.user.id),
|
||||
}),
|
||||
{ fetchComments, completeFetchComments }
|
||||
)(UserInfo);
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<Comment[] | null>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
class UserInfo extends Component<Props, State> {
|
||||
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<Props>, state: State): JSX.Element | null {
|
||||
const user = userInfo;
|
||||
const { comments = [] } = props;
|
||||
|
||||
// TODO: handle
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={b('user-info', {})}>
|
||||
<AvatarIcon mix="user-info__avatar" picture={user.picture} />
|
||||
<p className="user-info__title">Last comments by {user.name}</p>
|
||||
<p className="user-info__id">{user.id}</p>
|
||||
|
||||
{!!comments && <LastCommentsList isLoading={state.isLoading} comments={comments} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './with-theme';
|
||||
@@ -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<P extends { theme: Theme }, S extends object>(PlainComponent: AnyComponent<P, S>) {
|
||||
return connect((state: StoreState) => ({ theme: state.theme }))(PlainComponent);
|
||||
}
|
||||
|
||||
export default withTheme;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user