From d132d6a6793417c141893219898020fd2c7a0520 Mon Sep 17 00:00:00 2001 From: Alexey Zinoviev Date: Wed, 22 Jan 2025 19:29:43 +0400 Subject: [PATCH 1/3] UBERF-9236: Fetch GH usernames (#7766) Signed-off-by: Alexey Zinoviev --- dev/tool/src/account.ts | 202 +++++++++++++++++++++ dev/tool/src/index.ts | 12 +- dev/tool/src/renameAccount.ts | 99 ---------- pods/authProviders/src/github.ts | 11 +- server/account/src/collections/postgres.ts | 6 + server/account/src/operations.ts | 18 +- server/account/src/types.ts | 2 + 7 files changed, 244 insertions(+), 106 deletions(-) create mode 100644 dev/tool/src/account.ts delete mode 100644 dev/tool/src/renameAccount.ts diff --git a/dev/tool/src/account.ts b/dev/tool/src/account.ts new file mode 100644 index 0000000000..24c2228876 --- /dev/null +++ b/dev/tool/src/account.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }) +} diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index a6dac6c52b..4e7067d27b 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -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 ', '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) diff --git a/dev/tool/src/renameAccount.ts b/dev/tool/src/renameAccount.ts deleted file mode 100644 index 40e0a4072e..0000000000 --- a/dev/tool/src/renameAccount.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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() - } -} diff --git a/pods/authProviders/src/github.ts b/pods/authProviders/src/github.ts index 0feed30ece..7c3332cbbd 100644 --- a/pods/authProviders/src/github.ts +++ b/pods/authProviders/src/github.ts @@ -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 ) diff --git a/server/account/src/collections/postgres.ts b/server/account/src/collections/postgres.ts index dc872aa2a8..779876f10a 100644 --- a/server/account/src/collections/postgres.ts +++ b/server/account/src/collections/postgres.ts @@ -89,6 +89,12 @@ export class PostgresDbCollection> 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}`) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 810a594122..2ee99fd63b 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -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 diff --git a/server/account/src/types.ts b/server/account/src/types.ts index 512504df2c..73dbe634dc 100644 --- a/server/account/src/types.ts +++ b/server/account/src/types.ts @@ -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 { $in?: T[P][] + $ne?: T[P] | null $lt?: T[P] $lte?: T[P] $gt?: T[P] From 86da890c13824db8668a7a11cbc8711834736c56 Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Wed, 22 Jan 2025 21:04:36 +0500 Subject: [PATCH 2/3] Share video with audio (#7768) Signed-off-by: Denis Bykhov --- plugins/love-assets/lang/cs.json | 4 +- plugins/love-assets/lang/de.json | 4 +- plugins/love-assets/lang/en.json | 4 +- plugins/love-assets/lang/es.json | 4 +- plugins/love-assets/lang/fr.json | 4 +- plugins/love-assets/lang/it.json | 4 +- plugins/love-assets/lang/pt.json | 4 +- plugins/love-assets/lang/ru.json | 4 +- plugins/love-assets/lang/zh.json | 4 +- .../src/components/ControlBar.svelte | 48 ++++++++----- .../src/components/RoomPopup.svelte | 69 +++++++++++-------- .../src/components/ShareSettingPopup.svelte | 30 ++++++++ .../src/components/VideoPopup.svelte | 17 +++-- plugins/love-resources/src/plugin.ts | 4 +- plugins/love-resources/src/utils.ts | 10 ++- 15 files changed, 148 insertions(+), 66 deletions(-) create mode 100644 plugins/love-resources/src/components/ShareSettingPopup.svelte diff --git a/plugins/love-assets/lang/cs.json b/plugins/love-assets/lang/cs.json index 9b9c1f33c6..eb20c8a456 100644 --- a/plugins/love-assets/lang/cs.json +++ b/plugins/love-assets/lang/cs.json @@ -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" } } \ No newline at end of file diff --git a/plugins/love-assets/lang/de.json b/plugins/love-assets/lang/de.json index 15cd40bcc8..961199c492 100644 --- a/plugins/love-assets/lang/de.json +++ b/plugins/love-assets/lang/de.json @@ -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" } } diff --git a/plugins/love-assets/lang/en.json b/plugins/love-assets/lang/en.json index d86279790c..1cc4f66d73 100644 --- a/plugins/love-assets/lang/en.json +++ b/plugins/love-assets/lang/en.json @@ -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" } } diff --git a/plugins/love-assets/lang/es.json b/plugins/love-assets/lang/es.json index d51bb0d0da..0f01144369 100644 --- a/plugins/love-assets/lang/es.json +++ b/plugins/love-assets/lang/es.json @@ -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" } } diff --git a/plugins/love-assets/lang/fr.json b/plugins/love-assets/lang/fr.json index 3f602a4675..90d7a0c49e 100644 --- a/plugins/love-assets/lang/fr.json +++ b/plugins/love-assets/lang/fr.json @@ -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" } } \ No newline at end of file diff --git a/plugins/love-assets/lang/it.json b/plugins/love-assets/lang/it.json index 1a2bf3700a..105430eb73 100644 --- a/plugins/love-assets/lang/it.json +++ b/plugins/love-assets/lang/it.json @@ -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" } } diff --git a/plugins/love-assets/lang/pt.json b/plugins/love-assets/lang/pt.json index 6547a98695..b5dc1c8139 100644 --- a/plugins/love-assets/lang/pt.json +++ b/plugins/love-assets/lang/pt.json @@ -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" } } diff --git a/plugins/love-assets/lang/ru.json b/plugins/love-assets/lang/ru.json index c430ccfb9b..999b1e1479 100644 --- a/plugins/love-assets/lang/ru.json +++ b/plugins/love-assets/lang/ru.json @@ -81,6 +81,8 @@ "Finished": "Завершено", "StartWithRecording": "Начинать с записью", "Language": "Язык", - "Kick": "Выгнать" + "Kick": "Выгнать", + "WithAudio": "С аудио", + "ShareWithAudioTooltip": "Поделиться с аудио, вам нужно поделиться снова, чтобы применить изменения" } } diff --git a/plugins/love-assets/lang/zh.json b/plugins/love-assets/lang/zh.json index 8c7415331e..951433dd29 100644 --- a/plugins/love-assets/lang/zh.json +++ b/plugins/love-assets/lang/zh.json @@ -81,6 +81,8 @@ "Finished": "已完成", "StartWithRecording": "开始录制", "Language": "语言", - "Kick": "踢出" + "Kick": "踢出", + "WithAudio": "带音频", + "ShareWithAudioTooltip": "共享音频,您应重新共享以应用更改" } } diff --git a/plugins/love-resources/src/components/ControlBar.svelte b/plugins/love-resources/src/components/ControlBar.svelte index fd50fa8439..b5141a8e01 100644 --- a/plugins/love-resources/src/components/ControlBar.svelte +++ b/plugins/love-resources/src/components/ControlBar.svelte @@ -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 { - await setShare(!$isSharingEnabled) + const newValue = !$isSharingEnabled + const audio = newValue && $isShareWithSound + await setShare(newValue, audio) } async function leave (): Promise { @@ -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} - {/if} {#if hasAccountRole(getCurrentAccount(), AccountRole.User) && $isRecordingAvailable} diff --git a/plugins/love-resources/src/components/RoomPopup.svelte b/plugins/love-resources/src/components/RoomPopup.svelte index 4550079c1f..a13bcd06ed 100644 --- a/plugins/love-resources/src/components/RoomPopup.svelte +++ b/plugins/love-resources/src/components/RoomPopup.svelte @@ -14,43 +14,45 @@ --> + +
+
+ + diff --git a/plugins/love-resources/src/components/VideoPopup.svelte b/plugins/love-resources/src/components/VideoPopup.svelte index d162084bc5..361c423bb1 100644 --- a/plugins/love-resources/src/components/VideoPopup.svelte +++ b/plugins/love-resources/src/components/VideoPopup.svelte @@ -13,9 +13,14 @@ // limitations under the License. -->