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", "Manifest": "Manifest",
"Manifesto": "Manifesto", "Manifesto": "Manifesto",
"Many files, choose only one": "Many files, choose only one", "Many files, choose only one": "Many files, choose only one",
"Mark as read": "Mark as read",
"Material card": "Material card", "Material card": "Material card",
"Message": "Message", "Message": "Message",
"More": "More", "More": "More",

View File

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

View File

@ -75,6 +75,15 @@
} }
} }
&.L {
height: 40px;
width: 40px;
min-width: 40px;
.letters {
font-size: 1.2rem;
}
}
&.XL { &.XL {
aspect-ratio: 1/1; aspect-ratio: 1/1;
margin: 0 auto 1rem; margin: 0 auto 1rem;

View File

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

View File

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

View File

@ -1,17 +1,19 @@
.NotificationView { .NotificationView {
@include font-size(1.5rem);
display: flex; display: flex;
align-items: center; align-items: flex-start;
height: 72px; min-height: 72px;
margin-left: -16px; margin-left: -16px;
border-radius: 16px; border-radius: 16px;
padding: 16px; padding: 16px;
background-color: var(--yellow-50); background-color: var(--yellow-50);
// TODO: check markup // TODO: check markup
font-size: 15px;
// font-weight: 700; // font-weight: 700;
line-height: 20px; line-height: 20px;
cursor: pointer; cursor: pointer;
transition: background-color 100ms; transition: background-color 100ms;
max-width: 700px;
&.seen { &.seen {
background-color: transparent; background-color: transparent;

View File

@ -10,7 +10,6 @@ $transition-duration: 200ms;
bottom: 0; bottom: 0;
width: 0; width: 0;
z-index: 10000; z-index: 10000;
background-color: rgb(0 0 0 / 0%);
overflow: hidden; overflow: hidden;
transition: transition:
background-color $transition-duration, background-color $transition-duration,
@ -18,12 +17,49 @@ $transition-duration: 200ms;
.panel { .panel {
position: relative; position: relative;
background-color: #fff; background-color: var(--background-color);
width: 700px; width: 50%;
padding: 48px 96px 96px 48px; height: 100%;
transform: translateX(100%); transform: translateX(100%);
transition: transform $transition-duration; 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 { &.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 { .closeButton {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -4,10 +4,13 @@ import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler'
import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler' import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { createEffect, createMemo, For, Show } from 'solid-js' import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js'
import { useNotifications } from '../../context/notifications' import { PAGE_SIZE, useNotifications } from '../../context/notifications'
import { NotificationView } from './NotificationView' import { NotificationView } from './NotificationView'
import { EmptyMessage } from './EmptyMessage' import { EmptyMessage } from './EmptyMessage'
import { Button } from '../_shared/Button'
import throttle from 'just-throttle'
import { useSession } from '../../context/session'
type Props = { type Props = {
isOpen: boolean isOpen: boolean
@ -39,8 +42,17 @@ const isEarlier = (date: Date) => {
} }
export const NotificationsPanel = (props: Props) => { export const NotificationsPanel = (props: Props) => {
const [isLoading, setIsLoading] = createSignal(false)
const { isAuthenticated } = useSession()
const { t } = useLocalize() const { t } = useLocalize()
const { sortedNotifications } = useNotifications() const {
sortedNotifications,
unreadNotificationsCount,
loadedNotificationsCount,
totalNotificationsCount,
actions: { loadNotifications, markAllNotificationsAsRead }
} = useNotifications()
const handleHide = () => { const handleHide = () => {
props.onClose() props.onClose()
} }
@ -91,6 +103,57 @@ export const NotificationsPanel = (props: Props) => {
return sortedNotifications().filter((notification) => isEarlier(new Date(notification.createdAt))) 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 ( return (
<div <div
class={clsx(styles.container, { class={clsx(styles.container, {
@ -103,46 +166,72 @@ export const NotificationsPanel = (props: Props) => {
<Icon name="close" /> <Icon name="close" />
</div> </div>
<div class={styles.title}>{t('Notifications')}</div> <div class={styles.title}>{t('Notifications')}</div>
<Show when={sortedNotifications().length > 0} fallback={<EmptyMessage />}> <div class={clsx('wide-container', styles.content)} ref={(el) => (scrollContainerRef.current = el)}>
<Show when={todayNotifications().length > 0}> <Show
<div class={styles.periodTitle}>{t('today')}</div> when={sortedNotifications().length > 0}
<For each={todayNotifications()}> fallback={
{(notification) => ( <Show when={!isLoading()}>
<NotificationView <EmptyMessage />
notification={notification} </Show>
class={styles.notificationView} }
onClick={handleNotificationViewClick} >
dateTimeFormat={'ago'} <div class="row position-relative">
/> <div class="col-xs-24">
)} <Show when={todayNotifications().length > 0}>
</For> <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>
<Show when={yesterdayNotifications().length > 0}> <Show when={isLoading()}>
<div class={styles.periodTitle}>{t('yesterday')}</div> <div class={styles.loading}>{t('Loading')}</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> </Show>
</div>
<Show when={unreadNotificationsCount() > 0}>
<div class={styles.actions}>
<Button
onClick={() => markAllNotificationsAsRead()}
variant="secondary"
value={t('Mark as read')}
/>
</div>
</Show> </Show>
</div> </div>
</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 Banner from '../Discours/Banner'
import { Topics } from '../Nav/Topics' import { Topics } from '../Nav/Topics'
import { Row5 } from '../Feed/Row5' import { Row5 } from '../Feed/Row5'

View File

@ -14,7 +14,7 @@ export const GroupAvatar = (props: Props) => {
const avatarSize = () => { const avatarSize = () => {
switch (props.authors.length) { switch (props.authors.length) {
case 1: { case 1: {
return 'L' return 'M'
} }
case 2: { case 2: {
return 'S' 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 type { JSX } from 'solid-js'
import { getImageUrl } from '../../../utils/getImageUrl' import { getImageUrl } from '../../../utils/getImageUrl'

View File

@ -14,13 +14,18 @@ type NotificationsContextType = {
notificationEntities: Record<number, Notification> notificationEntities: Record<number, Notification>
unreadNotificationsCount: Accessor<number> unreadNotificationsCount: Accessor<number>
sortedNotifications: Accessor<Notification[]> sortedNotifications: Accessor<Notification[]>
loadedNotificationsCount: Accessor<number>
totalNotificationsCount: Accessor<number>
actions: { actions: {
showNotificationsPanel: () => void showNotificationsPanel: () => void
hideNotificationsPanel: () => void hideNotificationsPanel: () => void
markNotificationAsRead: (notification: Notification) => Promise<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>() const NotificationsContext = createContext<NotificationsContextType>()
export function useNotifications() { export function useNotifications() {
@ -32,18 +37,18 @@ const sseService = new SSEService()
export const NotificationsProvider = (props: { children: JSX.Element }) => { export const NotificationsProvider = (props: { children: JSX.Element }) => {
const [isNotificationsPanelOpen, setIsNotificationsPanelOpen] = createSignal(false) const [isNotificationsPanelOpen, setIsNotificationsPanelOpen] = createSignal(false)
const [unreadNotificationsCount, setUnreadNotificationsCount] = createSignal(0) const [unreadNotificationsCount, setUnreadNotificationsCount] = createSignal(0)
const [totalNotificationsCount, setTotalNotificationsCount] = createSignal(0)
const { isAuthenticated, user } = useSession() const { isAuthenticated, user } = useSession()
const [notificationEntities, setNotificationEntities] = createStore<Record<number, Notification>>({}) const [notificationEntities, setNotificationEntities] = createStore<Record<number, Notification>>({})
const loadNotifications = async () => { const loadNotifications = async (options: { limit: number; offset?: number }) => {
const { notifications, totalUnreadCount } = await apiClient.getNotifications({ const { notifications, totalUnreadCount, totalCount } = await apiClient.getNotifications(options)
limit: 100
})
const newNotificationEntities = notifications.reduce((acc, notification) => { const newNotificationEntities = notifications.reduce((acc, notification) => {
acc[notification.id] = notification acc[notification.id] = notification
return acc return acc
}, {}) }, {})
setTotalNotificationsCount(totalCount)
setUnreadNotificationsCount(totalUnreadCount) setUnreadNotificationsCount(totalUnreadCount)
setNotificationEntities(newNotificationEntities) setNotificationEntities(newNotificationEntities)
return notifications return notifications
@ -55,14 +60,13 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
) )
}) })
const loadedNotificationsCount = createMemo(() => Object.keys(notificationEntities).length)
createEffect(() => { createEffect(() => {
if (isAuthenticated()) { if (isAuthenticated()) {
loadNotifications()
sseService.connect(`${apiBaseUrl}/subscribe/${user().id}`) sseService.connect(`${apiBaseUrl}/subscribe/${user().id}`)
sseService.subscribeToEvent('message', (data: EventData) => { sseService.subscribeToEvent('message', (data: EventData) => {
if (data.type === 'newNotifications') { if (data.type === 'newNotifications') {
loadNotifications() loadNotifications({ limit: loadedNotificationsCount() })
} else { } else {
console.error(`[NotificationsProvider] unknown message type: ${JSON.stringify(data)}`) 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) => { const markNotificationAsRead = async (notification: Notification) => {
await apiClient.markNotificationAsRead(notification.id) await apiClient.markNotificationAsRead(notification.id)
loadNotifications() }
const markAllNotificationsAsRead = async () => {
await apiClient.markAllNotificationsAsRead()
loadNotifications({ limit: loadedNotificationsCount() })
} }
const showNotificationsPanel = () => { const showNotificationsPanel = () => {
@ -85,12 +92,20 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
setIsNotificationsPanelOpen(false) setIsNotificationsPanelOpen(false)
} }
const actions = { showNotificationsPanel, hideNotificationsPanel, markNotificationAsRead } const actions = {
showNotificationsPanel,
hideNotificationsPanel,
markNotificationAsRead,
markAllNotificationsAsRead,
loadNotifications
}
const value: NotificationsContextType = { const value: NotificationsContextType = {
notificationEntities, notificationEntities,
sortedNotifications, sortedNotifications,
unreadNotificationsCount, unreadNotificationsCount,
loadedNotificationsCount,
totalNotificationsCount,
actions 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' import { gql } from '@urql/core'
export default gql` export default gql`
query LoadNotificationsQuery { query LoadNotificationsQuery($params: NotificationsQueryParams!) {
loadNotifications(params: { limit: 10, offset: 0 }) { loadNotifications(params: $params) {
notifications { notifications {
id id
shout shout

View File

@ -59,6 +59,7 @@ import updateArticle from '../graphql/mutation/article-update'
import deleteShout from '../graphql/mutation/article-delete' import deleteShout from '../graphql/mutation/article-delete'
import notifications from '../graphql/query/notifications' import notifications from '../graphql/query/notifications'
import markNotificationAsRead from '../graphql/mutation/mark-notification-as-read' 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' import mySubscriptions from '../graphql/query/my-subscriptions'
type ApiErrorCode = type ApiErrorCode =
@ -352,12 +353,10 @@ export const apiClient = {
const resp = await publicGraphQLClient const resp = await publicGraphQLClient
.query(reactionsLoadBy, { by, limit: limit ?? 1000, offset: 0 }) .query(reactionsLoadBy, { by, limit: limit ?? 1000, offset: 0 })
.toPromise() .toPromise()
// console.debug(resp)
return resp.data.loadReactionsBy return resp.data.loadReactionsBy
}, },
getNotifications: async (params: NotificationsQueryParams): Promise<NotificationsQueryResult> => { getNotifications: async (params: NotificationsQueryParams): Promise<NotificationsQueryResult> => {
const resp = await privateGraphQLClient.query(notifications, params).toPromise() const resp = await privateGraphQLClient.query(notifications, { params }).toPromise()
// console.debug(resp.data)
return resp.data.loadNotifications return resp.data.loadNotifications
}, },
markNotificationAsRead: async (notificationId: number): Promise<void> => { markNotificationAsRead: async (notificationId: number): Promise<void> => {
@ -368,6 +367,10 @@ export const apiClient = {
.toPromise() .toPromise()
}, },
markAllNotificationsAsRead: async (): Promise<void> => {
await privateGraphQLClient.mutation(markAllNotificationsAsRead, {}).toPromise()
},
getMySubscriptions: async (): Promise<MySubscriptionsQueryResult> => { getMySubscriptions: async (): Promise<MySubscriptionsQueryResult> => {
const resp = await privateGraphQLClient.query(mySubscriptions, {}).toPromise() const resp = await privateGraphQLClient.query(mySubscriptions, {}).toPromise()
// console.debug(resp.data) // console.debug(resp.data)