diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 97824a78..6808b96c 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -200,6 +200,7 @@ "Manifest": "Manifest", "Manifesto": "Manifesto", "Many files, choose only one": "Many files, choose only one", + "Mark as read": "Mark as read", "Material card": "Material card", "Message": "Message", "More": "More", diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 87576d99..9f7333f8 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -209,6 +209,7 @@ "Manifest": "Манифест", "Manifesto": "Манифест", "Many files, choose only one": "Много файлов, выберете один", + "Mark as read": "Отметить прочитанным", "Material card": "Карточка материала", "Message": "Написать", "More": "Ещё", diff --git a/src/components/Author/Userpic/Userpic.module.scss b/src/components/Author/Userpic/Userpic.module.scss index cfa5ef80..f72c1c1d 100644 --- a/src/components/Author/Userpic/Userpic.module.scss +++ b/src/components/Author/Userpic/Userpic.module.scss @@ -75,6 +75,15 @@ } } + &.L { + height: 40px; + width: 40px; + min-width: 40px; + .letters { + font-size: 1.2rem; + } + } + &.XL { aspect-ratio: 1/1; margin: 0 auto 1rem; diff --git a/src/components/Editor/SimplifiedEditor.tsx b/src/components/Editor/SimplifiedEditor.tsx index 534a28ad..2e29f7d4 100644 --- a/src/components/Editor/SimplifiedEditor.tsx +++ b/src/components/Editor/SimplifiedEditor.tsx @@ -53,7 +53,7 @@ type Props = { onlyBubbleControls?: boolean controlsAlwaysVisible?: boolean autoFocus?: boolean - isCancelButtonVisible: boolean + isCancelButtonVisible?: boolean } export const MAX_DESCRIPTION_LIMIT = 400 diff --git a/src/components/NotificationsPanel/EmptyMessage/EmptyMessage.module.scss b/src/components/NotificationsPanel/EmptyMessage/EmptyMessage.module.scss index b1484a0d..947b9ada 100644 --- a/src/components/NotificationsPanel/EmptyMessage/EmptyMessage.module.scss +++ b/src/components/NotificationsPanel/EmptyMessage/EmptyMessage.module.scss @@ -5,6 +5,7 @@ font-size: 15px; line-height: 24px; white-space: pre-line; + padding: 4rem 0; } .title { diff --git a/src/components/NotificationsPanel/NotificationView/NotificationView.module.scss b/src/components/NotificationsPanel/NotificationView/NotificationView.module.scss index 3c3aaf77..3a326439 100644 --- a/src/components/NotificationsPanel/NotificationView/NotificationView.module.scss +++ b/src/components/NotificationsPanel/NotificationView/NotificationView.module.scss @@ -1,17 +1,19 @@ .NotificationView { + @include font-size(1.5rem); + display: flex; - align-items: center; - height: 72px; + align-items: flex-start; + min-height: 72px; margin-left: -16px; border-radius: 16px; padding: 16px; background-color: var(--yellow-50); // TODO: check markup - font-size: 15px; // font-weight: 700; line-height: 20px; cursor: pointer; transition: background-color 100ms; + max-width: 700px; &.seen { background-color: transparent; diff --git a/src/components/NotificationsPanel/NotificationsPanel.module.scss b/src/components/NotificationsPanel/NotificationsPanel.module.scss index ee997569..26be7838 100644 --- a/src/components/NotificationsPanel/NotificationsPanel.module.scss +++ b/src/components/NotificationsPanel/NotificationsPanel.module.scss @@ -10,7 +10,6 @@ $transition-duration: 200ms; bottom: 0; width: 0; z-index: 10000; - background-color: rgb(0 0 0 / 0%); overflow: hidden; transition: background-color $transition-duration, @@ -18,12 +17,49 @@ $transition-duration: 200ms; .panel { position: relative; - background-color: #fff; - width: 700px; - padding: 48px 96px 96px 48px; + background-color: var(--background-color); + width: 50%; + height: 100%; transform: translateX(100%); transition: transform $transition-duration; - overflow-y: auto; + display: flex; + flex-direction: column; + + .title { + @include font-size(2rem); + + color: var(--black-500); + font-style: normal; + font-weight: 700; + line-height: 36px; + position: sticky; + top: 0; + padding: 16px 38px; + border-bottom: 1px solid var(--black-100); + } + + .content { + overflow-y: auto; + flex: 1; + padding: 0 38px 1rem; + + .loading { + @include font-size(1.2rem); + + text-align: center; + padding: 1rem; + color: var(--black-300); + } + } + + .actions { + padding: 24px 38px; + width: 100%; + bottom: 0; + left: 0; + background: var(--background-color); + border-top: 1px solid var(--black-100); + } } &.isOpened { @@ -39,16 +75,6 @@ $transition-duration: 200ms; } } -.title { - // TODO: check markup - color: var(--black-500, #141414); - font-size: 32px; - font-style: normal; - font-weight: 700; - line-height: 36px; - margin-bottom: 32px; -} - .closeButton { position: absolute; top: 0; diff --git a/src/components/NotificationsPanel/NotificationsPanel.tsx b/src/components/NotificationsPanel/NotificationsPanel.tsx index 3c7c09fa..3c772a93 100644 --- a/src/components/NotificationsPanel/NotificationsPanel.tsx +++ b/src/components/NotificationsPanel/NotificationsPanel.tsx @@ -4,10 +4,13 @@ import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler' import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler' import { useLocalize } from '../../context/localize' import { Icon } from '../_shared/Icon' -import { createEffect, createMemo, For, Show } from 'solid-js' -import { useNotifications } from '../../context/notifications' +import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js' +import { PAGE_SIZE, useNotifications } from '../../context/notifications' import { NotificationView } from './NotificationView' import { EmptyMessage } from './EmptyMessage' +import { Button } from '../_shared/Button' +import throttle from 'just-throttle' +import { useSession } from '../../context/session' type Props = { isOpen: boolean @@ -39,8 +42,17 @@ const isEarlier = (date: Date) => { } export const NotificationsPanel = (props: Props) => { + const [isLoading, setIsLoading] = createSignal(false) + + const { isAuthenticated } = useSession() const { t } = useLocalize() - const { sortedNotifications } = useNotifications() + const { + sortedNotifications, + unreadNotificationsCount, + loadedNotificationsCount, + totalNotificationsCount, + actions: { loadNotifications, markAllNotificationsAsRead } + } = useNotifications() const handleHide = () => { props.onClose() } @@ -91,6 +103,57 @@ export const NotificationsPanel = (props: Props) => { return sortedNotifications().filter((notification) => isEarlier(new Date(notification.createdAt))) }) + const scrollContainerRef: { current: HTMLDivElement } = { current: null } + const loadNextPage = async () => { + await loadNotifications({ limit: PAGE_SIZE, offset: loadedNotificationsCount() }) + if (loadedNotificationsCount() < totalNotificationsCount()) { + const hasMore = scrollContainerRef.current.scrollHeight <= scrollContainerRef.current.offsetHeight + + if (hasMore) { + await loadNextPage() + } + } + } + const handleScroll = async () => { + if (!scrollContainerRef.current || isLoading()) { + return + } + if (totalNotificationsCount() === loadedNotificationsCount()) { + return + } + + const isNearBottom = + scrollContainerRef.current.scrollHeight - scrollContainerRef.current.scrollTop <= + scrollContainerRef.current.clientHeight * 1.5 + + if (isNearBottom) { + setIsLoading(true) + await loadNextPage() + setIsLoading(false) + } + } + const handleScrollThrottled = throttle(handleScroll, 50) + + onMount(() => { + scrollContainerRef.current.addEventListener('scroll', handleScrollThrottled) + onCleanup(() => { + scrollContainerRef.current.removeEventListener('scroll', handleScrollThrottled) + }) + }) + + createEffect( + on( + () => isAuthenticated(), + async () => { + if (isAuthenticated()) { + setIsLoading(true) + await loadNextPage() + setIsLoading(false) + } + } + ) + ) + return (
{
{t('Notifications')}
- 0} fallback={}> - 0}> -
{t('today')}
- - {(notification) => ( - - )} - +
(scrollContainerRef.current = el)}> + 0} + fallback={ + + + + } + > +
+
+ 0}> +
{t('today')}
+ + {(notification) => ( + + )} + +
+ 0}> +
{t('yesterday')}
+ + {(notification) => ( + + )} + +
+ 0}> +
{t('earlier')}
+ + {(notification) => ( + + )} + +
+
+
- 0}> -
{t('yesterday')}
- - {(notification) => ( - - )} - -
- 0}> -
{t('earlier')}
- - {(notification) => ( - - )} - + +
{t('Loading')}
+
+ + 0}> +
+
diff --git a/src/components/Views/Home.tsx b/src/components/Views/Home.tsx index 5a6144a8..c9b15cea 100644 --- a/src/components/Views/Home.tsx +++ b/src/components/Views/Home.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js' +import { createMemo, createSignal, For, onMount, Show } from 'solid-js' import Banner from '../Discours/Banner' import { Topics } from '../Nav/Topics' import { Row5 } from '../Feed/Row5' diff --git a/src/components/_shared/GroupAvatar/GroupAvatar.tsx b/src/components/_shared/GroupAvatar/GroupAvatar.tsx index 69c558b2..a0e83bb0 100644 --- a/src/components/_shared/GroupAvatar/GroupAvatar.tsx +++ b/src/components/_shared/GroupAvatar/GroupAvatar.tsx @@ -14,7 +14,7 @@ export const GroupAvatar = (props: Props) => { const avatarSize = () => { switch (props.authors.length) { case 1: { - return 'L' + return 'M' } case 2: { return 'S' diff --git a/src/components/_shared/Image/Image.tsx b/src/components/_shared/Image/Image.tsx index bf2ce8f3..b0a0c9a2 100644 --- a/src/components/_shared/Image/Image.tsx +++ b/src/components/_shared/Image/Image.tsx @@ -1,4 +1,4 @@ -import { createMemo, splitProps } from 'solid-js' +import { splitProps } from 'solid-js' import type { JSX } from 'solid-js' import { getImageUrl } from '../../../utils/getImageUrl' diff --git a/src/context/notifications.tsx b/src/context/notifications.tsx index 15061b77..0501e24d 100644 --- a/src/context/notifications.tsx +++ b/src/context/notifications.tsx @@ -14,13 +14,18 @@ type NotificationsContextType = { notificationEntities: Record unreadNotificationsCount: Accessor sortedNotifications: Accessor + loadedNotificationsCount: Accessor + totalNotificationsCount: Accessor actions: { showNotificationsPanel: () => void hideNotificationsPanel: () => void markNotificationAsRead: (notification: Notification) => Promise + markAllNotificationsAsRead: () => Promise + loadNotifications: (options: { limit: number; offset: number }) => Promise } } +export const PAGE_SIZE = 20 const NotificationsContext = createContext() export function useNotifications() { @@ -32,18 +37,18 @@ const sseService = new SSEService() export const NotificationsProvider = (props: { children: JSX.Element }) => { const [isNotificationsPanelOpen, setIsNotificationsPanelOpen] = createSignal(false) const [unreadNotificationsCount, setUnreadNotificationsCount] = createSignal(0) + const [totalNotificationsCount, setTotalNotificationsCount] = createSignal(0) const { isAuthenticated, user } = useSession() const [notificationEntities, setNotificationEntities] = createStore>({}) - const loadNotifications = async () => { - const { notifications, totalUnreadCount } = await apiClient.getNotifications({ - limit: 100 - }) + const loadNotifications = async (options: { limit: number; offset?: number }) => { + const { notifications, totalUnreadCount, totalCount } = await apiClient.getNotifications(options) const newNotificationEntities = notifications.reduce((acc, notification) => { acc[notification.id] = notification return acc }, {}) + setTotalNotificationsCount(totalCount) setUnreadNotificationsCount(totalUnreadCount) setNotificationEntities(newNotificationEntities) return notifications @@ -55,14 +60,13 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => { ) }) + const loadedNotificationsCount = createMemo(() => Object.keys(notificationEntities).length) createEffect(() => { if (isAuthenticated()) { - loadNotifications() - sseService.connect(`${apiBaseUrl}/subscribe/${user().id}`) sseService.subscribeToEvent('message', (data: EventData) => { if (data.type === 'newNotifications') { - loadNotifications() + loadNotifications({ limit: loadedNotificationsCount() }) } else { console.error(`[NotificationsProvider] unknown message type: ${JSON.stringify(data)}`) } @@ -74,7 +78,10 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => { const markNotificationAsRead = async (notification: Notification) => { await apiClient.markNotificationAsRead(notification.id) - loadNotifications() + } + const markAllNotificationsAsRead = async () => { + await apiClient.markAllNotificationsAsRead() + loadNotifications({ limit: loadedNotificationsCount() }) } const showNotificationsPanel = () => { @@ -85,12 +92,20 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => { setIsNotificationsPanelOpen(false) } - const actions = { showNotificationsPanel, hideNotificationsPanel, markNotificationAsRead } + const actions = { + showNotificationsPanel, + hideNotificationsPanel, + markNotificationAsRead, + markAllNotificationsAsRead, + loadNotifications + } const value: NotificationsContextType = { notificationEntities, sortedNotifications, unreadNotificationsCount, + loadedNotificationsCount, + totalNotificationsCount, actions } diff --git a/src/graphql/mutation/mark-all-notifications-as-read.ts b/src/graphql/mutation/mark-all-notifications-as-read.ts new file mode 100644 index 00000000..a02643d2 --- /dev/null +++ b/src/graphql/mutation/mark-all-notifications-as-read.ts @@ -0,0 +1,9 @@ +import { gql } from '@urql/core' + +export default gql` + mutation MarkAllNotificationsAsReadMutation { + markAllNotificationsAsRead { + error + } + } +` diff --git a/src/graphql/query/notifications.ts b/src/graphql/query/notifications.ts index a1951b0f..6538e931 100644 --- a/src/graphql/query/notifications.ts +++ b/src/graphql/query/notifications.ts @@ -1,8 +1,8 @@ import { gql } from '@urql/core' export default gql` - query LoadNotificationsQuery { - loadNotifications(params: { limit: 10, offset: 0 }) { + query LoadNotificationsQuery($params: NotificationsQueryParams!) { + loadNotifications(params: $params) { notifications { id shout diff --git a/src/utils/apiClient.ts b/src/utils/apiClient.ts index 134d4658..eab422be 100644 --- a/src/utils/apiClient.ts +++ b/src/utils/apiClient.ts @@ -59,6 +59,7 @@ import updateArticle from '../graphql/mutation/article-update' import deleteShout from '../graphql/mutation/article-delete' import notifications from '../graphql/query/notifications' import markNotificationAsRead from '../graphql/mutation/mark-notification-as-read' +import markAllNotificationsAsRead from '../graphql/mutation/mark-all-notifications-as-read' import mySubscriptions from '../graphql/query/my-subscriptions' type ApiErrorCode = @@ -352,12 +353,10 @@ export const apiClient = { const resp = await publicGraphQLClient .query(reactionsLoadBy, { by, limit: limit ?? 1000, offset: 0 }) .toPromise() - // console.debug(resp) return resp.data.loadReactionsBy }, getNotifications: async (params: NotificationsQueryParams): Promise => { - const resp = await privateGraphQLClient.query(notifications, params).toPromise() - // console.debug(resp.data) + const resp = await privateGraphQLClient.query(notifications, { params }).toPromise() return resp.data.loadNotifications }, markNotificationAsRead: async (notificationId: number): Promise => { @@ -368,6 +367,10 @@ export const apiClient = { .toPromise() }, + markAllNotificationsAsRead: async (): Promise => { + await privateGraphQLClient.mutation(markAllNotificationsAsRead, {}).toPromise() + }, + getMySubscriptions: async (): Promise => { const resp = await privateGraphQLClient.query(mySubscriptions, {}).toPromise() // console.debug(resp.data)