parent
56252046c1
commit
1e0e31cf09
|
@ -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",
|
||||
|
|
|
@ -209,6 +209,7 @@
|
|||
"Manifest": "Манифест",
|
||||
"Manifesto": "Манифест",
|
||||
"Many files, choose only one": "Много файлов, выберете один",
|
||||
"Mark as read": "Отметить прочитанным",
|
||||
"Material card": "Карточка материала",
|
||||
"Message": "Написать",
|
||||
"More": "Ещё",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -53,7 +53,7 @@ type Props = {
|
|||
onlyBubbleControls?: boolean
|
||||
controlsAlwaysVisible?: boolean
|
||||
autoFocus?: boolean
|
||||
isCancelButtonVisible: boolean
|
||||
isCancelButtonVisible?: boolean
|
||||
}
|
||||
|
||||
export const MAX_DESCRIPTION_LIMIT = 400
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
white-space: pre-line;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
9
src/graphql/mutation/mark-all-notifications-as-read.ts
Normal file
9
src/graphql/mutation/mark-all-notifications-as-read.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
mutation MarkAllNotificationsAsReadMutation {
|
||||
markAllNotificationsAsRead {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue
Block a user