From 359bfc3f7a58093039bba0380e492d9517265453 Mon Sep 17 00:00:00 2001 From: Untone Date: Sun, 15 Oct 2023 02:05:07 +0300 Subject: [PATCH] notifications-postmerge-fixes --- package-lock.json | 6 + package.json | 1 + .../NotificationView/NotificationView.tsx | 139 +++++++++--------- .../NotificationsPanel/NotificationsPanel.tsx | 2 +- src/context/inbox.tsx | 28 +--- src/context/notifications.tsx | 115 +++++++++++---- src/graphql/types.gen.ts | 6 +- src/utils/sseService.ts | 33 ----- 8 files changed, 173 insertions(+), 157 deletions(-) delete mode 100644 src/utils/sseService.ts diff --git a/package-lock.json b/package-lock.json index 7c0569a6..e7dab113 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "form-data": "4.0.0", "i18next": "22.4.15", "i18next-icu": "2.3.0", + "idb": "7.1.1", "intl-messageformat": "10.5.3", "mailgun.js": "8.2.1", "node-fetch": "3.3.1" @@ -10444,6 +10445,11 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index 1d9dcfd2..b718b3aa 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "form-data": "4.0.0", "i18next": "22.4.15", "i18next-icu": "2.3.0", + "idb": "7.1.1", "intl-messageformat": "10.5.3", "mailgun.js": "8.2.1", "node-fetch": "3.3.1" diff --git a/src/components/NotificationsPanel/NotificationView/NotificationView.tsx b/src/components/NotificationsPanel/NotificationView/NotificationView.tsx index 397572fb..7b068b57 100644 --- a/src/components/NotificationsPanel/NotificationView/NotificationView.tsx +++ b/src/components/NotificationsPanel/NotificationView/NotificationView.tsx @@ -1,112 +1,113 @@ import { clsx } from 'clsx' import styles from './NotificationView.module.scss' -import type { Notification } from '../../../graphql/types.gen' import { formatDate } from '../../../utils' import { createMemo, createSignal, onMount, Show } from 'solid-js' -import { NotificationType } from '../../../graphql/types.gen' +import { Author } from '../../../graphql/types.gen' import { openPage } from '@nanostores/router' import { router } from '../../../stores/router' -import { useNotifications } from '../../../context/notifications' +import { ServerNotification, useNotifications } from '../../../context/notifications' import { Userpic } from '../../Author/Userpic' import { useLocalize } from '../../../context/localize' -import notifications from '../../../graphql/query/notifications' type Props = { - notification: Notification + notification: ServerNotification onClick: () => void class?: string } -type NotificationData = { - shout: { - slug: string - title: string - } - users: { - id: number - name: string - slug: string - userpic: string - }[] +// NOTE: not a graphql generated type +export enum NotificationType { + NewComment = 'NEW_COMMENT', + NewReply = 'NEW_REPLY', + NewFollower = 'NEW_FOLLOWER', + NewShout = 'NEW_SHOUT', + NewLike = 'NEW_LIKE', + NewDislike = 'NEW_DISLIKE' +} + +const TEMPLATES = { + // FIXME: set proper templates + new_follower: 'new follower', + new_shout: 'new shout', + new_reaction0: 'new like', + new_reaction1: 'new dislike', + new_reaction2: 'new agreement', + new_reaction3: 'new disagreement', + new_reaction4: 'new proof', + new_reaction5: 'new disproof', + new_reaction6: 'new comment', + new_reaction7: 'new quote', + new_reaction8: 'new proposal', + new_reaction9: 'new question', + new_reaction10: 'new remark', + //"new_reaction11": "new footnote", + new_reaction12: 'new acception', + new_reaction13: 'new rejection' } export const NotificationView = (props: Props) => { const { actions: { markNotificationAsRead } } = useNotifications() - const { t } = useLocalize() - - const [data, setData] = createSignal(null) - + const [data, setData] = createSignal(null) + const [kind, setKind] = createSignal() onMount(() => { - setTimeout(() => setData(JSON.parse(props.notification.data))) + setTimeout(() => setData(props.notification)) }) - const lastUser = createMemo(() => { - if (!data()) { - return null - } - - return data().users[data().users.length - 1] + return props.notification.kind === 'new_follower' ? data().payload : data().payload.author }) - const content = createMemo(() => { if (!data()) { return null } + let caption: string, author: Author, ntype: NotificationType - let shoutTitle = '' - let i = 0 - const shoutTitleWords = data().shout.title.split(' ') + // TODO: count occurencies from in-browser notifications-db - while (shoutTitle.length <= 30 && i < shoutTitleWords.length) { - shoutTitle += shoutTitleWords[i] + ' ' - i++ - } - - if (shoutTitle.length < data().shout.title.length) { - shoutTitle += '...' - } - - switch (props.notification.type) { - case NotificationType.NewComment: { - return t('NewCommentNotificationText', { - commentsCount: props.notification.occurrences, - shoutTitle, - lastCommenterName: lastUser().name, - restUsersCount: data().users.length - 1 - }) + switch (props.notification.kind) { + case 'new_follower': { + caption = '' + author = data().payload + ntype = NotificationType.NewFollower + break } - case NotificationType.NewReply: { - return t('NewReplyNotificationText', { - commentsCount: props.notification.occurrences, - shoutTitle, - lastCommenterName: lastUser().name, - restUsersCount: data().users.length - 1 - }) + case 'new_shout': { + caption = data().payload.title + author = data().payload.authors[-1] + ntype = NotificationType.NewShout + break + } + case 'new_reaction6': { + ntype = data().payload.replyTo ? NotificationType.NewReply : NotificationType.NewComment + } + case 'new_reaction0': { + ntype = NotificationType.NewLike + } + case 'new_reaction0': { + ntype = NotificationType.NewDislike + } + // TODO: add more reaction types + default: { + caption = data().payload.shout.title + author = data().payload.author } } + setKind(ntype) // FIXME: use it somewhere if needed or remove + return t(TEMPLATES[props.notification.kind], { caption, author }) }) const handleClick = () => { if (!props.notification.seen) { markNotificationAsRead(props.notification) } - - openPage(router, 'article', { slug: data().shout.slug }) + const subpath = props.notification.kind === 'new_follower' ? 'author' : 'article' + const slug = props.notification.kind.startsWith('new_reaction') + ? data().payload.shout.slug + : data().payload.slug + openPage(router, subpath, { slug }) props.onClick() - - // switch (props.notification.type) { - // case NotificationType.NewComment: { - // openPage(router, 'article', { slug: data().shout.slug }) - // break - // } - // case NotificationType.NewReply: { - // openPage(router, 'article', { slug: data().shout.slug }) - // break - // } - // } } return ( @@ -120,7 +121,7 @@ export const NotificationView = (props: Props) => {
{content()}
- {/*{formatDate(new Date(props.notification.createdAt), { month: 'numeric' })}*/} + {/*{formatDate(new Date(props.notification.timestamp), { month: 'numeric' })}*/}
diff --git a/src/components/NotificationsPanel/NotificationsPanel.tsx b/src/components/NotificationsPanel/NotificationsPanel.tsx index ddfc66b2..4041dbed 100644 --- a/src/components/NotificationsPanel/NotificationsPanel.tsx +++ b/src/components/NotificationsPanel/NotificationsPanel.tsx @@ -4,7 +4,7 @@ import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler' import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler' import { useLocalize } from '../../context/localize' import { Icon } from '../_shared/Icon' -import { createEffect, For, onCleanup, onMount } from 'solid-js' +import { createEffect, For } from 'solid-js' import { useNotifications } from '../../context/notifications' import { NotificationView } from './NotificationView' diff --git a/src/context/inbox.tsx b/src/context/inbox.tsx index d37380e0..f9a28317 100644 --- a/src/context/inbox.tsx +++ b/src/context/inbox.tsx @@ -1,10 +1,9 @@ import type { Accessor, JSX } from 'solid-js' import { createContext, createSignal, useContext } from 'solid-js' -import { fetchEventSource } from '@microsoft/fetch-event-source' import type { Chat, Message, MutationCreateMessageArgs } from '../graphql/types.gen' import { inboxClient } from '../utils/apiClient' -import { getToken } from '../graphql/privateGraphQLClient' import { loadMessages } from '../stores/inbox' +import { ServerNotification, useNotifications } from './notifications' type InboxContextType = { chats: Accessor @@ -26,26 +25,13 @@ export function useInbox() { export const InboxProvider = (props: { children: JSX.Element }) => { const [chats, setChats] = createSignal([]) const [messages, setMessages] = createSignal([]) + const { + actions: { setMessageHandler } + } = useNotifications() - fetchEventSource('https://chat.discours.io/connect', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + getToken() - }, - onmessage(event) { - const message = JSON.parse(event.data) - console.log('Received message:', message) - // TODO: Add the message to the appropriate chat - }, - onclose() { - console.log('sse connection closed by server') - }, - onerror(err) { - console.error('sse connection closed by error', err) - - throw new Error() // NOTE: simple hack to close the connection - } + setMessageHandler((n: ServerNotification) => { + console.debug(n) + // TODO: handle new message }) const loadChats = async () => { diff --git a/src/context/notifications.tsx b/src/context/notifications.tsx index 06802100..b1ad5c48 100644 --- a/src/context/notifications.tsx +++ b/src/context/notifications.tsx @@ -1,22 +1,31 @@ import type { Accessor, JSX } from 'solid-js' import { createContext, createEffect, createMemo, createSignal, useContext } from 'solid-js' import { useSession } from './session' -import SSEService, { EventData } from '../utils/sseService' -import { apiBaseUrl } from '../utils/config' import { Portal } from 'solid-js/web' import { ShowIfAuthenticated } from '../components/_shared/ShowIfAuthenticated' import { NotificationsPanel } from '../components/NotificationsPanel' -import { apiClient } from '../utils/apiClient' import { createStore } from 'solid-js/store' -import { Notification } from '../graphql/types.gen' +import { openDB } from 'idb' +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { getToken } from '../graphql/privateGraphQLClient' +import { Author, Message, Reaction, Shout } from '../graphql/types.gen' +export interface ServerNotification { + kind: string + payload: any // Author | Shout | Reaction | Message + timestamp: number + seen: boolean +} + +type MessageHandler = (m: Message) => void type NotificationsContextType = { - notificationEntities: Record + notificationEntities: Record unreadNotificationsCount: Accessor - sortedNotifications: Accessor + sortedNotifications: Accessor actions: { showNotificationsPanel: () => void - markNotificationAsRead: (notification: Notification) => Promise + markNotificationAsRead: (notification: ServerNotification) => Promise + setMessageHandler: (MessageHandler) => void } } @@ -26,53 +35,95 @@ export function useNotifications() { return useContext(NotificationsContext) } -const sseService = new SSEService() - export const NotificationsProvider = (props: { children: JSX.Element }) => { const [isNotificationsPanelOpen, setIsNotificationsPanelOpen] = createSignal(false) const [unreadNotificationsCount, setUnreadNotificationsCount] = createSignal(0) const { isAuthenticated, user } = useSession() - const [notificationEntities, setNotificationEntities] = createStore>({}) + const [notificationEntities, setNotificationEntities] = createStore>( + {} + ) + + const dbPromise = openDB('notifications-db', 1, { + upgrade(db) { + db.createObjectStore('notifications') + } + }) const loadNotifications = async () => { - const { notifications, totalUnreadCount } = await apiClient.getNotifications({ - limit: 100 - }) - const newNotificationEntities = notifications.reduce((acc, notification) => { - acc[notification.id] = notification - return acc - }, {}) + const db = await dbPromise + const notifications = await db.getAll('notifications') + const totalUnreadCount = notifications.filter((notification) => !notification.read).length setUnreadNotificationsCount(totalUnreadCount) - setNotificationEntities(newNotificationEntities) + setNotificationEntities( + notifications.reduce((acc, notification) => { + acc[notification.id] = notification + return acc + }, {}) + ) + return notifications } const sortedNotifications = createMemo(() => { - return Object.values(notificationEntities).sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ) + return Object.values(notificationEntities).sort((a, b) => b.timestamp - a.timestamp) }) + const storeNotification = async (notification: ServerNotification) => { + const db = await dbPromise + const tx = db.transaction('notifications', 'readwrite') + const store = tx.objectStore('notifications') + const id = Date.now() + const data: ServerNotification = { + ...notification, + timestamp: id, + seen: false + } + await store.put(data, id) + await tx.done + loadNotifications() + } + + const [messageHandler, setMessageHandler] = createSignal<(m: Message) => void>() + createEffect(() => { if (isAuthenticated()) { loadNotifications() - sseService.connect(`${apiBaseUrl}/subscribe/${user().id}`) - sseService.subscribeToEvent('message', (data: EventData) => { - if (data.type === 'newNotifications') { - loadNotifications() - } else { - console.error(`[NotificationsProvider] unknown message type: ${JSON.stringify(data)}`) + fetchEventSource('https://chat.discours.io/connect', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + getToken() + }, + onmessage(event) { + const n: { kind: string; payload: any } = JSON.parse(event.data) + if (n.kind === 'new_message') { + messageHandler()(n.payload) + } else { + console.log('[context.notifications] Received notification:', n) + storeNotification({ + kind: n.kind, + payload: n.payload, + timestamp: Date.now(), + seen: false + }) + } + }, + onclose() { + console.log('[context.notifications] sse connection closed by server') + }, + onerror(err) { + console.error('[context.notifications] sse connection closed by error', err) + throw new Error() // NOTE: simple hack to close the connection } }) - } else { - sseService.disconnect() } }) - const markNotificationAsRead = async (notification: Notification) => { - await apiClient.markNotificationAsRead(notification.id) + const markNotificationAsRead = async (notification: ServerNotification) => { + const db = await dbPromise + await db.put('notifications', { ...notification, seen: true }) loadNotifications() } @@ -80,7 +131,7 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => { setIsNotificationsPanelOpen(true) } - const actions = { showNotificationsPanel, markNotificationAsRead } + const actions = { showNotificationsPanel, markNotificationAsRead, setMessageHandler } const value: NotificationsContextType = { notificationEntities, diff --git a/src/graphql/types.gen.ts b/src/graphql/types.gen.ts index 40384814..30a80fde 100644 --- a/src/graphql/types.gen.ts +++ b/src/graphql/types.gen.ts @@ -314,7 +314,11 @@ export type Notification = { export enum NotificationType { NewComment = 'NEW_COMMENT', - NewReply = 'NEW_REPLY' + NewReply = 'NEW_REPLY', + NewFollower = 'NEW_FOLLOWER', + NewShout = 'NEW_SHOUT', + NewLike = 'NEW_LIKE', + NewDislike = 'NEW_DISLIKE' } export type NotificationsQueryParams = { diff --git a/src/utils/sseService.ts b/src/utils/sseService.ts deleted file mode 100644 index 20121c35..00000000 --- a/src/utils/sseService.ts +++ /dev/null @@ -1,33 +0,0 @@ -export type EventData = { - type: string -} - -class SSEService { - private eventSource: EventSource | null - - constructor() { - this.eventSource = null - } - - public connect(url: string): void { - this.eventSource = new EventSource(url) - } - - public disconnect(): void { - if (this.eventSource) { - this.eventSource.close() - this.eventSource = null - } - } - - public subscribeToEvent(eventName: string, callback: (eventData: EventData) => void): void { - if (this.eventSource) { - this.eventSource.addEventListener(eventName, (event: MessageEvent) => { - const data = JSON.parse(event.data) - callback(data) - }) - } - } -} - -export default SSEService