1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-24 20:36:40 +07:00

add user notifications (#148)

* added notification settings

* notifications: added list to profile

* notifications: changed appearance for comment notifications
This commit is contained in:
muerwre 2023-03-11 17:16:31 +06:00 committed by GitHub
parent 23701a5261
commit a39d000ff2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 552 additions and 218 deletions

View file

@ -0,0 +1,17 @@
import { API } from '~/constants/api';
import { api, cleanResult } from '~/utils/api';
import {
ApiGetNotificationSettingsResponse,
ApiGetNotificationsResponse,
} from './types';
export const apiGetNotificationSettings = () =>
api
.get<ApiGetNotificationSettingsResponse>(API.NOTIFICATIONS.SETTINGS)
.then(cleanResult);
export const apiGetNotifications = () =>
api
.get<ApiGetNotificationsResponse>(API.NOTIFICATIONS.LIST)
.then(cleanResult);

View file

@ -0,0 +1,13 @@
import { NotificationItem } from '~/types/notifications';
export interface ApiGetNotificationSettingsResponse {
enabled: boolean;
flow: boolean;
comments: boolean;
last_seen?: string | null;
last_date?: string | null;
}
export interface ApiGetNotificationsResponse {
items?: NotificationItem[];
}

View file

@ -0,0 +1,20 @@
import React, { FC } from 'react';
import { useColorFromString } from '~/hooks/color/useColorFromString';
import styles from './styles.module.scss';
interface InlineUsernameProps {
children: string;
}
const InlineUsername: FC<InlineUsernameProps> = ({ children }) => {
const backgroundColor = useColorFromString(children);
return (
<span style={{ backgroundColor }} className={styles.username}>
~{children}
</span>
);
};
export { InlineUsername };

View file

@ -0,0 +1,6 @@
.username {
font-size: 0.9em;
padding: 0 2px;
text-transform: lowercase;
border-radius: 0.2em;
}

View file

@ -0,0 +1,77 @@
import React, { FC } from 'react';
import { Anchor } from '~/components/common/Anchor';
import { InlineUsername } from '~/components/common/InlineUsername';
import { Square } from '~/components/common/Square';
import { Card } from '~/components/containers/Card';
import { FlowRecentItem } from '~/components/flow/FlowRecentItem';
import { NotificationItem, NotificationType } from '~/types/notifications';
import { formatText, getPrettyDate, getURLFromString } from '~/utils/dom';
import styles from './styles.module.scss';
interface NotificationBadgeProps {
item: NotificationItem;
}
const getTitle = (item: NotificationItem) => {
if (!item.user.username) {
return '';
}
switch (item.type) {
case NotificationType.Comment:
return (
<span>
<InlineUsername>{item.user.username}</InlineUsername> пишет:
</span>
);
case NotificationType.Node:
return (
<span>
Новый пост от <InlineUsername>{item.user.username}</InlineUsername>:
</span>
);
}
};
const getContent = (item: NotificationItem) => {
switch (item.type) {
case NotificationType.Comment:
return (
<div
dangerouslySetInnerHTML={{
__html: formatText(item.text),
}}
/>
);
case NotificationType.Node:
return (
<div
dangerouslySetInnerHTML={{
__html: formatText(item.text),
}}
/>
);
}
};
const getIcon = (item: NotificationItem) => {
return <Square image={getURLFromString(item.thumbnail, 'avatar')} />;
};
const NotificationBadge: FC<NotificationBadgeProps> = ({ item }) => (
<Anchor href={item.url} className={styles.link}>
<div className={styles.message}>
<div className={styles.icon}>{getIcon(item)}</div>
<div>
<b className={styles.title}>{getTitle(item)}</b>
<div className={styles.text}>{getContent(item)}</div>
<div className={styles.time}>{getPrettyDate(item.created_at)}</div>
</div>
</div>
</Anchor>
);
export { NotificationBadge };

View file

@ -0,0 +1,33 @@
@import 'src/styles/variables';
.link {
text-decoration: none;
color: inherit;
}
.message {
font: $font_14_regular;
line-height: 1.3em;
padding: $gap/2 $gap/2 $gap/4 $gap/2;
min-height: calc(1.3em * 3 + $gap);
display: grid;
grid-template-columns: 40px auto;
column-gap: $gap;
}
.text {
@include clamp(2, 14px);
text-overflow: ellipsis;
}
.title {
font: $font_14_semibold;
margin-bottom: $gap / 2;
}
.time {
font: $font_10_regular;
text-align: right;
margin-top: 2px;
color: $gray_75;
}

View file

@ -1,47 +0,0 @@
import React, { createElement, FC } from 'react';
import { Icon } from '~/components/input/Icon';
import { useRandomPhrase } from '~/constants/phrases';
import { INotification, NOTIFICATION_TYPES } from '~/types';
import { NotificationMessage } from '../NotificationMessage';
import styles from './styles.module.scss';
interface IProps {
notifications: INotification[];
onClick: (notification: INotification) => void;
}
const NOTIFICATION_RENDERERS = {
[NOTIFICATION_TYPES.message]: NotificationMessage,
};
const NotificationBubble: FC<IProps> = ({ notifications, onClick }) => {
const placeholder = useRandomPhrase('NOTHING_HERE');
return (
<div className={styles.wrap}>
<div className={styles.list}>
{notifications.length === 0 && (
<div className={styles.placeholder}>
<Icon icon="bell_ring" />
<div>{placeholder}</div>
</div>
)}
{notifications.length > 0 &&
notifications
.filter(notification => notification.type && NOTIFICATION_RENDERERS[notification.type])
.map(notification =>
createElement(NOTIFICATION_RENDERERS[notification.type], {
notification,
onClick,
key: notification.content.id,
})
)}
</div>
</div>
);
};
export { NotificationBubble };

View file

@ -1,106 +0,0 @@
@import 'src/styles/variables';
$notification_color: $content_bg_dark;
@keyframes appear {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.wrap {
position: absolute;
background: $notification_color;
top: 42px;
left: 50%;
transform: translate(-50%, 0);
border-radius: $radius;
animation: appear 0.25s forwards;
z-index: 2;
&::before {
content: ' ';
width: 0;
height: 0;
border-style: solid;
border-width: 0 0 16px 16px;
border-color: transparent transparent $notification_color 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;
cursor: pointer;
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;
}
.placeholder {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-transform: uppercase;
font: $font_16_semibold;
box-sizing: border-box;
padding: 80px;
text-align: center;
line-height: 1.6em;
svg {
width: 120px;
height: 120px;
opacity: 0.05;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

View file

@ -0,0 +1,43 @@
import { FC } from 'react';
import { Anchor } from '~/components/common/Anchor';
import { InlineUsername } from '~/components/common/InlineUsername';
import { Square } from '~/components/common/Square';
import { NotificationItem } from '~/types/notifications';
import { formatText, getPrettyDate, getURLFromString } from '~/utils/dom';
import styles from './styles.module.scss';
interface NotificationCommentProps {
item: NotificationItem;
}
const NotificationComment: FC<NotificationCommentProps> = ({ item }) => (
<Anchor href={item.url} className={styles.link}>
<div className={styles.message}>
<div className={styles.icon}>
<Square
image={getURLFromString(item.user.photo, 'avatar')}
className={styles.circle}
/>
</div>
<div className={styles.content}>
<b className={styles.title}>
<span>
<InlineUsername>{item.user.username}</InlineUsername>:
</span>
</b>
<div className={styles.text}>
<div
dangerouslySetInnerHTML={{
__html: formatText(item.text),
}}
/>
</div>
</div>
</div>
</Anchor>
);
export { NotificationComment };

View file

@ -0,0 +1,52 @@
@import 'src/styles/variables';
.link {
text-decoration: none;
color: inherit;
}
.message {
font: $font_14_regular;
line-height: 1.3em;
padding: $gap/2 $gap/2 $gap/4 $gap/2;
min-height: calc(1.3em * 3 + $gap);
display: grid;
grid-template-columns: 40px auto;
column-gap: 0;
}
.content {
background: $content_bg;
padding: 5px;
border-radius: 0 4px 4px 4px;
position: relative;
&:before {
content: ' ';
position: absolute;
top: $gap;
right: 100%;
@include arrow_left(10px, $content_bg);
}
}
.text {
@include clamp(2, 14px);
text-overflow: ellipsis;
}
.title {
font: $font_14_semibold;
margin-bottom: $gap / 2;
}
.time {
font: $font_10_regular;
text-align: right;
margin-top: 2px;
color: $gray_75;
}
.circle {
border-radius: 4px 0 0 4px;
}

View file

@ -1,32 +0,0 @@
import React, { FC, useCallback } from 'react';
import { Icon } from '~/components/input/Icon';
import styles from '~/components/notifications/NotificationBubble/styles.module.scss';
import { IMessageNotification, INotification } from '~/types';
interface IProps {
notification: IMessageNotification;
onClick: (notification: INotification) => void;
}
const NotificationMessage: FC<IProps> = ({
notification,
notification: {
content: { text, from },
},
onClick,
}) => {
const onMouseDown = useCallback(() => onClick(notification), [onClick, notification]);
return (
<div className={styles.item} onMouseDown={onMouseDown}>
<div className={styles.item_head}>
<Icon icon="message" />
<div className={styles.item_title}>Сообщение от ~{from?.username}:</div>
</div>
<div className={styles.item_text}>{text}</div>
</div>
);
};
export { NotificationMessage };

View file

@ -1,7 +0,0 @@
@import "src/styles/variables";
.scroller {
flex: 1;
overflow: auto;
padding: $gap;
}

View file

@ -0,0 +1,27 @@
import { VFC } from 'react';
import { useStackContext } from '~/components/sidebar/SidebarStack';
import { SidebarStackCard } from '~/components/sidebar/SidebarStackCard';
import { NotificationList } from '../../../containers/notifications/NotificationList/index';
interface ProfileSidebarNotificationsProps {}
const ProfileSidebarNotifications: VFC<
ProfileSidebarNotificationsProps
> = () => {
const { closeAllTabs } = useStackContext();
return (
<SidebarStackCard
width={400}
headerFeature="back"
title="Уведомления"
onBackPress={closeAllTabs}
>
<NotificationList />
</SidebarStackCard>
);
};
export { ProfileSidebarNotifications };

View file

@ -62,4 +62,8 @@ export const API = {
STATS: '/nodes/lab/stats',
UPDATES: '/nodes/lab/updates',
},
NOTIFICATIONS: {
LIST: '/notifications/',
SETTINGS: '/notifications/settings',
},
};

View file

@ -0,0 +1,34 @@
import React, { FC } from 'react';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { NotificationComment } from '~/components/notifications/NotificationComment';
import { useNotificationsList } from '~/hooks/notifications/useNotificationsList';
import styles from './styles.module.scss';
interface NotificationListProps {}
const NotificationList: FC<NotificationListProps> = () => {
const { isLoading, items } = useNotificationsList();
if (isLoading) {
return <LoaderCircle />;
}
return (
<div className={styles.grid}>
{/* <div className={styles.head}>HEAD</div> */}
<div className={styles.list}>
<div className={styles.items}>
{items?.map((item) => (
<div className={styles.item} key={item.created_at}>
<NotificationComment item={item} />
</div>
))}
</div>
</div>
</div>
);
};
export { NotificationList };

View file

@ -0,0 +1,41 @@
@import 'src/styles/variables';
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 4;
height: 100%;
}
.head {
@include row_shadow;
width: 100%;
padding: $gap;
}
.list {
@include row_shadow;
overflow-y: auto;
flex: 1 1;
overflow: auto;
width: 100%;
}
.items {
display: grid;
grid-auto-flow: row;
}
.item {
@include row_shadow;
padding-right: $gap;
transition: background-color 0.25s;
&:hover {
background-color: $content_bg_lighter;
}
}

View file

@ -2,6 +2,7 @@ import React, { useCallback, VFC } from 'react';
import classNames from 'classnames';
import { Superpower } from '~/components/boris/Superpower';
import { Filler } from '~/components/containers/Filler';
import { Group } from '~/components/containers/Group';
import { Zone } from '~/components/containers/Zone';
@ -44,7 +45,13 @@ const ProfileSidebarMenu: VFC<ProfileSidebarMenuProps> = ({ onClose }) => {
Настройки
</VerticalMenu.Item>
<VerticalMenu.Item onClick={() => setActiveTab(1)}>
<Superpower>
<VerticalMenu.Item onClick={() => setActiveTab(1)}>
Уведомления
</VerticalMenu.Item>
</Superpower>
<VerticalMenu.Item onClick={() => setActiveTab(2)}>
Заметки
</VerticalMenu.Item>
</VerticalMenu>

View file

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, VFC } from 'react';
import { CoverBackdrop } from '~/components/containers/CoverBackdrop';
import { ProfileSidebarNotes } from '~/components/profile/ProfileSidebarNotes';
import { ProfileSidebarNotifications } from '~/components/profile/ProfileSidebarNotifications';
import { ProfileSidebarSettings } from '~/components/profile/ProfileSidebarSettings';
import { SidebarStack } from '~/components/sidebar/SidebarStack';
import { SidebarStackCard } from '~/components/sidebar/SidebarStackCard';
@ -13,7 +14,7 @@ import { useUser } from '~/hooks/auth/useUser';
import type { SidebarComponentProps } from '~/types/sidebar';
import { isNil } from '~/utils/ramda';
const tabs = ['profile', 'bookmarks'] as const;
const tabs = ['profile', 'notifications', 'bookmarks'] as const;
type TabName = typeof tabs[number];
interface SettingsSidebarProps
@ -71,6 +72,7 @@ const SettingsSidebar: VFC<SettingsSidebarProps> = ({
<SidebarStack.Cards>
<ProfileSidebarSettings />
<ProfileSidebarNotifications />
<ProfileSidebarNotes />
</SidebarStack.Cards>
</SidebarStack>

View file

@ -0,0 +1,28 @@
import { isAfter, isValid, parse, parseISO } from 'date-fns';
import { useAuth } from '../auth/useAuth';
import { useNotificationSettingsRequest } from './useNotificationSettingsRequest';
export const useNotificationSettings = () => {
const { isUser } = useAuth();
const {
error: settingsError,
enabled: settingsEnabled,
lastSeen,
lastDate,
isLoading: isLoadingSettings,
} = useNotificationSettingsRequest();
const enabled = !isLoadingSettings && !settingsError && settingsEnabled;
const hasNew =
enabled && !!lastDate && (!lastSeen || isAfter(lastDate, lastSeen));
return {
enabled,
hasNew,
available: isUser,
};
};

View file

@ -0,0 +1,36 @@
import { isValid, parseISO } from 'date-fns';
import useSWR from 'swr';
import { apiGetNotificationSettings } from '~/api/notifications/settings';
import { API } from '~/constants/api';
import { useAuth } from '../auth/useAuth';
const refreshInterval = 60e3; // 1min
export const useNotificationSettingsRequest = () => {
const { isUser } = useAuth();
const {
data,
isValidating: isLoading,
error,
} = useSWR(
isUser ? API.NOTIFICATIONS.SETTINGS : null,
async () => apiGetNotificationSettings(),
{ refreshInterval },
);
return {
isLoading,
error,
lastSeen:
data?.last_seen && isValid(parseISO(data.last_seen))
? parseISO(data?.last_seen)
: undefined,
lastDate:
data?.last_date && isValid(parseISO(data.last_date))
? parseISO(data?.last_date)
: undefined,
enabled: !!data?.enabled && (data.flow || data.comments),
};
};

View file

@ -0,0 +1,20 @@
import useSWR from 'swr';
import { apiGetNotifications } from '~/api/notifications/settings';
import { API } from '~/constants/api';
import { useAuth } from '../auth/useAuth';
export const useNotificationsList = () => {
const { isUser } = useAuth();
const {
data,
isValidating: isLoading,
error,
} = useSWR(isUser ? API.NOTIFICATIONS.LIST : null, async () =>
apiGetNotifications(),
);
return { isLoading, error, ...data };
};

View file

@ -1,5 +1,3 @@
import React from 'react';
import App from 'next/app';
import Head from 'next/head';
@ -16,14 +14,15 @@ import { UserContextProvider } from '~/utils/context/UserContextProvider';
import { AudioPlayerProvider } from '~/utils/providers/AudioPlayerProvider';
import { AuthProvider } from '~/utils/providers/AuthProvider';
import { MetadataProvider } from '~/utils/providers/MetadataProvider';
import { NotificationProvider } from '~/utils/providers/NotificationProvider';
import { SWRConfigProvider } from '~/utils/providers/SWRConfigProvider';
import { SearchProvider } from '~/utils/providers/SearchProvider';
import { SidebarProvider } from '~/utils/providers/SidebarProvider';
import { ThemeProvider } from '~/utils/providers/ThemeProvider';
import { ToastProvider } from '~/utils/providers/ToastProvider';
import '~/styles/main.scss';
import 'tippy.js/dist/tippy.css';
import '~/styles/main.scss';
const mobxStore = getMOBXStore();
@ -45,26 +44,28 @@ export default class MyApp extends App {
<AudioPlayerProvider>
<MetadataProvider>
<AuthProvider>
<SidebarProvider>
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=0"
/>
<NotificationProvider>
<SidebarProvider>
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=0"
/>
{!!canonicalURL && (
<link rel="canonical" href={canonicalURL} />
)}
</Head>
{!!canonicalURL && (
<link rel="canonical" href={canonicalURL} />
)}
</Head>
<MainLayout>
<ToastProvider />
<Modal />
<Sprites />
<Component {...pageProps} />
</MainLayout>
<BottomContainer />
</SidebarProvider>
<MainLayout>
<ToastProvider />
<Modal />
<Sprites />
<Component {...pageProps} />
</MainLayout>
<BottomContainer />
</SidebarProvider>
</NotificationProvider>
</AuthProvider>
</MetadataProvider>
</AudioPlayerProvider>

View file

@ -297,3 +297,11 @@
right: -10px;
}
}
@mixin arrow_left($size: 50, $color: $content_bg) {
width: 0;
height: 0;
border-style: solid;
border-width: $size $size $size 0;
border-color: transparent $color transparent transparent;
}

View file

@ -28,3 +28,9 @@ export interface ISocialAccount {
name: string;
photo: string;
}
export interface ShallowUser {
id: number;
username: string;
photo: string;
}

View file

@ -0,0 +1,16 @@
import { ShallowUser } from '../auth';
export interface NotificationItem {
id: number;
url: string;
type: NotificationType;
text: string;
user: ShallowUser;
thumbnail: string;
created_at: string;
}
export enum NotificationType {
Node = 'node',
Comment = 'comment',
}

View file

@ -88,10 +88,8 @@ export const getURL = (
return file?.url ? getURLFromString(file.url, size) : '';
};
export const formatText = pipe(
formatTextSanitizeYoutube,
export const formatTextWithoutImages = pipe(
formatTextComments,
formatTextTodos,
formatExclamations,
formatTextDash,
formatTextMarkdown,
@ -99,6 +97,12 @@ export const formatText = pipe(
formatTextClickableUsernames,
);
export const formatText = pipe(
formatTextSanitizeYoutube,
formatTextTodos,
formatTextWithoutImages,
);
export const formatTextParagraphs = (text: string): string =>
(text && formatText(text)) || '';

View file

@ -0,0 +1,31 @@
import { createContext, FC, useContext } from 'react';
import { observer } from 'mobx-react-lite';
import { useNotificationSettings } from '~/hooks/notifications/useNotificationSettings';
interface NotificationProviderProps {}
const defaultValue = {
available: false,
enabled: false,
hasNew: false,
};
const NotificationContext = createContext(defaultValue);
const NotificationProvider: FC<NotificationProviderProps> = observer(
({ children }) => {
const value = useNotificationSettings();
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
},
);
export const useNotifications = () => useContext(NotificationContext);
export { NotificationProvider };