mirror of
https://github.com/hcengineering/platform.git
synced 2025-01-22 19:38:17 +00:00
Merge branch 'develop' into staging
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
commit
a0c9eaec85
202
dev/tool/src/account.ts
Normal file
202
dev/tool/src/account.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import {
|
||||
type Account,
|
||||
type AccountDB,
|
||||
changeEmail,
|
||||
getAccount,
|
||||
listWorkspacesPure,
|
||||
type Workspace
|
||||
} from '@hcengineering/account'
|
||||
import core, { getWorkspaceId, type MeasureContext, systemAccountEmail, TxOperations } from '@hcengineering/core'
|
||||
import contact from '@hcengineering/model-contact'
|
||||
import { getTransactorEndpoint } from '@hcengineering/server-client'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
import { connect } from '@hcengineering/server-tool'
|
||||
|
||||
export async function renameAccount (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
accountsUrl: string,
|
||||
oldEmail: string,
|
||||
newEmail: string
|
||||
): Promise<void> {
|
||||
const account = await getAccount(db, oldEmail)
|
||||
if (account == null) {
|
||||
throw new Error("Account does'n exists")
|
||||
}
|
||||
|
||||
const newAccount = await getAccount(db, newEmail)
|
||||
if (newAccount != null) {
|
||||
throw new Error('New Account email already exists:' + newAccount?.email + ' ' + newAccount?._id?.toString())
|
||||
}
|
||||
|
||||
await changeEmail(ctx, db, account, newEmail)
|
||||
|
||||
await fixWorkspaceEmails(account, db, accountsUrl, oldEmail, newEmail)
|
||||
}
|
||||
|
||||
export async function fixAccountEmails (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
transactorUrl: string,
|
||||
oldEmail: string,
|
||||
newEmail: string
|
||||
): Promise<void> {
|
||||
const account = await getAccount(db, newEmail)
|
||||
if (account == null) {
|
||||
throw new Error("Account does'n exists")
|
||||
}
|
||||
|
||||
await fixWorkspaceEmails(account, db, transactorUrl, oldEmail, newEmail)
|
||||
}
|
||||
async function fixWorkspaceEmails (
|
||||
account: Account,
|
||||
db: AccountDB,
|
||||
accountsUrl: string,
|
||||
oldEmail: string,
|
||||
newEmail: string
|
||||
): Promise<void> {
|
||||
const accountWorkspaces = account.workspaces.map((it) => it.toString())
|
||||
// We need to update all workspaces
|
||||
const workspaces = await listWorkspacesPure(db)
|
||||
for (const ws of workspaces) {
|
||||
if (!accountWorkspaces.includes(ws._id.toString())) {
|
||||
continue
|
||||
}
|
||||
console.log('checking workspace', ws.workspaceName, ws.workspace)
|
||||
|
||||
const wsid = getWorkspaceId(ws.workspace)
|
||||
const endpoint = await getTransactorEndpoint(generateToken(systemAccountEmail, wsid))
|
||||
|
||||
// Let's connect and update account information.
|
||||
await fixEmailInWorkspace(endpoint, ws, oldEmail, newEmail)
|
||||
}
|
||||
}
|
||||
|
||||
async function fixEmailInWorkspace (
|
||||
transactorUrl: string,
|
||||
ws: Workspace,
|
||||
oldEmail: string,
|
||||
newEmail: string
|
||||
): Promise<void> {
|
||||
const connection = await connect(transactorUrl, { name: ws.workspace }, undefined, {
|
||||
mode: 'backup',
|
||||
model: 'upgrade', // Required for force all clients reload after operation will be complete.
|
||||
admin: 'true'
|
||||
})
|
||||
try {
|
||||
const personAccount = await connection.findOne(contact.class.PersonAccount, { email: oldEmail })
|
||||
|
||||
if (personAccount !== undefined) {
|
||||
console.log('update account in ', ws.workspace)
|
||||
const ops = new TxOperations(connection, core.account.ConfigUser)
|
||||
await ops.update(personAccount, { email: newEmail })
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
await connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
interface GithubUserResult {
|
||||
login: string | null
|
||||
code: number
|
||||
rateLimitReset?: number | null
|
||||
}
|
||||
|
||||
async function getGithubUser (githubId: string, ghToken?: string): Promise<GithubUserResult> {
|
||||
const options =
|
||||
ghToken !== undefined
|
||||
? {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
const res = await fetch(`https://api.github.com/user/${githubId}`, options)
|
||||
|
||||
if (res.status === 200) {
|
||||
return {
|
||||
login: (await res.json()).login,
|
||||
code: 200
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 403) {
|
||||
const rateLimitReset = res.headers.get('X-RateLimit-Reset')
|
||||
return {
|
||||
login: null,
|
||||
code: res.status,
|
||||
rateLimitReset: rateLimitReset != null ? parseInt(rateLimitReset) * 1000 : null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
login: null,
|
||||
code: res.status
|
||||
}
|
||||
}
|
||||
|
||||
export async function fillGithubUsers (ctx: MeasureContext, db: AccountDB, ghToken?: string): Promise<void> {
|
||||
const githubAccounts = await db.account.find({ githubId: { $ne: null } })
|
||||
if (githubAccounts.length === 0) {
|
||||
ctx.info('no github accounts found')
|
||||
return
|
||||
}
|
||||
|
||||
const accountsToProcess = githubAccounts.filter(({ githubId, githubUser }) => githubUser == null && githubId != null)
|
||||
if (accountsToProcess.length === 0) {
|
||||
ctx.info('no github accounts left to fill')
|
||||
return
|
||||
}
|
||||
|
||||
ctx.info('processing github accounts', { total: accountsToProcess.length })
|
||||
const defaultRetryTimeout = 1000 * 60 * 5 // 5 minutes
|
||||
let processed = 0
|
||||
for (const account of accountsToProcess) {
|
||||
while (true) {
|
||||
try {
|
||||
if (account.githubId == null) break
|
||||
let username: string | undefined
|
||||
if (account.email.startsWith('github:')) {
|
||||
username = account.email.slice(7)
|
||||
} else {
|
||||
const githubUserRes = await getGithubUser(account.githubId, ghToken)
|
||||
if (githubUserRes.code === 200 && githubUserRes.login != null) {
|
||||
username = githubUserRes.login
|
||||
} else if (githubUserRes.code === 404) {
|
||||
ctx.info('github user not found', { githubId: account.githubId })
|
||||
break
|
||||
} else if (githubUserRes.code === 403) {
|
||||
const timeout =
|
||||
githubUserRes.rateLimitReset != null
|
||||
? githubUserRes.rateLimitReset - Date.now() + 1000
|
||||
: defaultRetryTimeout
|
||||
ctx.info('rate limit exceeded. Retrying in ', {
|
||||
githubId: account.githubId,
|
||||
retryTimeoutMin: Math.ceil(timeout / (1000 * 60))
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, timeout))
|
||||
} else {
|
||||
ctx.error('failed to get github user', { githubId: account.githubId, ...githubUserRes })
|
||||
break
|
||||
}
|
||||
}
|
||||
if (username != null) {
|
||||
await db.account.updateOne({ _id: account._id }, { githubUser: username.toLowerCase() })
|
||||
ctx.info('github user added', { githubId: account.githubId, githubUser: username.toLowerCase() })
|
||||
break
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.error('failed to fill github user', { githubId: account.githubId, err })
|
||||
break
|
||||
}
|
||||
}
|
||||
processed++
|
||||
if (processed % 100 === 0) {
|
||||
ctx.info('processing accounts:', { processed, of: accountsToProcess.length })
|
||||
}
|
||||
}
|
||||
ctx.info('finished processing accounts:', { processed, of: accountsToProcess.length })
|
||||
}
|
@ -146,7 +146,7 @@ import {
|
||||
} from './db'
|
||||
import { restoreControlledDocContentMongo, restoreWikiContentMongo, restoreMarkupRefsMongo } from './markup'
|
||||
import { fixMixinForeignAttributes, showMixinForeignAttributes } from './mixin'
|
||||
import { fixAccountEmails, renameAccount } from './renameAccount'
|
||||
import { fixAccountEmails, renameAccount, fillGithubUsers } from './account'
|
||||
import { copyToDatalake, moveFiles, showLostFiles } from './storage'
|
||||
import { createPostgresTxAdapter, createPostgresAdapter, createPostgreeDestroyAdapter } from '@hcengineering/postgres'
|
||||
import { reindexWorkspace } from './fulltext'
|
||||
@ -2175,6 +2175,16 @@ export function devTool (
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command('fill-github-users')
|
||||
.option('-t, --token <token>', 'Github token to increase the limit of requests to GitHub')
|
||||
.description('adds github username info to all accounts')
|
||||
.action(async (cmd: { token?: string }) => {
|
||||
await withAccountDatabase(async (db) => {
|
||||
await fillGithubUsers(toolCtx, db, cmd.token)
|
||||
})
|
||||
})
|
||||
|
||||
extendProgram?.(program)
|
||||
|
||||
program.parse(process.argv)
|
||||
|
@ -1,99 +0,0 @@
|
||||
import {
|
||||
type Account,
|
||||
type AccountDB,
|
||||
changeEmail,
|
||||
getAccount,
|
||||
listWorkspacesPure,
|
||||
type Workspace
|
||||
} from '@hcengineering/account'
|
||||
import core, { getWorkspaceId, type MeasureContext, systemAccountEmail, TxOperations } from '@hcengineering/core'
|
||||
import contact from '@hcengineering/model-contact'
|
||||
import { getTransactorEndpoint } from '@hcengineering/server-client'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
import { connect } from '@hcengineering/server-tool'
|
||||
|
||||
export async function renameAccount (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
accountsUrl: string,
|
||||
oldEmail: string,
|
||||
newEmail: string
|
||||
): Promise<void> {
|
||||
const account = await getAccount(db, oldEmail)
|
||||
if (account == null) {
|
||||
throw new Error("Account does'n exists")
|
||||
}
|
||||
|
||||
const newAccount = await getAccount(db, newEmail)
|
||||
if (newAccount != null) {
|
||||
throw new Error('New Account email already exists:' + newAccount?.email + ' ' + newAccount?._id?.toString())
|
||||
}
|
||||
|
||||
await changeEmail(ctx, db, account, newEmail)
|
||||
|
||||
await fixWorkspaceEmails(account, db, accountsUrl, oldEmail, newEmail)
|
||||
}
|
||||
|
||||
export async function fixAccountEmails (
|
||||
ctx: MeasureContext,
|
||||
db: AccountDB,
|
||||
transactorUrl: string,
|
||||
oldEmail: string,
|
||||
newEmail: string
|
||||
): Promise<void> {
|
||||
const account = await getAccount(db, newEmail)
|
||||
if (account == null) {
|
||||
throw new Error("Account does'n exists")
|
||||
}
|
||||
|
||||
await fixWorkspaceEmails(account, db, transactorUrl, oldEmail, newEmail)
|
||||
}
|
||||
async function fixWorkspaceEmails (
|
||||
account: Account,
|
||||
db: AccountDB,
|
||||
accountsUrl: string,
|
||||
oldEmail: string,
|
||||
newEmail: string
|
||||
): Promise<void> {
|
||||
const accountWorkspaces = account.workspaces.map((it) => it.toString())
|
||||
// We need to update all workspaces
|
||||
const workspaces = await listWorkspacesPure(db)
|
||||
for (const ws of workspaces) {
|
||||
if (!accountWorkspaces.includes(ws._id.toString())) {
|
||||
continue
|
||||
}
|
||||
console.log('checking workspace', ws.workspaceName, ws.workspace)
|
||||
|
||||
const wsid = getWorkspaceId(ws.workspace)
|
||||
const endpoint = await getTransactorEndpoint(generateToken(systemAccountEmail, wsid))
|
||||
|
||||
// Let's connect and update account information.
|
||||
await fixEmailInWorkspace(endpoint, ws, oldEmail, newEmail)
|
||||
}
|
||||
}
|
||||
|
||||
async function fixEmailInWorkspace (
|
||||
transactorUrl: string,
|
||||
ws: Workspace,
|
||||
oldEmail: string,
|
||||
newEmail: string
|
||||
): Promise<void> {
|
||||
const connection = await connect(transactorUrl, { name: ws.workspace }, undefined, {
|
||||
mode: 'backup',
|
||||
model: 'upgrade', // Required for force all clients reload after operation will be complete.
|
||||
admin: 'true'
|
||||
})
|
||||
try {
|
||||
const personAccount = await connection.findOne(contact.class.PersonAccount, { email: oldEmail })
|
||||
|
||||
if (personAccount !== undefined) {
|
||||
console.log('update account in ', ws.workspace)
|
||||
const ops = new TxOperations(connection, core.account.ConfigUser)
|
||||
await ops.update(personAccount, { email: newEmail })
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
await connection.close()
|
||||
}
|
||||
}
|
@ -239,6 +239,9 @@ export class TInboxNotification extends TDoc implements InboxNotification {
|
||||
@Prop(TypeBoolean(), core.string.Boolean)
|
||||
archived!: boolean
|
||||
|
||||
objectId!: Ref<Doc>
|
||||
objectClass!: Ref<Class<Doc>>
|
||||
|
||||
declare space: Ref<PersonSpace>
|
||||
|
||||
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 = {
|
||||
async migrate (client: MigrationClient): Promise<void> {
|
||||
await tryMigrate(client, notificationId, [
|
||||
@ -429,6 +466,10 @@ export const notificationOperation: MigrateOperation = {
|
||||
func: async (client) => {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
|
||||
trigger: serverNotification.trigger.PushNotificationsHandler,
|
||||
isAsync: true,
|
||||
txMatch: {
|
||||
_class: core.class.TxCreateDoc,
|
||||
objectClass: notification.class.InboxNotification
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -81,6 +81,8 @@
|
||||
"Finished": "Dokončeno",
|
||||
"StartWithRecording": "Začít s nahráváním",
|
||||
"Language": "Jazyk",
|
||||
"Kick": "Vyhodit"
|
||||
"Kick": "Vyhodit",
|
||||
"WithAudio": "S audio",
|
||||
"ShareWithAudioTooltip": "Sdílet s audio, musíte znovu sdílet pro aplikování změn"
|
||||
}
|
||||
}
|
@ -79,6 +79,8 @@
|
||||
"Active": "Aktiv",
|
||||
"Finished": "Beendet",
|
||||
"StartWithRecording": "Mit Aufnahme starten",
|
||||
"Language": "Sprache"
|
||||
"Language": "Sprache",
|
||||
"WithAudio": "Mit Audio",
|
||||
"ShareWithAudioTooltip": "Mit Audio teilen, Sie müssen erneut teilen, um die Änderungen zu übernehmen"
|
||||
}
|
||||
}
|
||||
|
@ -81,6 +81,8 @@
|
||||
"Finished": "Finished",
|
||||
"StartWithRecording": "Start with recording",
|
||||
"Language": "Language",
|
||||
"Kick": "Kick"
|
||||
"Kick": "Kick",
|
||||
"WithAudio": "With audio",
|
||||
"ShareWithAudioTooltip": "Share with audio, you should reshare for apply changes"
|
||||
}
|
||||
}
|
||||
|
@ -81,6 +81,8 @@
|
||||
"Finished": "Terminado",
|
||||
"StartWithRecording": "Iniciar con grabación",
|
||||
"Language": "Idioma",
|
||||
"Kick": "Expulsar"
|
||||
"Kick": "Expulsar",
|
||||
"WithAudio": "Con audio",
|
||||
"ShareWithAudioTooltip": "Compartir con audio, debes compartir de nuevo para aplicar los cambios"
|
||||
}
|
||||
}
|
||||
|
@ -81,6 +81,8 @@
|
||||
"Finished": "Terminé",
|
||||
"StartWithRecording": "Démarrer avec l'enregistrement",
|
||||
"Language": "Langue",
|
||||
"Kick": "Expulser"
|
||||
"Kick": "Expulser",
|
||||
"WithAudio": "Avec audio",
|
||||
"ShareWithAudioTooltip": "Partager avec audio, vous devez partager à nouveau pour appliquer les modifications"
|
||||
}
|
||||
}
|
@ -81,6 +81,8 @@
|
||||
"Finished": "Finito",
|
||||
"StartWithRecording": "Inizia con la registrazione",
|
||||
"Language": "Lingua",
|
||||
"Kick": "Espellere"
|
||||
"Kick": "Espellere",
|
||||
"WithAudio": "Con audio",
|
||||
"ShareWithAudioTooltip": "Condividi con audio, devi condividere nuovamente per applicare le modifiche"
|
||||
}
|
||||
}
|
||||
|
@ -81,6 +81,8 @@
|
||||
"Finished": "Finalizado",
|
||||
"StartWithRecording": "Começar com gravação",
|
||||
"Language": "Idioma",
|
||||
"Kick": "Expulsar"
|
||||
"Kick": "Expulsar",
|
||||
"WithAudio": "Com áudio",
|
||||
"ShareWithAudioTooltip": "Compartilhar com áudio, você deve compartilhar novamente para aplicar as alterações"
|
||||
}
|
||||
}
|
||||
|
@ -81,6 +81,8 @@
|
||||
"Finished": "Завершено",
|
||||
"StartWithRecording": "Начинать с записью",
|
||||
"Language": "Язык",
|
||||
"Kick": "Выгнать"
|
||||
"Kick": "Выгнать",
|
||||
"WithAudio": "С аудио",
|
||||
"ShareWithAudioTooltip": "Поделиться с аудио, вам нужно поделиться снова, чтобы применить изменения"
|
||||
}
|
||||
}
|
||||
|
@ -81,6 +81,8 @@
|
||||
"Finished": "已完成",
|
||||
"StartWithRecording": "开始录制",
|
||||
"Language": "语言",
|
||||
"Kick": "踢出"
|
||||
"Kick": "踢出",
|
||||
"WithAudio": "带音频",
|
||||
"ShareWithAudioTooltip": "共享音频,您应重新共享以应用更改"
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,10 @@
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import {
|
||||
ButtonMenu,
|
||||
DropdownIntlItem,
|
||||
IconMaximize,
|
||||
IconMoreV,
|
||||
IconUpOutline,
|
||||
ModernButton,
|
||||
PopupInstance,
|
||||
@ -26,11 +30,7 @@
|
||||
eventToHTMLElement,
|
||||
showPopup,
|
||||
type AnySvelteComponent,
|
||||
type CompAndProps,
|
||||
IconMoreV,
|
||||
ButtonMenu,
|
||||
DropdownIntlItem,
|
||||
IconMaximize
|
||||
type CompAndProps
|
||||
} from '@hcengineering/ui'
|
||||
import view, { Action } from '@hcengineering/view'
|
||||
import { getActions } from '@hcengineering/view-resources'
|
||||
@ -38,30 +38,32 @@
|
||||
import love from '../plugin'
|
||||
import { currentRoom, myInfo, myOffice } from '../stores'
|
||||
import {
|
||||
isTranscriptionAllowed,
|
||||
isCameraEnabled,
|
||||
isConnected,
|
||||
isFullScreen,
|
||||
isMicEnabled,
|
||||
isRecording,
|
||||
isTranscription,
|
||||
isRecordingAvailable,
|
||||
isShareWithSound,
|
||||
isSharingEnabled,
|
||||
isTranscription,
|
||||
isTranscriptionAllowed,
|
||||
leaveRoom,
|
||||
record,
|
||||
screenSharing,
|
||||
setCam,
|
||||
setMic,
|
||||
setShare,
|
||||
stopTranscription,
|
||||
startTranscription
|
||||
startTranscription,
|
||||
stopTranscription
|
||||
} from '../utils'
|
||||
import CamSettingPopup from './CamSettingPopup.svelte'
|
||||
import ControlBarContainer from './ControlBarContainer.svelte'
|
||||
import MicSettingPopup from './MicSettingPopup.svelte'
|
||||
import RoomAccessPopup from './RoomAccessPopup.svelte'
|
||||
import RoomLanguageSelector from './RoomLanguageSelector.svelte'
|
||||
import ControlBarContainer from './ControlBarContainer.svelte'
|
||||
import RoomModal from './RoomModal.svelte'
|
||||
import ShareSettingPopup from './ShareSettingPopup.svelte'
|
||||
|
||||
export let room: Room
|
||||
export let canMaximize: boolean = true
|
||||
@ -86,7 +88,9 @@
|
||||
}
|
||||
|
||||
async function changeShare (): Promise<void> {
|
||||
await setShare(!$isSharingEnabled)
|
||||
const newValue = !$isSharingEnabled
|
||||
const audio = newValue && $isShareWithSound
|
||||
await setShare(newValue, audio)
|
||||
}
|
||||
|
||||
async function leave (): Promise<void> {
|
||||
@ -122,6 +126,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function shareSettings (e: MouseEvent): void {
|
||||
if (fullScreen) {
|
||||
popup = getPopup(ShareSettingPopup, e)
|
||||
} else {
|
||||
showPopup(ShareSettingPopup, {}, eventToHTMLElement(e))
|
||||
}
|
||||
}
|
||||
|
||||
function setAccess (e: MouseEvent): void {
|
||||
if (isOffice(room) && room.person !== me) return
|
||||
if (fullScreen) {
|
||||
@ -208,13 +220,15 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if allowShare}
|
||||
<ModernButton
|
||||
icon={$isSharingEnabled ? love.icon.SharingEnabled : love.icon.SharingDisabled}
|
||||
tooltip={{ label: $isSharingEnabled ? love.string.StopShare : love.string.Share }}
|
||||
disabled={($screenSharing && !$isSharingEnabled) || !$isConnected}
|
||||
kind={'secondary'}
|
||||
<SplitButton
|
||||
size={'large'}
|
||||
on:click={changeShare}
|
||||
icon={$isSharingEnabled ? love.icon.SharingEnabled : love.icon.SharingDisabled}
|
||||
showTooltip={{ label: $isSharingEnabled ? love.string.StopShare : love.string.Share }}
|
||||
disabled={($screenSharing && !$isSharingEnabled) || !$isConnected}
|
||||
action={changeShare}
|
||||
secondIcon={IconUpOutline}
|
||||
secondAction={shareSettings}
|
||||
separate
|
||||
/>
|
||||
{/if}
|
||||
{#if hasAccountRole(getCurrentAccount(), AccountRole.User) && $isRecordingAvailable}
|
||||
|
@ -14,43 +14,45 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Person, PersonAccount } from '@hcengineering/contact'
|
||||
import { personByIdStore, UserInfo } from '@hcengineering/contact-resources'
|
||||
import { IdMap, getCurrentAccount, Ref, Class, Doc } from '@hcengineering/core'
|
||||
import {
|
||||
ModernButton,
|
||||
SplitButton,
|
||||
IconArrowLeft,
|
||||
IconUpOutline,
|
||||
Label,
|
||||
eventToHTMLElement,
|
||||
Location,
|
||||
location,
|
||||
navigate,
|
||||
showPopup,
|
||||
Scroller,
|
||||
panelstore
|
||||
} from '@hcengineering/ui'
|
||||
import { UserInfo, personByIdStore } from '@hcengineering/contact-resources'
|
||||
import { Class, Doc, IdMap, Ref, getCurrentAccount } from '@hcengineering/core'
|
||||
import {
|
||||
MeetingMinutes,
|
||||
ParticipantInfo,
|
||||
Room,
|
||||
RoomType,
|
||||
isOffice,
|
||||
loveId,
|
||||
roomAccessIcon,
|
||||
roomAccessLabel,
|
||||
MeetingMinutes
|
||||
roomAccessLabel
|
||||
} from '@hcengineering/love'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { getObjectLinkFragment } from '@hcengineering/view-resources'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconUpOutline,
|
||||
Label,
|
||||
Location,
|
||||
ModernButton,
|
||||
Scroller,
|
||||
SplitButton,
|
||||
eventToHTMLElement,
|
||||
location,
|
||||
navigate,
|
||||
panelstore,
|
||||
showPopup
|
||||
} from '@hcengineering/ui'
|
||||
import view from '@hcengineering/view'
|
||||
import { getObjectLinkFragment } from '@hcengineering/view-resources'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import love from '../plugin'
|
||||
import { currentRoom, infos, invites, myInfo, myOffice, myRequests, currentMeetingMinutes, rooms } from '../stores'
|
||||
import { currentMeetingMinutes, currentRoom, infos, invites, myInfo, myOffice, myRequests, rooms } from '../stores'
|
||||
import {
|
||||
endMeeting,
|
||||
getRoomName,
|
||||
isCameraEnabled,
|
||||
isConnected,
|
||||
isMicEnabled,
|
||||
isShareWithSound,
|
||||
isSharingEnabled,
|
||||
leaveRoom,
|
||||
screenSharing,
|
||||
@ -62,7 +64,7 @@
|
||||
import CamSettingPopup from './CamSettingPopup.svelte'
|
||||
import MicSettingPopup from './MicSettingPopup.svelte'
|
||||
import RoomAccessPopup from './RoomAccessPopup.svelte'
|
||||
import view from '@hcengineering/view'
|
||||
import ShareSettingPopup from './ShareSettingPopup.svelte'
|
||||
|
||||
export let room: Room
|
||||
|
||||
@ -99,7 +101,9 @@
|
||||
}
|
||||
|
||||
async function changeShare (): Promise<void> {
|
||||
await setShare(!$isSharingEnabled)
|
||||
const newValue = !$isSharingEnabled
|
||||
const audio = newValue && $isShareWithSound
|
||||
await setShare(newValue, audio)
|
||||
}
|
||||
|
||||
async function leave (): Promise<void> {
|
||||
@ -114,6 +118,10 @@
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
function shareSettings (e: MouseEvent): void {
|
||||
showPopup(ShareSettingPopup, {}, eventToHTMLElement(e))
|
||||
}
|
||||
|
||||
async function connect (): Promise<void> {
|
||||
await tryConnect($personByIdStore, $myInfo, room, info, $myRequests, $invites)
|
||||
dispatch('close')
|
||||
@ -207,14 +215,15 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if allowShare}
|
||||
<ModernButton
|
||||
icon={$isSharingEnabled ? love.icon.SharingEnabled : love.icon.SharingDisabled}
|
||||
label={$isSharingEnabled ? love.string.StopShare : love.string.Share}
|
||||
tooltip={{ label: $isSharingEnabled ? love.string.StopShare : love.string.Share }}
|
||||
disabled={($screenSharing && !$isSharingEnabled) || !$isConnected}
|
||||
kind={'secondary'}
|
||||
<SplitButton
|
||||
size={'large'}
|
||||
on:click={changeShare}
|
||||
icon={$isSharingEnabled ? love.icon.SharingEnabled : love.icon.SharingDisabled}
|
||||
showTooltip={{ label: $isSharingEnabled ? love.string.StopShare : love.string.Share }}
|
||||
disabled={($screenSharing && !$isSharingEnabled) || !$isConnected}
|
||||
action={changeShare}
|
||||
secondIcon={IconUpOutline}
|
||||
secondAction={shareSettings}
|
||||
separate
|
||||
/>
|
||||
{/if}
|
||||
<ModernButton
|
||||
|
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Label, Toggle } from '@hcengineering/ui'
|
||||
import love from '../plugin'
|
||||
import { setShare, isShareWithSound, isSharingEnabled } from '../utils'
|
||||
</script>
|
||||
|
||||
<div class="antiPopup p-4 grid">
|
||||
<Label label={love.string.WithAudio} />
|
||||
{$isShareWithSound}
|
||||
<Toggle
|
||||
showTooltip={{ label: love.string.ShareWithAudioTooltip }}
|
||||
on={$isShareWithSound}
|
||||
on:change={(e) => {
|
||||
$isShareWithSound = e.detail
|
||||
if ($isSharingEnabled) {
|
||||
setShare($isSharingEnabled, e.detail)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
row-gap: 1rem;
|
||||
column-gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
@ -13,9 +13,14 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import aiBot from '@hcengineering/ai-bot'
|
||||
import { Person, PersonAccount } from '@hcengineering/contact'
|
||||
import { personIdByAccountId } from '@hcengineering/contact-resources'
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { RoomType, Room as TypeRoom } from '@hcengineering/love'
|
||||
import { MessageBox } from '@hcengineering/presentation'
|
||||
import { ActionIcon, Scroller, showPopup } from '@hcengineering/ui'
|
||||
import view from '@hcengineering/view'
|
||||
import { RoomType, Room as TypeRoom } from '@hcengineering/love'
|
||||
import {
|
||||
LocalParticipant,
|
||||
LocalTrackPublication,
|
||||
@ -28,11 +33,6 @@
|
||||
TrackPublication
|
||||
} from 'livekit-client'
|
||||
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { MessageBox } from '@hcengineering/presentation'
|
||||
import { Person, PersonAccount } from '@hcengineering/contact'
|
||||
import aiBot from '@hcengineering/ai-bot'
|
||||
import { personIdByAccountId } from '@hcengineering/contact-resources'
|
||||
|
||||
import love from '../plugin'
|
||||
import { currentRoom, infos, myInfo, myOffice } from '../stores'
|
||||
@ -41,6 +41,7 @@
|
||||
isCameraEnabled,
|
||||
isConnected,
|
||||
isMicEnabled,
|
||||
isShareWithSound,
|
||||
isSharingEnabled,
|
||||
leaveRoom,
|
||||
lk,
|
||||
@ -289,7 +290,9 @@
|
||||
|
||||
async function changeShare (): Promise<void> {
|
||||
if (!$isConnected) return
|
||||
await setShare(!$isSharingEnabled)
|
||||
const newValue = !$isSharingEnabled
|
||||
const audio = newValue && $isShareWithSound
|
||||
await setShare(newValue, audio)
|
||||
}
|
||||
|
||||
$: dispatchFit($isSharingEnabled)
|
||||
|
@ -95,6 +95,8 @@ export default mergeIds(loveId, love, {
|
||||
Select: '' as IntlString,
|
||||
ChooseShare: '' as IntlString,
|
||||
MoreOptions: '' as IntlString,
|
||||
Language: '' as IntlString
|
||||
Language: '' as IntlString,
|
||||
WithAudio: '' as IntlString,
|
||||
ShareWithAudioTooltip: '' as IntlString
|
||||
}
|
||||
})
|
||||
|
@ -77,6 +77,7 @@ import {
|
||||
type RemoteTrack,
|
||||
type RemoteTrackPublication,
|
||||
RoomEvent,
|
||||
type ScreenShareCaptureOptions,
|
||||
Track,
|
||||
type VideoCaptureOptions
|
||||
} from 'livekit-client'
|
||||
@ -174,6 +175,7 @@ export const isMicEnabled = writable<boolean>(false)
|
||||
export const isCameraEnabled = writable<boolean>(false)
|
||||
export const isSharingEnabled = writable<boolean>(false)
|
||||
export const isFullScreen = writable<boolean>(false)
|
||||
export const isShareWithSound = writable<boolean>(false)
|
||||
|
||||
function handleTrackSubscribed (
|
||||
track: RemoteTrack,
|
||||
@ -586,10 +588,14 @@ export async function setMic (value: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function setShare (value: boolean): Promise<void> {
|
||||
export async function setShare (value: boolean, withAudio: boolean = false): Promise<void> {
|
||||
if ($isCurrentInstanceConnected) {
|
||||
try {
|
||||
await lk.localParticipant.setScreenShareEnabled(value)
|
||||
const options: ScreenShareCaptureOptions = {}
|
||||
if (withAudio) {
|
||||
options.audio = true
|
||||
}
|
||||
await lk.localParticipant.setScreenShareEnabled(value, options)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
@ -235,6 +235,8 @@ export interface InboxNotification extends Doc<PersonSpace> {
|
||||
isViewed: boolean
|
||||
|
||||
docNotifyContext: Ref<DocNotifyContext>
|
||||
objectId: Ref<Doc>
|
||||
objectClass: Ref<Class<Doc>>
|
||||
|
||||
// For browser notifications
|
||||
title?: IntlString
|
||||
|
@ -63,11 +63,12 @@ export function registerGithub (
|
||||
async (ctx, next) => {
|
||||
try {
|
||||
let email = ctx.state.user.emails?.[0]?.value
|
||||
const username = ctx.state.user.username.toLowerCase()
|
||||
if (email == null || email === '') {
|
||||
email = `github:${ctx.state.user.username}`
|
||||
email = `github:${username}`
|
||||
}
|
||||
|
||||
const [first, last] = ctx.state.user.displayName?.split(' ') ?? [ctx.state.user.username, '']
|
||||
const [first, last] = ctx.state.user.displayName?.split(' ') ?? [username, '']
|
||||
measureCtx.info('Provider auth handler', { email, type: 'github' })
|
||||
if (email !== undefined) {
|
||||
let loginInfo: LoginInfo | null
|
||||
@ -76,7 +77,8 @@ export function registerGithub (
|
||||
const db = await dbPromise
|
||||
if (state.inviteId != null && state.inviteId !== '') {
|
||||
loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, {
|
||||
githubId: ctx.state.user.id
|
||||
githubId: ctx.state.user.id,
|
||||
githubUser: username
|
||||
})
|
||||
} else {
|
||||
loginInfo = await loginWithProvider(
|
||||
@ -87,7 +89,8 @@ export function registerGithub (
|
||||
first,
|
||||
last,
|
||||
{
|
||||
githubId: ctx.state.user.id
|
||||
githubId: ctx.state.user.id,
|
||||
githubUser: username
|
||||
},
|
||||
signUpDisabled
|
||||
)
|
||||
|
@ -151,6 +151,8 @@ export async function getPersonNotificationTxes (
|
||||
messageHtml: reference.message,
|
||||
mentionedIn: reference.attachedDocId ?? reference.srcDocId,
|
||||
mentionedInClass: reference.attachedDocClass ?? reference.srcDocClass,
|
||||
objectId: reference.srcDocId,
|
||||
objectClass: reference.srcDocClass,
|
||||
user: receiver[0]._id,
|
||||
isViewed: false,
|
||||
archived: false
|
||||
@ -238,9 +240,6 @@ export async function getPersonNotificationTxes (
|
||||
modifiedOn: originTx.modifiedOn,
|
||||
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)
|
||||
? (await control.findAll(control.ctx, data.mentionedInClass, { _id: data.mentionedIn }))[0]
|
||||
@ -248,14 +247,11 @@ export async function getPersonNotificationTxes (
|
||||
await applyNotificationProviders(
|
||||
notificationData,
|
||||
notifyResult,
|
||||
reference.srcDocId,
|
||||
reference.srcDocClass,
|
||||
control,
|
||||
res,
|
||||
doc,
|
||||
receiverInfo,
|
||||
senderInfo,
|
||||
subscriptions,
|
||||
notification.class.MentionInboxNotification,
|
||||
msg as ActivityMessage
|
||||
)
|
||||
|
@ -15,16 +15,8 @@
|
||||
//
|
||||
|
||||
import activity, { ActivityMessage, DocUpdateMessage } from '@hcengineering/activity'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import chunter, { ChatMessage } from '@hcengineering/chunter'
|
||||
import contact, {
|
||||
Employee,
|
||||
getAvatarProviderId,
|
||||
getGravatarUrl,
|
||||
Person,
|
||||
PersonAccount,
|
||||
type AvatarInfo
|
||||
} from '@hcengineering/contact'
|
||||
import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact'
|
||||
import core, {
|
||||
Account,
|
||||
AnyAttribute,
|
||||
@ -33,7 +25,6 @@ import core, {
|
||||
Class,
|
||||
Collection,
|
||||
combineAttributes,
|
||||
concatLink,
|
||||
Data,
|
||||
Doc,
|
||||
DocumentUpdate,
|
||||
@ -64,26 +55,28 @@ import notification, {
|
||||
DocNotifyContext,
|
||||
InboxNotification,
|
||||
MentionInboxNotification,
|
||||
notificationId,
|
||||
NotificationType,
|
||||
PushData,
|
||||
PushSubscription
|
||||
NotificationType
|
||||
} from '@hcengineering/notification'
|
||||
import { getMetadata, getResource, translate } from '@hcengineering/platform'
|
||||
import serverCore, { type TriggerControl } from '@hcengineering/server-core'
|
||||
import { getResource, translate } from '@hcengineering/platform'
|
||||
import { type TriggerControl } from '@hcengineering/server-core'
|
||||
import serverNotification, {
|
||||
getPersonAccountById,
|
||||
NOTIFICATION_BODY_SIZE,
|
||||
PUSH_NOTIFICATION_TITLE_SIZE,
|
||||
ReceiverInfo,
|
||||
SenderInfo
|
||||
} from '@hcengineering/server-notification'
|
||||
import serverView from '@hcengineering/server-view'
|
||||
import { markupToText, stripTags } from '@hcengineering/text-core'
|
||||
import { encodeObjectURI } from '@hcengineering/view'
|
||||
import { workbenchId } from '@hcengineering/workbench'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
|
||||
import { Content, ContextsCache, ContextsCacheKey, NotifyParams, NotifyResult } from './types'
|
||||
import {
|
||||
AvailableProvidersCache,
|
||||
AvailableProvidersCacheKey,
|
||||
Content,
|
||||
ContextsCache,
|
||||
ContextsCacheKey,
|
||||
NotifyParams,
|
||||
NotifyResult
|
||||
} from './types'
|
||||
import {
|
||||
createPullCollaboratorsTx,
|
||||
createPushCollaboratorsTx,
|
||||
@ -105,6 +98,7 @@ import {
|
||||
updateNotifyContextsSpace,
|
||||
type NotificationProviderControl
|
||||
} from './utils'
|
||||
import { PushNotificationsHandler } from './push'
|
||||
|
||||
export function getPushCollaboratorTx (
|
||||
control: TriggerControl,
|
||||
@ -165,20 +159,8 @@ export async function getCommonNotificationTxes (
|
||||
|
||||
if (notificationTx !== undefined) {
|
||||
const notificationData = TxProcessor.createDoc2Doc(notificationTx)
|
||||
const subscriptions = await control.findAll(ctx, notification.class.PushSubscription, { user: receiver._id })
|
||||
await applyNotificationProviders(
|
||||
notificationData,
|
||||
notifyResult,
|
||||
attachedTo,
|
||||
attachedToClass,
|
||||
control,
|
||||
res,
|
||||
doc,
|
||||
receiver,
|
||||
sender,
|
||||
subscriptions,
|
||||
_class
|
||||
)
|
||||
|
||||
await applyNotificationProviders(notificationData, notifyResult, control, res, doc, receiver, sender, _class)
|
||||
}
|
||||
|
||||
return res
|
||||
@ -383,6 +365,8 @@ export async function pushInboxNotifications (
|
||||
isViewed: false,
|
||||
docNotifyContext: docNotifyContextId,
|
||||
archived: false,
|
||||
objectId,
|
||||
objectClass,
|
||||
...data
|
||||
}
|
||||
const notificationTx = control.txFactory.createTxCreateDoc(_class, receiver.space, notificationData)
|
||||
@ -489,156 +473,6 @@ export async function getTranslatedNotificationContent (
|
||||
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
|
||||
*/
|
||||
@ -682,40 +516,16 @@ export async function pushActivityInboxNotifications (
|
||||
export async function applyNotificationProviders (
|
||||
data: InboxNotification,
|
||||
notifyResult: NotifyResult,
|
||||
attachedTo: Ref<Doc>,
|
||||
attachedToClass: Ref<Class<Doc>>,
|
||||
control: TriggerControl,
|
||||
res: Tx[],
|
||||
object: Doc,
|
||||
receiver: ReceiverInfo,
|
||||
sender: SenderInfo,
|
||||
subscriptions: PushSubscription[],
|
||||
_class = notification.class.ActivityInboxNotification,
|
||||
message?: ActivityMessage
|
||||
): Promise<void> {
|
||||
const resources = control.modelDb.findAllSync(serverNotification.class.NotificationProviderResources, {})
|
||||
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)
|
||||
|
||||
if (resource === undefined) continue
|
||||
@ -789,8 +599,7 @@ export async function getNotificationTxes (
|
||||
params: NotifyParams,
|
||||
docNotifyContexts: DocNotifyContext[],
|
||||
activityMessages: ActivityMessage[],
|
||||
settings: NotificationProviderControl,
|
||||
subscriptions: PushSubscription[]
|
||||
settings: NotificationProviderControl
|
||||
): Promise<Tx[]> {
|
||||
if (receiver.account === undefined) {
|
||||
return []
|
||||
@ -828,17 +637,23 @@ export async function getNotificationTxes (
|
||||
if (notificationTx !== undefined) {
|
||||
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(
|
||||
notificationData,
|
||||
notifyResult,
|
||||
message.attachedTo,
|
||||
message.attachedToClass,
|
||||
control,
|
||||
res,
|
||||
object,
|
||||
receiver,
|
||||
sender,
|
||||
subscriptions,
|
||||
notificationData._class,
|
||||
message
|
||||
)
|
||||
@ -1030,9 +845,6 @@ export async function createCollabDocInfo (
|
||||
}
|
||||
|
||||
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) {
|
||||
const info: ReceiverInfo | undefined = toReceiverInfo(control.hierarchy, usersInfo.get(target))
|
||||
@ -1049,8 +861,7 @@ export async function createCollabDocInfo (
|
||||
params,
|
||||
notifyContexts,
|
||||
docMessages,
|
||||
settings,
|
||||
subscriptions
|
||||
settings
|
||||
)
|
||||
const ids = new Set(targetRes.map((it) => it._id))
|
||||
if (info.account?.email !== undefined) {
|
||||
@ -2031,6 +1842,7 @@ async function OnDocRemove (txes: TxCUD<Doc>[], control: TriggerControl): Promis
|
||||
}
|
||||
|
||||
export * from './types'
|
||||
export * from './push'
|
||||
export * from './utils'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
@ -2039,7 +1851,8 @@ export default async () => ({
|
||||
OnAttributeCreate,
|
||||
OnAttributeUpdate,
|
||||
OnDocRemove,
|
||||
OnEmployeeDeactivate
|
||||
OnEmployeeDeactivate,
|
||||
PushNotificationsHandler
|
||||
},
|
||||
function: {
|
||||
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
|
||||
// limitations under the License.
|
||||
//
|
||||
import { BaseNotificationType, DocNotifyContext, NotificationProvider } from '@hcengineering/notification'
|
||||
import {
|
||||
BaseNotificationType,
|
||||
DocNotifyContext,
|
||||
InboxNotification,
|
||||
NotificationProvider
|
||||
} from '@hcengineering/notification'
|
||||
import { Ref } from '@hcengineering/core'
|
||||
|
||||
/**
|
||||
@ -39,3 +44,6 @@ export const ContextsCacheKey = 'DocNotifyContexts'
|
||||
export interface ContextsCache {
|
||||
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) ??
|
||||
(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>,
|
||||
OnReactionChanged: '' as Resource<TriggerFunc>,
|
||||
OnDocRemove: '' as Resource<TriggerFunc>,
|
||||
OnEmployeeDeactivate: '' as Resource<TriggerFunc>
|
||||
OnEmployeeDeactivate: '' as Resource<TriggerFunc>,
|
||||
PushNotificationsHandler: '' as Resource<TriggerFunc>
|
||||
},
|
||||
function: {
|
||||
IsUserInFieldValueTypeMatch: '' as TypeMatchFunc,
|
||||
|
@ -161,10 +161,7 @@ async function getRequestNotificationTx (
|
||||
}
|
||||
|
||||
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) {
|
||||
const targetInfo = toReceiverInfo(control.hierarchy, usersInfo.get(target))
|
||||
if (targetInfo === undefined) continue
|
||||
@ -179,8 +176,7 @@ async function getRequestNotificationTx (
|
||||
{ isOwn: true, isSpace: false, shouldUpdateTimestamp: true },
|
||||
notifyContexts,
|
||||
messages,
|
||||
notificationControl,
|
||||
subscriptions
|
||||
notificationControl
|
||||
)
|
||||
res.push(...txes)
|
||||
}
|
||||
|
@ -89,6 +89,12 @@ export class PostgresDbCollection<T extends Record<string, any>> implements DbCo
|
||||
values.push(Object.values(qKey as object)[0])
|
||||
break
|
||||
}
|
||||
case '$ne': {
|
||||
currIdx++
|
||||
whereChunks.push(`"${key}" != $${currIdx}`)
|
||||
values.push(Object.values(qKey as object)[0])
|
||||
break
|
||||
}
|
||||
default: {
|
||||
currIdx++
|
||||
whereChunks.push(`"${key}" = $${currIdx}`)
|
||||
|
@ -2678,7 +2678,14 @@ export async function joinWithProvider (
|
||||
}
|
||||
let account = await getAccount(db, email)
|
||||
if (account == null && extra !== undefined) {
|
||||
account = await getAccountByQuery(db, extra)
|
||||
// Temporary: we don't want to use githubUser to search yet
|
||||
// but we want to save it in the account
|
||||
const extraSearch = {
|
||||
...extra
|
||||
}
|
||||
delete extraSearch.githubUser
|
||||
|
||||
account = await getAccountByQuery(db, extraSearch)
|
||||
}
|
||||
if (account !== null) {
|
||||
// we should clean password if account is not confirmed
|
||||
@ -2760,7 +2767,14 @@ export async function loginWithProvider (
|
||||
}
|
||||
let account = await getAccount(db, email)
|
||||
if (account == null && extra !== undefined) {
|
||||
account = await getAccountByQuery(db, extra)
|
||||
// Temporary: we don't want to use githubUser to search yet
|
||||
// but we want to save it in the account
|
||||
const extraSearch = {
|
||||
...extra
|
||||
}
|
||||
delete extraSearch.githubUser
|
||||
|
||||
account = await getAccountByQuery(db, extraSearch)
|
||||
}
|
||||
if (account !== null) {
|
||||
// we should clean password if account is not confirmed
|
||||
|
@ -46,6 +46,7 @@ export interface Account {
|
||||
createdOn: number
|
||||
lastVisit: number
|
||||
githubId?: string
|
||||
githubUser?: string
|
||||
openId?: string
|
||||
}
|
||||
|
||||
@ -146,6 +147,7 @@ export interface UpgradeStatistic {
|
||||
|
||||
interface Operator<T, P extends keyof T> {
|
||||
$in?: T[P][]
|
||||
$ne?: T[P] | null
|
||||
$lt?: T[P]
|
||||
$lte?: T[P]
|
||||
$gt?: T[P]
|
||||
|
@ -36,6 +36,8 @@ export async function createNotification (
|
||||
} else {
|
||||
await client.createDoc(notification.class.CommonInboxNotification, data.space, {
|
||||
user: data.user,
|
||||
objectId: forDoc._id,
|
||||
objectClass: forDoc._class,
|
||||
icon: github.icon.Github,
|
||||
message: data.message,
|
||||
props: data.props,
|
||||
|
Loading…
Reference in New Issue
Block a user