diff --git a/.env b/.env deleted file mode 100644 index bfd29ef4..00000000 --- a/.env +++ /dev/null @@ -1,5 +0,0 @@ -#API_HOST = http://localhost:7777/ -#REMOTE_CURRENT = http://localhost:7777/static/ -REACT_APP_API_HOST = https://pig.staging.vault48.org/ -REACT_APP_REMOTE_CURRENT = https://pig.staging.vault48.org/static/ -EXPOSE = 4000 diff --git a/.gitignore b/.gitignore index a0d7b1b9..1e572c99 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /npm-debug.log /.idea /dist +/.env diff --git a/craco.config.js b/craco.config.js index 06864f66..a7ca11e9 100644 --- a/craco.config.js +++ b/craco.config.js @@ -1,4 +1,5 @@ const CracoAlias = require('craco-alias'); +const fastRefreshCracoPlugin = require('craco-fast-refresh'); module.exports = { webpack: { @@ -14,27 +15,28 @@ module.exports = { mode: 'file', }, jest: { - setupTestFrameworkScriptFile: "/src/setupTests.js", + setupTestFrameworkScriptFile: '/src/setupTests.js', configure: { moduleNameMapper: { - "^~/(.*)$": "/src/$1", - "^.+\\.scss$": "identity-obj-proxy" + '^~/(.*)$': '/src/$1', + '^.+\\.scss$': 'identity-obj-proxy', }, - snapshotSerializers: ["enzyme-to-json/serializer"], - moduleFileExtensions: ["js", "json", "ts", "tsx", "jsx", "node"], + snapshotSerializers: ['enzyme-to-json/serializer'], + moduleFileExtensions: ['js', 'json', 'ts', 'tsx', 'jsx', 'node'], verbose: true, - roots: ["/src"], + roots: ['/src'], transform: { - "^.+\\.tsx?$": "ts-jest", - "^.+\\.ts?$": "babel-jest", - "^.+\\.js?$": "ts-jest", - "^.+\\.jsx?$": "babel-jest" + '^.+\\.tsx?$': 'ts-jest', + '^.+\\.ts?$': 'babel-jest', + '^.+\\.js?$': 'ts-jest', + '^.+\\.jsx?$': 'babel-jest', }, - preset: "ts-jest/presets/js-with-ts", - testEnvironment: "node" - } + preset: 'ts-jest/presets/js-with-ts', + testEnvironment: 'node', + }, }, plugins: [ + { plugin: fastRefreshCracoPlugin }, { plugin: CracoAlias, options: { diff --git a/package.json b/package.json index ed843287..4374ead7 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,15 @@ "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "autosize": "^4.0.2", - "axios": "^0.18.0", + "axios": "^0.21.1", "body-scroll-lock": "^2.6.4", "classnames": "^2.2.6", "connected-react-router": "^6.5.2", "date-fns": "^2.4.1", "flexbin": "^0.2.0", + "formik": "^2.2.6", + "insane": "^2.6.2", + "marked": "^2.0.0", "node-sass": "4.14.1", "photoswipe": "^4.1.3", "raleway-cyrillic": "^4.0.2", @@ -21,7 +24,7 @@ "react": "^17.0.1", "react-dom": "^17.0.1", "react-popper": "^2.2.3", - "react-redux": "^6.0.1", + "react-redux": "^7.2.2", "react-router": "^5.1.2", "react-router-dom": "^5.1.2", "react-scripts": "3.4.4", @@ -34,18 +37,18 @@ "throttle-debounce": "^2.1.0", "typescript": "^4.0.5", "uuid4": "^1.1.4", - "web-vitals": "^0.2.4" + "web-vitals": "^0.2.4", + "yup": "^0.32.9" }, "scripts": { "start": "craco start", "build": "craco build", "test": "craco test", - "eject": "craco eject" + "ts-check": "tsc -p tsconfig.json --noEmit" }, "eslintConfig": { "extends": [ - "react-app", - "react-app/jest" + "react-app" ] }, "browserslist": { @@ -61,12 +64,15 @@ ] }, "devDependencies": { + "@craco/craco": "5.8.0", "@types/classnames": "^2.2.7", + "@types/marked": "^1.2.2", "@types/node": "^11.13.22", "@types/ramda": "^0.26.33", "@types/react-redux": "^7.1.11", - "@craco/craco": "5.8.0", + "@types/yup": "^0.29.11", "craco-alias": "^2.1.1", + "craco-fast-refresh": "^1.0.2", "prettier": "^1.18.2" } } diff --git a/src/components/comment/Comment/index.tsx b/src/components/comment/Comment/index.tsx index 7608e702..6ee92c31 100644 --- a/src/components/comment/Comment/index.tsx +++ b/src/components/comment/Comment/index.tsx @@ -1,11 +1,8 @@ import React, { FC, HTMLAttributes, memo } from 'react'; import { CommentWrapper } from '~/components/containers/CommentWrapper'; -import { ICommentGroup } from '~/redux/types'; +import { IComment, ICommentGroup } from '~/redux/types'; import { CommentContent } from '~/components/comment/CommentContent'; import styles from './styles.module.scss'; -import { nodeEditComment, nodeLockComment } from '~/redux/node/actions'; -import { INodeState } from '~/redux/node/reducer'; -import { CommentForm } from '../CommentForm'; import { CommendDeleted } from '../../node/CommendDeleted'; import * as MODAL_ACTIONS from '~/redux/modal/actions'; @@ -13,25 +10,21 @@ type IProps = HTMLAttributes & { is_empty?: boolean; is_loading?: boolean; comment_group: ICommentGroup; - comment_data: INodeState['comment_data']; is_same?: boolean; can_edit?: boolean; - onDelete: typeof nodeLockComment; - onEdit: typeof nodeEditComment; + onDelete: (id: IComment['id'], isLocked: boolean) => void; modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe; }; const Comment: FC = memo( ({ comment_group, - comment_data, is_empty, is_same, is_loading, className, can_edit, onDelete, - onEdit, modalShowPhotoswipe, ...props }) => { @@ -50,17 +43,12 @@ const Comment: FC = memo( return ; } - if (Object.prototype.hasOwnProperty.call(comment_data, comment.id)) { - return ; - } - return ( ); diff --git a/src/components/comment/CommentContent/index.tsx b/src/components/comment/CommentContent/index.tsx index 08e9c49a..318380f3 100644 --- a/src/components/comment/CommentContent/index.tsx +++ b/src/components/comment/CommentContent/index.tsx @@ -1,104 +1,116 @@ -import React, { FC, useMemo, memo, createElement, useCallback, Fragment } from 'react'; +import React, { createElement, FC, Fragment, memo, useCallback, useMemo, useState } from 'react'; import { IComment, IFile } from '~/redux/types'; -import { path } from 'ramda'; -import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom'; +import { append, assocPath, path } from 'ramda'; +import { formatCommentText, getPrettyDate, getURL } from '~/utils/dom'; import { Group } from '~/components/containers/Group'; import styles from './styles.module.scss'; import { UPLOAD_TYPES } from '~/redux/uploads/constants'; -import { assocPath } from 'ramda'; -import { append } from 'ramda'; import reduce from 'ramda/es/reduce'; import { AudioPlayer } from '~/components/media/AudioPlayer'; import classnames from 'classnames'; import { PRESETS } from '~/constants/urls'; import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment'; -import { nodeLockComment, nodeEditComment } from '~/redux/node/actions'; import { CommentMenu } from '../CommentMenu'; import * as MODAL_ACTIONS from '~/redux/modal/actions'; +import { CommentForm } from '~/components/comment/CommentForm'; +import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; +import { selectNode } from '~/redux/node/selectors'; interface IProps { comment: IComment; can_edit: boolean; - onDelete: typeof nodeLockComment; - onEdit: typeof nodeEditComment; + onDelete: (id: IComment['id'], isLocked: boolean) => void; modalShowPhotoswipe: typeof MODAL_ACTIONS.modalShowPhotoswipe; } -const CommentContent: FC = memo( - ({ comment, can_edit, onDelete, onEdit, modalShowPhotoswipe }) => { - const groupped = useMemo>( - () => - reduce( - (group, file) => assocPath([file.type], append(file, group[file.type]), group), - {}, - comment.files - ), - [comment] - ); +const CommentContent: FC = memo(({ comment, can_edit, onDelete, modalShowPhotoswipe }) => { + const [isEditing, setIsEditing] = useState(false); + const { current } = useShallowSelect(selectNode); - const onLockClick = useCallback(() => { - onDelete(comment.id, !comment.deleted_at); - }, [comment, onDelete]); + const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]); + const stopEditing = useCallback(() => setIsEditing(false), [setIsEditing]); - const onEditClick = useCallback(() => { - onEdit(comment.id); - }, [comment, onEdit]); + const groupped = useMemo>( + () => + reduce( + (group, file) => + file.type ? assocPath([file.type], append(file, group[file.type]), group) : group, + {}, + comment.files + ), + [comment] + ); - const menu = useMemo( - () => can_edit && , - [can_edit, comment, onEditClick, onLockClick] - ); + const onLockClick = useCallback(() => { + onDelete(comment.id, !comment.deleted_at); + }, [comment, onDelete]); - return ( -
- {comment.text && ( - - {menu} + const menu = useMemo( + () => can_edit && , + [can_edit, startEditing, onLockClick] + ); - - {formatCommentText(path(['user', 'username'], comment), comment.text).map( - (block, key) => - COMMENT_BLOCK_RENDERERS[block.type] && - createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key }) - )} - + const blocks = useMemo( + () => + !!comment.text.trim() + ? formatCommentText(path(['user', 'username'], comment), comment.text) + : [], + [comment] + ); -
{getPrettyDate(comment.created_at)}
+ if (isEditing) { + return ; + } + + return ( +
+ {comment.text && ( + + {menu} + + + {blocks.map( + (block, key) => + COMMENT_BLOCK_RENDERERS[block.type] && + createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key }) + )} - )} - {groupped.image && groupped.image.length > 0 && ( -
- {menu} +
{getPrettyDate(comment.created_at)}
+ + )} -
- {groupped.image.map((file, index) => ( -
modalShowPhotoswipe(groupped.image, index)}> - {file.name} -
- ))} -
+ {groupped.image && groupped.image.length > 0 && ( +
+ {menu} -
{getPrettyDate(comment.created_at)}
-
- )} - - {groupped.audio && groupped.audio.length > 0 && ( - - {groupped.audio.map(file => ( -
- {menu} - - - -
{getPrettyDate(comment.created_at)}
+
+ {groupped.image.map((file, index) => ( +
modalShowPhotoswipe(groupped.image, index)}> + {file.name}
))} - - )} -
- ); - } -); +
+ +
{getPrettyDate(comment.created_at)}
+
+ )} + + {groupped.audio && groupped.audio.length > 0 && ( + + {groupped.audio.map(file => ( +
+ {menu} + + + +
{getPrettyDate(comment.created_at)}
+
+ ))} +
+ )} +
+ ); +}); export { CommentContent }; diff --git a/src/components/comment/CommentEmbedBlock/index.tsx b/src/components/comment/CommentEmbedBlock/index.tsx index 46bdd08a..77a374d2 100644 --- a/src/components/comment/CommentEmbedBlock/index.tsx +++ b/src/components/comment/CommentEmbedBlock/index.tsx @@ -6,6 +6,7 @@ import { selectPlayer } from '~/redux/player/selectors'; import { connect } from 'react-redux'; import * as PLAYER_ACTIONS from '~/redux/player/actions'; import { Icon } from '~/components/input/Icon'; +import { path } from 'ramda'; const mapStateToProps = state => ({ youtubes: selectPlayer(state).youtubes, @@ -21,30 +22,32 @@ type Props = ReturnType & const CommentEmbedBlockUnconnected: FC = memo( ({ block, youtubes, playerGetYoutubeInfo }) => { - const link = useMemo( - () => - block.content.match( - /https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/(watch)?(\?v=)?([\w\-\=]+)/ - ), - [block.content] - ); + const id = useMemo(() => { + const match = block.content.match( + /https?:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch)?(?:\?v=)?([\w\-\=]+)/ + ); + + return (match && match[1]) || ''; + }, [block.content]); const preview = useMemo(() => getYoutubeThumb(block.content), [block.content]); useEffect(() => { - if (!link[5] || youtubes[link[5]]) return; - playerGetYoutubeInfo(link[5]); - }, [link, playerGetYoutubeInfo]); + if (!id) return; + playerGetYoutubeInfo(id); + }, [id, playerGetYoutubeInfo]); - const title = useMemo( - () => - (youtubes[link[5]] && youtubes[link[5]].metadata && youtubes[link[5]].metadata.title) || '', - [link, youtubes] - ); + const title = useMemo(() => { + if (!id) { + return block.content; + } + + return path([id, 'metadata', 'title'], youtubes) || block.content; + }, [id, youtubes, block.content]); return (
diff --git a/src/components/comment/CommentForm/index.tsx b/src/components/comment/CommentForm/index.tsx index f37053f5..b644509d 100644 --- a/src/components/comment/CommentForm/index.tsx +++ b/src/components/comment/CommentForm/index.tsx @@ -1,234 +1,103 @@ -import React, { FC, KeyboardEventHandler, memo, useCallback, useEffect, useMemo } from 'react'; -import { Textarea } from '~/components/input/Textarea'; -import styles from './styles.module.scss'; -import { Filler } from '~/components/containers/Filler'; +import React, { FC, useCallback, useState } from 'react'; +import { useCommentFormFormik } from '~/utils/hooks/useCommentFormFormik'; +import { FormikProvider } from 'formik'; +import { LocalCommentFormTextarea } from '~/components/comment/LocalCommentFormTextarea'; import { Button } from '~/components/input/Button'; -import assocPath from 'ramda/es/assocPath'; -import { IComment, IFileWithUUID, InputHandler } from '~/redux/types'; -import { connect } from 'react-redux'; -import * as NODE_ACTIONS from '~/redux/node/actions'; -import { selectNode } from '~/redux/node/selectors'; -import { LoaderCircle } from '~/components/input/LoaderCircle'; -import { Group } from '~/components/containers/Group'; -import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants'; -import uuid from 'uuid4'; -import * as UPLOAD_ACTIONS from '~/redux/uploads/actions'; -import { selectUploads } from '~/redux/uploads/selectors'; -import { IState } from '~/redux/store'; -import { getFileType } from '~/utils/uploader'; -import { useRandomPhrase } from '~/constants/phrases'; -import { ERROR_LITERAL } from '~/constants/errors'; +import { FileUploaderProvider, useFileUploader } from '~/utils/hooks/fileUploader'; +import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants'; +import { CommentFormAttachButtons } from '~/components/comment/CommentFormAttachButtons'; +import { CommentFormFormatButtons } from '~/components/comment/CommentFormFormatButtons'; import { CommentFormAttaches } from '~/components/comment/CommentFormAttaches'; -import { CommentFormAttachButtons } from '~/components/comment/CommentFormButtons'; +import { LoaderCircle } from '~/components/input/LoaderCircle'; +import { IComment, INode } from '~/redux/types'; +import { EMPTY_COMMENT } from '~/redux/node/constants'; import { CommentFormDropzone } from '~/components/comment/CommentFormDropzone'; +import styles from './styles.module.scss'; +import { ERROR_LITERAL } from '~/constants/errors'; +import { Group } from '~/components/containers/Group'; -const mapStateToProps = (state: IState) => ({ - node: selectNode(state), - uploads: selectUploads(state), -}); +interface IProps { + comment?: IComment; + nodeId: INode['id']; + onCancelEdit?: () => void; +} -const mapDispatchToProps = { - nodePostComment: NODE_ACTIONS.nodePostComment, - nodeCancelCommentEdit: NODE_ACTIONS.nodeCancelCommentEdit, - nodeSetCommentData: NODE_ACTIONS.nodeSetCommentData, - uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles, +const CommentForm: FC = ({ comment, nodeId, onCancelEdit }) => { + const [textarea, setTextarea] = useState(); + const uploader = useFileUploader( + UPLOAD_SUBJECTS.COMMENT, + UPLOAD_TARGETS.COMMENTS, + comment?.files + ); + const formik = useCommentFormFormik(comment || EMPTY_COMMENT, nodeId, uploader, onCancelEdit); + const isLoading = formik.isSubmitting || uploader.isUploading; + const isEditing = !!comment?.id; + + const clearError = useCallback(() => { + if (formik.status) { + formik.setStatus(''); + } + + if (formik.errors.text) { + formik.setErrors({ + ...formik.errors, + text: '', + }); + } + }, [formik]); + + const error = formik.status || formik.errors.text; + + return ( + +
+ + +
+ + + {!!error && ( +
+ {ERROR_LITERAL[error] || error} +
+ )} +
+ + + + + + + {!!textarea && ( + + )} + + {isLoading && } + + {isEditing && ( + + )} + + + +
+
+
+
+ ); }; -type IProps = ReturnType & - typeof mapDispatchToProps & { - id: number; - is_before?: boolean; - }; - -const CommentFormUnconnected: FC = memo( - ({ - node: { comment_data, is_sending_comment }, - uploads: { statuses, files }, - id, - is_before = false, - nodePostComment, - nodeSetCommentData, - uploadUploadFiles, - nodeCancelCommentEdit, - }) => { - const comment = useMemo(() => comment_data[id], [comment_data, id]); - - const onUpload = useCallback( - (files: File[]) => { - console.log(files); - - const items: IFileWithUUID[] = files.map( - (file: File): IFileWithUUID => ({ - file, - temp_id: uuid(), - subject: UPLOAD_SUBJECTS.COMMENT, - target: UPLOAD_TARGETS.COMMENTS, - type: getFileType(file), - }) - ); - - const temps = items.map(file => file.temp_id); - - nodeSetCommentData(id, assocPath(['temp_ids'], [...comment.temp_ids, ...temps], comment)); - uploadUploadFiles(items); - }, - [uploadUploadFiles, comment, id, nodeSetCommentData] - ); - - const onInput = useCallback( - text => { - nodeSetCommentData(id, assocPath(['text'], text, comment)); - }, - [nodeSetCommentData, comment, id] - ); - - useEffect(() => { - const temp_ids = (comment && comment.temp_ids) || []; - const added_files = temp_ids - .map(temp_uuid => statuses[temp_uuid] && statuses[temp_uuid].uuid) - .map(el => !!el && files[el]) - .filter(el => !!el && !comment.files.some(file => file && file.id === el.id)); - - const filtered_temps = temp_ids.filter( - temp_id => - statuses[temp_id] && - (!statuses[temp_id].uuid || !added_files.some(file => file.id === statuses[temp_id].uuid)) - ); - - if (added_files.length) { - nodeSetCommentData(id, { - ...comment, - temp_ids: filtered_temps, - files: [...comment.files, ...added_files], - }); - } - }, [statuses, files]); - - const isUploadingNow = useMemo(() => comment.temp_ids.length > 0, [comment.temp_ids]); - - const onSubmit = useCallback( - event => { - if (event) event.preventDefault(); - if (isUploadingNow || is_sending_comment) return; - - nodePostComment(id, is_before); - }, - [nodePostComment, id, is_before, isUploadingNow, is_sending_comment] - ); - - const onKeyDown = useCallback>( - ({ ctrlKey, key }) => { - if (!!ctrlKey && key === 'Enter') onSubmit(null); - }, - [onSubmit] - ); - - const images = useMemo( - () => comment.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), - [comment.files] - ); - - const locked_images = useMemo( - () => - comment.temp_ids - .filter(temp => statuses[temp] && statuses[temp].type === UPLOAD_TYPES.IMAGE) - .map(temp_id => statuses[temp_id]), - [statuses, comment.temp_ids] - ); - - const audios = useMemo( - () => comment.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), - [comment.files] - ); - - const locked_audios = useMemo( - () => - comment.temp_ids - .filter(temp => statuses[temp] && statuses[temp].type === UPLOAD_TYPES.AUDIO) - .map(temp_id => statuses[temp_id]), - [statuses, comment.temp_ids] - ); - - const onCancelEdit = useCallback(() => { - nodeCancelCommentEdit(id); - }, [nodeCancelCommentEdit, comment.id]); - - const placeholder = useRandomPhrase('SIMPLE'); - - const clearError = useCallback(() => nodeSetCommentData(id, { error: '' }), [ - id, - nodeSetCommentData, - ]); - - useEffect(() => { - if (comment.error) clearError(); - }, [comment.files, comment.text]); - - const setData = useCallback( - (data: Partial) => { - nodeSetCommentData(id, data); - }, - [nodeSetCommentData, id] - ); - - return ( - -
-
-