grouped-notifications
This commit is contained in:
parent
43b3de572b
commit
d11a50c2a3
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
mutation MarkAllNotificationsAsReadMutation {
|
||||
mark_all_notifications_as_read {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
9
src/graphql/mutation/notifier/mark-seen-after.ts
Normal file
9
src/graphql/mutation/notifier/mark-seen-after.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
mutation MarkSeenAfter($after: Int) {
|
||||
mark_seen_after(after: $after) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
9
src/graphql/mutation/notifier/mark-seen-thread.ts
Normal file
9
src/graphql/mutation/notifier/mark-seen-thread.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue
Block a user