mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-31 20:57:31 +00:00
Extract trigger for pushes (#7767)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
parent
86da890c13
commit
bba3b48bb5
@ -239,6 +239,9 @@ export class TInboxNotification extends TDoc implements InboxNotification {
|
|||||||
@Prop(TypeBoolean(), core.string.Boolean)
|
@Prop(TypeBoolean(), core.string.Boolean)
|
||||||
archived!: boolean
|
archived!: boolean
|
||||||
|
|
||||||
|
objectId!: Ref<Doc>
|
||||||
|
objectClass!: Ref<Class<Doc>>
|
||||||
|
|
||||||
declare space: Ref<PersonSpace>
|
declare space: Ref<PersonSpace>
|
||||||
|
|
||||||
title?: IntlString
|
title?: IntlString
|
||||||
|
@ -247,6 +247,43 @@ export async function migrateSettings (client: MigrationClient): Promise<void> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function migrateNotificationsObject (client: MigrationClient): Promise<void> {
|
||||||
|
while (true) {
|
||||||
|
const notifications = await client.find<InboxNotification>(
|
||||||
|
DOMAIN_NOTIFICATION,
|
||||||
|
{ objectId: { $exists: false }, docNotifyContext: { $exists: true } },
|
||||||
|
{ limit: 500 }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (notifications.length === 0) return
|
||||||
|
|
||||||
|
const contextIds = Array.from(new Set(notifications.map((n) => n.docNotifyContext)))
|
||||||
|
const contexts = await client.find<DocNotifyContext>(DOMAIN_DOC_NOTIFY, { _id: { $in: contextIds } })
|
||||||
|
|
||||||
|
for (const context of contexts) {
|
||||||
|
await client.update(
|
||||||
|
DOMAIN_NOTIFICATION,
|
||||||
|
{ docNotifyContext: context._id, objectId: { $exists: false } },
|
||||||
|
{ objectId: context.objectId, objectClass: context.objectClass }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDelete: Ref<InboxNotification>[] = []
|
||||||
|
|
||||||
|
for (const notification of notifications) {
|
||||||
|
const context = contexts.find((c) => c._id === notification.docNotifyContext)
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
toDelete.push(notification._id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDelete.length > 0) {
|
||||||
|
await client.deleteMany(DOMAIN_NOTIFICATION, { _id: { $in: toDelete } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const notificationOperation: MigrateOperation = {
|
export const notificationOperation: MigrateOperation = {
|
||||||
async migrate (client: MigrationClient): Promise<void> {
|
async migrate (client: MigrationClient): Promise<void> {
|
||||||
await tryMigrate(client, notificationId, [
|
await tryMigrate(client, notificationId, [
|
||||||
@ -429,6 +466,10 @@ export const notificationOperation: MigrateOperation = {
|
|||||||
func: async (client) => {
|
func: async (client) => {
|
||||||
await client.update(DOMAIN_DOC_NOTIFY, { space: core.space.Space }, { space: core.space.Workspace })
|
await client.update(DOMAIN_DOC_NOTIFY, { space: core.space.Space }, { space: core.space.Workspace })
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
state: 'migrate-notifications-object',
|
||||||
|
func: migrateNotificationsObject
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
|
@ -103,4 +103,13 @@ export function createModel (builder: Builder): void {
|
|||||||
mixin: contact.mixin.Employee
|
mixin: contact.mixin.Employee
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
|
||||||
|
trigger: serverNotification.trigger.PushNotificationsHandler,
|
||||||
|
isAsync: true,
|
||||||
|
txMatch: {
|
||||||
|
_class: core.class.TxCreateDoc,
|
||||||
|
objectClass: notification.class.InboxNotification
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
@ -235,6 +235,8 @@ export interface InboxNotification extends Doc<PersonSpace> {
|
|||||||
isViewed: boolean
|
isViewed: boolean
|
||||||
|
|
||||||
docNotifyContext: Ref<DocNotifyContext>
|
docNotifyContext: Ref<DocNotifyContext>
|
||||||
|
objectId: Ref<Doc>
|
||||||
|
objectClass: Ref<Class<Doc>>
|
||||||
|
|
||||||
// For browser notifications
|
// For browser notifications
|
||||||
title?: IntlString
|
title?: IntlString
|
||||||
|
@ -151,6 +151,8 @@ export async function getPersonNotificationTxes (
|
|||||||
messageHtml: reference.message,
|
messageHtml: reference.message,
|
||||||
mentionedIn: reference.attachedDocId ?? reference.srcDocId,
|
mentionedIn: reference.attachedDocId ?? reference.srcDocId,
|
||||||
mentionedInClass: reference.attachedDocClass ?? reference.srcDocClass,
|
mentionedInClass: reference.attachedDocClass ?? reference.srcDocClass,
|
||||||
|
objectId: reference.srcDocId,
|
||||||
|
objectClass: reference.srcDocClass,
|
||||||
user: receiver[0]._id,
|
user: receiver[0]._id,
|
||||||
isViewed: false,
|
isViewed: false,
|
||||||
archived: false
|
archived: false
|
||||||
@ -238,9 +240,6 @@ export async function getPersonNotificationTxes (
|
|||||||
modifiedOn: originTx.modifiedOn,
|
modifiedOn: originTx.modifiedOn,
|
||||||
modifiedBy: sender._id
|
modifiedBy: sender._id
|
||||||
}
|
}
|
||||||
const subscriptions = await control.findAll(control.ctx, notification.class.PushSubscription, {
|
|
||||||
user: receiverInfo._id
|
|
||||||
})
|
|
||||||
|
|
||||||
const msg = control.hierarchy.isDerived(data.mentionedInClass, activity.class.ActivityMessage)
|
const msg = control.hierarchy.isDerived(data.mentionedInClass, activity.class.ActivityMessage)
|
||||||
? (await control.findAll(control.ctx, data.mentionedInClass, { _id: data.mentionedIn }))[0]
|
? (await control.findAll(control.ctx, data.mentionedInClass, { _id: data.mentionedIn }))[0]
|
||||||
@ -248,14 +247,11 @@ export async function getPersonNotificationTxes (
|
|||||||
await applyNotificationProviders(
|
await applyNotificationProviders(
|
||||||
notificationData,
|
notificationData,
|
||||||
notifyResult,
|
notifyResult,
|
||||||
reference.srcDocId,
|
|
||||||
reference.srcDocClass,
|
|
||||||
control,
|
control,
|
||||||
res,
|
res,
|
||||||
doc,
|
doc,
|
||||||
receiverInfo,
|
receiverInfo,
|
||||||
senderInfo,
|
senderInfo,
|
||||||
subscriptions,
|
|
||||||
notification.class.MentionInboxNotification,
|
notification.class.MentionInboxNotification,
|
||||||
msg as ActivityMessage
|
msg as ActivityMessage
|
||||||
)
|
)
|
||||||
|
@ -15,16 +15,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import activity, { ActivityMessage, DocUpdateMessage } from '@hcengineering/activity'
|
import activity, { ActivityMessage, DocUpdateMessage } from '@hcengineering/activity'
|
||||||
import { Analytics } from '@hcengineering/analytics'
|
|
||||||
import chunter, { ChatMessage } from '@hcengineering/chunter'
|
import chunter, { ChatMessage } from '@hcengineering/chunter'
|
||||||
import contact, {
|
import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact'
|
||||||
Employee,
|
|
||||||
getAvatarProviderId,
|
|
||||||
getGravatarUrl,
|
|
||||||
Person,
|
|
||||||
PersonAccount,
|
|
||||||
type AvatarInfo
|
|
||||||
} from '@hcengineering/contact'
|
|
||||||
import core, {
|
import core, {
|
||||||
Account,
|
Account,
|
||||||
AnyAttribute,
|
AnyAttribute,
|
||||||
@ -33,7 +25,6 @@ import core, {
|
|||||||
Class,
|
Class,
|
||||||
Collection,
|
Collection,
|
||||||
combineAttributes,
|
combineAttributes,
|
||||||
concatLink,
|
|
||||||
Data,
|
Data,
|
||||||
Doc,
|
Doc,
|
||||||
DocumentUpdate,
|
DocumentUpdate,
|
||||||
@ -64,26 +55,28 @@ import notification, {
|
|||||||
DocNotifyContext,
|
DocNotifyContext,
|
||||||
InboxNotification,
|
InboxNotification,
|
||||||
MentionInboxNotification,
|
MentionInboxNotification,
|
||||||
notificationId,
|
NotificationType
|
||||||
NotificationType,
|
|
||||||
PushData,
|
|
||||||
PushSubscription
|
|
||||||
} from '@hcengineering/notification'
|
} from '@hcengineering/notification'
|
||||||
import { getMetadata, getResource, translate } from '@hcengineering/platform'
|
import { getResource, translate } from '@hcengineering/platform'
|
||||||
import serverCore, { type TriggerControl } from '@hcengineering/server-core'
|
import { type TriggerControl } from '@hcengineering/server-core'
|
||||||
import serverNotification, {
|
import serverNotification, {
|
||||||
getPersonAccountById,
|
getPersonAccountById,
|
||||||
NOTIFICATION_BODY_SIZE,
|
NOTIFICATION_BODY_SIZE,
|
||||||
PUSH_NOTIFICATION_TITLE_SIZE,
|
|
||||||
ReceiverInfo,
|
ReceiverInfo,
|
||||||
SenderInfo
|
SenderInfo
|
||||||
} from '@hcengineering/server-notification'
|
} from '@hcengineering/server-notification'
|
||||||
import serverView from '@hcengineering/server-view'
|
|
||||||
import { markupToText, stripTags } from '@hcengineering/text-core'
|
import { markupToText, stripTags } from '@hcengineering/text-core'
|
||||||
import { encodeObjectURI } from '@hcengineering/view'
|
import { Analytics } from '@hcengineering/analytics'
|
||||||
import { workbenchId } from '@hcengineering/workbench'
|
|
||||||
|
|
||||||
import { Content, ContextsCache, ContextsCacheKey, NotifyParams, NotifyResult } from './types'
|
import {
|
||||||
|
AvailableProvidersCache,
|
||||||
|
AvailableProvidersCacheKey,
|
||||||
|
Content,
|
||||||
|
ContextsCache,
|
||||||
|
ContextsCacheKey,
|
||||||
|
NotifyParams,
|
||||||
|
NotifyResult
|
||||||
|
} from './types'
|
||||||
import {
|
import {
|
||||||
createPullCollaboratorsTx,
|
createPullCollaboratorsTx,
|
||||||
createPushCollaboratorsTx,
|
createPushCollaboratorsTx,
|
||||||
@ -105,6 +98,7 @@ import {
|
|||||||
updateNotifyContextsSpace,
|
updateNotifyContextsSpace,
|
||||||
type NotificationProviderControl
|
type NotificationProviderControl
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
import { PushNotificationsHandler } from './push'
|
||||||
|
|
||||||
export function getPushCollaboratorTx (
|
export function getPushCollaboratorTx (
|
||||||
control: TriggerControl,
|
control: TriggerControl,
|
||||||
@ -165,20 +159,8 @@ export async function getCommonNotificationTxes (
|
|||||||
|
|
||||||
if (notificationTx !== undefined) {
|
if (notificationTx !== undefined) {
|
||||||
const notificationData = TxProcessor.createDoc2Doc(notificationTx)
|
const notificationData = TxProcessor.createDoc2Doc(notificationTx)
|
||||||
const subscriptions = await control.findAll(ctx, notification.class.PushSubscription, { user: receiver._id })
|
|
||||||
await applyNotificationProviders(
|
await applyNotificationProviders(notificationData, notifyResult, control, res, doc, receiver, sender, _class)
|
||||||
notificationData,
|
|
||||||
notifyResult,
|
|
||||||
attachedTo,
|
|
||||||
attachedToClass,
|
|
||||||
control,
|
|
||||||
res,
|
|
||||||
doc,
|
|
||||||
receiver,
|
|
||||||
sender,
|
|
||||||
subscriptions,
|
|
||||||
_class
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
@ -383,6 +365,8 @@ export async function pushInboxNotifications (
|
|||||||
isViewed: false,
|
isViewed: false,
|
||||||
docNotifyContext: docNotifyContextId,
|
docNotifyContext: docNotifyContextId,
|
||||||
archived: false,
|
archived: false,
|
||||||
|
objectId,
|
||||||
|
objectClass,
|
||||||
...data
|
...data
|
||||||
}
|
}
|
||||||
const notificationTx = control.txFactory.createTxCreateDoc(_class, receiver.space, notificationData)
|
const notificationTx = control.txFactory.createTxCreateDoc(_class, receiver.space, notificationData)
|
||||||
@ -489,156 +473,6 @@ export async function getTranslatedNotificationContent (
|
|||||||
return { title: '', body: '' }
|
return { title: '', body: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function isReactionMessage (message?: ActivityMessage): boolean {
|
|
||||||
return (
|
|
||||||
message !== undefined &&
|
|
||||||
message._class === activity.class.DocUpdateMessage &&
|
|
||||||
(message as DocUpdateMessage).objectClass === activity.class.Reaction
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPushFromInbox (
|
|
||||||
control: TriggerControl,
|
|
||||||
receiver: ReceiverInfo,
|
|
||||||
attachedTo: Ref<Doc>,
|
|
||||||
attachedToClass: Ref<Class<Doc>>,
|
|
||||||
data: Data<InboxNotification>,
|
|
||||||
_class: Ref<Class<InboxNotification>>,
|
|
||||||
sender: SenderInfo,
|
|
||||||
_id: Ref<Doc>,
|
|
||||||
subscriptions: PushSubscription[],
|
|
||||||
message?: ActivityMessage
|
|
||||||
): Promise<Tx | undefined> {
|
|
||||||
let { title, body } = await getTranslatedNotificationContent(data, _class, control)
|
|
||||||
if (title === '' || body === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
title = title.slice(0, PUSH_NOTIFICATION_TITLE_SIZE)
|
|
||||||
|
|
||||||
const senderPerson = sender.person
|
|
||||||
const linkProviders = control.modelDb.findAllSync(serverView.mixin.ServerLinkIdProvider, {})
|
|
||||||
const provider = linkProviders.find(({ _id }) => _id === attachedToClass)
|
|
||||||
|
|
||||||
let id: string = attachedTo
|
|
||||||
|
|
||||||
if (provider !== undefined) {
|
|
||||||
const encodeFn = await getResource(provider.encode)
|
|
||||||
const doc = (await control.findAll(control.ctx, attachedToClass, { _id: attachedTo }))[0]
|
|
||||||
|
|
||||||
if (doc === undefined) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id = await encodeFn(doc, control)
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = [workbenchId, control.workspace.workspaceUrl, notificationId, encodeObjectURI(id, attachedToClass)]
|
|
||||||
await createPushNotification(
|
|
||||||
control,
|
|
||||||
receiver._id as Ref<PersonAccount>,
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
_id,
|
|
||||||
subscriptions,
|
|
||||||
senderPerson,
|
|
||||||
path
|
|
||||||
)
|
|
||||||
return control.txFactory.createTxCreateDoc(notification.class.BrowserNotification, receiver.space, {
|
|
||||||
user: receiver._id,
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
senderId: sender._id,
|
|
||||||
tag: _id,
|
|
||||||
objectId: attachedTo,
|
|
||||||
objectClass: attachedToClass,
|
|
||||||
messageId: isReactionMessage(message) ? (message?.attachedTo as Ref<ActivityMessage>) : message?._id,
|
|
||||||
messageClass: isReactionMessage(message)
|
|
||||||
? (message?.attachedToClass as Ref<Class<ActivityMessage>>)
|
|
||||||
: message?._class,
|
|
||||||
onClickLocation: {
|
|
||||||
path
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPushNotification (
|
|
||||||
control: TriggerControl,
|
|
||||||
target: Ref<PersonAccount>,
|
|
||||||
title: string,
|
|
||||||
body: string,
|
|
||||||
_id: string,
|
|
||||||
subscriptions: PushSubscription[],
|
|
||||||
senderAvatar?: Data<AvatarInfo>,
|
|
||||||
path?: string[]
|
|
||||||
): Promise<void> {
|
|
||||||
const sesURL: string | undefined = getMetadata(serverNotification.metadata.SesUrl)
|
|
||||||
const sesAuth: string | undefined = getMetadata(serverNotification.metadata.SesAuthToken)
|
|
||||||
if (sesURL === undefined || sesURL === '') return
|
|
||||||
const userSubscriptions = subscriptions.filter((it) => it.user === target)
|
|
||||||
const data: PushData = {
|
|
||||||
title,
|
|
||||||
body
|
|
||||||
}
|
|
||||||
if (_id !== undefined) {
|
|
||||||
data.tag = _id
|
|
||||||
}
|
|
||||||
const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
|
|
||||||
const domainPath = `${workbenchId}/${control.workspace.workspaceUrl}`
|
|
||||||
data.domain = concatLink(front, domainPath)
|
|
||||||
if (path !== undefined) {
|
|
||||||
data.url = concatLink(front, path.join('/'))
|
|
||||||
}
|
|
||||||
if (senderAvatar != null) {
|
|
||||||
const provider = getAvatarProviderId(senderAvatar.avatarType)
|
|
||||||
if (provider === contact.avatarProvider.Image) {
|
|
||||||
if (senderAvatar.avatar != null) {
|
|
||||||
const url = await control.storageAdapter.getUrl(control.ctx, control.workspace, senderAvatar.avatar)
|
|
||||||
data.icon = url.includes('://') ? url : concatLink(front, url)
|
|
||||||
}
|
|
||||||
} else if (provider === contact.avatarProvider.Gravatar && senderAvatar.avatarProps?.url !== undefined) {
|
|
||||||
data.icon = getGravatarUrl(senderAvatar.avatarProps?.url, 512)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void sendPushToSubscription(sesURL, sesAuth, control, target, userSubscriptions, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendPushToSubscription (
|
|
||||||
sesURL: string,
|
|
||||||
sesAuth: string | undefined,
|
|
||||||
control: TriggerControl,
|
|
||||||
targetUser: Ref<Account>,
|
|
||||||
subscriptions: PushSubscription[],
|
|
||||||
data: PushData
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const result: Ref<PushSubscription>[] = (
|
|
||||||
await (
|
|
||||||
await fetch(concatLink(sesURL, '/web-push'), {
|
|
||||||
method: 'post',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(sesAuth != null ? { Authorization: `Bearer ${sesAuth}` } : {})
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
subscriptions,
|
|
||||||
data
|
|
||||||
})
|
|
||||||
})
|
|
||||||
).json()
|
|
||||||
).result
|
|
||||||
if (result.length > 0) {
|
|
||||||
const domain = control.hierarchy.findDomain(notification.class.PushSubscription)
|
|
||||||
if (domain !== undefined) {
|
|
||||||
await control.lowLevel.clean(control.ctx, domain, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
control.ctx.info('Cannot send push notification to', { user: targetUser, err })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
@ -682,40 +516,16 @@ export async function pushActivityInboxNotifications (
|
|||||||
export async function applyNotificationProviders (
|
export async function applyNotificationProviders (
|
||||||
data: InboxNotification,
|
data: InboxNotification,
|
||||||
notifyResult: NotifyResult,
|
notifyResult: NotifyResult,
|
||||||
attachedTo: Ref<Doc>,
|
|
||||||
attachedToClass: Ref<Class<Doc>>,
|
|
||||||
control: TriggerControl,
|
control: TriggerControl,
|
||||||
res: Tx[],
|
res: Tx[],
|
||||||
object: Doc,
|
object: Doc,
|
||||||
receiver: ReceiverInfo,
|
receiver: ReceiverInfo,
|
||||||
sender: SenderInfo,
|
sender: SenderInfo,
|
||||||
subscriptions: PushSubscription[],
|
|
||||||
_class = notification.class.ActivityInboxNotification,
|
_class = notification.class.ActivityInboxNotification,
|
||||||
message?: ActivityMessage
|
message?: ActivityMessage
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resources = control.modelDb.findAllSync(serverNotification.class.NotificationProviderResources, {})
|
const resources = control.modelDb.findAllSync(serverNotification.class.NotificationProviderResources, {})
|
||||||
for (const [provider, types] of notifyResult.entries()) {
|
for (const [provider, types] of notifyResult.entries()) {
|
||||||
if (provider === notification.providers.PushNotificationProvider) {
|
|
||||||
// const now = Date.now()
|
|
||||||
const pushTx = await createPushFromInbox(
|
|
||||||
control,
|
|
||||||
receiver,
|
|
||||||
attachedTo,
|
|
||||||
attachedToClass,
|
|
||||||
data,
|
|
||||||
_class,
|
|
||||||
sender,
|
|
||||||
data._id,
|
|
||||||
subscriptions,
|
|
||||||
message
|
|
||||||
)
|
|
||||||
if (pushTx !== undefined) {
|
|
||||||
res.push(pushTx)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const resource = resources.find((it) => it.provider === provider)
|
const resource = resources.find((it) => it.provider === provider)
|
||||||
|
|
||||||
if (resource === undefined) continue
|
if (resource === undefined) continue
|
||||||
@ -789,8 +599,7 @@ export async function getNotificationTxes (
|
|||||||
params: NotifyParams,
|
params: NotifyParams,
|
||||||
docNotifyContexts: DocNotifyContext[],
|
docNotifyContexts: DocNotifyContext[],
|
||||||
activityMessages: ActivityMessage[],
|
activityMessages: ActivityMessage[],
|
||||||
settings: NotificationProviderControl,
|
settings: NotificationProviderControl
|
||||||
subscriptions: PushSubscription[]
|
|
||||||
): Promise<Tx[]> {
|
): Promise<Tx[]> {
|
||||||
if (receiver.account === undefined) {
|
if (receiver.account === undefined) {
|
||||||
return []
|
return []
|
||||||
@ -828,17 +637,23 @@ export async function getNotificationTxes (
|
|||||||
if (notificationTx !== undefined) {
|
if (notificationTx !== undefined) {
|
||||||
const notificationData = TxProcessor.createDoc2Doc(notificationTx)
|
const notificationData = TxProcessor.createDoc2Doc(notificationTx)
|
||||||
|
|
||||||
|
const current: AvailableProvidersCache = control.contextCache.get(AvailableProvidersCacheKey) ?? new Map()
|
||||||
|
const providers = Array.from(notifyResult.keys()).filter(
|
||||||
|
(p) => p !== notification.providers.InboxNotificationProvider
|
||||||
|
)
|
||||||
|
if (providers.length > 0) {
|
||||||
|
current.set(notificationData._id, providers)
|
||||||
|
control.contextCache.set('AvailableNotificationProviders', current)
|
||||||
|
}
|
||||||
|
|
||||||
await applyNotificationProviders(
|
await applyNotificationProviders(
|
||||||
notificationData,
|
notificationData,
|
||||||
notifyResult,
|
notifyResult,
|
||||||
message.attachedTo,
|
|
||||||
message.attachedToClass,
|
|
||||||
control,
|
control,
|
||||||
res,
|
res,
|
||||||
object,
|
object,
|
||||||
receiver,
|
receiver,
|
||||||
sender,
|
sender,
|
||||||
subscriptions,
|
|
||||||
notificationData._class,
|
notificationData._class,
|
||||||
message
|
message
|
||||||
)
|
)
|
||||||
@ -1030,9 +845,6 @@ export async function createCollabDocInfo (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getNotificationProviderControl(ctx, control)
|
const settings = await getNotificationProviderControl(ctx, control)
|
||||||
const subscriptions = (await control.queryFind(ctx, notification.class.PushSubscription, {})).filter((it) =>
|
|
||||||
targets.has(it.user as Ref<PersonAccount>)
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const info: ReceiverInfo | undefined = toReceiverInfo(control.hierarchy, usersInfo.get(target))
|
const info: ReceiverInfo | undefined = toReceiverInfo(control.hierarchy, usersInfo.get(target))
|
||||||
@ -1049,8 +861,7 @@ export async function createCollabDocInfo (
|
|||||||
params,
|
params,
|
||||||
notifyContexts,
|
notifyContexts,
|
||||||
docMessages,
|
docMessages,
|
||||||
settings,
|
settings
|
||||||
subscriptions
|
|
||||||
)
|
)
|
||||||
const ids = new Set(targetRes.map((it) => it._id))
|
const ids = new Set(targetRes.map((it) => it._id))
|
||||||
if (info.account?.email !== undefined) {
|
if (info.account?.email !== undefined) {
|
||||||
@ -2031,6 +1842,7 @@ async function OnDocRemove (txes: TxCUD<Doc>[], control: TriggerControl): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
export * from './push'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||||
@ -2039,7 +1851,8 @@ export default async () => ({
|
|||||||
OnAttributeCreate,
|
OnAttributeCreate,
|
||||||
OnAttributeUpdate,
|
OnAttributeUpdate,
|
||||||
OnDocRemove,
|
OnDocRemove,
|
||||||
OnEmployeeDeactivate
|
OnEmployeeDeactivate,
|
||||||
|
PushNotificationsHandler
|
||||||
},
|
},
|
||||||
function: {
|
function: {
|
||||||
IsUserInFieldValueTypeMatch: isUserInFieldValueTypeMatch,
|
IsUserInFieldValueTypeMatch: isUserInFieldValueTypeMatch,
|
||||||
|
295
server-plugins/notification-resources/src/push.ts
Normal file
295
server-plugins/notification-resources/src/push.ts
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
//
|
||||||
|
// Copyright © 2025 Hardcore Engineering Inc.
|
||||||
|
//
|
||||||
|
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License. You may
|
||||||
|
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
//
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
//
|
||||||
|
|
||||||
|
import serverCore, { TriggerControl } from '@hcengineering/server-core'
|
||||||
|
import serverNotification, { PUSH_NOTIFICATION_TITLE_SIZE } from '@hcengineering/server-notification'
|
||||||
|
import {
|
||||||
|
Account,
|
||||||
|
Class,
|
||||||
|
concatLink,
|
||||||
|
Data,
|
||||||
|
Doc,
|
||||||
|
Hierarchy,
|
||||||
|
Ref,
|
||||||
|
Tx,
|
||||||
|
TxCreateDoc,
|
||||||
|
TxProcessor
|
||||||
|
} from '@hcengineering/core'
|
||||||
|
import notification, {
|
||||||
|
ActivityInboxNotification,
|
||||||
|
InboxNotification,
|
||||||
|
MentionInboxNotification,
|
||||||
|
notificationId,
|
||||||
|
PushData,
|
||||||
|
PushSubscription
|
||||||
|
} from '@hcengineering/notification'
|
||||||
|
import activity, { ActivityMessage } from '@hcengineering/activity'
|
||||||
|
import serverView from '@hcengineering/server-view'
|
||||||
|
import { getMetadata, getResource } from '@hcengineering/platform'
|
||||||
|
import { workbenchId } from '@hcengineering/workbench'
|
||||||
|
import { encodeObjectURI } from '@hcengineering/view'
|
||||||
|
import contact, {
|
||||||
|
type AvatarInfo,
|
||||||
|
getAvatarProviderId,
|
||||||
|
getGravatarUrl,
|
||||||
|
Person,
|
||||||
|
PersonAccount,
|
||||||
|
PersonSpace
|
||||||
|
} from '@hcengineering/contact'
|
||||||
|
import { AvailableProvidersCache, AvailableProvidersCacheKey, getTranslatedNotificationContent } from './index'
|
||||||
|
|
||||||
|
async function createPushFromInbox (
|
||||||
|
control: TriggerControl,
|
||||||
|
n: InboxNotification,
|
||||||
|
receiver: Ref<Account>,
|
||||||
|
receiverSpace: Ref<PersonSpace>,
|
||||||
|
subscriptions: PushSubscription[],
|
||||||
|
senderPerson?: Person
|
||||||
|
): Promise<Tx | undefined> {
|
||||||
|
let { title, body } = await getTranslatedNotificationContent(n, n._class, control)
|
||||||
|
|
||||||
|
if (title === '' || body === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title = title.slice(0, PUSH_NOTIFICATION_TITLE_SIZE)
|
||||||
|
|
||||||
|
const linkProviders = control.modelDb.findAllSync(serverView.mixin.ServerLinkIdProvider, {})
|
||||||
|
const provider = linkProviders.find(({ _id }) => _id === n.objectClass)
|
||||||
|
|
||||||
|
let id: string = n.objectId
|
||||||
|
|
||||||
|
if (provider !== undefined) {
|
||||||
|
const encodeFn = await getResource(provider.encode)
|
||||||
|
const cache: Map<Ref<Doc>, Doc> = control.contextCache.get('PushNotificationsHandler') ?? new Map()
|
||||||
|
const doc = cache.get(n.objectId) ?? (await control.findAll(control.ctx, n.objectClass, { _id: n.objectId }))[0]
|
||||||
|
|
||||||
|
if (doc === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(n.objectId, doc)
|
||||||
|
control.contextCache.set('PushNotificationsHandler', cache)
|
||||||
|
|
||||||
|
id = await encodeFn(doc, control)
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = [workbenchId, control.workspace.workspaceUrl, notificationId, encodeObjectURI(id, n.objectClass)]
|
||||||
|
await createPushNotification(
|
||||||
|
control,
|
||||||
|
receiver as Ref<PersonAccount>,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
n._id,
|
||||||
|
subscriptions,
|
||||||
|
senderPerson,
|
||||||
|
path
|
||||||
|
)
|
||||||
|
|
||||||
|
const messageInfo = getMessageInfo(n, control.hierarchy)
|
||||||
|
return control.txFactory.createTxCreateDoc(notification.class.BrowserNotification, receiverSpace, {
|
||||||
|
user: receiver,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
senderId: n.createdBy ?? n.modifiedBy,
|
||||||
|
tag: n._id,
|
||||||
|
objectId: n.objectId,
|
||||||
|
objectClass: n.objectClass,
|
||||||
|
messageId: messageInfo._id,
|
||||||
|
messageClass: messageInfo._class,
|
||||||
|
onClickLocation: {
|
||||||
|
path
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageInfo (
|
||||||
|
n: InboxNotification,
|
||||||
|
hierarchy: Hierarchy
|
||||||
|
): {
|
||||||
|
_id?: Ref<ActivityMessage>
|
||||||
|
_class?: Ref<Class<ActivityMessage>>
|
||||||
|
} {
|
||||||
|
if (hierarchy.isDerived(n._class, notification.class.ActivityInboxNotification)) {
|
||||||
|
const activityNotification = n as ActivityInboxNotification
|
||||||
|
|
||||||
|
if (
|
||||||
|
activityNotification.attachedToClass === activity.class.DocUpdateMessage &&
|
||||||
|
hierarchy.isDerived(activityNotification.objectClass, activity.class.ActivityMessage)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
_id: activityNotification.objectId as Ref<ActivityMessage>,
|
||||||
|
_class: activityNotification.objectClass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: activityNotification.attachedTo,
|
||||||
|
_class: activityNotification.attachedToClass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hierarchy.isDerived(n._class, notification.class.MentionInboxNotification)) {
|
||||||
|
const mentionNotification = n as MentionInboxNotification
|
||||||
|
if (hierarchy.isDerived(mentionNotification.mentionedInClass, activity.class.ActivityMessage)) {
|
||||||
|
return {
|
||||||
|
_id: mentionNotification.mentionedIn as Ref<ActivityMessage>,
|
||||||
|
_class: mentionNotification.mentionedInClass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPushNotification (
|
||||||
|
control: TriggerControl,
|
||||||
|
target: Ref<PersonAccount>,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
_id: string,
|
||||||
|
subscriptions: PushSubscription[],
|
||||||
|
senderAvatar?: Data<AvatarInfo>,
|
||||||
|
path?: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const sesURL: string | undefined = getMetadata(serverNotification.metadata.SesUrl)
|
||||||
|
const sesAuth: string | undefined = getMetadata(serverNotification.metadata.SesAuthToken)
|
||||||
|
if (sesURL === undefined || sesURL === '') return
|
||||||
|
const userSubscriptions = subscriptions.filter((it) => it.user === target)
|
||||||
|
const data: PushData = {
|
||||||
|
title,
|
||||||
|
body
|
||||||
|
}
|
||||||
|
if (_id !== undefined) {
|
||||||
|
data.tag = _id
|
||||||
|
}
|
||||||
|
const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? ''
|
||||||
|
const domainPath = `${workbenchId}/${control.workspace.workspaceUrl}`
|
||||||
|
data.domain = concatLink(front, domainPath)
|
||||||
|
if (path !== undefined) {
|
||||||
|
data.url = concatLink(front, path.join('/'))
|
||||||
|
}
|
||||||
|
if (senderAvatar != null) {
|
||||||
|
const provider = getAvatarProviderId(senderAvatar.avatarType)
|
||||||
|
if (provider === contact.avatarProvider.Image) {
|
||||||
|
if (senderAvatar.avatar != null) {
|
||||||
|
const url = await control.storageAdapter.getUrl(control.ctx, control.workspace, senderAvatar.avatar)
|
||||||
|
data.icon = url.includes('://') ? url : concatLink(front, url)
|
||||||
|
}
|
||||||
|
} else if (provider === contact.avatarProvider.Gravatar && senderAvatar.avatarProps?.url !== undefined) {
|
||||||
|
data.icon = getGravatarUrl(senderAvatar.avatarProps?.url, 512)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendPushToSubscription(sesURL, sesAuth, control, target, userSubscriptions, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPushToSubscription (
|
||||||
|
sesURL: string,
|
||||||
|
sesAuth: string | undefined,
|
||||||
|
control: TriggerControl,
|
||||||
|
targetUser: Ref<Account>,
|
||||||
|
subscriptions: PushSubscription[],
|
||||||
|
data: PushData
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result: Ref<PushSubscription>[] = (
|
||||||
|
await (
|
||||||
|
await fetch(concatLink(sesURL, '/web-push'), {
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(sesAuth != null ? { Authorization: `Bearer ${sesAuth}` } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
subscriptions,
|
||||||
|
data
|
||||||
|
})
|
||||||
|
})
|
||||||
|
).json()
|
||||||
|
).result
|
||||||
|
if (result.length > 0) {
|
||||||
|
const domain = control.hierarchy.findDomain(notification.class.PushSubscription)
|
||||||
|
if (domain !== undefined) {
|
||||||
|
await control.lowLevel.clean(control.ctx, domain, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
control.ctx.info('Cannot send push notification to', { user: targetUser, err })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PushNotificationsHandler (
|
||||||
|
txes: TxCreateDoc<InboxNotification>[],
|
||||||
|
control: TriggerControl
|
||||||
|
): Promise<Tx[]> {
|
||||||
|
const availableProviders: AvailableProvidersCache = control.contextCache.get(AvailableProvidersCacheKey) ?? new Map()
|
||||||
|
|
||||||
|
const all: InboxNotification[] = txes
|
||||||
|
.map((tx) => TxProcessor.createDoc2Doc(tx))
|
||||||
|
.filter(
|
||||||
|
(it) =>
|
||||||
|
availableProviders.get(it._id)?.find((p) => p === notification.providers.PushNotificationProvider) !== undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
if (all.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const receivers = new Set(all.map((it) => it.user))
|
||||||
|
const subscriptions = (await control.queryFind(control.ctx, notification.class.PushSubscription, {})).filter((it) =>
|
||||||
|
receivers.has(it.user)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const senders = Array.from(new Set(all.map((it) => it.createdBy)))
|
||||||
|
const senderAccounts = await control.modelDb.findAll(contact.class.PersonAccount, {
|
||||||
|
_id: { $in: senders as Ref<PersonAccount>[] }
|
||||||
|
})
|
||||||
|
const senderPersons = await control.findAll(control.ctx, contact.class.Person, {
|
||||||
|
_id: { $in: Array.from(new Set(senderAccounts.map((it) => it.person))) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const res: Tx[] = []
|
||||||
|
|
||||||
|
for (const inboxNotification of all) {
|
||||||
|
const { user } = inboxNotification
|
||||||
|
const userSubscriptions = subscriptions.filter((it) => it.user === user)
|
||||||
|
if (userSubscriptions.length === 0) continue
|
||||||
|
|
||||||
|
const senderAccount = senderAccounts.find(
|
||||||
|
(it) => it._id === (inboxNotification.createdBy ?? inboxNotification.modifiedBy)
|
||||||
|
)
|
||||||
|
const senderPerson =
|
||||||
|
senderAccount !== undefined ? senderPersons.find((it) => it._id === senderAccount.person) : undefined
|
||||||
|
const tx = await createPushFromInbox(
|
||||||
|
control,
|
||||||
|
inboxNotification,
|
||||||
|
user,
|
||||||
|
inboxNotification.space,
|
||||||
|
userSubscriptions,
|
||||||
|
senderPerson
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tx !== undefined) {
|
||||||
|
res.push(tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
@ -12,7 +12,12 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
import { BaseNotificationType, DocNotifyContext, NotificationProvider } from '@hcengineering/notification'
|
import {
|
||||||
|
BaseNotificationType,
|
||||||
|
DocNotifyContext,
|
||||||
|
InboxNotification,
|
||||||
|
NotificationProvider
|
||||||
|
} from '@hcengineering/notification'
|
||||||
import { Ref } from '@hcengineering/core'
|
import { Ref } from '@hcengineering/core'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,3 +44,6 @@ export const ContextsCacheKey = 'DocNotifyContexts'
|
|||||||
export interface ContextsCache {
|
export interface ContextsCache {
|
||||||
contexts: Map<string, Ref<DocNotifyContext>>
|
contexts: Map<string, Ref<DocNotifyContext>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const AvailableProvidersCacheKey = 'AvailableNotificationProviders'
|
||||||
|
export type AvailableProvidersCache = Map<Ref<InboxNotification>, Ref<NotificationProvider>[]>
|
||||||
|
@ -661,3 +661,11 @@ export async function getObjectSpace (control: TriggerControl, doc: Doc, cache:
|
|||||||
: (cache.get(doc.space) as Space) ??
|
: (cache.get(doc.space) as Space) ??
|
||||||
(await control.findAll<Space>(control.ctx, core.class.Space, { _id: doc.space }, { limit: 1 }))[0]
|
(await control.findAll<Space>(control.ctx, core.class.Space, { _id: doc.space }, { limit: 1 }))[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isReactionMessage (message?: ActivityMessage): boolean {
|
||||||
|
return (
|
||||||
|
message !== undefined &&
|
||||||
|
message._class === activity.class.DocUpdateMessage &&
|
||||||
|
(message as DocUpdateMessage).objectClass === activity.class.Reaction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -168,7 +168,8 @@ export default plugin(serverNotificationId, {
|
|||||||
OnAttributeUpdate: '' as Resource<TriggerFunc>,
|
OnAttributeUpdate: '' as Resource<TriggerFunc>,
|
||||||
OnReactionChanged: '' as Resource<TriggerFunc>,
|
OnReactionChanged: '' as Resource<TriggerFunc>,
|
||||||
OnDocRemove: '' as Resource<TriggerFunc>,
|
OnDocRemove: '' as Resource<TriggerFunc>,
|
||||||
OnEmployeeDeactivate: '' as Resource<TriggerFunc>
|
OnEmployeeDeactivate: '' as Resource<TriggerFunc>,
|
||||||
|
PushNotificationsHandler: '' as Resource<TriggerFunc>
|
||||||
},
|
},
|
||||||
function: {
|
function: {
|
||||||
IsUserInFieldValueTypeMatch: '' as TypeMatchFunc,
|
IsUserInFieldValueTypeMatch: '' as TypeMatchFunc,
|
||||||
|
@ -161,10 +161,7 @@ async function getRequestNotificationTx (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const notificationControl = await getNotificationProviderControl(ctx, control)
|
const notificationControl = await getNotificationProviderControl(ctx, control)
|
||||||
const collaboratorsSet = new Set(collaborators)
|
|
||||||
const subscriptions = (await control.queryFind(control.ctx, notification.class.PushSubscription, {})).filter((it) =>
|
|
||||||
collaboratorsSet.has(it.user)
|
|
||||||
)
|
|
||||||
for (const target of collaborators) {
|
for (const target of collaborators) {
|
||||||
const targetInfo = toReceiverInfo(control.hierarchy, usersInfo.get(target))
|
const targetInfo = toReceiverInfo(control.hierarchy, usersInfo.get(target))
|
||||||
if (targetInfo === undefined) continue
|
if (targetInfo === undefined) continue
|
||||||
@ -179,8 +176,7 @@ async function getRequestNotificationTx (
|
|||||||
{ isOwn: true, isSpace: false, shouldUpdateTimestamp: true },
|
{ isOwn: true, isSpace: false, shouldUpdateTimestamp: true },
|
||||||
notifyContexts,
|
notifyContexts,
|
||||||
messages,
|
messages,
|
||||||
notificationControl,
|
notificationControl
|
||||||
subscriptions
|
|
||||||
)
|
)
|
||||||
res.push(...txes)
|
res.push(...txes)
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,8 @@ export async function createNotification (
|
|||||||
} else {
|
} else {
|
||||||
await client.createDoc(notification.class.CommonInboxNotification, data.space, {
|
await client.createDoc(notification.class.CommonInboxNotification, data.space, {
|
||||||
user: data.user,
|
user: data.user,
|
||||||
|
objectId: forDoc._id,
|
||||||
|
objectClass: forDoc._class,
|
||||||
icon: github.icon.Github,
|
icon: github.icon.Github,
|
||||||
message: data.message,
|
message: data.message,
|
||||||
props: data.props,
|
props: data.props,
|
||||||
|
Loading…
Reference in New Issue
Block a user