diff --git a/package.json b/package.json index 39c04b54..fb67aae3 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@hot-loader/react-dom": "^16.10.2", "@typescript-eslint/eslint-plugin": "^1.13.0", "@typescript-eslint/parser": "^1.13.0", + "autosize": "^4.0.2", "axios": "^0.18.0", "babel-runtime": "^6.26.0", "body-scroll-lock": "^2.6.4", diff --git a/src/components/input/Textarea/index.tsx b/src/components/input/Textarea/index.tsx index 92e593a2..0d40aa22 100644 --- a/src/components/input/Textarea/index.tsx +++ b/src/components/input/Textarea/index.tsx @@ -2,14 +2,14 @@ import React, { ChangeEvent, LegacyRef, memo, + TextareaHTMLAttributes, useCallback, - useLayoutEffect, + useEffect, useRef, useState, - TextareaHTMLAttributes, } from 'react'; -import { getStyle } from '~/utils/dom'; import classNames from 'classnames'; +import autosize from 'autosize'; import * as styles from '~/styles/inputs.scss'; import { Icon } from '../Icon'; @@ -55,34 +55,13 @@ const Textarea = memo( const onFocus = useCallback(() => setFocused(true), [setFocused]); const onBlur = useCallback(() => setFocused(false), [setFocused]); - useLayoutEffect(() => { - const lineHeight = parseInt(getStyle(textarea.current, 'line-height'), 10) || 15; + useEffect(() => { + if (!textarea.current) return; - textarea.current.rows = 1; // reset number of rows in textarea + autosize(textarea.current); - const paddingTop = parseInt(getStyle(textarea.current, 'padding-top'), 10) || 0; - const paddingBottom = parseInt(getStyle(textarea.current, 'padding-bottom'), 10) || 0; - - const actualScrollHeight = - (textarea.current.scrollHeight || 0) - (paddingTop + paddingBottom); - - const rowsCount = Math.round(actualScrollHeight / lineHeight); - - let currentRows = minRows; - - if (rowsCount > maxRows) { - currentRows = maxRows; - textarea.current.scrollTop = textarea.current.scrollHeight; - } else if (rowsCount <= minRows) { - currentRows = minRows; - } else { - currentRows = rowsCount; - } - - textarea.current.rows = currentRows; - - setRows(currentRows); - }, [value, minRows, maxRows]); + return () => autosize.destroy(textarea.current); + }, [textarea.current]); return (
( ref={textarea} onFocus={onFocus} onBlur={onBlur} + style={{ + maxHeight: maxRows * 20, + minHeight: minRows * 20, + }} {...props} />
diff --git a/src/components/node/CommentMenu/styles.scss b/src/components/node/CommentMenu/styles.scss index 0c00373d..7ac03845 100644 --- a/src/components/node/CommentMenu/styles.scss +++ b/src/components/node/CommentMenu/styles.scss @@ -42,6 +42,7 @@ display: flex; flex-direction: column; z-index: 6; + white-space: nowrap; animation: appear 0.25s forwards; } diff --git a/src/components/profile/Message/index.tsx b/src/components/profile/Message/index.tsx index 62de74a3..c543f264 100644 --- a/src/components/profile/Message/index.tsx +++ b/src/components/profile/Message/index.tsx @@ -1,27 +1,82 @@ -import React, { FC } from 'react'; +import React, { FC, useCallback } from 'react'; import { IMessage } from '~/redux/types'; import styles from './styles.scss'; -import { formatText, getURL, getPrettyDate } from '~/utils/dom'; +import { formatText, getPrettyDate, getURL } from '~/utils/dom'; import { PRESETS } from '~/constants/urls'; import classNames from 'classnames'; import { Group } from '~/components/containers/Group'; +import { CommentMenu } from '~/components/node/CommentMenu'; +import { MessageForm } from '~/components/profile/MessageForm'; +import { Filler } from '~/components/containers/Filler'; +import { Button } from '~/components/input/Button'; interface IProps { message: IMessage; incoming: boolean; + onEdit: (id: number) => void; + onDelete: (id: number) => void; + onRestore: (id: number) => void; + onCancelEdit: () => void; + isEditing: boolean; } -const Message: FC = ({ message, incoming }) => ( -
- +const Message: FC = ({ + message, + incoming, + onEdit, + onDelete, + isEditing, + onCancelEdit, + onRestore, +}) => { + const onEditClicked = useCallback(() => onEdit(message.id), [onEdit, message.id]); + const onDeleteClicked = useCallback(() => onDelete(message.id), [onDelete, message.id]); + const onRestoreClicked = useCallback(() => onRestore(message.id), [onRestore, message.id]); -
+ if (message.deleted_at) { + return ( +
+ + Сообщение удалено + + -
{getPrettyDate(message.created_at)}
-
-); +
+
+ ); + } + return ( +
+ {isEditing ? ( +
+ +
+ ) : ( +
+ {!incoming && } + +
+ )} + +
+ +
{getPrettyDate(message.created_at)}
+
+ ); +}; export { Message }; diff --git a/src/components/profile/Message/styles.scss b/src/components/profile/Message/styles.scss index 27b991ca..1f20ef35 100644 --- a/src/components/profile/Message/styles.scss +++ b/src/components/profile/Message/styles.scss @@ -57,7 +57,6 @@ $outgoing_color: $comment_bg; background: 50% 50% no-repeat; background-size: cover; - // display: none; } .text { @@ -65,8 +64,17 @@ $outgoing_color: $comment_bg; background: $outgoing_color; word-wrap: break-word; word-break: break-word; - width: 90%; + width: 100%; border-radius: $radius $radius 0 $radius; + position: relative; + box-sizing: border-box; +} + +.form { + width: 100%; + border-radius: $radius $radius 0 $radius; + background: $outgoing_color; + box-sizing: border-box; } .stamp { @@ -79,3 +87,15 @@ $outgoing_color: $comment_bg; padding: 2px $gap; border-radius: $radius; } + +.restore { + color: $red; + fill: $red; +} + +.deleted { + background: mix($red, $content_bg, 50%); + border-radius: $radius $radius $radius 0; + padding: $gap / 2; + z-index: 2; +} diff --git a/src/components/profile/MessageForm/index.tsx b/src/components/profile/MessageForm/index.tsx index 732614e8..c674e6a7 100644 --- a/src/components/profile/MessageForm/index.tsx +++ b/src/components/profile/MessageForm/index.tsx @@ -1,38 +1,52 @@ -import React, { FC, useState, useCallback, KeyboardEventHandler } from 'react'; +import React, { FC, KeyboardEventHandler, useCallback, useMemo, useState } from 'react'; import styles from './styles.scss'; import { Textarea } from '~/components/input/Textarea'; import { Filler } from '~/components/containers/Filler'; import { Button } from '~/components/input/Button'; import { Group } from '~/components/containers/Group'; -import { selectAuthProfile } from '~/redux/auth/selectors'; import { connect } from 'react-redux'; import { LoaderCircle } from '~/components/input/LoaderCircle'; -import * as AUTH_ACTIONS from '~/redux/auth/actions'; +import * as MESSAGES_ACTIONS from '~/redux/messages/actions'; import { ERROR_LITERAL } from '~/constants/errors'; +import { selectMessages } from '~/redux/messages/selectors'; const mapStateToProps = state => ({ - profile: selectAuthProfile(state), + messages: selectMessages(state), }); const mapDispatchToProps = { - authSendMessage: AUTH_ACTIONS.authSendMessage, + messagesSendMessage: MESSAGES_ACTIONS.messagesSendMessage, }; -type IProps = ReturnType & typeof mapDispatchToProps & {}; +type IProps = ReturnType & + typeof mapDispatchToProps & { + id?: number; + text?: string; + onCancel?: () => void; + }; const MessageFormUnconnected: FC = ({ - profile: { is_sending_messages, is_loading_messages, messages_error }, - authSendMessage, + messages: { is_sending_messages, is_loading_messages, messages_error }, + messagesSendMessage, + + id = 0, + text: initialText = '', + onCancel, }) => { - const [text, setText] = useState(''); + const isEditing = useMemo(() => id > 0, [id]); + const [text, setText] = useState(initialText); const onSuccess = useCallback(() => { setText(''); - }, [setText]); + + if (isEditing) { + onCancel(); + } + }, [setText, isEditing, onCancel]); const onSubmit = useCallback(() => { - authSendMessage({ text }, onSuccess); - }, [authSendMessage, text, onSuccess]); + messagesSendMessage({ text, id }, onSuccess); + }, [messagesSendMessage, text, id, onSuccess]); const onKeyDown = useCallback>( ({ ctrlKey, key }) => { @@ -55,7 +69,7 @@ const MessageFormUnconnected: FC = ({ value={text} handler={setText} minRows={1} - maxRows={4} + maxRows={isEditing ? 15 : 5} seamless onKeyDown={onKeyDown} disabled={is_sending_messages} @@ -67,6 +81,12 @@ const MessageFormUnconnected: FC = ({ {is_sending_messages && } + {isEditing && ( + + )} + @@ -82,9 +102,6 @@ const MessageFormUnconnected: FC = ({ ); }; -const MessageForm = connect( - mapStateToProps, - mapDispatchToProps -)(MessageFormUnconnected); +const MessageForm = connect(mapStateToProps, mapDispatchToProps)(MessageFormUnconnected); export { MessageForm }; diff --git a/src/components/profile/MessageForm/styles.scss b/src/components/profile/MessageForm/styles.scss index f74c984f..7f935dbf 100644 --- a/src/components/profile/MessageForm/styles.scss +++ b/src/components/profile/MessageForm/styles.scss @@ -20,6 +20,7 @@ justify-content: center; flex-direction: row; padding: 0 $gap / 2 $gap / 2 $gap / 2; + border-radius: 0 0 $radius $radius; :global(.loader-circle) { svg { diff --git a/src/components/profile/ProfileAccounts/index.tsx b/src/components/profile/ProfileAccounts/index.tsx index cc4c80d8..33c1652d 100644 --- a/src/components/profile/ProfileAccounts/index.tsx +++ b/src/components/profile/ProfileAccounts/index.tsx @@ -111,7 +111,7 @@ const ProfileAccountsUnconnected: FC = ({
-
{it.name}
+
{it.name || it.id}
authDropSocial(it.provider, it.id)} /> diff --git a/src/components/profile/ProfileAccounts/styles.scss b/src/components/profile/ProfileAccounts/styles.scss index b425072d..f8f0073f 100644 --- a/src/components/profile/ProfileAccounts/styles.scss +++ b/src/components/profile/ProfileAccounts/styles.scss @@ -50,6 +50,7 @@ background-size: cover; border-radius: 2px; position: relative; + background: $content_bg; } &__provider { diff --git a/src/constants/api.ts b/src/constants/api.ts index 66f7069b..991fe958 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -11,6 +11,7 @@ export const API = { PROFILE: (username: string) => `/user/user/${username}/profile`, MESSAGES: (username: string) => `/user/user/${username}/messages`, MESSAGE_SEND: (username: string) => `/user/user/${username}/messages`, + MESSAGE_DELETE: (username: string, id: number) => `/user/user/${username}/messages/${id}`, GET_UPDATES: '/user/updates', REQUEST_CODE: (code?: string) => `/user/restore/${code || ''}`, UPLOAD: (target, type) => `/upload/${target}/${type}`, @@ -19,8 +20,6 @@ export const API = { DROP_SOCIAL: (provider, id) => `/oauth/${provider}/${id}`, ATTACH_SOCIAL: `/oauth/attach`, LOGIN_WITH_SOCIAL: `/oauth/login`, - // TODO: REMOVE - VKONTAKTE_LOGIN: `${process.env.API_HOST}/oauth/vkontakte/redirect/login`, }, NODE: { SAVE: '/node/', diff --git a/src/constants/errors.ts b/src/constants/errors.ts index 70dd4a27..1aac68d2 100644 --- a/src/constants/errors.ts +++ b/src/constants/errors.ts @@ -40,6 +40,7 @@ export const ERRORS = { CANT_SAVE_USER: 'CantSaveUser', CANT_DELETE_COMMENT: 'CantDeleteComment', CANT_RESTORE_COMMENT: 'CantRestoreComment', + MESSAGE_NOT_FOUND: 'MessageNotFound', }; export const ERROR_LITERAL = { @@ -85,4 +86,5 @@ export const ERROR_LITERAL = { [ERRORS.CANT_SAVE_USER]: 'Не удалось сохранить пользователя', [ERRORS.CANT_DELETE_COMMENT]: 'Не удалось удалить комментарий', [ERRORS.CANT_RESTORE_COMMENT]: 'Не удалось восстановить комментарий', + [ERRORS.MESSAGE_NOT_FOUND]: 'Сообщение не найдено', }; diff --git a/src/containers/profile/ProfileMessages/index.tsx b/src/containers/profile/ProfileMessages/index.tsx index a02fb2e6..7700f048 100644 --- a/src/containers/profile/ProfileMessages/index.tsx +++ b/src/containers/profile/ProfileMessages/index.tsx @@ -1,63 +1,89 @@ -import React, { FC, useEffect } from 'react'; +import React, { FC, useCallback, useEffect, useState } from 'react'; import { connect } from 'react-redux'; -import { selectAuthProfile, selectAuth, selectAuthUser } from '~/redux/auth/selectors'; +import { selectAuthProfile, selectAuthUser } from '~/redux/auth/selectors'; import styles from './styles.scss'; -import * as AUTH_ACTIONS from '~/redux/auth/actions'; +import * as AUTH_ACTIONS from '~/redux/messages/actions'; import { Message } from '~/components/profile/Message'; import { Group } from '~/components/containers/Group'; import pick from 'ramda/es/pick'; import { NodeNoComments } from '~/components/node/NodeNoComments'; +import { selectMessages } from '~/redux/messages/selectors'; const mapStateToProps = state => ({ profile: selectAuthProfile(state), + messages: selectMessages(state), user: pick(['id'], selectAuthUser(state)), }); const mapDispatchToProps = { - authGetMessages: AUTH_ACTIONS.authGetMessages, + messagesGetMessages: AUTH_ACTIONS.messagesGetMessages, + messagesDeleteMessage: AUTH_ACTIONS.messagesDeleteMessage, }; type IProps = ReturnType & typeof mapDispatchToProps & {}; -const ProfileMessagesUnconnected: FC = ({ profile, user: { id }, authGetMessages }) => { +const ProfileMessagesUnconnected: FC = ({ + profile, + messages, + user: { id }, + messagesGetMessages, + messagesDeleteMessage, +}) => { + const [editingMessageId, setEditingMessageId] = useState(0); + + const onEditMessage = useCallback((id: number) => setEditingMessageId(id), [setEditingMessageId]); + const onCancelEdit = useCallback(() => setEditingMessageId(0), [setEditingMessageId]); + const onDeleteMessage = useCallback((id: number) => messagesDeleteMessage(id, true), [ + messagesDeleteMessage, + ]); + const onRestoreMessage = useCallback((id: number) => messagesDeleteMessage(id, false), [ + messagesDeleteMessage, + ]); + useEffect(() => { if (profile.is_loading || !profile.user || !profile.user.username) return; - authGetMessages(profile.user.username); + messagesGetMessages(profile.user.username); }, [profile.user]); useEffect(() => { - if (profile.is_loading || !profile.user || !profile.user.username || profile.messages_error) + if (profile.is_loading || !profile.user || !profile.user.username || messages.messages_error) return; - const timer = setTimeout(() => authGetMessages(profile.user.username), 20000); + const timer = setTimeout(() => messagesGetMessages(profile.user.username), 20000); return () => clearTimeout(timer); - }, [profile.user, profile.messages]); + }, [profile.user, messages.messages]); - if (!profile.messages.length || profile.is_loading) - return ; + if (!messages.messages.length || profile.is_loading) + return ; return ( - {profile.messages + {messages.messages .filter(message => !!message.text) .map(( message // TODO: show files / memo ) => ( - + ))} - {!profile.is_loading_messages && profile.messages.length > 0 && ( + {!messages.is_loading_messages && messages.messages.length > 0 && (
Когда-нибудь здесь будут еще сообщения
)}
); }; -const ProfileMessages = connect( - mapStateToProps, - mapDispatchToProps -)(ProfileMessagesUnconnected); +const ProfileMessages = connect(mapStateToProps, mapDispatchToProps)(ProfileMessagesUnconnected); export { ProfileMessages }; diff --git a/src/redux/auth/actions.ts b/src/redux/auth/actions.ts index 4c03ae04..9c2dd8c9 100644 --- a/src/redux/auth/actions.ts +++ b/src/redux/auth/actions.ts @@ -1,6 +1,6 @@ import { AUTH_USER_ACTIONS } from '~/redux/auth/constants'; import { IAuthState, ISocialProvider, IUser } from '~/redux/auth/types'; -import { IMessage, IOAuthEvent } from '../types'; +import { IOAuthEvent } from '../types'; export const userSendLoginRequest = ({ username, @@ -54,17 +54,6 @@ export const authSetProfile = (profile: Partial) => ({ profile, }); -export const authGetMessages = (username: string) => ({ - type: AUTH_USER_ACTIONS.GET_MESSAGES, - username, -}); - -export const authSendMessage = (message: Partial, onSuccess) => ({ - type: AUTH_USER_ACTIONS.SEND_MESSAGE, - message, - onSuccess, -}); - export const authSetUpdates = (updates: Partial) => ({ type: AUTH_USER_ACTIONS.SET_UPDATES, updates, diff --git a/src/redux/auth/api.ts b/src/redux/auth/api.ts index 54a78223..18cb240c 100644 --- a/src/redux/auth/api.ts +++ b/src/redux/auth/api.ts @@ -1,8 +1,8 @@ import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api'; import { API } from '~/constants/api'; -import { IMessage, INotification, IResultWithStatus } from '~/redux/types'; +import { INotification, IResultWithStatus } from '~/redux/types'; import { userLoginTransform } from '~/redux/auth/transforms'; -import { ISocialAccount, IToken, IUser } from './types'; +import { ISocialAccount, IUser } from './types'; export const apiUserLogin = ({ username, @@ -32,25 +32,6 @@ export const apiAuthGetUserProfile = ({ .then(resultMiddleware) .catch(errorMiddleware); -export const apiAuthGetUserMessages = ({ - access, - username, -}): Promise> => - api - .get(API.USER.MESSAGES(username), configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); - -export const apiAuthSendMessage = ({ - access, - username, - message, -}): Promise> => - api - .post(API.USER.MESSAGE_SEND(username), { message }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); - export const apiAuthGetUpdates = ({ access, exclude_dialogs, diff --git a/src/redux/auth/constants.ts b/src/redux/auth/constants.ts index 82a58918..5c66e845 100644 --- a/src/redux/auth/constants.ts +++ b/src/redux/auth/constants.ts @@ -13,8 +13,6 @@ export const AUTH_USER_ACTIONS = { OPEN_PROFILE: 'OPEN_PROFILE', LOAD_PROFILE: 'LOAD_PROFILE', SET_PROFILE: 'SET_PROFILE', - GET_MESSAGES: 'GET_MESSAGES', - SEND_MESSAGE: 'SEND_MESSAGE', SET_UPDATES: 'SET_UPDATES', SET_LAST_SEEN_MESSAGES: 'SET_LAST_SEEN_MESSAGES', diff --git a/src/redux/auth/index.ts b/src/redux/auth/index.ts index 88f2de95..008d581f 100644 --- a/src/redux/auth/index.ts +++ b/src/redux/auth/index.ts @@ -26,11 +26,8 @@ const INITIAL_STATE: IAuthState = { profile: { tab: 'profile', is_loading: true, - is_loading_messages: true, - is_sending_messages: false, + user: null, - messages: [], - messages_error: null, patch_errors: {}, socials: { diff --git a/src/redux/auth/sagas.ts b/src/redux/auth/sagas.ts index 8b20c40c..89c75b15 100644 --- a/src/redux/auth/sagas.ts +++ b/src/redux/auth/sagas.ts @@ -3,7 +3,6 @@ import { AUTH_USER_ACTIONS, EMPTY_USER, USER_ERRORS, USER_ROLES } from '~/redux/ import { authAttachSocial, authDropSocial, - authGetMessages, authGotOauthLoginEvent, authLoadProfile, authLoggedIn, @@ -12,7 +11,6 @@ import { authPatchUser, authRequestRestoreCode, authRestorePassword, - authSendMessage, authSendRegisterSocial, authSetLastSeenMessages, authSetProfile, @@ -32,9 +30,7 @@ import { apiAttachSocial, apiAuthGetUpdates, apiAuthGetUser, - apiAuthGetUserMessages, apiAuthGetUserProfile, - apiAuthSendMessage, apiCheckRestoreCode, apiDropSocial, apiGetSocials, @@ -54,13 +50,14 @@ import { selectAuthUser, selectToken, } from './selectors'; -import { IMessageNotification, IResultWithStatus, OAUTH_EVENT_TYPES, Unwrap } from '../types'; +import { IResultWithStatus, OAUTH_EVENT_TYPES, Unwrap } from '../types'; import { IAuthState, IUser } from './types'; import { REHYDRATE, RehydrateAction } from 'redux-persist'; import { selectModal } from '~/redux/modal/selectors'; import { IModalState } from '~/redux/modal'; import { DIALOGS } from '~/redux/modal/constants'; import { ERRORS } from '~/constants/errors'; +import { messagesSet } from '~/redux/messages/actions'; export function* reqWrapper(requestAction, props = {}): ReturnType { const access = yield select(selectToken); @@ -157,7 +154,8 @@ function* loadProfile({ username }: ReturnType) { return false; } - yield put(authSetProfile({ is_loading: false, user, messages: [] })); + yield put(authSetProfile({ is_loading: false, user })); + yield put(messagesSet({ messages: [] })); return true; } @@ -172,94 +170,6 @@ function* openProfile({ username, tab = 'profile' }: ReturnType) { - // yield put(modalShowDialog(DIALOGS.PROFILE)); - const { messages } = yield select(selectAuthProfile); - - yield put( - authSetProfile({ - is_loading_messages: true, - messages: - messages && - messages.length > 0 && - (messages[0].to.username === username || messages[0].from.username === username) - ? messages - : [], - }) - ); - - const { - error, - data, - // data: { messages }, - } = yield call(reqWrapper, apiAuthGetUserMessages, { username }); - - if (error || !data.messages) { - return yield put( - authSetProfile({ - is_loading_messages: false, - messages_error: ERRORS.EMPTY_RESPONSE, - }) - ); - } - - yield put(authSetProfile({ is_loading_messages: false, messages: data.messages })); - - const { notifications } = yield select(selectAuthUpdates); - - // clear viewed message from notifcation list - const filtered = notifications.filter( - notification => - notification.type !== 'message' || - (notification as IMessageNotification).content.from.username !== username - ); - - if (filtered.length !== notifications.length) { - yield put(authSetUpdates({ notifications: filtered })); - } -} - -function* sendMessage({ message, onSuccess }: ReturnType) { - const { - user: { username }, - } = yield select(selectAuthProfile); - - if (!username) return; - - yield put(authSetProfile({ is_sending_messages: true, messages_error: null })); - - const { error, data } = yield call(reqWrapper, apiAuthSendMessage, { - username, - message, - }); - - console.log({ error, data }); - - if (error || !data.message) { - return yield put( - authSetProfile({ - is_sending_messages: false, - messages_error: error || ERRORS.EMPTY_RESPONSE, - }) - ); - } - - const { user, messages } = yield select(selectAuthProfile); - - if (user.username !== username) { - return yield put(authSetProfile({ is_sending_messages: false })); - } - - yield put( - authSetProfile({ - is_sending_messages: false, - messages: [data.message, ...messages], - }) - ); - - onSuccess(); -} - function* getUpdates() { const user: ReturnType = yield select(selectAuthUser); @@ -543,8 +453,6 @@ function* authSaga() { yield takeLatest(AUTH_USER_ACTIONS.GOT_AUTH_POST_MESSAGE, gotPostMessageSaga); yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile); yield takeLatest(AUTH_USER_ACTIONS.LOAD_PROFILE, loadProfile); - yield takeLatest(AUTH_USER_ACTIONS.GET_MESSAGES, getMessages); - yield takeLatest(AUTH_USER_ACTIONS.SEND_MESSAGE, sendMessage); yield takeLatest(AUTH_USER_ACTIONS.SET_LAST_SEEN_MESSAGES, setLastSeenMessages); yield takeLatest(AUTH_USER_ACTIONS.PATCH_USER, patchUser); yield takeLatest(AUTH_USER_ACTIONS.REQUEST_RESTORE_CODE, requestRestoreCode); diff --git a/src/redux/auth/selectors.ts b/src/redux/auth/selectors.ts index f49a5695..aa3aa475 100644 --- a/src/redux/auth/selectors.ts +++ b/src/redux/auth/selectors.ts @@ -5,6 +5,7 @@ export const selectUser = (state: IState) => state.auth.user; export const selectToken = (state: IState) => state.auth.token; export const selectAuthLogin = (state: IState) => state.auth.login; export const selectAuthProfile = (state: IState) => state.auth.profile; +export const selectAuthProfileUsername = (state: IState) => state.auth.profile.user.username; export const selectAuthUser = (state: IState) => state.auth.user; export const selectAuthUpdates = (state: IState) => state.auth.updates; export const selectAuthRestore = (state: IState) => state.auth.restore; diff --git a/src/redux/auth/types.ts b/src/redux/auth/types.ts index d51b7247..52d417d4 100644 --- a/src/redux/auth/types.ts +++ b/src/redux/auth/types.ts @@ -1,4 +1,4 @@ -import { IFile, IMessage, INotification } from '../types'; +import { IFile, INotification } from '../types'; export interface IToken { access: string; @@ -52,12 +52,8 @@ export type IAuthState = Readonly<{ profile: { tab: 'profile' | 'messages' | 'settings'; is_loading: boolean; - is_loading_messages: boolean; - is_sending_messages: boolean; user: IUser; - messages: IMessage[]; - messages_error: string; patch_errors: Record; socials: { diff --git a/src/redux/messages/actions.ts b/src/redux/messages/actions.ts new file mode 100644 index 00000000..ce563b68 --- /dev/null +++ b/src/redux/messages/actions.ts @@ -0,0 +1,25 @@ +import { IMessage } from '~/redux/types'; +import { MESSAGES_ACTIONS } from '~/redux/messages/constants'; +import { IMessagesState } from '~/redux/messages'; + +export const messagesGetMessages = (username: string) => ({ + type: MESSAGES_ACTIONS.GET_MESSAGES, + username, +}); + +export const messagesSendMessage = (message: Partial, onSuccess) => ({ + type: MESSAGES_ACTIONS.SEND_MESSAGE, + message, + onSuccess, +}); + +export const messagesDeleteMessage = (id: IMessage['id'], is_locked: boolean) => ({ + type: MESSAGES_ACTIONS.DELETE_MESSAGE, + id, + is_locked, +}); + +export const messagesSet = (messages: Partial) => ({ + type: MESSAGES_ACTIONS.SET_MESSAGES, + messages, +}); diff --git a/src/redux/messages/api.ts b/src/redux/messages/api.ts new file mode 100644 index 00000000..d485d2f4 --- /dev/null +++ b/src/redux/messages/api.ts @@ -0,0 +1,41 @@ +import { IMessage, IResultWithStatus } from '~/redux/types'; +import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api'; +import { API } from '~/constants/api'; + +export const apiMessagesGetUserMessages = ({ + access, + username, +}): Promise> => + api + .get(API.USER.MESSAGES(username), configWithToken(access)) + .then(resultMiddleware) + .catch(errorMiddleware); + +export const apiMessagesSendMessage = ({ + access, + username, + message, +}): Promise> => + api + .post(API.USER.MESSAGE_SEND(username), { message }, configWithToken(access)) + .then(resultMiddleware) + .catch(errorMiddleware); + +export const apiMessagesDeleteMessage = ({ + access, + username, + id, + is_locked, +}: { + access: string; + username: string; + id: number; + is_locked: boolean; +}): Promise> => + api + .delete( + API.USER.MESSAGE_DELETE(username, id), + configWithToken(access, { params: { is_locked } }) + ) + .then(resultMiddleware) + .catch(errorMiddleware); diff --git a/src/redux/messages/constants.ts b/src/redux/messages/constants.ts new file mode 100644 index 00000000..5145c23f --- /dev/null +++ b/src/redux/messages/constants.ts @@ -0,0 +1,8 @@ +const p = 'MESSAGES.'; + +export const MESSAGES_ACTIONS = { + SET_MESSAGES: `${p}SET_MESSAGES`, + GET_MESSAGES: `${p}GET_MESSAGES`, + SEND_MESSAGE: `${p}SEND_MESSAGE`, + DELETE_MESSAGE: `${p}DELETE_MESSAGE`, +}; diff --git a/src/redux/messages/handlers.ts b/src/redux/messages/handlers.ts new file mode 100644 index 00000000..3730e564 --- /dev/null +++ b/src/redux/messages/handlers.ts @@ -0,0 +1,15 @@ +import { MESSAGES_ACTIONS } from '~/redux/messages/constants'; +import { IMessagesState } from '~/redux/messages'; +import { messagesSet } from '~/redux/messages/actions'; + +const setMessages = ( + state: IMessagesState, + { messages }: ReturnType +): IMessagesState => ({ + ...state, + ...messages, +}); + +export const MESSAGE_HANDLERS = { + [MESSAGES_ACTIONS.SET_MESSAGES]: setMessages, +}; diff --git a/src/redux/messages/index.ts b/src/redux/messages/index.ts new file mode 100644 index 00000000..018fd4d6 --- /dev/null +++ b/src/redux/messages/index.ts @@ -0,0 +1,19 @@ +import { createReducer } from '~/utils/reducer'; +import { MESSAGE_HANDLERS } from '~/redux/messages/handlers'; +import { IMessage } from '~/redux/types'; + +export interface IMessagesState { + is_loading_messages: boolean; + is_sending_messages: boolean; + messages: IMessage[]; + messages_error: string; +} + +const INITIAL_STATE: IMessagesState = { + is_loading_messages: true, + is_sending_messages: false, + messages_error: null, + messages: [], +}; + +export default createReducer(INITIAL_STATE, MESSAGE_HANDLERS); diff --git a/src/redux/messages/sagas.ts b/src/redux/messages/sagas.ts new file mode 100644 index 00000000..c2609a28 --- /dev/null +++ b/src/redux/messages/sagas.ts @@ -0,0 +1,172 @@ +import { authSetUpdates } from '~/redux/auth/actions'; +import { call, put, select, takeLatest } from 'redux-saga/effects'; +import { + selectAuthProfile, + selectAuthProfileUsername, + selectAuthUpdates, +} from '~/redux/auth/selectors'; +import { + apiMessagesDeleteMessage, + apiMessagesGetUserMessages, + apiMessagesSendMessage, +} from '~/redux/messages/api'; +import { ERRORS } from '~/constants/errors'; +import { IMessageNotification, Unwrap } from '~/redux/types'; +import { reqWrapper } from '~/redux/auth/sagas'; +import { + messagesDeleteMessage, + messagesGetMessages, + messagesSendMessage, + messagesSet, +} from '~/redux/messages/actions'; +import { MESSAGES_ACTIONS } from '~/redux/messages/constants'; +import { selectMessages } from '~/redux/messages/selectors'; + +function* getMessages({ username }: ReturnType) { + const { messages }: ReturnType = yield select(selectMessages); + + yield put( + messagesSet({ + is_loading_messages: true, + messages: + messages && + messages.length > 0 && + (messages[0].to.username === username || messages[0].from.username === username) + ? messages + : [], + }) + ); + + const { error, data } = yield call(reqWrapper, apiMessagesGetUserMessages, { username }); + + if (error || !data.messages) { + return yield put( + messagesSet({ + is_loading_messages: false, + messages_error: ERRORS.EMPTY_RESPONSE, + }) + ); + } + + yield put(messagesSet({ is_loading_messages: false, messages: data.messages })); + + const { notifications } = yield select(selectAuthUpdates); + + // clear viewed message from notifcation list + const filtered = notifications.filter( + notification => + notification.type !== 'message' || + (notification as IMessageNotification).content.from.username !== username + ); + + if (filtered.length !== notifications.length) { + yield put(authSetUpdates({ notifications: filtered })); + } +} + +function* sendMessage({ message, onSuccess }: ReturnType) { + const username: ReturnType = yield select( + selectAuthProfileUsername + ); + + if (!username) return; + + yield put(messagesSet({ is_sending_messages: true, messages_error: null })); + + const { error, data }: Unwrap> = yield call( + reqWrapper, + apiMessagesSendMessage, + { + username, + message, + } + ); + + if (error || !data.message) { + return yield put( + messagesSet({ + is_sending_messages: false, + messages_error: error || ERRORS.EMPTY_RESPONSE, + }) + ); + } + + const { user }: ReturnType = yield select(selectAuthProfile); + + if (user.username !== username) { + return yield put(messagesSet({ is_sending_messages: false })); + } + + const { messages }: ReturnType = yield select(selectMessages); + + if (message.id > 0) { + // modified + yield put( + messagesSet({ + is_sending_messages: false, + messages: messages.map(item => (item.id === message.id ? data.message : item)), + }) + ); + } else { + // created + yield put( + messagesSet({ + is_sending_messages: false, + messages: [data.message, ...messages], + }) + ); + } + + onSuccess(); +} + +function* deleteMessage({ id, is_locked }: ReturnType) { + const username: ReturnType = yield select( + selectAuthProfileUsername + ); + + if (!username) return; + + yield put(messagesSet({ is_sending_messages: true, messages_error: null })); + + const { error, data }: Unwrap> = yield call( + reqWrapper, + apiMessagesDeleteMessage, + { + username, + id, + is_locked, + } + ); + + if (error || !data.message) { + return yield put( + messagesSet({ + is_sending_messages: false, + }) + ); + } + + const currentUsername: ReturnType = yield select( + selectAuthProfileUsername + ); + + if (currentUsername !== username) { + return yield put(messagesSet({ is_sending_messages: false })); + } + + const { messages }: ReturnType = yield select(selectMessages); + + yield put( + messagesSet({ + is_sending_messages: false, + messages: messages.map(item => (item.id === id ? data.message : item)), + }) + ); +} + +export default function*() { + yield takeLatest(MESSAGES_ACTIONS.GET_MESSAGES, getMessages); + yield takeLatest(MESSAGES_ACTIONS.SEND_MESSAGE, sendMessage); + yield takeLatest(MESSAGES_ACTIONS.DELETE_MESSAGE, deleteMessage); +} diff --git a/src/redux/messages/selectors.ts b/src/redux/messages/selectors.ts new file mode 100644 index 00000000..9ae9cfb1 --- /dev/null +++ b/src/redux/messages/selectors.ts @@ -0,0 +1,3 @@ +import { IState } from '~/redux/store'; + +export const selectMessages = (state: IState) => state.messages; diff --git a/src/redux/store.ts b/src/redux/store.ts index 1af0b57e..523b069a 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,9 +1,9 @@ -import { createStore, applyMiddleware, combineReducers, compose, Store } from 'redux'; +import { applyMiddleware, combineReducers, compose, createStore, Store } from 'redux'; -import { persistStore, persistReducer } from 'redux-persist'; +import { persistReducer, persistStore } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; import createSagaMiddleware from 'redux-saga'; -import { connectRouter, RouterState, routerMiddleware } from 'connected-react-router'; +import { connectRouter, routerMiddleware, RouterState } from 'connected-react-router'; import { createBrowserHistory } from 'history'; import { PersistConfig, Persistor } from 'redux-persist/es/types'; @@ -26,11 +26,14 @@ import playerSaga from '~/redux/player/sagas'; import modal, { IModalState } from '~/redux/modal'; import { modalSaga } from './modal/sagas'; -import { gotAuthPostMessage, authOpenProfile } from './auth/actions'; +import { authOpenProfile, gotAuthPostMessage } from './auth/actions'; import boris, { IBorisState } from './boris/reducer'; import borisSaga from './boris/sagas'; +import messages, { IMessagesState } from './messages'; +import messagesSaga from './messages/sagas'; + const authPersistConfig: PersistConfig = { key: 'auth', whitelist: ['token', 'user', 'updates'], @@ -58,6 +61,7 @@ export interface IState { flow: IFlowState; player: IPlayerState; boris: IBorisState; + messages: IMessagesState; } export const sagaMiddleware = createSagaMiddleware(); @@ -78,6 +82,7 @@ export const store = createStore( uploads, flow: persistReducer(flowPersistConfig, flow), player: persistReducer(playerPersistConfig, player), + messages, }), composeEnhancers(applyMiddleware(routerMiddleware(history), sagaMiddleware)) ); @@ -93,6 +98,7 @@ export function configureStore(): { sagaMiddleware.run(playerSaga); sagaMiddleware.run(modalSaga); sagaMiddleware.run(borisSaga); + sagaMiddleware.run(messagesSaga); window.addEventListener('message', message => { if (message && message.data && message.data.type === 'oauth_login' && message.data.token) diff --git a/src/styles/inputs.scss b/src/styles/inputs.scss index 3fc643dd..477b7cb4 100644 --- a/src/styles/inputs.scss +++ b/src/styles/inputs.scss @@ -31,7 +31,7 @@ padding: 0; textarea { - padding: $gap; + padding: $gap / 2 $gap; } } } @@ -77,20 +77,20 @@ color: white; position: relative; - &::before { - content: ' '; - background: linear-gradient(270deg, $input_bg_color $gap, transparentize($input_bg_color, 1)); - position: absolute; - width: $gap * 2; - height: $input_height; - top: 1px; - right: 1px; - transform: translateX(0); - transition: transform 0.25s; - border-radius: 0 $input_radius $input_radius 0; - pointer-events: none; - touch-action: none; - } + //&::before { + // content: ' '; + // background: linear-gradient(270deg, $input_bg_color $gap, transparentize($input_bg_color, 1)); + // position: absolute; + // width: $gap * 2; + // height: $input_height; + // top: 1px; + // right: 1px; + // transform: translateX(0); + // transition: transform 0.25s; + // border-radius: 0 $input_radius $input_radius 0; + // pointer-events: none; + // touch-action: none; + //} } &.required { diff --git a/yarn.lock b/yarn.lock index 10333913..b6bf4884 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1368,6 +1368,11 @@ autoresponsive-react@^1.1.31: autoresponsive-core "^1.0.1" exenv "^1.2.0" +autosize@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.2.tgz#073cfd07c8bf45da4b9fd153437f5bafbba1e4c9" + integrity sha512-jnSyH2d+qdfPGpWlcuhGiHmqBJ6g3X+8T+iRwFrHPLVcdoGJE/x6Qicm6aDHfTsbgZKxyV8UU/YB2p4cjKDRRA== + awesome-typescript-loader@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/awesome-typescript-loader/-/awesome-typescript-loader-5.2.1.tgz#a41daf7847515f4925cdbaa3075d61f289e913fc"