Push notification (#5364)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2024-04-16 11:54:29 +05:00 committed by GitHub
parent d2681eba2e
commit bb1abdc8cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 470 additions and 147 deletions

View File

@ -935,6 +935,9 @@ dependencies:
'@types/uuid':
specifier: ^8.3.1
version: 8.3.4
'@types/web-push':
specifier: ~3.6.3
version: 3.6.3
'@types/ws':
specifier: ^8.5.3
version: 8.5.10
@ -1268,6 +1271,9 @@ dependencies:
uuid:
specifier: ^8.3.2
version: 8.3.2
web-push:
specifier: ~3.6.7
version: 3.6.7
webpack:
specifier: ^5.75.0
version: 5.90.3(esbuild@0.20.1)(webpack-cli@5.1.4)
@ -6548,6 +6554,12 @@ packages:
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
dev: false
/@types/web-push@3.6.3:
resolution: {integrity: sha512-v3oT4mMJsHeJ/rraliZ+7TbZtr5bQQuxcgD7C3/1q/zkAj29c8RE0F9lVZVu3hiQe5Z9fYcBreV7TLnfKR+4mg==}
dependencies:
'@types/node': 20.11.19
dev: false
/@types/webidl-conversions@7.0.3:
resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
dev: false
@ -7016,6 +7028,15 @@ packages:
- supports-color
dev: false
/agent-base@7.1.1:
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==}
engines: {node: '>= 14'}
dependencies:
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: false
/aggregate-error@3.1.0:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
@ -7276,6 +7297,15 @@ packages:
is-shared-array-buffer: 1.0.3
dev: false
/asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
dependencies:
bn.js: 4.12.0
inherits: 2.0.4
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
dev: false
/assert@2.1.0:
resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==}
dependencies:
@ -7594,6 +7624,10 @@ packages:
readable-stream: 3.6.2
dev: false
/bn.js@4.12.0:
resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==}
dev: false
/body-parser@1.20.2:
resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@ -7714,6 +7748,10 @@ packages:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
dev: false
/buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
dev: false
/buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
dev: false
@ -8869,6 +8907,12 @@ packages:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
dev: false
/ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
dependencies:
safe-buffer: 5.2.1
dev: false
/ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: false
@ -10784,6 +10828,11 @@ packages:
resolve-alpn: 1.2.1
dev: false
/http_ece@1.2.0:
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
engines: {node: '>=16'}
dev: false
/https-proxy-agent@4.0.0:
resolution: {integrity: sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==}
engines: {node: '>= 6.0.0'}
@ -10804,6 +10853,16 @@ packages:
- supports-color
dev: false
/https-proxy-agent@7.0.4:
resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==}
engines: {node: '>= 14'}
dependencies:
agent-base: 7.1.1
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: false
/human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@ -11981,6 +12040,21 @@ packages:
resolution: {integrity: sha512-1IynUYEc/HAwxhi3WDpIpxJbZpMCvvrrmZVqvj9EhpvbH8lls7HhdhiByjL7DkAaWlLIzpC0Xc/VPvy/UxLNjA==}
dev: false
/jwa@2.0.0:
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
dev: false
/jws@4.0.0:
resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
dependencies:
jwa: 2.0.0
safe-buffer: 5.2.1
dev: false
/jwt-simple@0.5.6:
resolution: {integrity: sha512-40aUybvhH9t2h71ncA1/1SbtTNCVZHgsTsTgqPUxGWDmUDrXyDf2wMNQKEbdBjbf4AI+fQhbECNTV6lWxQKUzg==}
engines: {node: '>= 0.4.0'}
@ -16341,6 +16415,20 @@ packages:
'@zxing/text-encoding': 0.9.0
dev: false
/web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
hasBin: true
dependencies:
asn1.js: 5.4.1
http_ece: 1.2.0
https-proxy-agent: 7.0.4
jws: 4.0.0
minimist: 1.2.8
transitivePeerDependencies:
- supports-color
dev: false
/webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: false
@ -22283,12 +22371,13 @@ packages:
dev: false
file:projects/server-notification-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2):
resolution: {integrity: sha512-syXpJRNP0osGwuo3+uTE08SmRqjBPEo4mv6B6hqSvwTXLc7ZbmkEAXbonr+ykj1YQk15u5hS8JjRnWSGStuy7A==, tarball: file:projects/server-notification-resources.tgz}
resolution: {integrity: sha512-9ctaiwEU+M3RZ4Qz0/OuQ1lLh4Os9V8vlpf2FK/cpGOvAt5RUfV0qwiJLaImsLVbS62uz6hbZ1oy7Q3T+qex8Q==, tarball: file:projects/server-notification-resources.tgz}
id: file:projects/server-notification-resources.tgz
name: '@rush-temp/server-notification-resources'
version: 0.0.0
dependencies:
'@types/jest': 29.5.12
'@types/web-push': 3.6.3
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3)
eslint: 8.56.0
@ -22301,6 +22390,7 @@ packages:
prettier-plugin-svelte: 3.2.1(prettier@3.2.5)(svelte@4.2.11)
ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3)
typescript: 5.3.3
web-push: 3.6.7
transitivePeerDependencies:
- '@babel/core'
- '@jest/types'

View File

@ -156,6 +156,7 @@ services:
- MINIO_SECRET_KEY=minioadmin
- REKONI_URL=http://rekoni:4004
- FRONT_URL=http://localhost:8087
- UPLOAD_URL=http://localhost:8087/files
# - APM_SERVER_URL=http://apm-server:8200
- SERVER_PROVIDER=ws
- ACCOUNTS_URL=http://account:3000

View File

@ -30,7 +30,7 @@ import { imageCropperId } from '@hcengineering/image-cropper'
import { inventoryId } from '@hcengineering/inventory'
import { leadId } from '@hcengineering/lead'
import login, { loginId } from '@hcengineering/login'
import { notificationId } from '@hcengineering/notification'
import notification, { notificationId } from '@hcengineering/notification'
import { recruitId } from '@hcengineering/recruit'
import rekoni from '@hcengineering/rekoni'
import { requestId } from '@hcengineering/request'
@ -96,6 +96,7 @@ interface Config {
CALENDAR_URL: string
COLLABORATOR_URL: string
COLLABORATOR_API_URL: string
PUSH_PUBLIC_KEY: string
TITLE?: string
LANGUAGES?: string
DEFAULT_LANGUAGE?: string
@ -158,6 +159,7 @@ export async function configurePlatform() {
setMetadata(telegram.metadata.TelegramURL, config.TELEGRAM_URL ?? 'http://localhost:8086')
setMetadata(gmail.metadata.GmailURL, config.GMAIL_URL ?? 'http://localhost:8087')
setMetadata(calendar.metadata.CalendarServiceURL, config.CALENDAR_URL ?? 'http://localhost:8095')
setMetadata(notification.metadata.PushPublicKey, config.PUSH_PUBLIC_KEY)
setMetadata(login.metadata.OverrideEndpoint, process.env.LOGIN_ENDPOINT)

View File

@ -36,7 +36,42 @@ const doValidate = !prod || (process.env.DO_VALIDATE === 'true')
/**
* @type {Configuration}
*/
module.exports = {
module.exports = [
{
mode: dev ? 'development' : mode,
entry: {
serviceWorker: '@hcengineering/notification/src/serviceWorker.ts'
},
module: {
rules: [
{
test: /\.ts$/,
exclude: /(node_modules|\.webpack)/,
use: {
loader: 'esbuild-loader',
options: {
target: 'es2021',
keepNames: true,
minify: !prod,
sourcemap: !prod
}
}
}
]
},
output: {
path: __dirname + '/dist',
filename: '[name].js',
chunkFilename: '[name].js',
publicPath: '/',
pathinfo: false
},
resolve: {
extensions: ['.ts', '.js'],
conditionNames: ['svelte', 'browser', 'import']
}
},
{
entry: {
bundle: [
'@hcengineering/theme/styles/global.scss',
@ -278,4 +313,4 @@ module.exports = {
}
}
}
}
}]

View File

@ -73,7 +73,9 @@ import {
type NotificationSetting,
type NotificationStatus,
type NotificationTemplate,
type NotificationType
type PushSubscription,
type NotificationType,
type PushSubscriptionKeys
} from '@hcengineering/notification'
import { getEmbeddedLabel, type Asset, type IntlString, type Resource } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
@ -96,6 +98,13 @@ export class TBrowserNotification extends TDoc implements BrowserNotification {
status!: NotificationStatus
}
@Model(notification.class.PushSubscription, core.class.Doc, DOMAIN_NOTIFICATION)
export class TPushSubscription extends TDoc implements PushSubscription {
user!: Ref<Account>
endpoint!: string
keys!: PushSubscriptionKeys
}
@Model(notification.class.BaseNotificationType, core.class.Doc, DOMAIN_MODEL)
export class TBaseNotificationType extends TDoc implements BaseNotificationType {
generated!: boolean
@ -335,7 +344,8 @@ export function createModel (builder: Builder): void {
TActivityNotificationViewlet,
TBaseNotificationType,
TCommonNotificationType,
TMentionInboxNotification
TMentionInboxNotification,
TPushSubscription
)
builder.createDoc(

View File

@ -29,7 +29,13 @@
</script>
<script lang="ts">
import contact, { AvatarProvider, AvatarType, getFirstName, getLastName } from '@hcengineering/contact'
import contact, {
AvatarProvider,
AvatarType,
getFirstName,
getLastName,
getAvatarProviderId
} from '@hcengineering/contact'
import { Asset, getMetadata, getResource } from '@hcengineering/platform'
import { getBlobURL, getClient } from '@hcengineering/presentation'
import {
@ -43,7 +49,6 @@
themeStore,
resizeObserver
} from '@hcengineering/ui'
import { getAvatarProviderId } from '../utils'
import AvatarIcon from './icons/Avatar.svelte'
import { onMount } from 'svelte'

View File

@ -15,43 +15,42 @@
//
import {
type AvatarProvider,
AvatarType,
type ChannelProvider,
type Contact,
type Employee,
type Person,
type PersonAccount,
contactId,
formatName,
getFirstName,
getLastName,
getName,
type Channel
type Channel,
type ChannelProvider,
type Contact,
type Employee,
type Person,
type PersonAccount
} from '@hcengineering/contact'
import {
getCurrentAccount,
toIdMap,
type Class,
type Client,
type Doc,
type IdMap,
type ObjQueryType,
type Ref,
type Timestamp,
type TxOperations,
getCurrentAccount,
toIdMap,
type Class
type TxOperations
} from '@hcengineering/core'
import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification'
import { getEmbeddedLabel, getResource, translate } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { type TemplateDataProvider } from '@hcengineering/templates'
import {
type Location,
type ResolvedLocation,
type TabItem,
getCurrentResolvedLocation,
getPanelURI,
type LabelAndProps
type LabelAndProps,
type Location,
type ResolvedLocation,
type TabItem
} from '@hcengineering/ui'
import view, { type Filter } from '@hcengineering/view'
import { FilterQuery } from '@hcengineering/view-resources'
@ -370,24 +369,6 @@ export function getAvatarTypeDropdownItems (hasGravatar: boolean, imageOnly?: bo
]
}
export function getAvatarProviderId (avatar?: string | null): Ref<AvatarProvider> | undefined {
if (avatar === null || avatar === undefined || avatar === '') {
return
}
if (!avatar.includes('://')) {
return contact.avatarProvider.Image
}
const [schema] = avatar.split('://')
switch (schema) {
case AvatarType.GRAVATAR:
return contact.avatarProvider.Gravatar
case AvatarType.COLOR:
return contact.avatarProvider.Color
}
return contact.avatarProvider.Image
}
export async function contactTitleProvider (client: Client, ref: Ref<Contact>, doc?: Contact): Promise<string> {
const object = doc ?? (await client.findOne(contact.class.Contact, { _id: ref }))
if (object === undefined) return ''

View File

@ -16,7 +16,7 @@
import { AttachedData, Class, Client, Doc, FindResult, Ref, Hierarchy } from '@hcengineering/core'
import { IconSize, ColorDefinition } from '@hcengineering/ui'
import { MD5 } from 'crypto-js'
import { Channel, Contact, contactPlugin, Person } from '.'
import { AvatarProvider, AvatarType, Channel, Contact, contactPlugin, Person } from '.'
import { AVATAR_COLORS, GravatarPlaceholderType } from './types'
import { getMetadata } from '@hcengineering/platform'
@ -55,6 +55,27 @@ export function buildGravatarId (email: string): string {
return MD5(email.trim().toLowerCase()).toString()
}
/**
* @public
*/
export function getAvatarProviderId (avatar?: string | null): Ref<AvatarProvider> | undefined {
if (avatar === null || avatar === undefined || avatar === '') {
return
}
if (!avatar.includes('://')) {
return contactPlugin.avatarProvider.Image
}
const [schema] = avatar.split('://')
switch (schema) {
case AvatarType.GRAVATAR:
return contactPlugin.avatarProvider.Gravatar
case AvatarType.COLOR:
return contactPlugin.avatarProvider.Color
}
return contactPlugin.avatarProvider.Image
}
/**
* @public
*/

View File

@ -14,60 +14,76 @@
-->
<script lang="ts">
import { getCurrentAccount } from '@hcengineering/core'
import notification, { BrowserNotification, NotificationStatus } from '@hcengineering/notification'
import { createQuery, getClient } from '@hcengineering/presentation'
import { getCurrentLocation, navigate } from '@hcengineering/ui'
import { askPermission } from '../utils'
let notifications: BrowserNotification[] = []
const query = createQuery()
query.query(
notification.class.BrowserNotification,
{
user: getCurrentAccount()._id,
status: NotificationStatus.New
},
(res) => {
notifications = res
}
)
import notification from '@hcengineering/notification'
import { getMetadata } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { getCurrentLocation, navigate, parseLocation } from '@hcengineering/ui'
const client = getClient()
$: process(notifications)
const publicKey = getMetadata(notification.metadata.PushPublicKey)
async function process (notifications: BrowserNotification[]): Promise<void> {
if (notifications.length === 0) return
await askPermission()
if ('Notification' in window && Notification?.permission === 'granted') {
for (const value of notifications) {
const req: NotificationOptions = {
body: value.body,
tag: value._id,
silent: false
async function subscribe (): Promise<void> {
if ('serviceWorker' in navigator && 'PushManager' in window && publicKey !== undefined) {
try {
const loc = getCurrentLocation()
const registration = await navigator.serviceWorker.register('/serviceWorker.js', {
scope: `./${loc.path[0]}/${loc.path[1]}`
})
const current = await registration.pushManager.getSubscription()
if (current == null) {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey
})
await client.createDoc(notification.class.PushSubscription, notification.space.Notifications, {
user: getCurrentAccount()._id,
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
auth: arrayBufferToBase64(subscription.getKey('auth'))
}
})
} else {
const exists = await client.findOne(notification.class.PushSubscription, {
user: getCurrentAccount()._id,
endpoint: current.endpoint
})
if (exists === undefined) {
await client.createDoc(notification.class.PushSubscription, notification.space.Notifications, {
user: getCurrentAccount()._id,
endpoint: current.endpoint,
keys: {
p256dh: arrayBufferToBase64(current.getKey('p256dh')),
auth: arrayBufferToBase64(current.getKey('auth'))
}
})
}
}
const notification = new Notification(value.title, req)
if (value.onClickLocation !== undefined) {
const loc = getCurrentLocation()
loc.path.length = 3
loc.path[2] = value.onClickLocation.path[2]
if (value.onClickLocation.path[3]) {
loc.path[3] = value.onClickLocation.path[3]
if (value.onClickLocation.path[4]) {
loc.path[4] = value.onClickLocation.path[4]
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'notification-click') {
const { url } = event.data
if (url !== undefined) {
navigate(parseLocation(new URL(url)))
}
}
loc.query = value.onClickLocation.query
loc.fragment = value.onClickLocation.fragment
const onClick = () => {
navigate(loc)
window.parent.parent.focus()
}
notification.onclick = onClick
}
await client.update(value, { status: NotificationStatus.Notified })
})
} catch (err) {
console.error('Service Worker registration failed:', err)
}
}
}
function arrayBufferToBase64 (buffer: ArrayBuffer | null): string {
if (buffer) {
const bytes = new Uint8Array(buffer)
const array = Array.from(bytes)
const binary = String.fromCharCode.apply(null, array)
return btoa(binary)
} else {
return ''
}
}
subscribe()
</script>

View File

@ -30,7 +30,7 @@ import {
TxCUD,
TxOperations
} from '@hcengineering/core'
import type { Asset, IntlString, Plugin, Resource } from '@hcengineering/platform'
import type { Asset, IntlString, Metadata, Plugin, Resource } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import { Preference } from '@hcengineering/preference'
import { IntegrationType } from '@hcengineering/setting'
@ -51,6 +51,26 @@ export interface BrowserNotification extends Doc {
onClickLocation?: Location
}
export interface PushData {
tag?: string
title: string
body: string
icon?: string
domain?: string
url?: string
}
export interface PushSubscriptionKeys {
p256dh: string
auth: string
}
export interface PushSubscription extends Doc {
user: Ref<Account>
endpoint: string
keys: PushSubscriptionKeys
}
/**
* @public
*/
@ -332,6 +352,7 @@ const notification = plugin(notificationId, {
},
class: {
BrowserNotification: '' as Ref<Class<BrowserNotification>>,
PushSubscription: '' as Ref<Class<PushSubscription>>,
BaseNotificationType: '' as Ref<Class<BaseNotificationType>>,
NotificationType: '' as Ref<Class<NotificationType>>,
CommonNotificationType: '' as Ref<Class<CommonNotificationType>>,
@ -353,6 +374,9 @@ const notification = plugin(notificationId, {
CollaboratoAddNotification: '' as Ref<NotificationType>,
MentionCommonNotificationType: '' as Ref<CommonNotificationType>
},
metadata: {
PushPublicKey: '' as Metadata<string>
},
providers: {
PlatformNotification: '' as Ref<NotificationProvider>,
BrowserNotification: '' as Ref<NotificationProvider>,

View File

@ -0,0 +1,58 @@
import type { PushData } from './index'
declare const self: any
interface PushEvent extends Event {
data: PushMessageData
}
interface PushMessageData {
json: () => any
}
self.addEventListener('push', (event: PushEvent) => {
const payload: PushData = event.data.json()
self.registration.showNotification(payload.title, {
body: payload.body,
icon: payload.icon,
tag: payload.tag,
data: {
domain: payload.domain,
url: payload.url
}
})
})
// Listen for notification click event
self.addEventListener('notificationclick', (event: any) => {
const clickedNotification = event.notification
const notificationData = clickedNotification.data
const notificationUrl = notificationData.url
const domain = notificationData.domain
if (notificationUrl !== undefined && domain !== undefined) {
// Check if any client with the same origin is already open
event.waitUntil(
// Check all active clients (browser windows or tabs)
self.clients
.matchAll({
type: 'window',
includeUncontrolled: true
})
.then((clientList: any) => {
// Loop through each client
for (const client of clientList) {
// If a client has the same URL origin, focus and navigate to it
if ((client.url as string)?.startsWith(domain)) {
client.postMessage({
type: 'notification-click',
url: notificationUrl
})
return client.focus()
}
}
// If no client with the same URL origin is found, open a new window/tab
return self.clients.openWindow(notificationUrl)
})
)
}
})

View File

@ -22,49 +22,42 @@ import serverCore, { type StorageConfiguration } from '@hcengineering/server-cor
import serverNotification from '@hcengineering/server-notification'
import serverToken from '@hcengineering/server-token'
import { start } from '.'
import notification from '@hcengineering/notification'
const {
url,
frontUrl,
serverSecret,
sesUrl,
elasticUrl,
elasticIndexName,
accountsUrl,
rekoniUrl,
serverFactory,
serverPort,
enableCompression
} = serverConfigFromEnv()
const config = serverConfigFromEnv()
const storageConfig: StorageConfiguration = storageConfigFromEnv()
const cursorMaxTime = process.env.SERVER_CURSOR_MAXTIMEMS
const lastNameFirst = process.env.LAST_NAME_FIRST === 'true'
setMetadata(serverCore.metadata.CursorMaxTimeMS, cursorMaxTime)
setMetadata(serverCore.metadata.FrontUrl, frontUrl)
setMetadata(serverToken.metadata.Secret, serverSecret)
setMetadata(serverNotification.metadata.SesUrl, sesUrl ?? '')
setMetadata(serverCore.metadata.FrontUrl, config.frontUrl)
setMetadata(serverCore.metadata.UploadURL, config.uploadUrl)
setMetadata(serverToken.metadata.Secret, config.serverSecret)
setMetadata(serverNotification.metadata.SesUrl, config.sesUrl ?? '')
setMetadata(notification.metadata.PushPublicKey, config.pushPublicKey)
setMetadata(serverNotification.metadata.PushPrivateKey, config.pushPrivateKey)
setMetadata(serverNotification.metadata.PushSubject, config.pushSubject)
setMetadata(contactPlugin.metadata.LastNameFirst, lastNameFirst)
setMetadata(serverCore.metadata.ElasticIndexName, elasticIndexName)
setMetadata(serverCore.metadata.ElasticIndexName, config.elasticIndexName)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
console.log(
`starting server on ${serverPort} git_version: ${process.env.GIT_REVISION ?? ''} model_version: ${
`starting server on ${config.serverPort} git_version: ${process.env.GIT_REVISION ?? ''} model_version: ${
process.env.MODEL_VERSION ?? ''
}`
)
const shutdown = start(url, {
fullTextUrl: elasticUrl,
const shutdown = start(config.url, {
fullTextUrl: config.elasticUrl,
storageConfig,
rekoniUrl,
port: serverPort,
serverFactory,
rekoniUrl: config.rekoniUrl,
port: config.serverPort,
serverFactory: config.serverFactory,
indexParallel: 2,
indexProcessing: 50,
productId: '',
enableCompression,
accountsUrl
enableCompression: config.enableCompression,
accountsUrl: config.accountsUrl
})
const close = (): void => {

View File

@ -29,7 +29,8 @@
"typescript": "^5.3.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5"
"@types/jest": "^29.5.5",
"@types/web-push": "~3.6.3"
},
"dependencies": {
"@hcengineering/activity": "^0.6.0",
@ -38,8 +39,10 @@
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/server-notification": "^0.6.1",
"@hcengineering/notification": "^0.6.16",
"@hcengineering/workbench": "^0.6.9",
"@hcengineering/chunter": "^0.6.12",
"@hcengineering/view": "^0.6.9",
"@hcengineering/contact": "^0.6.20"
"@hcengineering/contact": "^0.6.20",
"web-push": "~3.6.7"
}
}

View File

@ -14,8 +14,16 @@
// limitations under the License.
//
import activity, { ActivityMessage } from '@hcengineering/activity'
import chunter, { ChatMessage } from '@hcengineering/chunter'
import contact, { Employee, formatName, Person, PersonAccount } from '@hcengineering/contact'
import contact, {
Employee,
formatName,
getAvatarProviderId,
getGravatarUrl,
Person,
PersonAccount
} from '@hcengineering/contact'
import core, {
Account,
AnyAttribute,
@ -45,25 +53,25 @@ import core, {
import notification, {
ActivityInboxNotification,
BaseNotificationType,
BrowserNotification,
ClassCollaborators,
Collaborators,
CommonInboxNotification,
DocNotifyContext,
InboxNotification,
notificationId,
NotificationStatus,
NotificationType
NotificationType,
PushData
} from '@hcengineering/notification'
import { getMetadata, getResource, translate } from '@hcengineering/platform'
import type { TriggerControl } from '@hcengineering/server-core'
import serverCore from '@hcengineering/server-core'
import serverNotification, {
getEmployee,
getPersonAccount,
getPersonAccountById
} from '@hcengineering/server-notification'
import activity, { ActivityMessage } from '@hcengineering/activity'
import { workbenchId } from '@hcengineering/workbench'
import webpush, { WebPushError } from 'web-push'
import { Content, NotifyResult } from './types'
import {
getHTMLPresenter,
@ -139,6 +147,7 @@ export async function getCommonNotificationTxes (
data,
_class,
modifiedOn,
sender as Ref<PersonAccount>,
notifyResult.push
)
}
@ -369,6 +378,7 @@ export async function pushInboxNotifications (
data: Partial<Data<InboxNotification>>,
_class: Ref<Class<InboxNotification>>,
modifiedOn: Timestamp,
senderId: Ref<PersonAccount>,
shouldPush: boolean,
shouldUpdateTimestamp = true
): Promise<void> {
@ -410,10 +420,15 @@ export async function pushInboxNotifications (
const notificationTx = control.txFactory.createTxCreateDoc(_class, space, notificationData)
res.push(notificationTx)
if (shouldPush) {
const pushTx = await createPushFromInbox(control, targetUser, space, docNotifyContextId, notificationData, _class)
if (pushTx !== undefined) {
res.push(pushTx)
}
await createPushFromInbox(
control,
targetUser,
docNotifyContextId,
notificationData,
_class,
senderId,
notificationTx.objectId
)
}
}
}
@ -467,11 +482,12 @@ async function commonInboxNotificationToText (doc: Data<CommonInboxNotification>
export async function createPushFromInbox (
control: TriggerControl,
targetUser: Ref<Account>,
space: Ref<Space>,
docNotifyContextId: Ref<DocNotifyContext>,
data: Data<InboxNotification>,
_class: Ref<Class<InboxNotification>>
): Promise<Tx | undefined> {
_class: Ref<Class<InboxNotification>>,
senderId: Ref<PersonAccount>,
_id: string
): Promise<void> {
let title: string = ''
let body: string = ''
if (control.hierarchy.isDerived(_class, notification.class.ActivityInboxNotification)) {
@ -482,9 +498,14 @@ export async function createPushFromInbox (
if (title === '' || body === '') {
return
}
return await createPushNotification(control, targetUser, space, title, body, [
'',
'',
const sender = (await control.modelDb.findAll(contact.class.PersonAccount, { _id: senderId }))[0]
let senderPerson: Person | undefined
if (sender !== undefined) {
senderPerson = (await control.findAll(contact.class.Person, { _id: sender.person }))[0]
}
await createPushNotification(control, targetUser, title, body, _id, senderPerson?.avatar, [
notificationId,
docNotifyContextId
])
@ -493,24 +514,62 @@ export async function createPushFromInbox (
export async function createPushNotification (
control: TriggerControl,
targetUser: Ref<Account>,
space: Ref<Space>,
title: string,
body: string,
onClick?: string[]
): Promise<TxCreateDoc<BrowserNotification>> {
const data: Data<BrowserNotification> = {
user: targetUser,
status: NotificationStatus.New,
_id: string,
senderAvatar?: string | null,
subPath?: string[]
): Promise<void> {
const publicKey = getMetadata(notification.metadata.PushPublicKey)
const privateKey = getMetadata(serverNotification.metadata.PushPrivateKey)
const subject = getMetadata(serverNotification.metadata.PushSubject) ?? 'mailto:hey@huly.io'
if (privateKey === undefined || publicKey === undefined) return
const subscriptions = (await control.queryFind(notification.class.PushSubscription, {})).filter(
(p) => p.user === targetUser
)
const data: PushData = {
title,
body
}
if (onClick !== undefined) {
data.onClickLocation = {
path: onClick
if (_id !== undefined) {
data.tag = _id
}
const front = getMetadata(serverCore.metadata.FrontUrl) ?? ''
const uploadUrl = getMetadata(serverCore.metadata.UploadURL) ?? ''
const domainPath = `${workbenchId}/${control.workspace.workspaceUrl}`
const domain = concatLink(front, domainPath)
data.domain = domain
if (subPath !== undefined) {
const path = [domainPath, ...subPath].join('/')
const url = concatLink(front, path)
data.url = url
}
if (senderAvatar != null) {
const provider = getAvatarProviderId(senderAvatar)
if (provider === contact.avatarProvider.Image) {
if (senderAvatar.includes('://')) {
data.icon = senderAvatar
} else {
data.icon = concatLink(uploadUrl, `?file=${senderAvatar}`)
}
} else if (provider === contact.avatarProvider.Gravatar) {
data.icon = getGravatarUrl(senderAvatar.split('://')[1], 'medium')
}
}
webpush.setVapidDetails(subject, publicKey, privateKey)
for (const subscription of subscriptions) {
try {
await webpush.sendNotification(subscription, JSON.stringify(data))
} catch (err) {
console.log('Cannot send push notification to', targetUser, err)
if (err instanceof WebPushError && err.body.includes('expired')) {
const tx = control.txFactory.createTxRemoveDoc(subscription._class, subscription.space, subscription._id)
await control.apply([tx], true)
}
}
}
const res = control.txFactory.createTxCreateDoc(notification.class.BrowserNotification, space, data)
return res
}
/**
@ -555,6 +614,7 @@ export async function pushActivityInboxNotifications (
data,
notification.class.ActivityInboxNotification,
activityMessage.modifiedOn,
originTx.modifiedBy as Ref<PersonAccount>,
shouldPush,
shouldUpdateTimestamp
)

View File

@ -134,7 +134,9 @@ export interface NotificationPresenter extends Class<Doc> {
*/
export default plugin(serverNotificationId, {
metadata: {
SesUrl: '' as Metadata<string>
SesUrl: '' as Metadata<string>,
PushPrivateKey: '' as Metadata<string>,
PushSubject: '' as Metadata<string>
},
mixin: {
HTMLPresenter: '' as Ref<Mixin<HTMLPresenter>>,

View File

@ -41,6 +41,7 @@ const serverCore = plugin(serverCoreId, {
},
metadata: {
FrontUrl: '' as Metadata<string>,
UploadURL: '' as Metadata<string>,
CursorMaxTimeMS: '' as Metadata<string>,
ElasticIndexName: '' as Metadata<string>
}

View File

@ -50,19 +50,25 @@ export function storageConfigFromEnv (): StorageConfiguration {
return storageConfig
}
export function serverConfigFromEnv (): {
export interface ServerEnv {
url: string
elasticUrl: string
serverSecret: string
rekoniUrl: string
frontUrl: string
uploadUrl: string
sesUrl: string | undefined
accountsUrl: string
serverPort: number
serverFactory: ServerFactory
enableCompression: boolean
elasticIndexName: string
} {
pushPublicKey: string | undefined
pushPrivateKey: string | undefined
pushSubject: string | undefined
}
export function serverConfigFromEnv (): ServerEnv {
const serverPort = parseInt(process.env.SERVER_PORT ?? '3333')
const serverFactory = serverFactories[(process.env.SERVER_PROVIDER as string) ?? 'ws'] ?? serverFactories.ws
const enableCompression = (process.env.ENABLE_COMPRESSION ?? 'true') === 'true'
@ -102,6 +108,12 @@ export function serverConfigFromEnv (): {
process.exit(1)
}
const uploadUrl = process.env.UPLOAD_URL
if (uploadUrl === undefined) {
console.log('Please provide UPLOAD_URL url')
process.exit(1)
}
const sesUrl = process.env.SES_URL
const accountsUrl = process.env.ACCOUNTS_URL
@ -109,6 +121,10 @@ export function serverConfigFromEnv (): {
console.log('Please provide ACCOUNTS_URL url')
process.exit(1)
}
const pushPublicKey = process.env.PUSH_PUBLIC_KEY
const pushPrivateKey = process.env.PUSH_PRIVATE_KEY
const pushSubject = process.env.PUSH_SUBJECT
return {
url,
elasticUrl,
@ -116,11 +132,15 @@ export function serverConfigFromEnv (): {
serverSecret,
rekoniUrl,
frontUrl,
uploadUrl,
sesUrl,
accountsUrl,
serverPort,
serverFactory,
enableCompression
enableCompression,
pushPublicKey,
pushPrivateKey,
pushSubject
}
}

View File

@ -102,6 +102,7 @@ services:
- MINIO_SECRET_KEY=minioadmin
- REKONI_URL=http://rekoni:4005
- FRONT_URL=http://localhost:8083
- UPLOAD_URL=http://localhost:8083/files
- ACCOUNTS_URL=http://account:3003
- LAST_NAME_FIRST=true
- ELASTIC_INDEX_NAME=local_storage_index