536 lines
14 KiB
TypeScript
536 lines
14 KiB
TypeScript
import React, { useState } from 'react';
|
|
import styled, { css } from 'styled-components';
|
|
import TimeAgo from 'react-timeago';
|
|
import { Link } from 'react-router-dom';
|
|
import { mixin } from 'shared/utils/styles';
|
|
import {
|
|
useNotificationsQuery,
|
|
NotificationFilter,
|
|
ActionType,
|
|
useNotificationAddedSubscription,
|
|
useNotificationToggleReadMutation,
|
|
} from 'shared/generated/graphql';
|
|
import dayjs from 'dayjs';
|
|
|
|
import { Popup, usePopup } from 'shared/components/PopupMenu';
|
|
import { CheckCircleOutline, Circle, CircleSolid, UserCircle } from 'shared/icons';
|
|
import produce from 'immer';
|
|
|
|
const ItemWrapper = styled.div`
|
|
cursor: pointer;
|
|
border-bottom: 1px solid #414561;
|
|
padding-left: 1rem;
|
|
padding-right: 1rem;
|
|
padding-top: 1rem;
|
|
padding-bottom: 1rem;
|
|
justify-content: space-between;
|
|
display: flex;
|
|
|
|
&:hover {
|
|
background: #10163a;
|
|
}
|
|
`;
|
|
const ItemWrapperContent = styled.div`
|
|
display: flex;
|
|
align-items: flex-start;
|
|
`;
|
|
|
|
const ItemIconContainer = styled.span`
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
`;
|
|
|
|
const ItemTextContainer = styled.div`
|
|
margin-left: 0.5rem;
|
|
margin-right: 0.5rem;
|
|
`;
|
|
|
|
const ItemTextTitle = styled.span`
|
|
font-weight: 500;
|
|
display: block;
|
|
color: ${(props) => props.theme.colors.primary};
|
|
font-size: 14px;
|
|
`;
|
|
const ItemTextDesc = styled.span`
|
|
font-size: 12px;
|
|
`;
|
|
|
|
const ItemTimeAgo = styled.span`
|
|
margin-top: 0.25rem;
|
|
white-space: nowrap;
|
|
font-size: 11px;
|
|
`;
|
|
|
|
type NotificationItemProps = {
|
|
title: string;
|
|
description: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
export const NotificationItem: React.FC<NotificationItemProps> = ({ title, description, createdAt }) => {
|
|
return (
|
|
<ItemWrapper>
|
|
<ItemWrapperContent>
|
|
<ItemIconContainer />
|
|
<ItemTextContainer>
|
|
<ItemTextTitle>{title}</ItemTextTitle>
|
|
<ItemTextDesc>{description}</ItemTextDesc>
|
|
</ItemTextContainer>
|
|
</ItemWrapperContent>
|
|
<TimeAgo date={createdAt} component={ItemTimeAgo} />
|
|
</ItemWrapper>
|
|
);
|
|
};
|
|
|
|
const NotificationHeader = styled.div`
|
|
padding: 20px 28px;
|
|
text-align: center;
|
|
border-top-left-radius: 6px;
|
|
border-top-right-radius: 6px;
|
|
background: ${(props) => props.theme.colors.primary};
|
|
`;
|
|
|
|
const NotificationHeaderTitle = styled.span`
|
|
font-size: 14px;
|
|
color: ${(props) => props.theme.colors.text.secondary};
|
|
`;
|
|
|
|
const Notifications = styled.div`
|
|
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-color: #414561;
|
|
height: 448px;
|
|
overflow-y: scroll;
|
|
user-select: none;
|
|
`;
|
|
const NotificationFooter = styled.div`
|
|
cursor: pointer;
|
|
padding: 0.5rem;
|
|
text-align: center;
|
|
color: ${(props) => props.theme.colors.primary};
|
|
&:hover {
|
|
background: ${(props) => props.theme.colors.bg.primary};
|
|
}
|
|
border-bottom-left-radius: 6px;
|
|
border-bottom-right-radius: 6px;
|
|
|
|
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-color: #414561;
|
|
`;
|
|
|
|
const NotificationTabs = styled.div`
|
|
align-items: flex-end;
|
|
align-self: stretch;
|
|
display: flex;
|
|
flex: 1 0 auto;
|
|
justify-content: flex-start;
|
|
max-width: 100%;
|
|
padding-top: 4px;
|
|
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-color: #414561;
|
|
`;
|
|
|
|
const NotificationTab = styled.div<{ active: boolean }>`
|
|
font-size: 80%;
|
|
color: ${(props) => props.theme.colors.text.primary};
|
|
font-size: 15px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
user-select: none;
|
|
|
|
justify-content: center;
|
|
line-height: normal;
|
|
min-width: 1px;
|
|
transition-duration: 0.2s;
|
|
transition-property: box-shadow, color;
|
|
white-space: nowrap;
|
|
flex: 0 1 auto;
|
|
padding: 12px 16px;
|
|
|
|
&:first-child {
|
|
margin-left: 12px;
|
|
}
|
|
|
|
&:hover {
|
|
box-shadow: inset 0 -2px ${(props) => props.theme.colors.text.secondary};
|
|
color: ${(props) => props.theme.colors.text.secondary};
|
|
}
|
|
&:not(:last-child) {
|
|
margin-right: 12px;
|
|
}
|
|
|
|
${(props) =>
|
|
props.active &&
|
|
css`
|
|
box-shadow: inset 0 -2px ${props.theme.colors.secondary};
|
|
color: ${props.theme.colors.secondary};
|
|
&:hover {
|
|
box-shadow: inset 0 -2px ${props.theme.colors.secondary};
|
|
color: ${props.theme.colors.secondary};
|
|
}
|
|
`}
|
|
`;
|
|
|
|
const NotificationLink = styled(Link)`
|
|
display: flex;
|
|
align-items: center;
|
|
text-decoration: none;
|
|
padding: 16px 8px;
|
|
width: 100%;
|
|
`;
|
|
|
|
const NotificationControls = styled.div`
|
|
width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: space-around;
|
|
visibility: hidden;
|
|
padding: 4px;
|
|
`;
|
|
|
|
const NotificationButtons = styled.div`
|
|
display: flex;
|
|
align-self: flex-end;
|
|
align-items: center;
|
|
margin-top: auto;
|
|
margin-bottom: 6px;
|
|
`;
|
|
|
|
const NotificationButton = styled.div`
|
|
padding: 4px 15px;
|
|
cursor: pointer;
|
|
&:hover svg {
|
|
fill: rgb(216, 93, 216);
|
|
stroke: rgb(216, 93, 216);
|
|
}
|
|
`;
|
|
|
|
const NotificationWrapper = styled.li`
|
|
min-height: 112px;
|
|
display: flex;
|
|
font-size: 14px;
|
|
transition: background-color 0.1s ease-in-out;
|
|
margin: 2px 8px;
|
|
border-radius: 8px;
|
|
justify-content: space-between;
|
|
position: relative;
|
|
&:hover {
|
|
background: ${(props) => mixin.rgba(props.theme.colors.primary, 0.5)};
|
|
}
|
|
&:hover ${NotificationLink} {
|
|
color: #fff;
|
|
}
|
|
&:hover ${NotificationControls} {
|
|
visibility: visible;
|
|
}
|
|
`;
|
|
|
|
const NotificationContentFooter = styled.div`
|
|
margin-top: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
color: ${(props) => props.theme.colors.text.primary};
|
|
`;
|
|
|
|
const NotificationCausedBy = styled.div`
|
|
height: 60px;
|
|
width: 60px;
|
|
min-height: 60px;
|
|
min-width: 60px;
|
|
`;
|
|
const NotificationCausedByInitials = styled.div`
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
text: #fff;
|
|
font-size: 18px;
|
|
justify-content: center;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
height: 100%;
|
|
width: 100%;
|
|
border: none;
|
|
background: #7367f0;
|
|
`;
|
|
|
|
const NotificationCausedByImage = styled.img`
|
|
position: relative;
|
|
display: flex;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
height: 100%;
|
|
width: 100%;
|
|
border: none;
|
|
background: #7367f0;
|
|
`;
|
|
|
|
const NotificationContent = styled.div`
|
|
display: flex;
|
|
overflow: hidden;
|
|
flex-direction: column;
|
|
margin-left: 16px;
|
|
`;
|
|
|
|
const NotificationContentHeader = styled.div`
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
color: #fff;
|
|
|
|
svg {
|
|
margin-left: 8px;
|
|
fill: rgb(216, 93, 216);
|
|
stroke: rgb(216, 93, 216);
|
|
}
|
|
`;
|
|
|
|
const NotificationBody = styled.div`
|
|
margin-top: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
color: #fff;
|
|
svg {
|
|
fill: rgb(216, 93, 216);
|
|
stroke: rgb(216, 93, 216);
|
|
}
|
|
`;
|
|
|
|
const NotificationPrefix = styled.span`
|
|
color: rgb(216, 93, 216);
|
|
margin: 0 4px;
|
|
`;
|
|
|
|
const NotificationSeparator = styled.span`
|
|
margin: 0 6px;
|
|
`;
|
|
|
|
type NotificationProps = {
|
|
causedBy?: { fullname: string; username: string; id: string } | null;
|
|
createdAt: string;
|
|
read: boolean;
|
|
data: Array<{ key: string; value: string }>;
|
|
actionType: ActionType;
|
|
onToggleRead: () => void;
|
|
};
|
|
|
|
const Notification: React.FC<NotificationProps> = ({ causedBy, createdAt, data, actionType, read, onToggleRead }) => {
|
|
const prefix: any = [];
|
|
const { hidePopup } = usePopup();
|
|
const dataMap = new Map<string, string>();
|
|
data.forEach((d) => dataMap.set(d.key, d.value));
|
|
let link = '#';
|
|
switch (actionType) {
|
|
case ActionType.TaskAssigned:
|
|
prefix.push(<UserCircle width={14} height={16} />);
|
|
prefix.push(<NotificationPrefix>Assigned </NotificationPrefix>);
|
|
prefix.push(<span>you to the task "{dataMap.get('TaskName')}"</span>);
|
|
link = `/projects/${dataMap.get('ProjectID')}/board/c/${dataMap.get('TaskID')}`;
|
|
break;
|
|
default:
|
|
throw new Error('unknown action type');
|
|
}
|
|
|
|
return (
|
|
<NotificationWrapper>
|
|
<NotificationLink to={link} onClick={hidePopup}>
|
|
<NotificationCausedBy>
|
|
<NotificationCausedByInitials>
|
|
{causedBy
|
|
? causedBy.fullname
|
|
.split(' ')
|
|
.map((n) => n[0])
|
|
.join('.')
|
|
: 'RU'}
|
|
</NotificationCausedByInitials>
|
|
</NotificationCausedBy>
|
|
<NotificationContent>
|
|
<NotificationContentHeader>
|
|
{causedBy ? causedBy.fullname : 'Removed user'}
|
|
{!read && <CircleSolid width={10} height={10} />}
|
|
</NotificationContentHeader>
|
|
<NotificationBody>{prefix}</NotificationBody>
|
|
<NotificationContentFooter>
|
|
<span>{dayjs.duration(dayjs(createdAt).diff(dayjs())).humanize(true)}</span>
|
|
<NotificationSeparator>•</NotificationSeparator>
|
|
<span>{dataMap.get('ProjectName')}</span>
|
|
</NotificationContentFooter>
|
|
</NotificationContent>
|
|
</NotificationLink>
|
|
<NotificationControls>
|
|
<NotificationButtons>
|
|
<NotificationButton onClick={() => onToggleRead()}>
|
|
{read ? <Circle width={18} height={18} /> : <CheckCircleOutline width={18} height={18} />}
|
|
</NotificationButton>
|
|
</NotificationButtons>
|
|
</NotificationControls>
|
|
</NotificationWrapper>
|
|
);
|
|
};
|
|
|
|
const PopupContent = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
padding-bottom: 10px;
|
|
border-color: #414561;
|
|
`;
|
|
|
|
const tabs = [
|
|
{ label: 'All', key: NotificationFilter.All },
|
|
{ label: 'Unread', key: NotificationFilter.Unread },
|
|
{ label: 'I was mentioned', key: NotificationFilter.Mentioned },
|
|
{ label: 'Assigned to me', key: NotificationFilter.Assigned },
|
|
];
|
|
|
|
type NotificationEntry = {
|
|
id: string;
|
|
read: boolean;
|
|
readAt?: string | undefined | null;
|
|
notification: {
|
|
id: string;
|
|
data: Array<{ key: string; value: string }>;
|
|
actionType: ActionType;
|
|
causedBy?: { id: string; username: string; fullname: string } | undefined | null;
|
|
createdAt: string;
|
|
};
|
|
};
|
|
const NotificationPopup: React.FC = ({ children }) => {
|
|
const [filter, setFilter] = useState<NotificationFilter>(NotificationFilter.Unread);
|
|
const [data, setData] = useState<{ nodes: Array<NotificationEntry>; hasNextPage: boolean; cursor: string }>({
|
|
nodes: [],
|
|
hasNextPage: false,
|
|
cursor: '',
|
|
});
|
|
const [toggleRead] = useNotificationToggleReadMutation({
|
|
onCompleted: (data) => {
|
|
setData((prev) => {
|
|
return produce(prev, (draft) => {
|
|
const idx = draft.nodes.findIndex((n) => n.id === data.notificationToggleRead.id);
|
|
if (idx !== -1) {
|
|
draft.nodes[idx].read = data.notificationToggleRead.read;
|
|
draft.nodes[idx].readAt = data.notificationToggleRead.readAt;
|
|
}
|
|
});
|
|
});
|
|
},
|
|
});
|
|
const { data: nData, fetchMore } = useNotificationsQuery({
|
|
variables: { limit: 5, filter },
|
|
onCompleted: (d) => {
|
|
setData((prev) => ({
|
|
hasNextPage: d.notified.pageInfo.hasNextPage,
|
|
cursor: d.notified.pageInfo.endCursor,
|
|
nodes: [...prev.nodes, ...d.notified.notified],
|
|
}));
|
|
},
|
|
});
|
|
const { data: sData, loading } = useNotificationAddedSubscription({
|
|
onSubscriptionData: (d) => {
|
|
setData((n) => {
|
|
if (d.subscriptionData.data) {
|
|
return {
|
|
...n,
|
|
nodes: [d.subscriptionData.data.notificationAdded, ...n.nodes],
|
|
};
|
|
}
|
|
return n;
|
|
});
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Popup title={null} tab={0} borders={false} padding={false}>
|
|
<PopupContent>
|
|
<NotificationHeader>
|
|
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
|
|
</NotificationHeader>
|
|
<NotificationTabs>
|
|
{tabs.map((tab) => (
|
|
<NotificationTab
|
|
key={tab.key}
|
|
onClick={() => {
|
|
if (filter !== tab.key) {
|
|
setData({ cursor: '', hasNextPage: false, nodes: [] });
|
|
setFilter(tab.key);
|
|
}
|
|
}}
|
|
active={tab.key === filter}
|
|
>
|
|
{tab.label}
|
|
</NotificationTab>
|
|
))}
|
|
</NotificationTabs>
|
|
<Notifications
|
|
onScroll={({ currentTarget }) => {
|
|
if (currentTarget.scrollTop + currentTarget.clientHeight >= currentTarget.scrollHeight) {
|
|
if (data.hasNextPage) {
|
|
console.log(`fetching more = ${data.cursor} - ${data.hasNextPage}`);
|
|
fetchMore({
|
|
variables: {
|
|
limit: 5,
|
|
filter,
|
|
cursor: data.cursor,
|
|
},
|
|
updateQuery: (prev, { fetchMoreResult }) => {
|
|
if (!fetchMoreResult) return prev;
|
|
setData((d) => ({
|
|
cursor: fetchMoreResult.notified.pageInfo.endCursor,
|
|
hasNextPage: fetchMoreResult.notified.pageInfo.hasNextPage,
|
|
nodes: [...d.nodes, ...fetchMoreResult.notified.notified],
|
|
}));
|
|
return {
|
|
...prev,
|
|
notified: {
|
|
...prev.notified,
|
|
pageInfo: {
|
|
...fetchMoreResult.notified.pageInfo,
|
|
},
|
|
notified: [...prev.notified.notified, ...fetchMoreResult.notified.notified],
|
|
},
|
|
};
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{data.nodes.map((n) => (
|
|
<Notification
|
|
key={n.id}
|
|
read={n.read}
|
|
actionType={n.notification.actionType}
|
|
data={n.notification.data}
|
|
createdAt={n.notification.createdAt}
|
|
causedBy={n.notification.causedBy}
|
|
onToggleRead={() =>
|
|
toggleRead({
|
|
variables: { notifiedID: n.id },
|
|
optimisticResponse: {
|
|
__typename: 'Mutation',
|
|
notificationToggleRead: {
|
|
__typename: 'Notified',
|
|
id: n.id,
|
|
read: !n.read,
|
|
readAt: new Date().toUTCString(),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</Notifications>
|
|
</PopupContent>
|
|
</Popup>
|
|
);
|
|
};
|
|
|
|
export default NotificationPopup;
|