diff --git a/locales/en/translation.json b/locales/en/translation.json index 4b27a4b863..8ea9603e7a 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -24,34 +24,44 @@ }, "notification": { "favourite": { + "title": "Favourite", "body": "{user} favourited your post" }, "reblog": { + "title": "Boost", "body": "{user} boosted your post" }, "quote": { + "title": "Quote", "body": "{user} quoted your post" }, "poll_expired": { + "title": "Poll", "body": "{user} poll has ended" }, "poll_vote": { + "title": "Poll", "body": "{user} voted your poll" }, "status": { + "title": "Status", "body": "{user} just posted" }, "update": { + "title": "Update", "body": "{user} updated the post" }, "emoji_reaction": { + "title": "Reaction", "body": "{user} reacted your post" }, "follow": { + "title": "Follow", "body": "{user} followed you", "followers": "{num} followers" }, "follow_request": { + "title": "Follow Request", "body": "{user} requested to follow you" } } diff --git a/package.json b/package.json index 28b4df6c5d..9984cd9d33 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,15 @@ "megalodon": "^9.1.1", "react-icons": "^4.11.0", "react-intl": "^6.5.1", - "react-virtuoso": "^4.6.2" + "react-virtuoso": "^4.6.2", + "sanitize-html": "^2.11.0" }, "devDependencies": { "@babel/runtime-corejs3": "^7.23.2", "@electron/notarize": "^2.1.0", "@types/node": "^18.11.18", "@types/react": "^18.0.26", + "@types/sanitize-html": "^2.9.4", "autoprefixer": "^10.4.16", "electron": "^26.2.2", "electron-builder": "^24.6.4", diff --git a/renderer/components/timelines/Notifications.tsx b/renderer/components/timelines/Notifications.tsx index 304b434eee..2380d0b6ad 100644 --- a/renderer/components/timelines/Notifications.tsx +++ b/renderer/components/timelines/Notifications.tsx @@ -47,6 +47,7 @@ export default function Notifications(props: Props) { if (streaming.current) { streaming.current.removeAllListeners() streaming.current.stop() + streaming.current = null console.log('closed notifications') } } diff --git a/renderer/components/timelines/Timeline.tsx b/renderer/components/timelines/Timeline.tsx index c3d044173f..566732b345 100644 --- a/renderer/components/timelines/Timeline.tsx +++ b/renderer/components/timelines/Timeline.tsx @@ -62,6 +62,7 @@ export default function Timeline(props: Props) { if (streaming.current) { streaming.current.removeAllListeners() streaming.current.stop() + streaming.current = null console.log(`closed ${props.timeline}`) } } diff --git a/renderer/pages/accounts/[id]/[timeline].tsx b/renderer/pages/accounts/[id]/[timeline].tsx index f53586ab7f..04308e29d9 100644 --- a/renderer/pages/accounts/[id]/[timeline].tsx +++ b/renderer/pages/accounts/[id]/[timeline].tsx @@ -1,14 +1,18 @@ import { useRouter } from 'next/router' import Timeline from '@/components/timelines/Timeline' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Account, db } from '@/db' -import generator, { MegalodonInterface } from 'megalodon' +import generator, { Entity, MegalodonInterface, WebSocketInterface } from 'megalodon' import Notifications from '@/components/timelines/Notifications' +import generateNotification from '@/utils/notification' +import { useIntl } from 'react-intl' export default function Page() { const router = useRouter() const [account, setAccount] = useState(null) const [client, setClient] = useState(null) + const streaming = useRef(null) + const { formatMessage } = useIntl() useEffect(() => { if (router.query.id) { @@ -18,10 +22,33 @@ export default function Page() { setAccount(a) const c = generator(a.sns, a.url, a.access_token, 'Whalebird') setClient(c) + + // Start user streaming for notification + const instance = await c.getInstance() + const ws = generator(a.sns, instance.data.urls.streaming_api, a.access_token, 'Whalebird') + streaming.current = ws.userSocket() + streaming.current.on('connect', () => { + console.log('connect to user streaming') + }) + streaming.current.on('notification', (notification: Entity.Notification) => { + const [title, body] = generateNotification(notification, formatMessage) + if (title.length > 0) { + new window.Notification(title, { body: body }) + } + }) } } f() } + + return () => { + if (streaming.current) { + streaming.current.removeAllListeners() + streaming.current.stop() + streaming.current = null + console.log('close user streaming') + } + } }, [router.query.id]) if (!account || !client) return null diff --git a/renderer/utils/notification.ts b/renderer/utils/notification.ts new file mode 100644 index 0000000000..1bbd7ab334 --- /dev/null +++ b/renderer/utils/notification.ts @@ -0,0 +1,74 @@ +import sanitizeHtml from 'sanitize-html' +import { Entity } from 'megalodon' +import { MessageDescriptor } from 'react-intl' + +const generateNotification = ( + notification: Entity.Notification, + formatMessage: (descriptor: MessageDescriptor, values?: any, opts?: any) => string +): [string, string] => { + switch (notification.type) { + case 'follow': + return [ + formatMessage({ id: 'timeline.notification.follow.title' }), + formatMessage({ id: 'timeline.notification.follow.body' }, { user: notification.account.acct }) + ] + case 'follow_request': + return [ + formatMessage({ id: 'timeline.notification.follow_request.title' }), + formatMessage({ id: 'timeline.notification.follow_requested.body' }, { user: notification.account.acct }) + ] + case 'favourite': + return [ + formatMessage({ id: 'timeline.notification.favourite.title' }), + formatMessage({ id: 'timeline.notification.favourite.body' }, { user: notification.account.acct }) + ] + case 'reblog': + return [ + formatMessage({ id: 'timeline.notification.reblog.title' }), + formatMessage({ id: 'timeline.notification.reblog.body' }, { user: notification.account.acct }) + ] + case 'poll_expired': + return [ + formatMessage({ id: 'timeline.notification.poll_expired.title' }), + formatMessage({ id: 'timeline.notification.poll_expired.body' }, { user: notification.account.acct }) + ] + case 'poll_vote': + return [ + formatMessage({ id: 'timeline.notification.poll_vote.title' }), + formatMessage({ id: 'timeline.notification.poll_vote.body' }, { user: notification.account.acct }) + ] + case 'quote': + return [ + formatMessage({ id: 'timeline.notification.quote.title' }), + formatMessage({ id: 'timeline.notification.quote.body' }, { user: notification.account.acct }) + ] + case 'status': + return [ + formatMessage({ id: 'timeline.notification.status.title' }), + formatMessage({ id: 'timeline.notification.status.body' }, { user: notification.account.acct }) + ] + case 'update': + return [ + formatMessage({ id: 'timeline.notification.update.title' }), + formatMessage({ id: 'timeline.notification.update.body' }, { user: notification.account.acct }) + ] + case 'emoji_reaction': + case 'reaction': + return [ + formatMessage({ id: 'timeline.notification.emoji_reaction.title' }), + formatMessage({ id: 'timeline.notification.emoji_reaction.body' }, { user: notification.account.acct }) + ] + case 'mention': + return [ + `${notification.account.acct}`, + sanitizeHtml(notification.status!.content, { + allowedTags: [], + allowedAttributes: false + }) + ] + default: + return ['', ''] + } +} + +export default generateNotification diff --git a/yarn.lock b/yarn.lock index bd63224545..52ccb37505 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1480,6 +1480,13 @@ dependencies: "@types/node" "*" +"@types/sanitize-html@^2.9.4": + version "2.9.4" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.9.4.tgz#bfc2df463ec35904fecc57b29ba080e53732a140" + integrity sha512-Ym4hjmAFxF/eux7nW2yDPAj2o9RYh0vP/9V5ECoHtgJ/O9nPGslUd20CMn6WatRMlFVfjMTg3lMcWq8YyO6QnA== + dependencies: + htmlparser2 "^8.0.0" + "@types/scheduler@*": version "0.16.5" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.5.tgz#4751153abbf8d6199babb345a52e1eb4167d64af" @@ -2271,6 +2278,11 @@ decompress-response@^6.0.0: dependencies: mimic-response "^3.1.0" +deepmerge@^4.2.2: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + defer-to-connect@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" @@ -2355,6 +2367,36 @@ dmg-license@^1.0.11: smart-buffer "^4.0.2" verror "^1.10.0" +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" + integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + dot-prop@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-6.0.1.tgz#fc26b3cf142b9e59b74dbd39ed66ce620c681083" @@ -2461,6 +2503,11 @@ enhanced-resolve@^5.15.0, enhanced-resolve@^5.7.0: graceful-fs "^4.2.4" tapable "^2.2.0" +entities@^4.2.0, entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + env-paths@^2.2.0, env-paths@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" @@ -2915,6 +2962,16 @@ hosted-git-info@^4.1.0: dependencies: lru-cache "^6.0.0" +htmlparser2@^8.0.0: + version "8.0.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" + integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + entities "^4.4.0" + http-cache-semantics@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" @@ -3048,6 +3105,11 @@ is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -3563,6 +3625,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -3689,7 +3756,7 @@ postcss@8.4.14: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.4.23, postcss@^8.4.31: +postcss@^8.3.11, postcss@^8.4.23, postcss@^8.4.31: version "8.4.31" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== @@ -3954,6 +4021,18 @@ sanitize-filename@^1.6.3: dependencies: truncate-utf8-bytes "^1.0.0" +sanitize-html@^2.11.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.11.0.tgz#9a6434ee8fcaeddc740d8ae7cd5dd71d3981f8f6" + integrity sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA== + dependencies: + deepmerge "^4.2.2" + escape-string-regexp "^4.0.0" + htmlparser2 "^8.0.0" + is-plain-object "^5.0.0" + parse-srcset "^1.0.2" + postcss "^8.3.11" + sax@^1.2.4: version "1.3.0" resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0"