notifications-postmerge-fixes

This commit is contained in:
Untone 2023-10-15 02:05:07 +03:00
parent 26ba530f9e
commit 359bfc3f7a
8 changed files with 173 additions and 157 deletions

6
package-lock.json generated
View File

@ -12,6 +12,7 @@
"form-data": "4.0.0", "form-data": "4.0.0",
"i18next": "22.4.15", "i18next": "22.4.15",
"i18next-icu": "2.3.0", "i18next-icu": "2.3.0",
"idb": "7.1.1",
"intl-messageformat": "10.5.3", "intl-messageformat": "10.5.3",
"mailgun.js": "8.2.1", "mailgun.js": "8.2.1",
"node-fetch": "3.3.1" "node-fetch": "3.3.1"
@ -10444,6 +10445,11 @@
"node": ">=0.10.0" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",

View File

@ -32,6 +32,7 @@
"form-data": "4.0.0", "form-data": "4.0.0",
"i18next": "22.4.15", "i18next": "22.4.15",
"i18next-icu": "2.3.0", "i18next-icu": "2.3.0",
"idb": "7.1.1",
"intl-messageformat": "10.5.3", "intl-messageformat": "10.5.3",
"mailgun.js": "8.2.1", "mailgun.js": "8.2.1",
"node-fetch": "3.3.1" "node-fetch": "3.3.1"

View File

@ -1,112 +1,113 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import styles from './NotificationView.module.scss' import styles from './NotificationView.module.scss'
import type { Notification } from '../../../graphql/types.gen'
import { formatDate } from '../../../utils' import { formatDate } from '../../../utils'
import { createMemo, createSignal, onMount, Show } from 'solid-js' 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 { openPage } from '@nanostores/router'
import { router } from '../../../stores/router' import { router } from '../../../stores/router'
import { useNotifications } from '../../../context/notifications' import { ServerNotification, useNotifications } from '../../../context/notifications'
import { Userpic } from '../../Author/Userpic' import { Userpic } from '../../Author/Userpic'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import notifications from '../../../graphql/query/notifications'
type Props = { type Props = {
notification: Notification notification: ServerNotification
onClick: () => void onClick: () => void
class?: string class?: string
} }
type NotificationData = { // NOTE: not a graphql generated type
shout: { export enum NotificationType {
slug: string NewComment = 'NEW_COMMENT',
title: string NewReply = 'NEW_REPLY',
} NewFollower = 'NEW_FOLLOWER',
users: { NewShout = 'NEW_SHOUT',
id: number NewLike = 'NEW_LIKE',
name: string NewDislike = 'NEW_DISLIKE'
slug: string }
userpic: string
}[] 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) => { export const NotificationView = (props: Props) => {
const { const {
actions: { markNotificationAsRead } actions: { markNotificationAsRead }
} = useNotifications() } = useNotifications()
const { t } = useLocalize() const { t } = useLocalize()
const [data, setData] = createSignal<ServerNotification>(null)
const [data, setData] = createSignal<NotificationData>(null) const [kind, setKind] = createSignal<NotificationType>()
onMount(() => { onMount(() => {
setTimeout(() => setData(JSON.parse(props.notification.data))) setTimeout(() => setData(props.notification))
}) })
const lastUser = createMemo(() => { const lastUser = createMemo(() => {
if (!data()) { return props.notification.kind === 'new_follower' ? data().payload : data().payload.author
return null
}
return data().users[data().users.length - 1]
}) })
const content = createMemo(() => { const content = createMemo(() => {
if (!data()) { if (!data()) {
return null return null
} }
let caption: string, author: Author, ntype: NotificationType
let shoutTitle = '' // TODO: count occurencies from in-browser notifications-db
let i = 0
const shoutTitleWords = data().shout.title.split(' ')
while (shoutTitle.length <= 30 && i < shoutTitleWords.length) { switch (props.notification.kind) {
shoutTitle += shoutTitleWords[i] + ' ' case 'new_follower': {
i++ caption = ''
author = data().payload
ntype = NotificationType.NewFollower
break
} }
case 'new_shout': {
if (shoutTitle.length < data().shout.title.length) { caption = data().payload.title
shoutTitle += '...' author = data().payload.authors[-1]
ntype = NotificationType.NewShout
break
} }
case 'new_reaction6': {
switch (props.notification.type) { ntype = data().payload.replyTo ? NotificationType.NewReply : NotificationType.NewComment
case NotificationType.NewComment: {
return t('NewCommentNotificationText', {
commentsCount: props.notification.occurrences,
shoutTitle,
lastCommenterName: lastUser().name,
restUsersCount: data().users.length - 1
})
} }
case NotificationType.NewReply: { case 'new_reaction0': {
return t('NewReplyNotificationText', { ntype = NotificationType.NewLike
commentsCount: props.notification.occurrences, }
shoutTitle, case 'new_reaction0': {
lastCommenterName: lastUser().name, ntype = NotificationType.NewDislike
restUsersCount: data().users.length - 1 }
}) // 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 = () => { const handleClick = () => {
if (!props.notification.seen) { if (!props.notification.seen) {
markNotificationAsRead(props.notification) markNotificationAsRead(props.notification)
} }
const subpath = props.notification.kind === 'new_follower' ? 'author' : 'article'
openPage(router, 'article', { slug: data().shout.slug }) const slug = props.notification.kind.startsWith('new_reaction')
? data().payload.shout.slug
: data().payload.slug
openPage(router, subpath, { slug })
props.onClick() 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 ( return (
@ -120,7 +121,7 @@ export const NotificationView = (props: Props) => {
<Userpic name={lastUser().name} userpic={lastUser().userpic} class={styles.userpic} /> <Userpic name={lastUser().name} userpic={lastUser().userpic} class={styles.userpic} />
<div>{content()}</div> <div>{content()}</div>
<div class={styles.timeContainer}> <div class={styles.timeContainer}>
{/*{formatDate(new Date(props.notification.createdAt), { month: 'numeric' })}*/} {/*{formatDate(new Date(props.notification.timestamp), { month: 'numeric' })}*/}
</div> </div>
</div> </div>
</Show> </Show>

View File

@ -4,7 +4,7 @@ import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler'
import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler' import { useOutsideClickHandler } from '../../utils/useOutsideClickHandler'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { Icon } from '../_shared/Icon' 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 { useNotifications } from '../../context/notifications'
import { NotificationView } from './NotificationView' import { NotificationView } from './NotificationView'

View File

@ -1,10 +1,9 @@
import type { Accessor, JSX } from 'solid-js' import type { Accessor, JSX } from 'solid-js'
import { createContext, createSignal, useContext } 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 type { Chat, Message, MutationCreateMessageArgs } from '../graphql/types.gen'
import { inboxClient } from '../utils/apiClient' import { inboxClient } from '../utils/apiClient'
import { getToken } from '../graphql/privateGraphQLClient'
import { loadMessages } from '../stores/inbox' import { loadMessages } from '../stores/inbox'
import { ServerNotification, useNotifications } from './notifications'
type InboxContextType = { type InboxContextType = {
chats: Accessor<Chat[]> chats: Accessor<Chat[]>
@ -26,26 +25,13 @@ export function useInbox() {
export const InboxProvider = (props: { children: JSX.Element }) => { export const InboxProvider = (props: { children: JSX.Element }) => {
const [chats, setChats] = createSignal<Chat[]>([]) const [chats, setChats] = createSignal<Chat[]>([])
const [messages, setMessages] = createSignal<Message[]>([]) const [messages, setMessages] = createSignal<Message[]>([])
const {
actions: { setMessageHandler }
} = useNotifications()
fetchEventSource('https://chat.discours.io/connect', { setMessageHandler((n: ServerNotification) => {
method: 'GET', console.debug(n)
headers: { // TODO: handle new message
'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
}
}) })
const loadChats = async () => { const loadChats = async () => {

View File

@ -1,22 +1,31 @@
import type { Accessor, JSX } from 'solid-js' import type { Accessor, JSX } from 'solid-js'
import { createContext, createEffect, createMemo, createSignal, useContext } from 'solid-js' import { createContext, createEffect, createMemo, createSignal, useContext } from 'solid-js'
import { useSession } from './session' import { useSession } from './session'
import SSEService, { EventData } from '../utils/sseService'
import { apiBaseUrl } from '../utils/config'
import { Portal } from 'solid-js/web' 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 { apiClient } from '../utils/apiClient'
import { createStore } from 'solid-js/store' 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 = { type NotificationsContextType = {
notificationEntities: Record<number, Notification> notificationEntities: Record<number, ServerNotification>
unreadNotificationsCount: Accessor<number> unreadNotificationsCount: Accessor<number>
sortedNotifications: Accessor<Notification[]> sortedNotifications: Accessor<ServerNotification[]>
actions: { actions: {
showNotificationsPanel: () => void showNotificationsPanel: () => void
markNotificationAsRead: (notification: Notification) => Promise<void> markNotificationAsRead: (notification: ServerNotification) => Promise<void>
setMessageHandler: (MessageHandler) => void
} }
} }
@ -26,53 +35,95 @@ export function useNotifications() {
return useContext(NotificationsContext) return useContext(NotificationsContext)
} }
const sseService = new SSEService()
export const NotificationsProvider = (props: { children: JSX.Element }) => { 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 { isAuthenticated, user } = useSession() const { isAuthenticated, user } = useSession()
const [notificationEntities, setNotificationEntities] = createStore<Record<number, Notification>>({}) const [notificationEntities, setNotificationEntities] = createStore<Record<number, ServerNotification>>(
{}
)
const dbPromise = openDB('notifications-db', 1, {
upgrade(db) {
db.createObjectStore('notifications')
}
})
const loadNotifications = async () => { const loadNotifications = async () => {
const { notifications, totalUnreadCount } = await apiClient.getNotifications({ const db = await dbPromise
limit: 100 const notifications = await db.getAll('notifications')
}) const totalUnreadCount = notifications.filter((notification) => !notification.read).length
const newNotificationEntities = notifications.reduce((acc, notification) => {
setUnreadNotificationsCount(totalUnreadCount)
setNotificationEntities(
notifications.reduce((acc, notification) => {
acc[notification.id] = notification acc[notification.id] = notification
return acc return acc
}, {}) }, {})
)
setUnreadNotificationsCount(totalUnreadCount)
setNotificationEntities(newNotificationEntities)
return notifications return notifications
} }
const sortedNotifications = createMemo(() => { const sortedNotifications = createMemo(() => {
return Object.values(notificationEntities).sort( return Object.values(notificationEntities).sort((a, b) => b.timestamp - a.timestamp)
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
}) })
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(() => { createEffect(() => {
if (isAuthenticated()) { if (isAuthenticated()) {
loadNotifications() loadNotifications()
sseService.connect(`${apiBaseUrl}/subscribe/${user().id}`) fetchEventSource('https://chat.discours.io/connect', {
sseService.subscribeToEvent('message', (data: EventData) => { method: 'GET',
if (data.type === 'newNotifications') { headers: {
loadNotifications() '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 { } else {
console.error(`[NotificationsProvider] unknown message type: ${JSON.stringify(data)}`) 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) => { const markNotificationAsRead = async (notification: ServerNotification) => {
await apiClient.markNotificationAsRead(notification.id) const db = await dbPromise
await db.put('notifications', { ...notification, seen: true })
loadNotifications() loadNotifications()
} }
@ -80,7 +131,7 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
setIsNotificationsPanelOpen(true) setIsNotificationsPanelOpen(true)
} }
const actions = { showNotificationsPanel, markNotificationAsRead } const actions = { showNotificationsPanel, markNotificationAsRead, setMessageHandler }
const value: NotificationsContextType = { const value: NotificationsContextType = {
notificationEntities, notificationEntities,

View File

@ -314,7 +314,11 @@ export type Notification = {
export enum NotificationType { export enum NotificationType {
NewComment = 'NEW_COMMENT', NewComment = 'NEW_COMMENT',
NewReply = 'NEW_REPLY' NewReply = 'NEW_REPLY',
NewFollower = 'NEW_FOLLOWER',
NewShout = 'NEW_SHOUT',
NewLike = 'NEW_LIKE',
NewDislike = 'NEW_DISLIKE'
} }
export type NotificationsQueryParams = { export type NotificationsQueryParams = {

View File

@ -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