1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-25 12:56:41 +07:00

Merge branch 'develop' into master

This commit is contained in:
Fedor Katurov 2020-09-08 13:27:13 +07:00
commit a8dc543c3c
29 changed files with 511 additions and 241 deletions

View file

@ -57,6 +57,7 @@
"@hot-loader/react-dom": "^16.10.2", "@hot-loader/react-dom": "^16.10.2",
"@typescript-eslint/eslint-plugin": "^1.13.0", "@typescript-eslint/eslint-plugin": "^1.13.0",
"@typescript-eslint/parser": "^1.13.0", "@typescript-eslint/parser": "^1.13.0",
"autosize": "^4.0.2",
"axios": "^0.18.0", "axios": "^0.18.0",
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"body-scroll-lock": "^2.6.4", "body-scroll-lock": "^2.6.4",

View file

@ -2,14 +2,14 @@ import React, {
ChangeEvent, ChangeEvent,
LegacyRef, LegacyRef,
memo, memo,
TextareaHTMLAttributes,
useCallback, useCallback,
useLayoutEffect, useEffect,
useRef, useRef,
useState, useState,
TextareaHTMLAttributes,
} from 'react'; } from 'react';
import { getStyle } from '~/utils/dom';
import classNames from 'classnames'; import classNames from 'classnames';
import autosize from 'autosize';
import * as styles from '~/styles/inputs.scss'; import * as styles from '~/styles/inputs.scss';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
@ -55,34 +55,13 @@ const Textarea = memo<IProps>(
const onFocus = useCallback(() => setFocused(true), [setFocused]); const onFocus = useCallback(() => setFocused(true), [setFocused]);
const onBlur = useCallback(() => setFocused(false), [setFocused]); const onBlur = useCallback(() => setFocused(false), [setFocused]);
useLayoutEffect(() => { useEffect(() => {
const lineHeight = parseInt(getStyle(textarea.current, 'line-height'), 10) || 15; 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; return () => autosize.destroy(textarea.current);
const paddingBottom = parseInt(getStyle(textarea.current, 'padding-bottom'), 10) || 0; }, [textarea.current]);
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 ( return (
<div <div
@ -104,6 +83,10 @@ const Textarea = memo<IProps>(
ref={textarea} ref={textarea}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
style={{
maxHeight: maxRows * 20,
minHeight: minRows * 20,
}}
{...props} {...props}
/> />
</div> </div>

View file

@ -42,6 +42,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
z-index: 6; z-index: 6;
white-space: nowrap;
animation: appear 0.25s forwards; animation: appear 0.25s forwards;
} }

View file

@ -1,19 +1,74 @@
import React, { FC } from 'react'; import React, { FC, useCallback } from 'react';
import { IMessage } from '~/redux/types'; import { IMessage } from '~/redux/types';
import styles from './styles.scss'; 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 { PRESETS } from '~/constants/urls';
import classNames from 'classnames'; import classNames from 'classnames';
import { Group } from '~/components/containers/Group'; 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 { interface IProps {
message: IMessage; message: IMessage;
incoming: boolean; incoming: boolean;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
onRestore: (id: number) => void;
onCancelEdit: () => void;
isEditing: boolean;
} }
const Message: FC<IProps> = ({ message, incoming }) => ( const Message: FC<IProps> = ({
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 (
<div className={classNames(styles.message)}>
<Group className={styles.deleted} horizontal>
<Filler>Сообщение удалено</Filler>
<Button
size="mini"
onClick={onRestoreClicked}
color="link"
iconLeft="restore"
className={styles.restore}
>
Восстановить
</Button>
</Group>
<div
className={styles.avatar}
style={{ backgroundImage: `url("${getURL(message.from.photo, PRESETS.avatar)}")` }}
/>
</div>
);
}
return (
<div className={classNames(styles.message, { [styles.incoming]: incoming })}> <div className={classNames(styles.message, { [styles.incoming]: incoming })}>
<Group className={styles.text} dangerouslySetInnerHTML={{ __html: formatText(message.text) }} /> {isEditing ? (
<div className={styles.form}>
<MessageForm id={message.id} text={message.text} onCancel={onCancelEdit} />
</div>
) : (
<div className={styles.text}>
{!incoming && <CommentMenu onEdit={onEditClicked} onDelete={onDeleteClicked} />}
<Group dangerouslySetInnerHTML={{ __html: formatText(message.text) }} />
</div>
)}
<div <div
className={styles.avatar} className={styles.avatar}
@ -22,6 +77,6 @@ const Message: FC<IProps> = ({ message, incoming }) => (
<div className={styles.stamp}>{getPrettyDate(message.created_at)}</div> <div className={styles.stamp}>{getPrettyDate(message.created_at)}</div>
</div> </div>
); );
};
export { Message }; export { Message };

View file

@ -57,7 +57,6 @@ $outgoing_color: $comment_bg;
background: 50% 50% no-repeat; background: 50% 50% no-repeat;
background-size: cover; background-size: cover;
// display: none;
} }
.text { .text {
@ -65,8 +64,17 @@ $outgoing_color: $comment_bg;
background: $outgoing_color; background: $outgoing_color;
word-wrap: break-word; word-wrap: break-word;
word-break: break-word; word-break: break-word;
width: 90%; width: 100%;
border-radius: $radius $radius 0 $radius; 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 { .stamp {
@ -79,3 +87,15 @@ $outgoing_color: $comment_bg;
padding: 2px $gap; padding: 2px $gap;
border-radius: $radius; 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;
}

View file

@ -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 styles from './styles.scss';
import { Textarea } from '~/components/input/Textarea'; import { Textarea } from '~/components/input/Textarea';
import { Filler } from '~/components/containers/Filler'; import { Filler } from '~/components/containers/Filler';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import { selectAuthProfile } from '~/redux/auth/selectors';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { LoaderCircle } from '~/components/input/LoaderCircle'; 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 { ERROR_LITERAL } from '~/constants/errors';
import { selectMessages } from '~/redux/messages/selectors';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
profile: selectAuthProfile(state), messages: selectMessages(state),
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
authSendMessage: AUTH_ACTIONS.authSendMessage, messagesSendMessage: MESSAGES_ACTIONS.messagesSendMessage,
}; };
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {}; type IProps = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & {
id?: number;
text?: string;
onCancel?: () => void;
};
const MessageFormUnconnected: FC<IProps> = ({ const MessageFormUnconnected: FC<IProps> = ({
profile: { is_sending_messages, is_loading_messages, messages_error }, messages: { is_sending_messages, is_loading_messages, messages_error },
authSendMessage, messagesSendMessage,
id = 0,
text: initialText = '',
onCancel,
}) => { }) => {
const [text, setText] = useState(''); const isEditing = useMemo(() => id > 0, [id]);
const [text, setText] = useState(initialText);
const onSuccess = useCallback(() => { const onSuccess = useCallback(() => {
setText(''); setText('');
}, [setText]);
if (isEditing) {
onCancel();
}
}, [setText, isEditing, onCancel]);
const onSubmit = useCallback(() => { const onSubmit = useCallback(() => {
authSendMessage({ text }, onSuccess); messagesSendMessage({ text, id }, onSuccess);
}, [authSendMessage, text, onSuccess]); }, [messagesSendMessage, text, id, onSuccess]);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>( const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
({ ctrlKey, key }) => { ({ ctrlKey, key }) => {
@ -55,7 +69,7 @@ const MessageFormUnconnected: FC<IProps> = ({
value={text} value={text}
handler={setText} handler={setText}
minRows={1} minRows={1}
maxRows={4} maxRows={isEditing ? 15 : 5}
seamless seamless
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
disabled={is_sending_messages} disabled={is_sending_messages}
@ -67,6 +81,12 @@ const MessageFormUnconnected: FC<IProps> = ({
{is_sending_messages && <LoaderCircle size={20} />} {is_sending_messages && <LoaderCircle size={20} />}
{isEditing && (
<Button size="small" color="link" onClick={onCancel}>
Отмена
</Button>
)}
<Button <Button
size="small" size="small"
color="gray" color="gray"
@ -74,7 +94,7 @@ const MessageFormUnconnected: FC<IProps> = ({
disabled={is_sending_messages} disabled={is_sending_messages}
onClick={onSubmit} onClick={onSubmit}
> >
Сказать {isEditing ? 'Схоронить' : 'Сказать'}
</Button> </Button>
</Group> </Group>
</Group> </Group>
@ -82,9 +102,6 @@ const MessageFormUnconnected: FC<IProps> = ({
); );
}; };
const MessageForm = connect( const MessageForm = connect(mapStateToProps, mapDispatchToProps)(MessageFormUnconnected);
mapStateToProps,
mapDispatchToProps
)(MessageFormUnconnected);
export { MessageForm }; export { MessageForm };

View file

@ -20,6 +20,7 @@
justify-content: center; justify-content: center;
flex-direction: row; flex-direction: row;
padding: 0 $gap / 2 $gap / 2 $gap / 2; padding: 0 $gap / 2 $gap / 2 $gap / 2;
border-radius: 0 0 $radius $radius;
:global(.loader-circle) { :global(.loader-circle) {
svg { svg {

View file

@ -111,7 +111,7 @@ const ProfileAccountsUnconnected: FC<IProps> = ({
</div> </div>
</div> </div>
<div className={styles.account__name}>{it.name}</div> <div className={styles.account__name}>{it.name || it.id}</div>
<div className={styles.account__drop}> <div className={styles.account__drop}>
<Icon icon="close" size={22} onClick={() => authDropSocial(it.provider, it.id)} /> <Icon icon="close" size={22} onClick={() => authDropSocial(it.provider, it.id)} />

View file

@ -50,6 +50,7 @@
background-size: cover; background-size: cover;
border-radius: 2px; border-radius: 2px;
position: relative; position: relative;
background: $content_bg;
} }
&__provider { &__provider {

View file

@ -11,6 +11,7 @@ export const API = {
PROFILE: (username: string) => `/user/user/${username}/profile`, PROFILE: (username: string) => `/user/user/${username}/profile`,
MESSAGES: (username: string) => `/user/user/${username}/messages`, MESSAGES: (username: string) => `/user/user/${username}/messages`,
MESSAGE_SEND: (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', GET_UPDATES: '/user/updates',
REQUEST_CODE: (code?: string) => `/user/restore/${code || ''}`, REQUEST_CODE: (code?: string) => `/user/restore/${code || ''}`,
UPLOAD: (target, type) => `/upload/${target}/${type}`, UPLOAD: (target, type) => `/upload/${target}/${type}`,
@ -19,8 +20,6 @@ export const API = {
DROP_SOCIAL: (provider, id) => `/oauth/${provider}/${id}`, DROP_SOCIAL: (provider, id) => `/oauth/${provider}/${id}`,
ATTACH_SOCIAL: `/oauth/attach`, ATTACH_SOCIAL: `/oauth/attach`,
LOGIN_WITH_SOCIAL: `/oauth/login`, LOGIN_WITH_SOCIAL: `/oauth/login`,
// TODO: REMOVE
VKONTAKTE_LOGIN: `${process.env.API_HOST}/oauth/vkontakte/redirect/login`,
}, },
NODE: { NODE: {
SAVE: '/node/', SAVE: '/node/',

View file

@ -40,6 +40,7 @@ export const ERRORS = {
CANT_SAVE_USER: 'CantSaveUser', CANT_SAVE_USER: 'CantSaveUser',
CANT_DELETE_COMMENT: 'CantDeleteComment', CANT_DELETE_COMMENT: 'CantDeleteComment',
CANT_RESTORE_COMMENT: 'CantRestoreComment', CANT_RESTORE_COMMENT: 'CantRestoreComment',
MESSAGE_NOT_FOUND: 'MessageNotFound',
}; };
export const ERROR_LITERAL = { export const ERROR_LITERAL = {
@ -85,4 +86,5 @@ export const ERROR_LITERAL = {
[ERRORS.CANT_SAVE_USER]: 'Не удалось сохранить пользователя', [ERRORS.CANT_SAVE_USER]: 'Не удалось сохранить пользователя',
[ERRORS.CANT_DELETE_COMMENT]: 'Не удалось удалить комментарий', [ERRORS.CANT_DELETE_COMMENT]: 'Не удалось удалить комментарий',
[ERRORS.CANT_RESTORE_COMMENT]: 'Не удалось восстановить комментарий', [ERRORS.CANT_RESTORE_COMMENT]: 'Не удалось восстановить комментарий',
[ERRORS.MESSAGE_NOT_FOUND]: 'Сообщение не найдено',
}; };

View file

@ -1,63 +1,89 @@
import React, { FC, useEffect } from 'react'; import React, { FC, useCallback, useEffect, useState } from 'react';
import { connect } from 'react-redux'; 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 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 { Message } from '~/components/profile/Message';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import pick from 'ramda/es/pick'; import pick from 'ramda/es/pick';
import { NodeNoComments } from '~/components/node/NodeNoComments'; import { NodeNoComments } from '~/components/node/NodeNoComments';
import { selectMessages } from '~/redux/messages/selectors';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
profile: selectAuthProfile(state), profile: selectAuthProfile(state),
messages: selectMessages(state),
user: pick(['id'], selectAuthUser(state)), user: pick(['id'], selectAuthUser(state)),
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
authGetMessages: AUTH_ACTIONS.authGetMessages, messagesGetMessages: AUTH_ACTIONS.messagesGetMessages,
messagesDeleteMessage: AUTH_ACTIONS.messagesDeleteMessage,
}; };
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {}; type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
const ProfileMessagesUnconnected: FC<IProps> = ({ profile, user: { id }, authGetMessages }) => { const ProfileMessagesUnconnected: FC<IProps> = ({
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(() => { useEffect(() => {
if (profile.is_loading || !profile.user || !profile.user.username) return; if (profile.is_loading || !profile.user || !profile.user.username) return;
authGetMessages(profile.user.username); messagesGetMessages(profile.user.username);
}, [profile.user]); }, [profile.user]);
useEffect(() => { 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; return;
const timer = setTimeout(() => authGetMessages(profile.user.username), 20000); const timer = setTimeout(() => messagesGetMessages(profile.user.username), 20000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [profile.user, profile.messages]); }, [profile.user, messages.messages]);
if (!profile.messages.length || profile.is_loading) if (!messages.messages.length || profile.is_loading)
return <NodeNoComments is_loading={profile.is_loading_messages || profile.is_loading} />; return <NodeNoComments is_loading={messages.is_loading_messages || profile.is_loading} />;
return ( return (
<Group className={styles.messages}> <Group className={styles.messages}>
{profile.messages {messages.messages
.filter(message => !!message.text) .filter(message => !!message.text)
.map(( .map((
message // TODO: show files / memo message // TODO: show files / memo
) => ( ) => (
<Message message={message} incoming={id !== message.from.id} key={message.id} /> <Message
message={message}
incoming={id !== message.from.id}
key={message.id}
onEdit={onEditMessage}
onDelete={onDeleteMessage}
isEditing={editingMessageId === message.id}
onCancelEdit={onCancelEdit}
onRestore={onRestoreMessage}
/>
))} ))}
{!profile.is_loading_messages && profile.messages.length > 0 && ( {!messages.is_loading_messages && messages.messages.length > 0 && (
<div className={styles.placeholder}>Когда-нибудь здесь будут еще сообщения</div> <div className={styles.placeholder}>Когда-нибудь здесь будут еще сообщения</div>
)} )}
</Group> </Group>
); );
}; };
const ProfileMessages = connect( const ProfileMessages = connect(mapStateToProps, mapDispatchToProps)(ProfileMessagesUnconnected);
mapStateToProps,
mapDispatchToProps
)(ProfileMessagesUnconnected);
export { ProfileMessages }; export { ProfileMessages };

View file

@ -1,6 +1,6 @@
import { AUTH_USER_ACTIONS } from '~/redux/auth/constants'; import { AUTH_USER_ACTIONS } from '~/redux/auth/constants';
import { IAuthState, ISocialProvider, IUser } from '~/redux/auth/types'; import { IAuthState, ISocialProvider, IUser } from '~/redux/auth/types';
import { IMessage, IOAuthEvent } from '../types'; import { IOAuthEvent } from '../types';
export const userSendLoginRequest = ({ export const userSendLoginRequest = ({
username, username,
@ -54,17 +54,6 @@ export const authSetProfile = (profile: Partial<IAuthState['profile']>) => ({
profile, profile,
}); });
export const authGetMessages = (username: string) => ({
type: AUTH_USER_ACTIONS.GET_MESSAGES,
username,
});
export const authSendMessage = (message: Partial<IMessage>, onSuccess) => ({
type: AUTH_USER_ACTIONS.SEND_MESSAGE,
message,
onSuccess,
});
export const authSetUpdates = (updates: Partial<IAuthState['updates']>) => ({ export const authSetUpdates = (updates: Partial<IAuthState['updates']>) => ({
type: AUTH_USER_ACTIONS.SET_UPDATES, type: AUTH_USER_ACTIONS.SET_UPDATES,
updates, updates,

View file

@ -1,8 +1,8 @@
import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api'; import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api';
import { API } from '~/constants/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 { userLoginTransform } from '~/redux/auth/transforms';
import { ISocialAccount, IToken, IUser } from './types'; import { ISocialAccount, IUser } from './types';
export const apiUserLogin = ({ export const apiUserLogin = ({
username, username,
@ -32,25 +32,6 @@ export const apiAuthGetUserProfile = ({
.then(resultMiddleware) .then(resultMiddleware)
.catch(errorMiddleware); .catch(errorMiddleware);
export const apiAuthGetUserMessages = ({
access,
username,
}): Promise<IResultWithStatus<{ messages: IMessage[] }>> =>
api
.get(API.USER.MESSAGES(username), configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiAuthSendMessage = ({
access,
username,
message,
}): Promise<IResultWithStatus<{ message: IMessage }>> =>
api
.post(API.USER.MESSAGE_SEND(username), { message }, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiAuthGetUpdates = ({ export const apiAuthGetUpdates = ({
access, access,
exclude_dialogs, exclude_dialogs,

View file

@ -13,8 +13,6 @@ export const AUTH_USER_ACTIONS = {
OPEN_PROFILE: 'OPEN_PROFILE', OPEN_PROFILE: 'OPEN_PROFILE',
LOAD_PROFILE: 'LOAD_PROFILE', LOAD_PROFILE: 'LOAD_PROFILE',
SET_PROFILE: 'SET_PROFILE', SET_PROFILE: 'SET_PROFILE',
GET_MESSAGES: 'GET_MESSAGES',
SEND_MESSAGE: 'SEND_MESSAGE',
SET_UPDATES: 'SET_UPDATES', SET_UPDATES: 'SET_UPDATES',
SET_LAST_SEEN_MESSAGES: 'SET_LAST_SEEN_MESSAGES', SET_LAST_SEEN_MESSAGES: 'SET_LAST_SEEN_MESSAGES',

View file

@ -26,11 +26,8 @@ const INITIAL_STATE: IAuthState = {
profile: { profile: {
tab: 'profile', tab: 'profile',
is_loading: true, is_loading: true,
is_loading_messages: true,
is_sending_messages: false,
user: null, user: null,
messages: [],
messages_error: null,
patch_errors: {}, patch_errors: {},
socials: { socials: {

View file

@ -3,7 +3,6 @@ import { AUTH_USER_ACTIONS, EMPTY_USER, USER_ERRORS, USER_ROLES } from '~/redux/
import { import {
authAttachSocial, authAttachSocial,
authDropSocial, authDropSocial,
authGetMessages,
authGotOauthLoginEvent, authGotOauthLoginEvent,
authLoadProfile, authLoadProfile,
authLoggedIn, authLoggedIn,
@ -12,7 +11,6 @@ import {
authPatchUser, authPatchUser,
authRequestRestoreCode, authRequestRestoreCode,
authRestorePassword, authRestorePassword,
authSendMessage,
authSendRegisterSocial, authSendRegisterSocial,
authSetLastSeenMessages, authSetLastSeenMessages,
authSetProfile, authSetProfile,
@ -32,9 +30,7 @@ import {
apiAttachSocial, apiAttachSocial,
apiAuthGetUpdates, apiAuthGetUpdates,
apiAuthGetUser, apiAuthGetUser,
apiAuthGetUserMessages,
apiAuthGetUserProfile, apiAuthGetUserProfile,
apiAuthSendMessage,
apiCheckRestoreCode, apiCheckRestoreCode,
apiDropSocial, apiDropSocial,
apiGetSocials, apiGetSocials,
@ -54,13 +50,14 @@ import {
selectAuthUser, selectAuthUser,
selectToken, selectToken,
} from './selectors'; } 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 { IAuthState, IUser } from './types';
import { REHYDRATE, RehydrateAction } from 'redux-persist'; import { REHYDRATE, RehydrateAction } from 'redux-persist';
import { selectModal } from '~/redux/modal/selectors'; import { selectModal } from '~/redux/modal/selectors';
import { IModalState } from '~/redux/modal'; import { IModalState } from '~/redux/modal';
import { DIALOGS } from '~/redux/modal/constants'; import { DIALOGS } from '~/redux/modal/constants';
import { ERRORS } from '~/constants/errors'; import { ERRORS } from '~/constants/errors';
import { messagesSet } from '~/redux/messages/actions';
export function* reqWrapper(requestAction, props = {}): ReturnType<typeof requestAction> { export function* reqWrapper(requestAction, props = {}): ReturnType<typeof requestAction> {
const access = yield select(selectToken); const access = yield select(selectToken);
@ -157,7 +154,8 @@ function* loadProfile({ username }: ReturnType<typeof authLoadProfile>) {
return false; return false;
} }
yield put(authSetProfile({ is_loading: false, user, messages: [] })); yield put(authSetProfile({ is_loading: false, user }));
yield put(messagesSet({ messages: [] }));
return true; return true;
} }
@ -172,94 +170,6 @@ function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenP
} }
} }
function* getMessages({ username }: ReturnType<typeof authGetMessages>) {
// 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<typeof authSendMessage>) {
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() { function* getUpdates() {
const user: ReturnType<typeof selectAuthUser> = yield select(selectAuthUser); const user: ReturnType<typeof selectAuthUser> = yield select(selectAuthUser);
@ -543,8 +453,6 @@ function* authSaga() {
yield takeLatest(AUTH_USER_ACTIONS.GOT_AUTH_POST_MESSAGE, gotPostMessageSaga); yield takeLatest(AUTH_USER_ACTIONS.GOT_AUTH_POST_MESSAGE, gotPostMessageSaga);
yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile); yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile);
yield takeLatest(AUTH_USER_ACTIONS.LOAD_PROFILE, loadProfile); 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.SET_LAST_SEEN_MESSAGES, setLastSeenMessages);
yield takeLatest(AUTH_USER_ACTIONS.PATCH_USER, patchUser); yield takeLatest(AUTH_USER_ACTIONS.PATCH_USER, patchUser);
yield takeLatest(AUTH_USER_ACTIONS.REQUEST_RESTORE_CODE, requestRestoreCode); yield takeLatest(AUTH_USER_ACTIONS.REQUEST_RESTORE_CODE, requestRestoreCode);

View file

@ -5,6 +5,7 @@ export const selectUser = (state: IState) => state.auth.user;
export const selectToken = (state: IState) => state.auth.token; export const selectToken = (state: IState) => state.auth.token;
export const selectAuthLogin = (state: IState) => state.auth.login; export const selectAuthLogin = (state: IState) => state.auth.login;
export const selectAuthProfile = (state: IState) => state.auth.profile; 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 selectAuthUser = (state: IState) => state.auth.user;
export const selectAuthUpdates = (state: IState) => state.auth.updates; export const selectAuthUpdates = (state: IState) => state.auth.updates;
export const selectAuthRestore = (state: IState) => state.auth.restore; export const selectAuthRestore = (state: IState) => state.auth.restore;

View file

@ -1,4 +1,4 @@
import { IFile, IMessage, INotification } from '../types'; import { IFile, INotification } from '../types';
export interface IToken { export interface IToken {
access: string; access: string;
@ -52,12 +52,8 @@ export type IAuthState = Readonly<{
profile: { profile: {
tab: 'profile' | 'messages' | 'settings'; tab: 'profile' | 'messages' | 'settings';
is_loading: boolean; is_loading: boolean;
is_loading_messages: boolean;
is_sending_messages: boolean;
user: IUser; user: IUser;
messages: IMessage[];
messages_error: string;
patch_errors: Record<string, string>; patch_errors: Record<string, string>;
socials: { socials: {

View file

@ -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<IMessage>, 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<IMessagesState>) => ({
type: MESSAGES_ACTIONS.SET_MESSAGES,
messages,
});

41
src/redux/messages/api.ts Normal file
View file

@ -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<IResultWithStatus<{ messages: IMessage[] }>> =>
api
.get(API.USER.MESSAGES(username), configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiMessagesSendMessage = ({
access,
username,
message,
}): Promise<IResultWithStatus<{ message: IMessage }>> =>
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<IResultWithStatus<{ message: IMessage }>> =>
api
.delete(
API.USER.MESSAGE_DELETE(username, id),
configWithToken(access, { params: { is_locked } })
)
.then(resultMiddleware)
.catch(errorMiddleware);

View file

@ -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`,
};

View file

@ -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<typeof messagesSet>
): IMessagesState => ({
...state,
...messages,
});
export const MESSAGE_HANDLERS = {
[MESSAGES_ACTIONS.SET_MESSAGES]: setMessages,
};

View file

@ -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);

172
src/redux/messages/sagas.ts Normal file
View file

@ -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<typeof messagesGetMessages>) {
const { messages }: ReturnType<typeof selectMessages> = 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<typeof messagesSendMessage>) {
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
selectAuthProfileUsername
);
if (!username) return;
yield put(messagesSet({ is_sending_messages: true, messages_error: null }));
const { error, data }: Unwrap<ReturnType<typeof apiMessagesSendMessage>> = 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<typeof selectAuthProfile> = yield select(selectAuthProfile);
if (user.username !== username) {
return yield put(messagesSet({ is_sending_messages: false }));
}
const { messages }: ReturnType<typeof selectMessages> = 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<typeof messagesDeleteMessage>) {
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
selectAuthProfileUsername
);
if (!username) return;
yield put(messagesSet({ is_sending_messages: true, messages_error: null }));
const { error, data }: Unwrap<ReturnType<typeof apiMessagesDeleteMessage>> = yield call(
reqWrapper,
apiMessagesDeleteMessage,
{
username,
id,
is_locked,
}
);
if (error || !data.message) {
return yield put(
messagesSet({
is_sending_messages: false,
})
);
}
const currentUsername: ReturnType<typeof selectAuthProfileUsername> = yield select(
selectAuthProfileUsername
);
if (currentUsername !== username) {
return yield put(messagesSet({ is_sending_messages: false }));
}
const { messages }: ReturnType<typeof selectMessages> = 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);
}

View file

@ -0,0 +1,3 @@
import { IState } from '~/redux/store';
export const selectMessages = (state: IState) => state.messages;

View file

@ -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 storage from 'redux-persist/lib/storage';
import createSagaMiddleware from 'redux-saga'; 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 { createBrowserHistory } from 'history';
import { PersistConfig, Persistor } from 'redux-persist/es/types'; 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 modal, { IModalState } from '~/redux/modal';
import { modalSaga } from './modal/sagas'; import { modalSaga } from './modal/sagas';
import { gotAuthPostMessage, authOpenProfile } from './auth/actions'; import { authOpenProfile, gotAuthPostMessage } from './auth/actions';
import boris, { IBorisState } from './boris/reducer'; import boris, { IBorisState } from './boris/reducer';
import borisSaga from './boris/sagas'; import borisSaga from './boris/sagas';
import messages, { IMessagesState } from './messages';
import messagesSaga from './messages/sagas';
const authPersistConfig: PersistConfig = { const authPersistConfig: PersistConfig = {
key: 'auth', key: 'auth',
whitelist: ['token', 'user', 'updates'], whitelist: ['token', 'user', 'updates'],
@ -58,6 +61,7 @@ export interface IState {
flow: IFlowState; flow: IFlowState;
player: IPlayerState; player: IPlayerState;
boris: IBorisState; boris: IBorisState;
messages: IMessagesState;
} }
export const sagaMiddleware = createSagaMiddleware(); export const sagaMiddleware = createSagaMiddleware();
@ -78,6 +82,7 @@ export const store = createStore(
uploads, uploads,
flow: persistReducer(flowPersistConfig, flow), flow: persistReducer(flowPersistConfig, flow),
player: persistReducer(playerPersistConfig, player), player: persistReducer(playerPersistConfig, player),
messages,
}), }),
composeEnhancers(applyMiddleware(routerMiddleware(history), sagaMiddleware)) composeEnhancers(applyMiddleware(routerMiddleware(history), sagaMiddleware))
); );
@ -93,6 +98,7 @@ export function configureStore(): {
sagaMiddleware.run(playerSaga); sagaMiddleware.run(playerSaga);
sagaMiddleware.run(modalSaga); sagaMiddleware.run(modalSaga);
sagaMiddleware.run(borisSaga); sagaMiddleware.run(borisSaga);
sagaMiddleware.run(messagesSaga);
window.addEventListener('message', message => { window.addEventListener('message', message => {
if (message && message.data && message.data.type === 'oauth_login' && message.data.token) if (message && message.data && message.data.type === 'oauth_login' && message.data.token)

View file

@ -31,7 +31,7 @@
padding: 0; padding: 0;
textarea { textarea {
padding: $gap; padding: $gap / 2 $gap;
} }
} }
} }
@ -77,20 +77,20 @@
color: white; color: white;
position: relative; position: relative;
&::before { //&::before {
content: ' '; // content: ' ';
background: linear-gradient(270deg, $input_bg_color $gap, transparentize($input_bg_color, 1)); // background: linear-gradient(270deg, $input_bg_color $gap, transparentize($input_bg_color, 1));
position: absolute; // position: absolute;
width: $gap * 2; // width: $gap * 2;
height: $input_height; // height: $input_height;
top: 1px; // top: 1px;
right: 1px; // right: 1px;
transform: translateX(0); // transform: translateX(0);
transition: transform 0.25s; // transition: transform 0.25s;
border-radius: 0 $input_radius $input_radius 0; // border-radius: 0 $input_radius $input_radius 0;
pointer-events: none; // pointer-events: none;
touch-action: none; // touch-action: none;
} //}
} }
&.required { &.required {

View file

@ -1368,6 +1368,11 @@ autoresponsive-react@^1.1.31:
autoresponsive-core "^1.0.1" autoresponsive-core "^1.0.1"
exenv "^1.2.0" 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: awesome-typescript-loader@^5.2.1:
version "5.2.1" version "5.2.1"
resolved "https://registry.yarnpkg.com/awesome-typescript-loader/-/awesome-typescript-loader-5.2.1.tgz#a41daf7847515f4925cdbaa3075d61f289e913fc" resolved "https://registry.yarnpkg.com/awesome-typescript-loader/-/awesome-typescript-loader-5.2.1.tgz#a41daf7847515f4925cdbaa3075d61f289e913fc"