Refactor frontend to typescript (#281)

This commit is contained in:
Misha Vyrtsev
2019-03-24 22:53:55 +04:00
committed by Aleksei Gurianov
parent 83de28c2da
commit 5809419bce
163 changed files with 6232 additions and 4084 deletions
+1
View File
@@ -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
+27
View File
@@ -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`
+27 -13
View File
@@ -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
+4 -2
View File
@@ -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
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
-13
View File
@@ -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 } : {}),
});
+13
View File
@@ -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 } : {}),
});
-180
View File
@@ -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,
};
+246
View File
@@ -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,
};
+21
View File
@@ -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) {
-39
View File
@@ -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 });
}
+49
View File
@@ -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;
-52
View File
@@ -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;
+81
View File
@@ -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';
-19
View File
@@ -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();
}
+27
View File
@@ -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;
}
-18
View File
@@ -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];
+40
View File
@@ -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;
+30
View File
@@ -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,
};
-123
View File
@@ -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();
-13
View File
@@ -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' } }]);
});
});
+130
View File
@@ -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,
}
+23
View File
@@ -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');
});
});
});
@@ -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=""
/>
);
}
-7
View File
@@ -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');
+4
View File
@@ -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,4 +1,4 @@
import withTheme from 'components/with-theme';
import withTheme from '../../components/with-theme';
import BlockedUsers from './blocked-users';
export default withTheme(BlockedUsers);
-74
View File
@@ -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,
};
+96
View File
@@ -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');
-757
View File
@@ -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>&nbsp;${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}`;
}
+216
View File
@@ -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);
});
});
});
+721
View File
@@ -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>&nbsp;${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);
+1
View File
@@ -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');
+59
View File
@@ -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;
-87
View File
@@ -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>
);
}
}
+101
View File
@@ -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';
+1
View File
@@ -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&nbsp;
<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&nbsp;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,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>
);
@@ -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;
});
@@ -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');
-359
View File
@@ -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>
);
}
}
+319
View File
@@ -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;
-8
View File
@@ -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';
+1
View File
@@ -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);
});
});
-64
View File
@@ -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 });
});
});
+53
View File
@@ -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
View File
@@ -1 +0,0 @@
export { default } from './with-theme';
+15
View File
@@ -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