grouped-notifications

This commit is contained in:
Untone 2023-12-22 20:34:50 +03:00
parent 43b3de572b
commit d11a50c2a3
11 changed files with 117 additions and 173 deletions

View File

@ -1,11 +1,10 @@
import { getPagePath, openPage } from '@nanostores/router'
import { clsx } from 'clsx'
import { createEffect, For, Show } from 'solid-js'
import { For, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useNotifications } from '../../../context/notifications'
import { Reaction } from '../../../graphql/schema/core.gen'
import { Notification } from '../../../graphql/schema/notifier.gen'
import { NotificationGroup as Group } from '../../../graphql/schema/notifier.gen'
import { useRouter, router } from '../../../stores/router'
import { GroupAvatar } from '../../_shared/GroupAvatar'
import { TimeAgo } from '../../_shared/TimeAgo'
@ -14,7 +13,7 @@ import { ArticlePageSearchParams } from '../../Article/FullArticle'
import styles from './NotificationView.module.scss'
type NotificationGroupProps = {
notifications: Notification[]
notifications: Group[]
onClick: () => void
dateTimeFormat: 'ago' | 'time' | 'date'
class?: string
@ -41,101 +40,23 @@ const getTitle = (title: string) => {
}
const reactionsCaption = (threadId: string) =>
threadId.includes('__') ? 'Some new replies to your comment' : 'Some new comments to your publication'
threadId.includes('::') ? 'Some new replies to your comment' : 'Some new comments to your publication'
export const NotificationGroup = (props: NotificationGroupProps) => {
const { t, formatTime, formatDate } = useLocalize()
const { changeSearchParam } = useRouter<ArticlePageSearchParams>()
const {
actions: { hideNotificationsPanel, markNotificationAsRead },
actions: { hideNotificationsPanel, markSeenThread },
} = useNotifications()
const threads = new Map<string, Reaction[]>()
const notificationsByThread = new Map<string, Notification[]>()
const handleClick = (threadId: string) => {
props.onClick()
notificationsByThread.get(threadId).forEach((n: Notification) => {
if (!n.seen) markNotificationAsRead(n)
})
const threadParts = threadId.replace('::seen', '').split('__')
openPage(router, 'article', { slug: threadParts[0] })
if (threadParts.length > 1) {
changeSearchParam({ commentId: threadParts[1] })
}
markSeenThread(threadId)
const [slug, commentId] = threadId.split('::')
openPage(router, 'article', { slug })
if (commentId) changeSearchParam({ commentId })
}
createEffect(() => {
const threadsLatest = {}
// count occurencies and sort reactions by threads
props.notifications.forEach((n: Notification) => {
const reaction = JSON.parse(n.payload)
const slug = reaction.shout.slug
const timestamp = reaction.created_at
// threadId never shows up and looks like <slug>-<reaction_id>
const threadId = slug + (reaction.reply_to ? `__${reaction.reply_to}` : '') + (n.seen ? `::seen` : '')
const rrr = threads.get(threadId) || []
const nnn = notificationsByThread.get(threadId) || []
switch (n.entity) {
case 'reaction': {
switch (n.action) {
case 'create': {
rrr.push(reaction)
threads.set(threadId, rrr)
nnn.push(n)
notificationsByThread.set(threadId, nnn)
if (!(threadId in threadsLatest)) threadsLatest[threadId] = timestamp
else if (timestamp > threadsLatest) threadsLatest[threadId] = timestamp
break
}
case 'delete': {
// TODO: remove reaction from thread, update storage
break
}
case 'update': {
// TODO: ignore for thread, update in storage
break
}
// No default
}
break
}
case 'shout': {
// TODO: handle notification about the
// new shout from subscribed author, topic
// or community (means all for one community)
break
}
case 'follower': {
// TODO: handle new follower notification
break
}
default:
// bypass chat and messages SSE
}
})
// sort reactions and threads by created_at
Object.entries(threads)
.sort(([ak, _av], [bk, _bv]) => threadsLatest[bk] - threadsLatest[ak])
.forEach(([threadId, reaction], _idx, _arr) => {
const rrr = threads.get(threadId) || []
if (!rrr.includes(reaction)) {
const updatedReactions: Reaction[] = [...rrr, reaction].sort(
(a, b) => b.created_at - a.created_at,
)
threads.set(threadId, updatedReactions)
}
})
})
const handleLinkClick = (event: MouseEvent | TouchEvent) => {
event.stopPropagation()
hideNotificationsPanel()
@ -143,46 +64,39 @@ export const NotificationGroup = (props: NotificationGroupProps) => {
return (
<>
<For each={[...threads.entries()]}>
{([threadId, reactions], _index) => (
<For each={props.notifications}>
{(n: Group) => (
<>
{t(reactionsCaption(threadId), { commentsCount: reactions.length })}{' '}
{t(reactionsCaption(n.id), { commentsCount: n.reactions.length })}{' '}
<div
class={clsx(styles.NotificationView, props.class, {
[styles.seen]: threadId.endsWith('::seen'),
})}
onClick={(_) => handleClick(threadId)}
class={clsx(styles.NotificationView, props.class, { [styles.seen]: n.seen })}
onClick={(_) => handleClick(n.id)}
>
<div class={styles.userpic}>
<GroupAvatar authors={reactions.map((r: Reaction) => r.created_by)} />
<GroupAvatar authors={n.authors} />
</div>
<div>
<a
href={getPagePath(router, 'article', { slug: reactions[-1].shout.slug })}
onClick={handleLinkClick}
>
{getTitle(reactions[-1].shout.title)}
<a href={getPagePath(router, 'article', { slug: n.shout.slug })} onClick={handleLinkClick}>
{getTitle(n.shout.title)}
</a>{' '}
{t('from')}{' '}
<a
href={getPagePath(router, 'author', { slug: reactions[-1].created_by.slug })}
href={getPagePath(router, 'author', { slug: n.authors[0].slug })}
onClick={handleLinkClick}
>
{reactions[-1].created_by.name}
{n.authors[0].name}
</a>{' '}
</div>
<div class={styles.timeContainer}>
<Show when={props.dateTimeFormat === 'ago'}>
<TimeAgo date={reactions[-1].created_at} />
<TimeAgo date={n.updated_at} />
</Show>
<Show when={props.dateTimeFormat === 'time'}>
{formatTime(new Date(reactions[-1].created_at))}
</Show>
<Show when={props.dateTimeFormat === 'time'}>{formatTime(new Date(n.updated_at))}</Show>
<Show when={props.dateTimeFormat === 'date'}>
{formatDate(new Date(reactions[-1].created_at), { month: 'numeric', year: '2-digit' })}
{formatDate(new Date(n.updated_at), { month: 'numeric', year: '2-digit' })}
</Show>
</div>
</div>

View File

@ -55,7 +55,7 @@ export const NotificationsPanel = (props: Props) => {
unreadNotificationsCount,
loadedNotificationsCount,
totalNotificationsCount,
actions: { loadNotifications, markAllNotificationsAsRead },
actions: { loadNotificationsGrouped, markSeenAll },
} = useNotifications()
const handleHide = () => {
props.onClose()
@ -96,24 +96,24 @@ export const NotificationsPanel = (props: Props) => {
}
const todayNotifications = createMemo(() => {
return sortedNotifications().filter((notification) => isToday(new Date(notification.created_at * 1000)))
return sortedNotifications().filter((notification) => isToday(new Date(notification.updated_at * 1000)))
})
const yesterdayNotifications = createMemo(() => {
return sortedNotifications().filter((notification) =>
isYesterday(new Date(notification.created_at * 1000)),
isYesterday(new Date(notification.updated_at * 1000)),
)
})
const earlierNotifications = createMemo(() => {
return sortedNotifications().filter((notification) =>
isEarlier(new Date(notification.created_at * 1000)),
isEarlier(new Date(notification.updated_at * 1000)),
)
})
const scrollContainerRef: { current: HTMLDivElement } = { current: null }
const loadNextPage = async () => {
await loadNotifications({ after: after(), limit: PAGE_SIZE, offset: loadedNotificationsCount() })
await loadNotificationsGrouped({ after: after(), limit: PAGE_SIZE, offset: loadedNotificationsCount() })
if (loadedNotificationsCount() < totalNotificationsCount()) {
const hasMore = scrollContainerRef.current.scrollHeight <= scrollContainerRef.current.offsetHeight
@ -221,11 +221,7 @@ export const NotificationsPanel = (props: Props) => {
<Show when={unreadNotificationsCount() > 0}>
<div class={styles.actions}>
<Button
onClick={() => markAllNotificationsAsRead()}
variant="secondary"
value={t('Mark as read')}
/>
<Button onClick={(_e) => markSeenAll()} variant="secondary" value={t('Mark as read')} />
</div>
</Show>
</div>

View File

@ -2,13 +2,14 @@ import { clsx } from 'clsx'
import { For } from 'solid-js'
import { Author } from '../../../graphql/schema/core.gen'
import { NotificationAuthor } from '../../../graphql/schema/notifier.gen'
import { Userpic } from '../../Author/Userpic'
import styles from './GroupAvatar.module.scss'
type Props = {
class?: string
authors: Author[]
authors: Author[] | NotificationAuthor[]
}
export const GroupAvatar = (props: Props) => {

View File

@ -8,24 +8,25 @@ import { Portal } from 'solid-js/web'
import { ShowIfAuthenticated } from '../components/_shared/ShowIfAuthenticated'
import { NotificationsPanel } from '../components/NotificationsPanel'
import { notifierClient } from '../graphql/client/notifier'
import { Notification, QueryLoad_NotificationsArgs } from '../graphql/schema/notifier.gen'
import { NotificationGroup, QueryLoad_NotificationsArgs } from '../graphql/schema/notifier.gen'
import { SSEMessage, useConnect } from './connect'
import { useSession } from './session'
type NotificationsContextType = {
notificationEntities: Record<number, Notification>
notificationEntities: Record<string, NotificationGroup>
unreadNotificationsCount: Accessor<number>
after: Accessor<number>
sortedNotifications: Accessor<Notification[]>
sortedNotifications: Accessor<NotificationGroup[]>
loadedNotificationsCount: Accessor<number>
totalNotificationsCount: Accessor<number>
actions: {
showNotificationsPanel: () => void
hideNotificationsPanel: () => void
markNotificationAsRead: (notification: Notification) => Promise<void>
markAllNotificationsAsRead: () => Promise<void>
loadNotifications: (options: QueryLoad_NotificationsArgs) => Promise<Notification[]>
markSeen: (notification_id: number) => Promise<void>
markSeenThread: (threadId: string) => Promise<void>
markSeenAll: () => Promise<void>
loadNotificationsGrouped: (options: QueryLoad_NotificationsArgs) => Promise<NotificationGroup[]>
}
}
@ -40,56 +41,65 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
const [isNotificationsPanelOpen, setIsNotificationsPanelOpen] = createSignal(false)
const [unreadNotificationsCount, setUnreadNotificationsCount] = createSignal(0)
const [totalNotificationsCount, setTotalNotificationsCount] = createSignal(0)
const [notificationEntities, setNotificationEntities] = createStore<Record<number, Notification>>({})
const { isAuthenticated, author } = useSession()
const [notificationEntities, setNotificationEntities] = createStore<Record<string, NotificationGroup>>({})
const { isAuthenticated } = useSession()
const { addHandler } = useConnect()
const loadNotifications = async (options: { after: number; limit?: number; offset?: number }) => {
const loadNotificationsGrouped = async (options: { after: number; limit?: number; offset?: number }) => {
if (isAuthenticated() && notifierClient?.private) {
const { notifications, unread, total } = await notifierClient.getNotifications(options)
const newNotificationEntities = notifications.reduce((acc, notification) => {
acc[notification.id] = notification
const { notifications: groups, unread, total } = await notifierClient.getNotifications(options)
const newGroupsEntries = groups.reduce((acc, group: NotificationGroup) => {
acc[group.id] = group
return acc
}, {})
setTotalNotificationsCount(total)
setUnreadNotificationsCount(unread)
setNotificationEntities(newNotificationEntities)
console.debug(`[context.notifications] updated`)
return notifications
setNotificationEntities(newGroupsEntries)
console.debug(`[context.notifications] groups updated`)
return groups
} else {
return []
}
}
const sortedNotifications = createMemo(() => {
return Object.values(notificationEntities).sort((a, b) => b.created_at - a.created_at)
return Object.values(notificationEntities).sort((a, b) => b.updated_at - a.updated_at)
})
const now = Math.floor(Date.now() / 1000)
const loadedNotificationsCount = createMemo(() => Object.keys(notificationEntities).length)
const [after, setAfter] = createStorageSignal('notifier_timestamp', now)
onMount(() => {
addHandler((data: SSEMessage) => {
if (data.entity === 'reaction' && isAuthenticated()) {
console.info(`[context.notifications] event`, data)
loadNotifications({ after: after(), limit: Math.max(PAGE_SIZE, loadedNotificationsCount()) })
loadNotificationsGrouped({ after: after(), limit: Math.max(PAGE_SIZE, loadedNotificationsCount()) })
}
})
setAfter(now)
})
const markNotificationAsRead = async (notification: Notification) => {
if (notifierClient.private) await notifierClient.markNotificationAsRead(notification.id)
notification.seen.push(author().id)
setNotificationEntities((nnn: Notification) => ({ ...nnn, [notification.id]: notification }))
const markSeenThread = async (threadId: string) => {
if (notifierClient.private) await notifierClient.markSeenThread(threadId)
const thread = notificationEntities[threadId]
thread.seen = true
setNotificationEntities((nnn) => ({ ...nnn, [threadId]: thread }))
setUnreadNotificationsCount((oldCount) => oldCount - 1)
}
const markAllNotificationsAsRead = async () => {
const markSeenAll = async () => {
if (isAuthenticated() && notifierClient.private) {
await notifierClient.markAllNotificationsAsRead()
await loadNotifications({ after: after(), limit: loadedNotificationsCount() })
await notifierClient.markSeenAfter({ after: after() })
await loadNotificationsGrouped({ after: after(), limit: loadedNotificationsCount() })
}
}
const markSeen = async (notification_id: number) => {
if (isAuthenticated() && notifierClient.private) {
await notifierClient.markSeen(notification_id)
await loadNotificationsGrouped({ after: after(), limit: loadedNotificationsCount() })
}
}
@ -104,9 +114,10 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
const actions = {
showNotificationsPanel,
hideNotificationsPanel,
markNotificationAsRead,
markAllNotificationsAsRead,
loadNotifications,
markSeenThread,
markSeenAll,
markSeen,
loadNotificationsGrouped,
}
const value: NotificationsContextType = {

View File

@ -90,7 +90,7 @@ export const apiClient = {
getAllTopics: async () => {
const response = await publicGraphQLClient.query(topicsAll, {}).toPromise()
if (response.error) {
console.debug('[graphql.client.core] get_topicss_all', response.error)
console.debug('[graphql.client.core] get_topics_all', response.error)
}
return response.data.get_topics_all
},

View File

@ -1,8 +1,13 @@
import { createGraphQLClient } from '../createGraphQLClient'
import markAllNotificationsAsRead from '../mutation/notifier/mark-all-notifications-as-read'
import markNotificationAsRead from '../mutation/notifier/mark-notification-as-read'
import markSeenMutation from '../mutation/notifier/mark-seen'
import markSeenAfterMutation from '../mutation/notifier/mark-seen-after'
import markThreadSeenMutation from '../mutation/notifier/mark-seen-thread'
import loadNotifications from '../query/notifier/notifications-load'
import { NotificationsResult, QueryLoad_NotificationsArgs } from '../schema/notifier.gen'
import {
MutationMark_Seen_AfterArgs,
NotificationsResult,
QueryLoad_NotificationsArgs,
} from '../schema/notifier.gen'
export const notifierClient = {
private: null,
@ -12,15 +17,15 @@ export const notifierClient = {
const resp = await notifierClient.private.query(loadNotifications, params).toPromise()
return resp.data?.load_notifications
},
markNotificationAsRead: async (notification_id: number): Promise<void> => {
await notifierClient.private
.mutation(markNotificationAsRead, {
notification_id,
})
.toPromise()
markSeen: async (notification_id: number): Promise<void> => {
await notifierClient.private.mutation(markSeenMutation, { notification_id }).toPromise()
},
markAllNotificationsAsRead: async (): Promise<void> => {
await notifierClient.private.mutation(markAllNotificationsAsRead, {}).toPromise()
markSeenAfter: async (options: MutationMark_Seen_AfterArgs): Promise<void> => {
await notifierClient.private.mutation(markSeenAfterMutation, options).toPromise()
},
markSeenThread: async (thread: string): Promise<void> => {
await notifierClient.private.mutation(markThreadSeenMutation, { thread }).toPromise()
},
}

View File

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

View File

@ -0,0 +1,9 @@
import { gql } from '@urql/core'
export default gql`
mutation MarkSeenAfter($after: Int) {
mark_seen_after(after: $after) {
error
}
}
`

View File

@ -0,0 +1,9 @@
import { gql } from '@urql/core'
export default gql`
mutation MarkThreadSeen($thread: String!, $after: Int) {
mark_seen_thread(thread: $thread, after: $after) {
error
}
}
`

View File

@ -2,7 +2,7 @@ import { gql } from '@urql/core'
export default gql`
mutation MarkNotificationAsReadMutation($notificationId: Int!) {
mark_notification_as_read(notification_id: $notificationId) {
mark_seen(notification_id: $notificationId) {
error
}
}

View File

@ -5,11 +5,19 @@ export default gql`
load_notifications(after: $after, limit: $limit, offset: $offset) {
notifications {
id
entity
action
payload
created_at
seen
updated_at
authors {
id
slug
name
pic
}
reactions
shout {
id
slug
title
}
}
unread
total