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 { getPagePath, openPage } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createEffect, For, Show } from 'solid-js' import { For, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useNotifications } from '../../../context/notifications' import { useNotifications } from '../../../context/notifications'
import { Reaction } from '../../../graphql/schema/core.gen' import { NotificationGroup as Group } from '../../../graphql/schema/notifier.gen'
import { Notification } from '../../../graphql/schema/notifier.gen'
import { useRouter, router } from '../../../stores/router' import { useRouter, router } from '../../../stores/router'
import { GroupAvatar } from '../../_shared/GroupAvatar' import { GroupAvatar } from '../../_shared/GroupAvatar'
import { TimeAgo } from '../../_shared/TimeAgo' import { TimeAgo } from '../../_shared/TimeAgo'
@ -14,7 +13,7 @@ import { ArticlePageSearchParams } from '../../Article/FullArticle'
import styles from './NotificationView.module.scss' import styles from './NotificationView.module.scss'
type NotificationGroupProps = { type NotificationGroupProps = {
notifications: Notification[] notifications: Group[]
onClick: () => void onClick: () => void
dateTimeFormat: 'ago' | 'time' | 'date' dateTimeFormat: 'ago' | 'time' | 'date'
class?: string class?: string
@ -41,101 +40,23 @@ const getTitle = (title: string) => {
} }
const reactionsCaption = (threadId: 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) => { export const NotificationGroup = (props: NotificationGroupProps) => {
const { t, formatTime, formatDate } = useLocalize() const { t, formatTime, formatDate } = useLocalize()
const { changeSearchParam } = useRouter<ArticlePageSearchParams>() const { changeSearchParam } = useRouter<ArticlePageSearchParams>()
const { const {
actions: { hideNotificationsPanel, markNotificationAsRead }, actions: { hideNotificationsPanel, markSeenThread },
} = useNotifications() } = useNotifications()
const threads = new Map<string, Reaction[]>()
const notificationsByThread = new Map<string, Notification[]>()
const handleClick = (threadId: string) => { const handleClick = (threadId: string) => {
props.onClick() props.onClick()
notificationsByThread.get(threadId).forEach((n: Notification) => { markSeenThread(threadId)
if (!n.seen) markNotificationAsRead(n) const [slug, commentId] = threadId.split('::')
}) openPage(router, 'article', { slug })
if (commentId) changeSearchParam({ commentId })
const threadParts = threadId.replace('::seen', '').split('__')
openPage(router, 'article', { slug: threadParts[0] })
if (threadParts.length > 1) {
changeSearchParam({ commentId: threadParts[1] })
}
} }
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) => { const handleLinkClick = (event: MouseEvent | TouchEvent) => {
event.stopPropagation() event.stopPropagation()
hideNotificationsPanel() hideNotificationsPanel()
@ -143,46 +64,39 @@ export const NotificationGroup = (props: NotificationGroupProps) => {
return ( return (
<> <>
<For each={[...threads.entries()]}> <For each={props.notifications}>
{([threadId, reactions], _index) => ( {(n: Group) => (
<> <>
{t(reactionsCaption(threadId), { commentsCount: reactions.length })}{' '} {t(reactionsCaption(n.id), { commentsCount: n.reactions.length })}{' '}
<div <div
class={clsx(styles.NotificationView, props.class, { class={clsx(styles.NotificationView, props.class, { [styles.seen]: n.seen })}
[styles.seen]: threadId.endsWith('::seen'), onClick={(_) => handleClick(n.id)}
})}
onClick={(_) => handleClick(threadId)}
> >
<div class={styles.userpic}> <div class={styles.userpic}>
<GroupAvatar authors={reactions.map((r: Reaction) => r.created_by)} /> <GroupAvatar authors={n.authors} />
</div> </div>
<div> <div>
<a <a href={getPagePath(router, 'article', { slug: n.shout.slug })} onClick={handleLinkClick}>
href={getPagePath(router, 'article', { slug: reactions[-1].shout.slug })} {getTitle(n.shout.title)}
onClick={handleLinkClick}
>
{getTitle(reactions[-1].shout.title)}
</a>{' '} </a>{' '}
{t('from')}{' '} {t('from')}{' '}
<a <a
href={getPagePath(router, 'author', { slug: reactions[-1].created_by.slug })} href={getPagePath(router, 'author', { slug: n.authors[0].slug })}
onClick={handleLinkClick} onClick={handleLinkClick}
> >
{reactions[-1].created_by.name} {n.authors[0].name}
</a>{' '} </a>{' '}
</div> </div>
<div class={styles.timeContainer}> <div class={styles.timeContainer}>
<Show when={props.dateTimeFormat === 'ago'}> <Show when={props.dateTimeFormat === 'ago'}>
<TimeAgo date={reactions[-1].created_at} /> <TimeAgo date={n.updated_at} />
</Show> </Show>
<Show when={props.dateTimeFormat === 'time'}> <Show when={props.dateTimeFormat === 'time'}>{formatTime(new Date(n.updated_at))}</Show>
{formatTime(new Date(reactions[-1].created_at))}
</Show>
<Show when={props.dateTimeFormat === 'date'}> <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> </Show>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@ -90,7 +90,7 @@ export const apiClient = {
getAllTopics: async () => { getAllTopics: async () => {
const response = await publicGraphQLClient.query(topicsAll, {}).toPromise() const response = await publicGraphQLClient.query(topicsAll, {}).toPromise()
if (response.error) { 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 return response.data.get_topics_all
}, },

View File

@ -1,8 +1,13 @@
import { createGraphQLClient } from '../createGraphQLClient' import { createGraphQLClient } from '../createGraphQLClient'
import markAllNotificationsAsRead from '../mutation/notifier/mark-all-notifications-as-read' import markSeenMutation from '../mutation/notifier/mark-seen'
import markNotificationAsRead from '../mutation/notifier/mark-notification-as-read' import markSeenAfterMutation from '../mutation/notifier/mark-seen-after'
import markThreadSeenMutation from '../mutation/notifier/mark-seen-thread'
import loadNotifications from '../query/notifier/notifications-load' 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 = { export const notifierClient = {
private: null, private: null,
@ -12,15 +17,15 @@ export const notifierClient = {
const resp = await notifierClient.private.query(loadNotifications, params).toPromise() const resp = await notifierClient.private.query(loadNotifications, params).toPromise()
return resp.data?.load_notifications return resp.data?.load_notifications
}, },
markNotificationAsRead: async (notification_id: number): Promise<void> => {
await notifierClient.private markSeen: async (notification_id: number): Promise<void> => {
.mutation(markNotificationAsRead, { await notifierClient.private.mutation(markSeenMutation, { notification_id }).toPromise()
notification_id,
})
.toPromise()
}, },
markAllNotificationsAsRead: async (): Promise<void> => { markSeenAfter: async (options: MutationMark_Seen_AfterArgs): Promise<void> => {
await notifierClient.private.mutation(markAllNotificationsAsRead, {}).toPromise() 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` export default gql`
mutation MarkNotificationAsReadMutation($notificationId: Int!) { mutation MarkNotificationAsReadMutation($notificationId: Int!) {
mark_notification_as_read(notification_id: $notificationId) { mark_seen(notification_id: $notificationId) {
error error
} }
} }

View File

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