diff --git a/src/components/main/Header/index.tsx b/src/components/main/Header/index.tsx index 0bcb508c..07e7ae9b 100644 --- a/src/components/main/Header/index.tsx +++ b/src/components/main/Header/index.tsx @@ -13,6 +13,8 @@ import * as AUTH_ACTIONS from '~/redux/auth/actions'; import { DIALOGS } from '~/redux/modal/constants'; import { pick } from 'ramda'; import { UserButton } from '../UserButton'; +import { Icon } from '~/components/input/Icon'; +import { Notifications } from '../Notifications'; const mapStateToProps = state => ({ user: pick(['username', 'is_user', 'photo'])(selectUser(state)), @@ -39,8 +41,7 @@ const HeaderUnconnected: FC = memo(
- ((( boris ))) - flow +
{is_user && ( diff --git a/src/components/main/Header/style.scss b/src/components/main/Header/style.scss index f9e56a01..9cc81182 100644 --- a/src/components/main/Header/style.scss +++ b/src/components/main/Header/style.scss @@ -14,11 +14,24 @@ .plugs { display: flex; user-select: none; - font: $font_16_medium; text-transform: uppercase; + align-items: center; - > a, - > div { + &::after { + content: ' '; + margin-left: $spc; + background: white; + width: 4px; + height: $gap; + display: block; + opacity: 0.2; + border-radius: 4px; + margin-right: 10px; + } + + & > a.item, + & > div.item { + font: $font_16_medium; display: flex; align-items: center; position: relative; @@ -33,17 +46,6 @@ color: $red; } - &::after { - content: ' '; - margin-left: $spc; - background: white; - width: 4px; - height: $gap; - display: block; - opacity: 0.2; - border-radius: 4px; - } - &:first-child { padding-left: $spc + $gap; } diff --git a/src/components/main/Logo/index.tsx b/src/components/main/Logo/index.tsx index 992edd09..639ba981 100644 --- a/src/components/main/Logo/index.tsx +++ b/src/components/main/Logo/index.tsx @@ -1,8 +1,9 @@ -import * as React from 'react'; -import * as styles from './style.scss'; +import React from 'react'; +import styles from './style.scss'; +import { Link } from 'react-router-dom'; export const Logo = () => ( -
+ VAULT -
+ ); diff --git a/src/components/main/Logo/style.scss b/src/components/main/Logo/style.scss index d944b988..06a02399 100644 --- a/src/components/main/Logo/style.scss +++ b/src/components/main/Logo/style.scss @@ -1,9 +1,7 @@ .logo { - // font-family: Raleway; - //font-size: $text_sign; - //font-weight: 800; font: $font_24_bold; - //font-family: Raleway; display: flex; user-select: none; + color: white; + text-decoration: none; } diff --git a/src/components/main/Notifications/index.tsx b/src/components/main/Notifications/index.tsx new file mode 100644 index 00000000..d14b6045 --- /dev/null +++ b/src/components/main/Notifications/index.tsx @@ -0,0 +1,62 @@ +import React, { FC, useMemo, useState, useCallback, useEffect } from 'react'; +import { Icon } from '~/components/input/Icon'; +import styles from './styles.scss'; +import { connect } from 'react-redux'; +import { selectAuthUpdates, selectAuthUser } from '~/redux/auth/selectors'; +import pick from 'ramda/es/pick'; +import classNames from 'classnames'; +import * as AUTH_ACTIONS from '~/redux/auth/actions'; +import { NotificationBubble } from '../../notifications/NotificationBubble'; + +const mapStateToProps = state => ({ + user: pick(['last_seen_messages'], selectAuthUser(state)), + updates: selectAuthUpdates(state), +}); + +const mapDispatchToProps = { + authSetLastSeenMessages: AUTH_ACTIONS.authSetLastSeenMessages, +}; + +type IProps = ReturnType & typeof mapDispatchToProps & {}; + +const NotificationsUnconnected: FC = ({ + updates: { last, notifications }, + user: { last_seen_messages }, + authSetLastSeenMessages, +}) => { + const [visible, setVisible] = useState(true); + const has_new = useMemo( + () => + notifications.length && + last && + Date.parse(last) && + (!last_seen_messages || + (Date.parse(last_seen_messages) && Date.parse(last) > Date.parse(last_seen_messages))), + [last, last_seen_messages, notifications] + ); + + useEffect(() => { + if (!visible || !has_new) return; + authSetLastSeenMessages(new Date().toISOString()); + }, [visible]); + + const showList = useCallback(() => setVisible(true), [setVisible]); + const hideList = useCallback(() => setVisible(false), [setVisible]); + + return ( +
+
+ {has_new ? : } +
+ + {visible && } +
+ ); +}; + +const Notifications = connect( + mapStateToProps, + mapDispatchToProps +)(NotificationsUnconnected); + +export { Notifications }; diff --git a/src/components/main/Notifications/styles.scss b/src/components/main/Notifications/styles.scss new file mode 100644 index 00000000..e258c92e --- /dev/null +++ b/src/components/main/Notifications/styles.scss @@ -0,0 +1,38 @@ +@keyframes ring { + 0% { + transform: rotate(-10deg); + } + + 20% { + transform: rotate(10deg); + } + + 40% { + transform: rotate(-10deg); + } + + 100% { + transform: rotate(0); + } +} + +.wrap { + fill: white; + position: relative; + outline: none; + + &.is_new { + .icon { + animation: ring 1s infinite alternate; + + svg { + fill: $red; + } + } + } +} + +.icon { + outline: none; + cursor: pointer; +} diff --git a/src/components/notifications/NotificationBubble/index.tsx b/src/components/notifications/NotificationBubble/index.tsx new file mode 100644 index 00000000..2db047e1 --- /dev/null +++ b/src/components/notifications/NotificationBubble/index.tsx @@ -0,0 +1,31 @@ +import React, { FC, createElement } from 'react'; +import { INotification, NOTIFICATION_TYPES } from '~/redux/types'; +import styles from './styles.scss'; +import { NotificationMessage } from '../NotificationMessage'; + +interface IProps { + notifications: INotification[]; +} + +const NOTIFICATION_RENDERERS = { + [NOTIFICATION_TYPES.message]: NotificationMessage, +}; + +const NotificationBubble: FC = ({ notifications }) => { + return ( +
+
+ {notifications + .filter(notification => notification.type && NOTIFICATION_RENDERERS[notification.type]) + .map(notification => + createElement(NOTIFICATION_RENDERERS[notification.type], { + notification, + key: notification.content.id, + }) + )} +
+
+ ); +}; + +export { NotificationBubble }; diff --git a/src/components/notifications/NotificationBubble/styles.scss b/src/components/notifications/NotificationBubble/styles.scss new file mode 100644 index 00000000..77ec52f9 --- /dev/null +++ b/src/components/notifications/NotificationBubble/styles.scss @@ -0,0 +1,76 @@ +@keyframes appear { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +.wrap { + position: absolute; + position: absolute; + background: $content_bg; + top: 50px; + left: 50%; + transform: translate(-50%, 0); + border-radius: $radius; + animation: appear 0.5s forwards; + + &::before { + content: ' '; + width: 0; + height: 0; + border-style: solid; + border-width: 0 0 16px 16px; + border-color: transparent transparent $content_bg transparent; + position: absolute; + left: 50%; + top: -16px; + transform: translate(-20px, 0); + } +} + +.list { + width: 300px; + max-width: 100vw; + min-width: 0; + max-height: 400px; + overflow: auto; +} + +.item { + display: flex; + align-items: stretch; + justify-content: stretch; + flex-direction: column; + padding: $gap; + min-width: 0; + + svg { + fill: white; + margin-right: $gap; + } +} + +.item_head { + display: flex; + align-items: center; + justify-content: flex-start; + flex-direction: row; +} + +.item_title { + flex: 1; + white-space: nowrap; + font: $font_14_semibold; + overflow: hidden; + text-overflow: ellipsis; + // text-transform: none; +} + +.item_text { + font: $font_14_regular; + max-height: 2.4em; + padding-left: 30px; + overflow: hidden; +} diff --git a/src/components/notifications/NotificationMessage/index.tsx b/src/components/notifications/NotificationMessage/index.tsx new file mode 100644 index 00000000..d5fd2054 --- /dev/null +++ b/src/components/notifications/NotificationMessage/index.tsx @@ -0,0 +1,26 @@ +import React, { FC } from 'react'; +import styles from '~/components/notifications/NotificationBubble/styles.scss'; +import { Icon } from '~/components/input/Icon'; +import { IMessageNotification } from '~/redux/types'; + +interface IProps { + notification: IMessageNotification; +} + +const NotificationMessage: FC = ({ + notification: { + content: { text, from }, + }, +}) => { + return ( +
+
+ +
Сообщение от ~{from.username}:
+
+
{text}
+
+ ); +}; + +export { NotificationMessage }; diff --git a/src/redux/auth/actions.ts b/src/redux/auth/actions.ts index cf1e7279..fc708cbb 100644 --- a/src/redux/auth/actions.ts +++ b/src/redux/auth/actions.ts @@ -54,3 +54,15 @@ export const authSendMessage = (message: Partial, onSuccess) => ({ message, onSuccess, }); + +export const authSetUpdates = (updates: Partial) => ({ + type: AUTH_USER_ACTIONS.SET_UPDATES, + updates, +}); + +export const authSetLastSeenMessages = ( + last_seen_messages: IAuthState['user']['last_seen_messages'] +) => ({ + type: AUTH_USER_ACTIONS.SET_LAST_SEEN_MESSAGES, + last_seen_messages, +}); diff --git a/src/redux/auth/api.ts b/src/redux/auth/api.ts index bf6650f6..a984909e 100644 --- a/src/redux/auth/api.ts +++ b/src/redux/auth/api.ts @@ -54,8 +54,9 @@ export const apiAuthSendMessage = ({ export const apiAuthGetUpdates = ({ access, exclude_dialogs, + last, }): Promise> => api - .get(API.USER.GET_UPDATES, configWithToken(access, { params: { exclude_dialogs } })) + .get(API.USER.GET_UPDATES, configWithToken(access, { params: { exclude_dialogs, last } })) .then(resultMiddleware) .catch(errorMiddleware); diff --git a/src/redux/auth/constants.ts b/src/redux/auth/constants.ts index 22626c02..38e5a3c6 100644 --- a/src/redux/auth/constants.ts +++ b/src/redux/auth/constants.ts @@ -13,6 +13,9 @@ export const AUTH_USER_ACTIONS = { SET_PROFILE: 'SET_PROFILE', GET_MESSAGES: 'GET_MESSAGES', SEND_MESSAGE: 'SEND_MESSAGE', + + SET_UPDATES: 'SET_UPDATES', + SET_LAST_SEEN_MESSAGES: 'SET_LAST_SEEN_MESSAGES', }; export const USER_ERRORS = { @@ -46,9 +49,11 @@ export const EMPTY_USER: IUser = { cover: null, is_activated: false, is_user: false, - last_seen: null, fullname: null, description: null, + + last_seen: null, + last_seen_messages: null, }; export interface IApiUser { diff --git a/src/redux/auth/handlers.ts b/src/redux/auth/handlers.ts index 0c46fb9a..21c9932e 100644 --- a/src/redux/auth/handlers.ts +++ b/src/redux/auth/handlers.ts @@ -37,9 +37,31 @@ const setProfile: ActionHandler = (state, ...profile, }, }); + +const setUpdates: ActionHandler = (state, { updates }) => ({ + ...state, + updates: { + ...state.updates, + ...updates, + }, +}); + +const setLastSeenMessages: ActionHandler = ( + state, + { last_seen_messages } +) => ({ + ...state, + user: { + ...state.user, + last_seen_messages, + }, +}); + export const AUTH_USER_HANDLERS = { [AUTH_USER_ACTIONS.SET_LOGIN_ERROR]: setLoginError, [AUTH_USER_ACTIONS.SET_USER]: setUser, [AUTH_USER_ACTIONS.SET_TOKEN]: setToken, [AUTH_USER_ACTIONS.SET_PROFILE]: setProfile, + [AUTH_USER_ACTIONS.SET_UPDATES]: setUpdates, + [AUTH_USER_ACTIONS.SET_LAST_SEEN_MESSAGES]: setLastSeenMessages, }; diff --git a/src/redux/auth/reducer.ts b/src/redux/auth/reducer.ts index 6759bb77..1255f788 100644 --- a/src/redux/auth/reducer.ts +++ b/src/redux/auth/reducer.ts @@ -12,7 +12,8 @@ const INITIAL_STATE: IAuthState = { user: { ...EMPTY_USER }, updates: { - messages: [], + last: null, + notifications: [], }, login: { diff --git a/src/redux/auth/sagas.ts b/src/redux/auth/sagas.ts index 7287c796..5b211fee 100644 --- a/src/redux/auth/sagas.ts +++ b/src/redux/auth/sagas.ts @@ -10,6 +10,7 @@ import { authSetProfile, authGetMessages, authSendMessage, + authSetUpdates, } from '~/redux/auth/actions'; import { apiUserLogin, @@ -20,8 +21,8 @@ import { apiAuthGetUpdates, } from '~/redux/auth/api'; import { modalSetShown, modalShowDialog } from '~/redux/modal/actions'; -import { selectToken, selectAuthProfile, selectAuthUser } from './selectors'; -import { IResultWithStatus } from '../types'; +import { selectToken, selectAuthProfile, selectAuthUser, selectAuthUpdates } from './selectors'; +import { IResultWithStatus, INotification } from '../types'; import { IUser, IAuthState } from './types'; import { REHYDRATE, RehydrateAction } from 'redux-persist'; import { selectModal } from '../modal/selectors'; @@ -197,19 +198,32 @@ function* getUpdates() { const modal: IModalState = yield select(selectModal); const profile: IAuthState['profile'] = yield select(selectAuthProfile); - + const { last }: IAuthState['updates'] = yield select(selectAuthUpdates); const exclude_dialogs = modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user.id ? profile.user.id : null; - const { error, data } = yield call(reqWrapper, apiAuthGetUpdates, { exclude_dialogs }); + const { error, data }: IResultWithStatus<{ notifications: INotification[] }> = yield call( + reqWrapper, + apiAuthGetUpdates, + { exclude_dialogs, last } + ); - if (error || !data) return; + if (error || !data || !data.notifications || !data.notifications.length) return; + + const { notifications } = data; + + yield put( + authSetUpdates({ + last: notifications[0].created_at, + notifications, + }) + ); } function* startPollingSaga() { while (true) { yield call(getUpdates); - yield delay(30000); + yield delay(60000); } } diff --git a/src/redux/auth/selectors.ts b/src/redux/auth/selectors.ts index 4a80bb6f..9004dc0d 100644 --- a/src/redux/auth/selectors.ts +++ b/src/redux/auth/selectors.ts @@ -6,3 +6,4 @@ export const selectToken = (state: IState): IState['auth']['token'] => state.aut export const selectAuthLogin = (state: IState): IState['auth']['login'] => state.auth.login; export const selectAuthProfile = (state: IState): IState['auth']['profile'] => state.auth.profile; export const selectAuthUser = (state: IState): IState['auth']['user'] => state.auth.user; +export const selectAuthUpdates = (state: IState): IState['auth']['updates'] => state.auth.updates; diff --git a/src/redux/auth/types.ts b/src/redux/auth/types.ts index 6999c6ef..391221bb 100644 --- a/src/redux/auth/types.ts +++ b/src/redux/auth/types.ts @@ -1,4 +1,4 @@ -import { IFile, IMessage } from '../types'; +import { IFile, IMessage, INotification } from '../types'; export interface IToken { access: string; @@ -13,10 +13,12 @@ export interface IUser { photo: IFile; cover: IFile; name: string; - last_seen: string; fullname: string; description: string; + last_seen: string; + last_seen_messages: string; + is_activated: boolean; is_user: boolean; } @@ -26,7 +28,8 @@ export type IAuthState = Readonly<{ token: string; updates: { - messages: IMessage[]; + last: string; + notifications: INotification[]; }; login: { diff --git a/src/redux/store.ts b/src/redux/store.ts index 1e022ea7..4c0ec13f 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -29,7 +29,7 @@ import { gotAuthPostMessage, authOpenProfile } from './auth/actions'; const authPersistConfig: PersistConfig = { key: 'auth', - whitelist: ['token', 'user'], + whitelist: ['token', 'user', 'updates'], storage, }; @@ -50,8 +50,6 @@ history.listen(({ pathname }) => { console.log({ pathname }); if (pathname.match(/~([\wа-яА-Я]+)/)) { - console.log('hi!', pathname.match(/~([\wа-яА-Я]+)/)); - const [, username] = pathname.match(/~([\wа-яА-Я]+)/); window.postMessage({ type: 'username', username }, '*'); } diff --git a/src/redux/types.ts b/src/redux/types.ts index adfd79c6..9f2410d3 100644 --- a/src/redux/types.ts +++ b/src/redux/types.ts @@ -161,3 +161,28 @@ export type IUploadProgressHandler = (progress: ProgressEvent) => void; export type IError = ValueOf; export type IValidationErrors = Record; export type InputHandler = (val: T) => void; + +export const NOTIFICATION_TYPES = { + message: 'message', + comment: 'comment', + node: 'node', +}; + +export type IMessageNotification = { + type: typeof NOTIFICATION_TYPES['message']; + content: Partial; +}; + +export type ICommentNotification = { + type: typeof NOTIFICATION_TYPES['comment']; + content: Partial; +}; + +export type INodeNotification = { + type: typeof NOTIFICATION_TYPES['node']; + content: Partial; +}; + +export type INotification = (IMessageNotification | ICommentNotification) & { + created_at: string; +}; diff --git a/src/sprites/Sprites.tsx b/src/sprites/Sprites.tsx index 8259c439..e2618518 100644 --- a/src/sprites/Sprites.tsx +++ b/src/sprites/Sprites.tsx @@ -171,6 +171,21 @@ const Sprites: FC<{}> = () => ( + + + + + + + + + + + + + + + );