diff --git a/src/components/NotificationsPanel/NotificationView/NotificationGroup.tsx b/src/components/NotificationsPanel/NotificationView/NotificationGroup.tsx index 20a180d4..7a8f8058 100644 --- a/src/components/NotificationsPanel/NotificationView/NotificationGroup.tsx +++ b/src/components/NotificationsPanel/NotificationView/NotificationGroup.tsx @@ -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() const { - actions: { hideNotificationsPanel, markNotificationAsRead }, + actions: { hideNotificationsPanel, markSeenThread }, } = useNotifications() - const threads = new Map() - const notificationsByThread = new Map() - 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 - - 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 ( <> - - {([threadId, reactions], _index) => ( + + {(n: Group) => ( <> - {t(reactionsCaption(threadId), { commentsCount: reactions.length })}{' '} + {t(reactionsCaption(n.id), { commentsCount: n.reactions.length })}{' '}
handleClick(threadId)} + class={clsx(styles.NotificationView, props.class, { [styles.seen]: n.seen })} + onClick={(_) => handleClick(n.id)} >
- r.created_by)} /> +
- + - - {formatTime(new Date(reactions[-1].created_at))} - + {formatTime(new Date(n.updated_at))} - {formatDate(new Date(reactions[-1].created_at), { month: 'numeric', year: '2-digit' })} + {formatDate(new Date(n.updated_at), { month: 'numeric', year: '2-digit' })}
diff --git a/src/components/NotificationsPanel/NotificationsPanel.tsx b/src/components/NotificationsPanel/NotificationsPanel.tsx index 44d1edb3..dc66a230 100644 --- a/src/components/NotificationsPanel/NotificationsPanel.tsx +++ b/src/components/NotificationsPanel/NotificationsPanel.tsx @@ -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) => { 0}>
-
diff --git a/src/components/_shared/GroupAvatar/GroupAvatar.tsx b/src/components/_shared/GroupAvatar/GroupAvatar.tsx index 087e9b88..7c63520e 100644 --- a/src/components/_shared/GroupAvatar/GroupAvatar.tsx +++ b/src/components/_shared/GroupAvatar/GroupAvatar.tsx @@ -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) => { diff --git a/src/context/notifications.tsx b/src/context/notifications.tsx index 1093a60a..48a3a71c 100644 --- a/src/context/notifications.tsx +++ b/src/context/notifications.tsx @@ -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 + notificationEntities: Record unreadNotificationsCount: Accessor after: Accessor - sortedNotifications: Accessor + sortedNotifications: Accessor loadedNotificationsCount: Accessor totalNotificationsCount: Accessor actions: { showNotificationsPanel: () => void hideNotificationsPanel: () => void - markNotificationAsRead: (notification: Notification) => Promise - markAllNotificationsAsRead: () => Promise - loadNotifications: (options: QueryLoad_NotificationsArgs) => Promise + markSeen: (notification_id: number) => Promise + markSeenThread: (threadId: string) => Promise + markSeenAll: () => Promise + loadNotificationsGrouped: (options: QueryLoad_NotificationsArgs) => Promise } } @@ -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>({}) - const { isAuthenticated, author } = useSession() + const [notificationEntities, setNotificationEntities] = createStore>({}) + 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 = { diff --git a/src/graphql/client/core.ts b/src/graphql/client/core.ts index cd2b9eaf..1c7ead48 100644 --- a/src/graphql/client/core.ts +++ b/src/graphql/client/core.ts @@ -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 }, diff --git a/src/graphql/client/notifier.ts b/src/graphql/client/notifier.ts index 676ff0a9..edbd58c5 100644 --- a/src/graphql/client/notifier.ts +++ b/src/graphql/client/notifier.ts @@ -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 => { - await notifierClient.private - .mutation(markNotificationAsRead, { - notification_id, - }) - .toPromise() + + markSeen: async (notification_id: number): Promise => { + await notifierClient.private.mutation(markSeenMutation, { notification_id }).toPromise() }, - markAllNotificationsAsRead: async (): Promise => { - await notifierClient.private.mutation(markAllNotificationsAsRead, {}).toPromise() + markSeenAfter: async (options: MutationMark_Seen_AfterArgs): Promise => { + await notifierClient.private.mutation(markSeenAfterMutation, options).toPromise() + }, + markSeenThread: async (thread: string): Promise => { + await notifierClient.private.mutation(markThreadSeenMutation, { thread }).toPromise() }, } diff --git a/src/graphql/mutation/notifier/mark-all-notifications-as-read.ts b/src/graphql/mutation/notifier/mark-all-notifications-as-read.ts deleted file mode 100644 index bea2e7fc..00000000 --- a/src/graphql/mutation/notifier/mark-all-notifications-as-read.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { gql } from '@urql/core' - -export default gql` - mutation MarkAllNotificationsAsReadMutation { - mark_all_notifications_as_read { - error - } - } -` diff --git a/src/graphql/mutation/notifier/mark-seen-after.ts b/src/graphql/mutation/notifier/mark-seen-after.ts new file mode 100644 index 00000000..9eee3f36 --- /dev/null +++ b/src/graphql/mutation/notifier/mark-seen-after.ts @@ -0,0 +1,9 @@ +import { gql } from '@urql/core' + +export default gql` + mutation MarkSeenAfter($after: Int) { + mark_seen_after(after: $after) { + error + } + } +` diff --git a/src/graphql/mutation/notifier/mark-seen-thread.ts b/src/graphql/mutation/notifier/mark-seen-thread.ts new file mode 100644 index 00000000..ab7a9db1 --- /dev/null +++ b/src/graphql/mutation/notifier/mark-seen-thread.ts @@ -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 + } + } +` diff --git a/src/graphql/mutation/notifier/mark-notification-as-read.ts b/src/graphql/mutation/notifier/mark-seen.ts similarity index 68% rename from src/graphql/mutation/notifier/mark-notification-as-read.ts rename to src/graphql/mutation/notifier/mark-seen.ts index 09c287ae..b245bdc5 100644 --- a/src/graphql/mutation/notifier/mark-notification-as-read.ts +++ b/src/graphql/mutation/notifier/mark-seen.ts @@ -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 } } diff --git a/src/graphql/query/notifier/notifications-load.ts b/src/graphql/query/notifier/notifications-load.ts index 666081ca..a86ea60c 100644 --- a/src/graphql/query/notifier/notifications-load.ts +++ b/src/graphql/query/notifier/notifications-load.ts @@ -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