mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-13 19:31:46 +00:00
change plugin system
This commit is contained in:
345
src/index.ts
345
src/index.ts
@ -2,8 +2,19 @@ import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron';
|
||||
import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request';
|
||||
import {
|
||||
BrowserWindow,
|
||||
app,
|
||||
screen,
|
||||
globalShortcut,
|
||||
session,
|
||||
shell,
|
||||
dialog,
|
||||
ipcMain,
|
||||
} from 'electron';
|
||||
import enhanceWebRequest, {
|
||||
BetterSession,
|
||||
} from '@jellybrick/electron-better-web-request';
|
||||
import is from 'electron-is';
|
||||
import unhandled from 'electron-unhandled';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
@ -12,30 +23,31 @@ import { parse } from 'node-html-parser';
|
||||
import { deepmerge } from 'deepmerge-ts';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
|
||||
import { mainPlugins } from 'virtual:MainPlugins';
|
||||
import { pluginBuilders } from 'virtual:PluginBuilders';
|
||||
import { mainPlugins } from 'virtual:plugins';
|
||||
|
||||
import config from './config';
|
||||
import config from '@/config';
|
||||
|
||||
import { refreshMenu, setApplicationMenu } from './menu';
|
||||
import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils/main';
|
||||
import { isTesting } from './utils/testing';
|
||||
import { setUpTray } from './tray';
|
||||
import { setupSongInfo } from './providers/song-info';
|
||||
import { restart, setupAppControls } from './providers/app-controls';
|
||||
import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler';
|
||||
import { refreshMenu, setApplicationMenu } from '@/menu';
|
||||
import { fileExists, injectCSS, injectCSSAsFile } from '@/plugins/utils/main';
|
||||
import { isTesting } from '@/utils/testing';
|
||||
import { setUpTray } from '@/tray';
|
||||
import { setupSongInfo } from '@/providers/song-info';
|
||||
import { restart, setupAppControls } from '@/providers/app-controls';
|
||||
import {
|
||||
APP_PROTOCOL,
|
||||
handleProtocol,
|
||||
setupProtocolHandler,
|
||||
} from '@/providers/protocol-handler';
|
||||
|
||||
|
||||
import youtubeMusicCSS from './youtube-music.css?inline';
|
||||
import youtubeMusicCSS from '@/youtube-music.css?inline';
|
||||
|
||||
import {
|
||||
forceLoadMainPlugin,
|
||||
forceUnloadMainPlugin,
|
||||
getAllLoadedMainPlugins,
|
||||
loadAllMainPlugins,
|
||||
registerMainPlugin
|
||||
} from './loader/main';
|
||||
import { MainPluginFactory, PluginBaseConfig, PluginBuilder } from './plugins/utils/builder';
|
||||
} from '@/loader/main';
|
||||
import { PluginBaseConfig } from '@/plugins/utils/builder';
|
||||
|
||||
// Catch errors and log them
|
||||
unhandled({
|
||||
@ -57,7 +69,10 @@ if (!gotTheLock) {
|
||||
|
||||
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
|
||||
// OverlayScrollbar: Required for overlay scrollbars
|
||||
app.commandLine.appendSwitch('enable-features', 'OverlayScrollbar,SharedArrayBuffer');
|
||||
app.commandLine.appendSwitch(
|
||||
'enable-features',
|
||||
'OverlayScrollbar,SharedArrayBuffer',
|
||||
);
|
||||
if (config.get('options.disableHardwareAcceleration')) {
|
||||
if (is.dev()) {
|
||||
console.log('Disabling hardware acceleration');
|
||||
@ -95,42 +110,58 @@ function onClosed() {
|
||||
|
||||
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
|
||||
|
||||
|
||||
const initHook = (win: BrowserWindow) => {
|
||||
ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => deepmerge(pluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as PluginBuilderList[typeof id]['config']);
|
||||
ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial(`plugins.${name}`, obj));
|
||||
ipcMain.handle(
|
||||
'get-config',
|
||||
(_, id: keyof PluginBuilderList) =>
|
||||
deepmerge(
|
||||
mainPlugins[id].config,
|
||||
config.get(`plugins.${id}`) ?? {},
|
||||
) as PluginBuilderList[typeof id]['config'],
|
||||
);
|
||||
ipcMain.handle('set-config', (_, name: string, obj: object) =>
|
||||
config.setPartial(`plugins.${name}`, obj),
|
||||
);
|
||||
|
||||
config.watch((newValue, oldValue) => {
|
||||
const newPluginConfigList = (newValue?.plugins ?? {}) as Record<string, unknown>;
|
||||
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<string, unknown>;
|
||||
const newPluginConfigList = (newValue?.plugins ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => {
|
||||
const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig);
|
||||
|
||||
if (!isEqual) {
|
||||
const oldConfig = oldPluginConfigList[id] as PluginBaseConfig;
|
||||
const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig) as PluginBaseConfig;
|
||||
const config = deepmerge(
|
||||
mainPlugins[id].config,
|
||||
newPluginConfig,
|
||||
) as PluginBaseConfig;
|
||||
|
||||
if (config.enabled !== oldConfig?.enabled) {
|
||||
if (config.enabled) {
|
||||
win.webContents.send('plugin:enable', id);
|
||||
ipcMain.emit('plugin:enable', id);
|
||||
forceLoadMainPlugin(id as keyof PluginBuilderList, win);
|
||||
forceLoadMainPlugin(id, win);
|
||||
} else {
|
||||
win.webContents.send('plugin:unload', id);
|
||||
ipcMain.emit('plugin:unload', id);
|
||||
forceUnloadMainPlugin(id as keyof PluginBuilderList, win);
|
||||
forceUnloadMainPlugin(id, win);
|
||||
}
|
||||
|
||||
if (pluginBuilders[id as keyof PluginBuilderList].restartNeeded) {
|
||||
showNeedToRestartDialog(id as keyof PluginBuilderList);
|
||||
if (mainPlugins[id].restartNeeded) {
|
||||
showNeedToRestartDialog(id);
|
||||
}
|
||||
}
|
||||
|
||||
const mainPlugin = getAllLoadedMainPlugins()[id];
|
||||
if (mainPlugin) {
|
||||
if (config.enabled) {
|
||||
mainPlugin.onConfigChange?.(config);
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,14 +171,15 @@ const initHook = (win: BrowserWindow) => {
|
||||
});
|
||||
};
|
||||
|
||||
const showNeedToRestartDialog = (id: keyof PluginBuilderList) => {
|
||||
const builder = pluginBuilders[id];
|
||||
const showNeedToRestartDialog = (id: string) => {
|
||||
const plugin = mainPlugins[id];
|
||||
|
||||
const dialogOptions: Electron.MessageBoxOptions = {
|
||||
type: 'info',
|
||||
buttons: ['Restart Now', 'Later'],
|
||||
title: 'Restart Required',
|
||||
message: `"${builder.name ?? builder.id}" needs to restart`,
|
||||
detail: `"${builder.name ?? builder.id}" plugin requires a restart to take effect`,
|
||||
message: `"${plugin.name ?? id}" needs to restart`,
|
||||
detail: `"${plugin.name ?? id}" plugin requires a restart to take effect`,
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
};
|
||||
@ -186,7 +218,10 @@ function initTheme(win: BrowserWindow) {
|
||||
injectCSSAsFile(win.webContents, cssFile);
|
||||
},
|
||||
() => {
|
||||
console.warn('[YTMusic]', `CSS file "${cssFile}" does not exist, ignoring`);
|
||||
console.warn(
|
||||
'[YTMusic]',
|
||||
`CSS file "${cssFile}" does not exist, ignoring`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -224,46 +259,43 @@ async function createMainWindow() {
|
||||
...(isTesting()
|
||||
? undefined
|
||||
: {
|
||||
// Sandbox is only enabled in tests for now
|
||||
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
|
||||
sandbox: false,
|
||||
}),
|
||||
// Sandbox is only enabled in tests for now
|
||||
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
|
||||
sandbox: false,
|
||||
}),
|
||||
},
|
||||
frame: !is.macOS() && !useInlineMenu,
|
||||
titleBarOverlay: defaultTitleBarOverlayOptions,
|
||||
titleBarStyle: useInlineMenu
|
||||
? 'hidden'
|
||||
: (is.macOS()
|
||||
? 'hiddenInset'
|
||||
: 'default'),
|
||||
: is.macOS()
|
||||
? 'hiddenInset'
|
||||
: 'default',
|
||||
autoHideMenuBar: config.get('options.hideMenu'),
|
||||
});
|
||||
initHook(win);
|
||||
initTheme(win);
|
||||
|
||||
Object.entries(pluginBuilders).forEach(([id, builder]) => {
|
||||
const typedBuilder = builder as PluginBuilder<string, PluginBaseConfig>;
|
||||
const plugin = mainPlugins[id] as MainPluginFactory<PluginBaseConfig> | undefined;
|
||||
|
||||
registerMainPlugin(id, typedBuilder, plugin);
|
||||
});
|
||||
await loadAllMainPlugins(win);
|
||||
|
||||
if (windowPosition) {
|
||||
const { x: windowX, y: windowY } = windowPosition;
|
||||
const winSize = win.getSize();
|
||||
const displaySize
|
||||
= screen.getDisplayNearestPoint(windowPosition).bounds;
|
||||
const displaySize = screen.getDisplayNearestPoint(windowPosition).bounds;
|
||||
if (
|
||||
windowX + winSize[0] < displaySize.x - 8
|
||||
|| windowX - winSize[0] > displaySize.x + displaySize.width
|
||||
|| windowY < displaySize.y - 8
|
||||
|| windowY > displaySize.y + displaySize.height
|
||||
windowX + winSize[0] < displaySize.x - 8 ||
|
||||
windowX - winSize[0] > displaySize.x + displaySize.width ||
|
||||
windowY < displaySize.y - 8 ||
|
||||
windowY > displaySize.y + displaySize.height
|
||||
) {
|
||||
// Window is offscreen
|
||||
if (is.dev()) {
|
||||
console.log(
|
||||
`Window tried to render offscreen, windowSize=${String(winSize)}, displaySize=${String(displaySize)}, position=${String(windowPosition)}`,
|
||||
`Window tried to render offscreen, windowSize=${String(
|
||||
winSize,
|
||||
)}, displaySize=${String(displaySize)}, position=${String(
|
||||
windowPosition,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@ -316,7 +348,11 @@ async function createMainWindow() {
|
||||
|
||||
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
|
||||
|
||||
function lateSave(key: string, value: unknown, fn: (key: string, value: unknown) => void = config.set) {
|
||||
function lateSave(
|
||||
key: string,
|
||||
value: unknown,
|
||||
fn: (key: string, value: unknown) => void = config.set,
|
||||
) {
|
||||
if (savedTimeouts[key]) {
|
||||
clearTimeout(savedTimeouts[key]);
|
||||
}
|
||||
@ -343,7 +379,10 @@ async function createMainWindow() {
|
||||
if (useInlineMenu) {
|
||||
win.setTitleBarOverlay({
|
||||
...defaultTitleBarOverlayOptions,
|
||||
height: Math.floor(defaultTitleBarOverlayOptions.height! * win.webContents.getZoomFactor()),
|
||||
height: Math.floor(
|
||||
defaultTitleBarOverlayOptions.height! *
|
||||
win.webContents.getZoomFactor(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@ -365,14 +404,25 @@ async function createMainWindow() {
|
||||
`);
|
||||
} else {
|
||||
const rendererPath = path.join(__dirname, '..', 'renderer');
|
||||
const indexHTML = parse(fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'));
|
||||
const indexHTML = parse(
|
||||
fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'),
|
||||
);
|
||||
const scriptSrc = indexHTML.querySelector('script')!;
|
||||
const scriptPath = path.join(rendererPath, scriptSrc.getAttribute('src')!);
|
||||
const scriptPath = path.join(
|
||||
rendererPath,
|
||||
scriptSrc.getAttribute('src')!,
|
||||
);
|
||||
const scriptString = fs.readFileSync(scriptPath, 'utf-8');
|
||||
await win.webContents.executeJavaScriptInIsolatedWorld(0, [{
|
||||
code: scriptString + ';0',
|
||||
url: url.pathToFileURL(scriptPath).toString(),
|
||||
}], true);
|
||||
await win.webContents.executeJavaScriptInIsolatedWorld(
|
||||
0,
|
||||
[
|
||||
{
|
||||
code: scriptString + ';0',
|
||||
url: url.pathToFileURL(scriptPath).toString(),
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -387,21 +437,26 @@ app.once('browser-window-created', (event, win) => {
|
||||
const originalUserAgent = win.webContents.userAgent;
|
||||
const userAgents = {
|
||||
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0',
|
||||
windows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
||||
windows:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
||||
linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
||||
};
|
||||
|
||||
const updatedUserAgent
|
||||
= is.macOS() ? userAgents.mac
|
||||
: (is.windows() ? userAgents.windows
|
||||
: userAgents.linux);
|
||||
const updatedUserAgent = is.macOS()
|
||||
? userAgents.mac
|
||||
: is.windows()
|
||||
? userAgents.windows
|
||||
: userAgents.linux;
|
||||
|
||||
win.webContents.userAgent = updatedUserAgent;
|
||||
app.userAgentFallback = updatedUserAgent;
|
||||
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
|
||||
// This will only happen if login failed, and "retry" was pressed
|
||||
if (win.webContents.getURL().startsWith('https://accounts.google.com') && details.url.startsWith('https://accounts.google.com')) {
|
||||
if (
|
||||
win.webContents.getURL().startsWith('https://accounts.google.com') &&
|
||||
details.url.startsWith('https://accounts.google.com')
|
||||
) {
|
||||
details.requestHeaders['User-Agent'] = originalUserAgent;
|
||||
}
|
||||
|
||||
@ -412,33 +467,41 @@ app.once('browser-window-created', (event, win) => {
|
||||
setupSongInfo(win);
|
||||
setupAppControls();
|
||||
|
||||
win.webContents.on('did-fail-load', (
|
||||
_event,
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
isMainFrame,
|
||||
frameProcessId,
|
||||
frameRoutingId,
|
||||
) => {
|
||||
const log = JSON.stringify({
|
||||
error: 'did-fail-load',
|
||||
win.webContents.on(
|
||||
'did-fail-load',
|
||||
(
|
||||
_event,
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
isMainFrame,
|
||||
frameProcessId,
|
||||
frameRoutingId,
|
||||
}, null, '\t');
|
||||
if (is.dev()) {
|
||||
console.log(log);
|
||||
}
|
||||
) => {
|
||||
const log = JSON.stringify(
|
||||
{
|
||||
error: 'did-fail-load',
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
isMainFrame,
|
||||
frameProcessId,
|
||||
frameRoutingId,
|
||||
},
|
||||
null,
|
||||
'\t',
|
||||
);
|
||||
if (is.dev()) {
|
||||
console.log(log);
|
||||
}
|
||||
|
||||
if (errorCode !== -3) { // -3 is a false positive
|
||||
win.webContents.send('log', log);
|
||||
win.webContents.loadFile(path.join(__dirname, 'error.html'));
|
||||
}
|
||||
});
|
||||
if (errorCode !== -3) {
|
||||
// -3 is a false positive
|
||||
win.webContents.send('log', log);
|
||||
win.webContents.loadFile(path.join(__dirname, 'error.html'));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
win.webContents.on('will-prevent-unload', (event) => {
|
||||
event.preventDefault();
|
||||
@ -484,17 +547,29 @@ app.on('ready', async () => {
|
||||
const appLocation = process.execPath;
|
||||
const appData = app.getPath('appData');
|
||||
// Check shortcut validity if not in dev mode / running portable app
|
||||
if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) {
|
||||
const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk');
|
||||
try { // Check if shortcut is registered and valid
|
||||
if (
|
||||
!is.dev() &&
|
||||
!appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))
|
||||
) {
|
||||
const shortcutPath = path.join(
|
||||
appData,
|
||||
'Microsoft',
|
||||
'Windows',
|
||||
'Start Menu',
|
||||
'Programs',
|
||||
'YouTube Music.lnk',
|
||||
);
|
||||
try {
|
||||
// Check if shortcut is registered and valid
|
||||
const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
|
||||
if (
|
||||
shortcutDetails.target !== appLocation
|
||||
|| shortcutDetails.appUserModelId !== appID
|
||||
shortcutDetails.target !== appLocation ||
|
||||
shortcutDetails.appUserModelId !== appID
|
||||
) {
|
||||
throw 'needUpdate';
|
||||
}
|
||||
} catch (error) { // If not valid -> Register shortcut
|
||||
} catch (error) {
|
||||
// If not valid -> Register shortcut
|
||||
shell.writeShortcutLink(
|
||||
shortcutPath,
|
||||
error === 'needUpdate' ? 'update' : 'create',
|
||||
@ -556,8 +631,8 @@ app.on('ready', async () => {
|
||||
clearTimeout(updateTimeout);
|
||||
}, 2000);
|
||||
autoUpdater.on('update-available', () => {
|
||||
const downloadLink
|
||||
= 'https://github.com/th-ch/youtube-music/releases/latest';
|
||||
const downloadLink =
|
||||
'https://github.com/th-ch/youtube-music/releases/latest';
|
||||
const dialogOptions: Electron.MessageBoxOptions = {
|
||||
type: 'info',
|
||||
buttons: ['OK', 'Download', 'Disable updates'],
|
||||
@ -597,8 +672,10 @@ app.on('ready', async () => {
|
||||
|
||||
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
|
||||
dialog.showMessageBox(mainWindow, {
|
||||
type: 'info', title: 'Hide Menu Enabled',
|
||||
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
|
||||
type: 'info',
|
||||
title: 'Hide Menu Enabled',
|
||||
message:
|
||||
"Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
|
||||
});
|
||||
config.set('options.hideMenuWarned', true);
|
||||
}
|
||||
@ -624,31 +701,36 @@ app.on('ready', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) {
|
||||
function showUnresponsiveDialog(
|
||||
win: BrowserWindow,
|
||||
details: Electron.RenderProcessGoneDetails,
|
||||
) {
|
||||
if (details) {
|
||||
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
|
||||
}
|
||||
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'error',
|
||||
title: 'Window Unresponsive',
|
||||
message: 'The Application is Unresponsive',
|
||||
detail: 'We are sorry for the inconvenience! please choose what to do:',
|
||||
buttons: ['Wait', 'Relaunch', 'Quit'],
|
||||
cancelId: 0,
|
||||
}).then((result) => {
|
||||
switch (result.response) {
|
||||
case 1: {
|
||||
restart();
|
||||
break;
|
||||
}
|
||||
dialog
|
||||
.showMessageBox(win, {
|
||||
type: 'error',
|
||||
title: 'Window Unresponsive',
|
||||
message: 'The Application is Unresponsive',
|
||||
detail: 'We are sorry for the inconvenience! please choose what to do:',
|
||||
buttons: ['Wait', 'Relaunch', 'Quit'],
|
||||
cancelId: 0,
|
||||
})
|
||||
.then((result) => {
|
||||
switch (result.response) {
|
||||
case 1: {
|
||||
restart();
|
||||
break;
|
||||
}
|
||||
|
||||
case 2: {
|
||||
app.quit();
|
||||
break;
|
||||
case 2: {
|
||||
app.quit();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeContentSecurityPolicy(
|
||||
@ -671,18 +753,21 @@ function removeContentSecurityPolicy(
|
||||
});
|
||||
|
||||
// When multiple listeners are defined, apply them all
|
||||
betterSession.webRequest.setResolver('onHeadersReceived', async (listeners) => {
|
||||
return listeners.reduce(
|
||||
async (accumulator, listener) => {
|
||||
const acc = await accumulator;
|
||||
if (acc.cancel) {
|
||||
return acc;
|
||||
}
|
||||
betterSession.webRequest.setResolver(
|
||||
'onHeadersReceived',
|
||||
async (listeners) => {
|
||||
return listeners.reduce(
|
||||
async (accumulator, listener) => {
|
||||
const acc = await accumulator;
|
||||
if (acc.cancel) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const result = await listener.apply();
|
||||
return { ...accumulator, ...result };
|
||||
},
|
||||
Promise.resolve({ cancel: false }),
|
||||
);
|
||||
});
|
||||
const result = await listener.apply();
|
||||
return { ...accumulator, ...result };
|
||||
},
|
||||
Promise.resolve({ cancel: false }),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user