Feature/infinite scroll (#290)

notifications infinity scroll
This commit is contained in:
Ilya Y 2023-11-01 18:13:54 +03:00 committed by GitHub
parent 56252046c1
commit 1e0e31cf09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 233 additions and 77 deletions

View File

@ -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",

View File

@ -209,6 +209,7 @@
"Manifest": "Манифест",
"Manifesto": "Манифест",
"Many files, choose only one": "Много файлов, выберете один",
"Mark as read": "Отметить прочитанным",
"Material card": "Карточка материала",
"Message": "Написать",
"More": "Ещё",

View File

@ -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;

View File

@ -53,7 +53,7 @@ type Props = {
onlyBubbleControls?: boolean
controlsAlwaysVisible?: boolean
autoFocus?: boolean
isCancelButtonVisible: boolean
isCancelButtonVisible?: boolean
}
export const MAX_DESCRIPTION_LIMIT = 400

View File

@ -5,6 +5,7 @@
font-size: 15px;
line-height: 24px;
white-space: pre-line;
padding: 4rem 0;
}
.title {

View File

@ -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;

View File

@ -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;

View File

@ -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 (
<div
class={clsx(styles.container, {
@ -103,46 +166,72 @@ export const NotificationsPanel = (props: Props) => {
<Icon name="close" />
</div>
<div class={styles.title}>{t('Notifications')}</div>
<Show when={sortedNotifications().length > 0} fallback={<EmptyMessage />}>
<Show when={todayNotifications().length > 0}>
<div class={styles.periodTitle}>{t('today')}</div>
<For each={todayNotifications()}>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
dateTimeFormat={'ago'}
/>
)}
</For>
<div class={clsx('wide-container', styles.content)} ref={(el) => (scrollContainerRef.current = el)}>
<Show
when={sortedNotifications().length > 0}
fallback={
<Show when={!isLoading()}>
<EmptyMessage />
</Show>
}
>
<div class="row position-relative">
<div class="col-xs-24">
<Show when={todayNotifications().length > 0}>
<div class={styles.periodTitle}>{t('today')}</div>
<For each={todayNotifications()}>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
dateTimeFormat={'ago'}
/>
)}
</For>
</Show>
<Show when={yesterdayNotifications().length > 0}>
<div class={styles.periodTitle}>{t('yesterday')}</div>
<For each={yesterdayNotifications()}>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
dateTimeFormat={'time'}
/>
)}
</For>
</Show>
<Show when={earlierNotifications().length > 0}>
<div class={styles.periodTitle}>{t('earlier')}</div>
<For each={earlierNotifications()}>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
dateTimeFormat={'date'}
/>
)}
</For>
</Show>
</div>
</div>
</Show>
<Show when={yesterdayNotifications().length > 0}>
<div class={styles.periodTitle}>{t('yesterday')}</div>
<For each={yesterdayNotifications()}>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
dateTimeFormat={'time'}
/>
)}
</For>
</Show>
<Show when={earlierNotifications().length > 0}>
<div class={styles.periodTitle}>{t('earlier')}</div>
<For each={earlierNotifications()}>
{(notification) => (
<NotificationView
notification={notification}
class={styles.notificationView}
onClick={handleNotificationViewClick}
dateTimeFormat={'date'}
/>
)}
</For>
<Show when={isLoading()}>
<div class={styles.loading}>{t('Loading')}</div>
</Show>
</div>
<Show when={unreadNotificationsCount() > 0}>
<div class={styles.actions}>
<Button
onClick={() => markAllNotificationsAsRead()}
variant="secondary"
value={t('Mark as read')}
/>
</div>
</Show>
</div>
</div>

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -14,13 +14,18 @@ type NotificationsContextType = {
notificationEntities: Record<number, Notification>
unreadNotificationsCount: Accessor<number>
sortedNotifications: Accessor<Notification[]>
loadedNotificationsCount: Accessor<number>
totalNotificationsCount: Accessor<number>
actions: {
showNotificationsPanel: () => void
hideNotificationsPanel: () => void
markNotificationAsRead: (notification: Notification) => Promise<void>
markAllNotificationsAsRead: () => Promise<void>
loadNotifications: (options: { limit: number; offset: number }) => Promise<Notification[]>
}
}
export const PAGE_SIZE = 20
const NotificationsContext = createContext<NotificationsContextType>()
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<Record<number, Notification>>({})
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
}

View File

@ -0,0 +1,9 @@
import { gql } from '@urql/core'
export default gql`
mutation MarkAllNotificationsAsReadMutation {
markAllNotificationsAsRead {
error
}
}
`

View File

@ -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

View File

@ -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<NotificationsQueryResult> => {
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<void> => {
@ -368,6 +367,10 @@ export const apiClient = {
.toPromise()
},
markAllNotificationsAsRead: async (): Promise<void> => {
await privateGraphQLClient.mutation(markAllNotificationsAsRead, {}).toPromise()
},
getMySubscriptions: async (): Promise<MySubscriptionsQueryResult> => {
const resp = await privateGraphQLClient.query(mySubscriptions, {}).toPromise()
// console.debug(resp.data)