mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 12:56:41 +07:00
message notifications working
This commit is contained in:
parent
83c9900af1
commit
7583a57b04
11 changed files with 137 additions and 32 deletions
|
@ -7,6 +7,7 @@ import pick from 'ramda/es/pick';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as AUTH_ACTIONS from '~/redux/auth/actions';
|
import * as AUTH_ACTIONS from '~/redux/auth/actions';
|
||||||
import { NotificationBubble } from '../../notifications/NotificationBubble';
|
import { NotificationBubble } from '../../notifications/NotificationBubble';
|
||||||
|
import { INotification, IMessageNotification } from '~/redux/types';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
user: pick(['last_seen_messages'], selectAuthUser(state)),
|
user: pick(['last_seen_messages'], selectAuthUser(state)),
|
||||||
|
@ -15,6 +16,7 @@ const mapStateToProps = state => ({
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
authSetLastSeenMessages: AUTH_ACTIONS.authSetLastSeenMessages,
|
authSetLastSeenMessages: AUTH_ACTIONS.authSetLastSeenMessages,
|
||||||
|
authOpenProfile: AUTH_ACTIONS.authOpenProfile,
|
||||||
};
|
};
|
||||||
|
|
||||||
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
|
@ -23,8 +25,9 @@ const NotificationsUnconnected: FC<IProps> = ({
|
||||||
updates: { last, notifications },
|
updates: { last, notifications },
|
||||||
user: { last_seen_messages },
|
user: { last_seen_messages },
|
||||||
authSetLastSeenMessages,
|
authSetLastSeenMessages,
|
||||||
|
authOpenProfile,
|
||||||
}) => {
|
}) => {
|
||||||
const [visible, setVisible] = useState(true);
|
const [visible, setVisible] = useState(false);
|
||||||
const has_new = useMemo(
|
const has_new = useMemo(
|
||||||
() =>
|
() =>
|
||||||
notifications.length &&
|
notifications.length &&
|
||||||
|
@ -35,21 +38,42 @@ const NotificationsUnconnected: FC<IProps> = ({
|
||||||
[last, last_seen_messages, notifications]
|
[last, last_seen_messages, notifications]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onNotificationClick = useCallback(
|
||||||
|
(notification: INotification) => {
|
||||||
|
switch (notification.type) {
|
||||||
|
case 'message':
|
||||||
|
return authOpenProfile(
|
||||||
|
(notification as IMessageNotification).content.from.username,
|
||||||
|
'messages'
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authOpenProfile]
|
||||||
|
);
|
||||||
|
const showList = useCallback(() => setVisible(true), [setVisible]);
|
||||||
|
const hideList = useCallback(() => setVisible(false), [setVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !has_new) return;
|
if (!visible || !has_new) return;
|
||||||
authSetLastSeenMessages(new Date().toISOString());
|
authSetLastSeenMessages(new Date().toISOString());
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const showList = useCallback(() => setVisible(true), [setVisible]);
|
|
||||||
const hideList = useCallback(() => setVisible(false), [setVisible]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.wrap, { [styles.is_new]: has_new })}>
|
<div
|
||||||
|
className={classNames(styles.wrap, {
|
||||||
|
[styles.is_new]: has_new,
|
||||||
|
[styles.active]: notifications.length > 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div className={styles.icon} onFocus={showList} onBlur={hideList} tabIndex={-1}>
|
<div className={styles.icon} onFocus={showList} onBlur={hideList} tabIndex={-1}>
|
||||||
{has_new ? <Icon icon="bell_ring" size={24} /> : <Icon icon="bell" size={24} />}
|
{has_new ? <Icon icon="bell_ring" size={24} /> : <Icon icon="bell" size={24} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visible && <NotificationBubble notifications={notifications} />}
|
{visible && (
|
||||||
|
<NotificationBubble notifications={notifications} onClick={onNotificationClick} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -21,9 +21,16 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
.icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.is_new {
|
&.is_new {
|
||||||
.icon {
|
.icon {
|
||||||
animation: ring 1s infinite alternate;
|
animation: ring 1s infinite alternate;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: $red;
|
fill: $red;
|
||||||
|
@ -35,4 +42,5 @@
|
||||||
.icon {
|
.icon {
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,25 +2,34 @@ import React, { FC, createElement } from 'react';
|
||||||
import { INotification, NOTIFICATION_TYPES } from '~/redux/types';
|
import { INotification, NOTIFICATION_TYPES } from '~/redux/types';
|
||||||
import styles from './styles.scss';
|
import styles from './styles.scss';
|
||||||
import { NotificationMessage } from '../NotificationMessage';
|
import { NotificationMessage } from '../NotificationMessage';
|
||||||
|
import { Icon } from '~/components/input/Icon';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
notifications: INotification[];
|
notifications: INotification[];
|
||||||
|
onClick: (notification: INotification) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOTIFICATION_RENDERERS = {
|
const NOTIFICATION_RENDERERS = {
|
||||||
[NOTIFICATION_TYPES.message]: NotificationMessage,
|
[NOTIFICATION_TYPES.message]: NotificationMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
const NotificationBubble: FC<IProps> = ({ notifications }) => {
|
const NotificationBubble: FC<IProps> = ({ notifications, onClick }) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{notifications
|
{notifications.length === 0 && (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<Icon icon="bell_ring" />
|
||||||
|
<div>НЕТ УВЕДОМЛЕНИЙ</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{notifications.length > 0 &&
|
||||||
|
notifications
|
||||||
.filter(notification => notification.type && NOTIFICATION_RENDERERS[notification.type])
|
.filter(notification => notification.type && NOTIFICATION_RENDERERS[notification.type])
|
||||||
.map(notification =>
|
.map(notification =>
|
||||||
createElement(NOTIFICATION_RENDERERS[notification.type], {
|
createElement(NOTIFICATION_RENDERERS[notification.type], {
|
||||||
notification,
|
notification,
|
||||||
onClick: console.log,
|
onClick,
|
||||||
key: notification.content.id,
|
key: notification.content.id,
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
$notification_color: darken($content_bg, 2%);
|
||||||
|
|
||||||
@keyframes appear {
|
@keyframes appear {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
@ -6,15 +8,16 @@
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
position: absolute;
|
background: $notification_color;
|
||||||
background: $content_bg;
|
top: 42px;
|
||||||
top: 50px;
|
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
animation: appear 0.5s forwards;
|
animation: appear 0.25s forwards;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: ' ';
|
content: ' ';
|
||||||
|
@ -22,7 +25,7 @@
|
||||||
height: 0;
|
height: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 0 0 16px 16px;
|
border-width: 0 0 16px 16px;
|
||||||
border-color: transparent transparent $content_bg transparent;
|
border-color: transparent transparent $notification_color transparent;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: -16px;
|
top: -16px;
|
||||||
|
@ -45,6 +48,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: $gap;
|
padding: $gap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: white;
|
fill: white;
|
||||||
|
@ -74,3 +78,23 @@
|
||||||
padding-left: 30px;
|
padding-left: 30px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font: $font_16_semibold;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
opacity: 0.05;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC, useState } from 'react';
|
import React, { FC, useState, useCallback } from 'react';
|
||||||
import { BetterScrollDialog } from '../BetterScrollDialog';
|
import { BetterScrollDialog } from '../BetterScrollDialog';
|
||||||
import { ProfileInfo } from '~/containers/profile/ProfileInfo';
|
import { ProfileInfo } from '~/containers/profile/ProfileInfo';
|
||||||
import { IDialogProps } from '~/redux/types';
|
import { IDialogProps } from '~/redux/types';
|
||||||
|
@ -6,18 +6,30 @@ import { connect } from 'react-redux';
|
||||||
import { selectAuthProfile } from '~/redux/auth/selectors';
|
import { selectAuthProfile } from '~/redux/auth/selectors';
|
||||||
import { ProfileMessages } from '~/containers/profile/ProfileMessages';
|
import { ProfileMessages } from '~/containers/profile/ProfileMessages';
|
||||||
import { ProfileDescription } from '~/components/profile/ProfileDescription';
|
import { ProfileDescription } from '~/components/profile/ProfileDescription';
|
||||||
|
import * as AUTH_ACTIONS from '~/redux/auth/actions';
|
||||||
|
import { IAuthState } from '~/redux/auth/types';
|
||||||
|
|
||||||
const TAB_CONTENT = {
|
const TAB_CONTENT = {
|
||||||
profile: <ProfileDescription />,
|
profile: <ProfileDescription />,
|
||||||
messages: <ProfileMessages />,
|
messages: <ProfileMessages />,
|
||||||
};
|
};
|
||||||
const mapStateToProps = selectAuthProfile;
|
const mapStateToProps = selectAuthProfile;
|
||||||
const mapDispatchToProps = {};
|
const mapDispatchToProps = {
|
||||||
|
authSetProfile: AUTH_ACTIONS.authSetProfile,
|
||||||
|
};
|
||||||
|
|
||||||
type IProps = IDialogProps & ReturnType<typeof mapStateToProps> & {};
|
type IProps = IDialogProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & {};
|
||||||
|
|
||||||
const ProfileDialogUnconnected: FC<IProps> = ({ onRequestClose, is_loading, user }) => {
|
const ProfileDialogUnconnected: FC<IProps> = ({
|
||||||
const [tab, setTab] = useState('profile');
|
onRequestClose,
|
||||||
|
authSetProfile,
|
||||||
|
is_loading,
|
||||||
|
user,
|
||||||
|
tab,
|
||||||
|
}) => {
|
||||||
|
const setTab = useCallback((val: IAuthState['profile']['tab']) => authSetProfile({ tab: val }), [
|
||||||
|
authSetProfile,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BetterScrollDialog
|
<BetterScrollDialog
|
||||||
|
|
|
@ -34,9 +34,14 @@ export const authLogout = () => ({
|
||||||
type: AUTH_USER_ACTIONS.LOGOUT,
|
type: AUTH_USER_ACTIONS.LOGOUT,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const authOpenProfile = (username: string) => ({
|
export const authLoggedIn = () => ({
|
||||||
|
type: AUTH_USER_ACTIONS.LOGGED_IN,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authOpenProfile = (username: string, tab?: IAuthState['profile']['tab']) => ({
|
||||||
type: AUTH_USER_ACTIONS.OPEN_PROFILE,
|
type: AUTH_USER_ACTIONS.OPEN_PROFILE,
|
||||||
username,
|
username,
|
||||||
|
tab,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const authSetProfile = (profile: Partial<IAuthState['profile']>) => ({
|
export const authSetProfile = (profile: Partial<IAuthState['profile']>) => ({
|
||||||
|
|
|
@ -7,6 +7,7 @@ export const AUTH_USER_ACTIONS = {
|
||||||
SET_TOKEN: 'SET_TOKEN',
|
SET_TOKEN: 'SET_TOKEN',
|
||||||
|
|
||||||
LOGOUT: 'LOGOUT',
|
LOGOUT: 'LOGOUT',
|
||||||
|
LOGGED_IN: 'LOGGED_IN',
|
||||||
|
|
||||||
GOT_AUTH_POST_MESSAGE: 'GOT_POST_MESSAGE',
|
GOT_AUTH_POST_MESSAGE: 'GOT_POST_MESSAGE',
|
||||||
OPEN_PROFILE: 'OPEN_PROFILE',
|
OPEN_PROFILE: 'OPEN_PROFILE',
|
||||||
|
|
|
@ -22,6 +22,7 @@ const INITIAL_STATE: IAuthState = {
|
||||||
},
|
},
|
||||||
|
|
||||||
profile: {
|
profile: {
|
||||||
|
tab: 'profile',
|
||||||
is_loading: true,
|
is_loading: true,
|
||||||
is_loading_messages: true,
|
is_loading_messages: true,
|
||||||
is_sending_messages: false,
|
is_sending_messages: false,
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
authGetMessages,
|
authGetMessages,
|
||||||
authSendMessage,
|
authSendMessage,
|
||||||
authSetUpdates,
|
authSetUpdates,
|
||||||
|
authLoggedIn,
|
||||||
} from '~/redux/auth/actions';
|
} from '~/redux/auth/actions';
|
||||||
import {
|
import {
|
||||||
apiUserLogin,
|
apiUserLogin,
|
||||||
|
@ -22,7 +23,7 @@ import {
|
||||||
} from '~/redux/auth/api';
|
} from '~/redux/auth/api';
|
||||||
import { modalSetShown, modalShowDialog } from '~/redux/modal/actions';
|
import { modalSetShown, modalShowDialog } from '~/redux/modal/actions';
|
||||||
import { selectToken, selectAuthProfile, selectAuthUser, selectAuthUpdates } from './selectors';
|
import { selectToken, selectAuthProfile, selectAuthUser, selectAuthUpdates } from './selectors';
|
||||||
import { IResultWithStatus, INotification } from '../types';
|
import { IResultWithStatus, INotification, IMessageNotification } from '../types';
|
||||||
import { IUser, IAuthState } from './types';
|
import { IUser, IAuthState } from './types';
|
||||||
import { REHYDRATE, RehydrateAction } from 'redux-persist';
|
import { REHYDRATE, RehydrateAction } from 'redux-persist';
|
||||||
import { selectModal } from '../modal/selectors';
|
import { selectModal } from '../modal/selectors';
|
||||||
|
@ -60,6 +61,7 @@ function* sendLoginRequestSaga({ username, password }: ReturnType<typeof userSen
|
||||||
|
|
||||||
yield put(authSetToken(token));
|
yield put(authSetToken(token));
|
||||||
yield put(authSetUser({ ...user, is_user: true }));
|
yield put(authSetUser({ ...user, is_user: true }));
|
||||||
|
yield put(authLoggedIn());
|
||||||
yield put(modalSetShown(false));
|
yield put(modalSetShown(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,11 +103,17 @@ function* gotPostMessageSaga({ token }: ReturnType<typeof gotAuthPostMessage>) {
|
||||||
function* logoutSaga() {
|
function* logoutSaga() {
|
||||||
yield put(authSetToken(null));
|
yield put(authSetToken(null));
|
||||||
yield put(authSetUser({ ...EMPTY_USER }));
|
yield put(authSetUser({ ...EMPTY_USER }));
|
||||||
|
yield put(
|
||||||
|
authSetUpdates({
|
||||||
|
last: null,
|
||||||
|
notifications: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function* openProfile({ username }: ReturnType<typeof authOpenProfile>) {
|
function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenProfile>) {
|
||||||
yield put(modalShowDialog(DIALOGS.PROFILE));
|
yield put(modalShowDialog(DIALOGS.PROFILE));
|
||||||
yield put(authSetProfile({ is_loading: true }));
|
yield put(authSetProfile({ is_loading: true, tab }));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
error,
|
error,
|
||||||
|
@ -151,6 +159,19 @@ function* getMessages({ username }: ReturnType<typeof authGetMessages>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
yield put(authSetProfile({ is_loading_messages: false, messages: data.messages }));
|
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>) {
|
function* sendMessage({ message, onSuccess }: ReturnType<typeof authSendMessage>) {
|
||||||
|
@ -235,7 +256,7 @@ function* authSaga() {
|
||||||
yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile);
|
yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile);
|
||||||
yield takeLatest(AUTH_USER_ACTIONS.GET_MESSAGES, getMessages);
|
yield takeLatest(AUTH_USER_ACTIONS.GET_MESSAGES, getMessages);
|
||||||
yield takeLatest(AUTH_USER_ACTIONS.SEND_MESSAGE, sendMessage);
|
yield takeLatest(AUTH_USER_ACTIONS.SEND_MESSAGE, sendMessage);
|
||||||
yield takeLatest(REHYDRATE, startPollingSaga);
|
yield takeLatest([REHYDRATE, AUTH_USER_ACTIONS.LOGGED_IN], startPollingSaga);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default authSaga;
|
export default authSaga;
|
||||||
|
|
|
@ -38,6 +38,7 @@ export type IAuthState = Readonly<{
|
||||||
};
|
};
|
||||||
|
|
||||||
profile: {
|
profile: {
|
||||||
|
tab: 'profile' | 'messages' | 'settings';
|
||||||
is_loading: boolean;
|
is_loading: boolean;
|
||||||
is_loading_messages: boolean;
|
is_loading_messages: boolean;
|
||||||
is_sending_messages: boolean;
|
is_sending_messages: boolean;
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { DetailedHTMLProps, InputHTMLAttributes } from 'react';
|
||||||
import { DIALOGS } from '~/redux/modal/constants';
|
import { DIALOGS } from '~/redux/modal/constants';
|
||||||
import { ERRORS } from '~/constants/errors';
|
import { ERRORS } from '~/constants/errors';
|
||||||
import { IUser } from './auth/types';
|
import { IUser } from './auth/types';
|
||||||
import { string } from 'prop-types';
|
|
||||||
|
|
||||||
export interface ITag {
|
export interface ITag {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue