feat: desktop notification when goose finishes a task (#8647)
Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
This commit is contained in:
@@ -32,6 +32,14 @@ const i18n = defineMessages({
|
|||||||
},
|
},
|
||||||
configGuide: { id: 'settings.notifications.configGuide', defaultMessage: 'Configuration guide' },
|
configGuide: { id: 'settings.notifications.configGuide', defaultMessage: 'Configuration guide' },
|
||||||
openSettings: { id: 'settings.notifications.openSettings', defaultMessage: 'Open Settings' },
|
openSettings: { id: 'settings.notifications.openSettings', defaultMessage: 'Open Settings' },
|
||||||
|
taskNotifications: {
|
||||||
|
id: 'settings.notifications.task.title',
|
||||||
|
defaultMessage: 'Task completion notifications',
|
||||||
|
},
|
||||||
|
taskNotificationsDesc: {
|
||||||
|
id: 'settings.notifications.task.description',
|
||||||
|
defaultMessage: 'Notify when Goose finishes a task while the window is in the background',
|
||||||
|
},
|
||||||
menuBarIcon: { id: 'settings.menuBarIcon.title', defaultMessage: 'Menu bar icon' },
|
menuBarIcon: { id: 'settings.menuBarIcon.title', defaultMessage: 'Menu bar icon' },
|
||||||
menuBarIconDesc: {
|
menuBarIconDesc: {
|
||||||
id: 'settings.menuBarIcon.description',
|
id: 'settings.menuBarIcon.description',
|
||||||
@@ -209,6 +217,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
|||||||
const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true);
|
const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true);
|
||||||
const [dockIconEnabled, setDockIconEnabled] = useState(true);
|
const [dockIconEnabled, setDockIconEnabled] = useState(true);
|
||||||
const [wakelockEnabled, setWakelockEnabled] = useState(true);
|
const [wakelockEnabled, setWakelockEnabled] = useState(true);
|
||||||
|
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
|
||||||
const [isMacOS, setIsMacOS] = useState(false);
|
const [isMacOS, setIsMacOS] = useState(false);
|
||||||
const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false);
|
const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false);
|
||||||
const [showNotificationModal, setShowNotificationModal] = useState(false);
|
const [showNotificationModal, setShowNotificationModal] = useState(false);
|
||||||
@@ -258,6 +267,10 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
|||||||
setWakelockEnabled(enabled);
|
setWakelockEnabled(enabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.electron.getSetting('enableNotifications').then((enabled) => {
|
||||||
|
setNotificationsEnabled(enabled ?? true);
|
||||||
|
});
|
||||||
|
|
||||||
if (isMacOS) {
|
if (isMacOS) {
|
||||||
window.electron.getDockIconState().then((enabled) => {
|
window.electron.getDockIconState().then((enabled) => {
|
||||||
setDockIconEnabled(enabled);
|
setDockIconEnabled(enabled);
|
||||||
@@ -316,6 +329,12 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNotificationsToggle = async (checked: boolean) => {
|
||||||
|
setNotificationsEnabled(checked);
|
||||||
|
await window.electron.setSetting('enableNotifications', checked);
|
||||||
|
trackSettingToggled('task_notifications', checked);
|
||||||
|
};
|
||||||
|
|
||||||
const handleShowPricingToggle = async (checked: boolean) => {
|
const handleShowPricingToggle = async (checked: boolean) => {
|
||||||
setShowPricing(checked);
|
setShowPricing(checked);
|
||||||
await window.electron.setSetting('showPricing', checked);
|
await window.electron.setSetting('showPricing', checked);
|
||||||
@@ -371,6 +390,24 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-text-primary text-xs">
|
||||||
|
{intl.formatMessage(i18n.taskNotifications)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-text-secondary max-w-md mt-[2px]">
|
||||||
|
{intl.formatMessage(i18n.taskNotificationsDesc)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Switch
|
||||||
|
checked={notificationsEnabled}
|
||||||
|
onCheckedChange={handleNotificationsToggle}
|
||||||
|
variant="mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-text-primary text-xs">{intl.formatMessage(i18n.menuBarIcon)}</h3>
|
<h3 className="text-text-primary text-xs">{intl.formatMessage(i18n.menuBarIcon)}</h3>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||||
|
import { defineMessages, useIntl } from '../i18n';
|
||||||
import { v7 as uuidv7 } from 'uuid';
|
import { v7 as uuidv7 } from 'uuid';
|
||||||
import { AppEvents } from '../constants/events';
|
import { AppEvents } from '../constants/events';
|
||||||
import { ChatState } from '../types/chatState';
|
import { ChatState } from '../types/chatState';
|
||||||
@@ -227,7 +228,7 @@ function createEventProcessor(
|
|||||||
dispatch: React.Dispatch<StreamAction>,
|
dispatch: React.Dispatch<StreamAction>,
|
||||||
onFinish: (error?: string) => void,
|
onFinish: (error?: string) => void,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
onReloadNeeded?: () => void,
|
onReloadNeeded?: () => void
|
||||||
) {
|
) {
|
||||||
let currentMessages = initialMessages;
|
let currentMessages = initialMessages;
|
||||||
const reduceMotion = prefersReducedMotion();
|
const reduceMotion = prefersReducedMotion();
|
||||||
@@ -343,11 +344,23 @@ function createEventProcessor(
|
|||||||
return processEvent;
|
return processEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const i18n = defineMessages({
|
||||||
|
notificationTitle: {
|
||||||
|
id: 'chat.notification.taskComplete.title',
|
||||||
|
defaultMessage: 'Goose finished the task.',
|
||||||
|
},
|
||||||
|
notificationBody: {
|
||||||
|
id: 'chat.notification.taskComplete.body',
|
||||||
|
defaultMessage: 'Click here to bring Goose back into focus.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function useChatStream({
|
export function useChatStream({
|
||||||
sessionId,
|
sessionId,
|
||||||
onStreamFinish,
|
onStreamFinish,
|
||||||
onSessionLoaded,
|
onSessionLoaded,
|
||||||
}: UseChatStreamProps): UseChatStreamReturn {
|
}: UseChatStreamProps): UseChatStreamReturn {
|
||||||
|
const intl = useIntl();
|
||||||
const [state, dispatch] = useReducer(streamReducer, initialState);
|
const [state, dispatch] = useReducer(streamReducer, initialState);
|
||||||
|
|
||||||
// Long-lived SSE connection for this session
|
// Long-lived SSE connection for this session
|
||||||
@@ -358,7 +371,6 @@ export function useChatStream({
|
|||||||
const activeRequestSessionIdRef = useRef<string | null>(null);
|
const activeRequestSessionIdRef = useRef<string | null>(null);
|
||||||
const activeAbortRef = useRef<AbortController | null>(null);
|
const activeAbortRef = useRef<AbortController | null>(null);
|
||||||
const activeUnsubscribeRef = useRef<(() => void) | null>(null);
|
const activeUnsubscribeRef = useRef<(() => void) | null>(null);
|
||||||
const lastInteractionTimeRef = useRef<number>(Date.now());
|
|
||||||
// When ActiveRequests fires before resumeAgent populates messages (cold mount),
|
// When ActiveRequests fires before resumeAgent populates messages (cold mount),
|
||||||
// defer the reattach until the session is loaded so the event processor has
|
// defer the reattach until the session is loaded so the event processor has
|
||||||
// the full conversation history. Events are buffered in the meantime.
|
// the full conversation history. Events are buffered in the meantime.
|
||||||
@@ -399,13 +411,22 @@ export function useChatStream({
|
|||||||
|
|
||||||
dispatch({ type: 'STREAM_FINISH', payload: error });
|
dispatch({ type: 'STREAM_FINISH', payload: error });
|
||||||
|
|
||||||
const timeSinceLastInteraction = Date.now() - lastInteractionTimeRef.current;
|
if (!error) {
|
||||||
if (!error && timeSinceLastInteraction > 60000) {
|
try {
|
||||||
|
const [notificationsEnabled, anyWindowFocused] = await Promise.all([
|
||||||
|
window.electron.getSetting('enableNotifications'),
|
||||||
|
window.electron.isAnyWindowFocused(),
|
||||||
|
]);
|
||||||
|
if (notificationsEnabled === true && !anyWindowFocused) {
|
||||||
window.electron.showNotification({
|
window.electron.showNotification({
|
||||||
title: 'goose finished the task.',
|
title: intl.formatMessage(i18n.notificationTitle),
|
||||||
body: 'Click here to expand.',
|
body: intl.formatMessage(i18n.notificationBody),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (notifyError) {
|
||||||
|
console.warn('Failed to show task completion notification:', notifyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/);
|
const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/);
|
||||||
if (isNewSession) {
|
if (isNewSession) {
|
||||||
@@ -444,7 +465,7 @@ export function useChatStream({
|
|||||||
|
|
||||||
onStreamFinish();
|
onStreamFinish();
|
||||||
},
|
},
|
||||||
[onStreamFinish, sessionId]
|
[intl, onStreamFinish, sessionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reload the full conversation from the server, e.g. after the SSE
|
// Reload the full conversation from the server, e.g. after the SSE
|
||||||
@@ -453,12 +474,14 @@ export function useChatStream({
|
|||||||
getSession({
|
getSession({
|
||||||
path: { session_id: sessionId },
|
path: { session_id: sessionId },
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
}).then((response) => {
|
})
|
||||||
|
.then((response) => {
|
||||||
const session = response.data as Session;
|
const session = response.data as Session;
|
||||||
if (session?.conversation) {
|
if (session?.conversation) {
|
||||||
dispatch({ type: 'SET_MESSAGES', payload: session.conversation });
|
dispatch({ type: 'SET_MESSAGES', payload: session.conversation });
|
||||||
}
|
}
|
||||||
}).catch((e) => {
|
})
|
||||||
|
.catch((e) => {
|
||||||
console.warn('Failed to reload conversation after buffer overflow:', e);
|
console.warn('Failed to reload conversation after buffer overflow:', e);
|
||||||
});
|
});
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
@@ -479,7 +502,7 @@ export function useChatStream({
|
|||||||
dispatch,
|
dispatch,
|
||||||
onFinish,
|
onFinish,
|
||||||
sessionId,
|
sessionId,
|
||||||
reloadConversation,
|
reloadConversation
|
||||||
);
|
);
|
||||||
|
|
||||||
// Replay any events that were buffered during cold-mount wait
|
// Replay any events that were buffered during cold-mount wait
|
||||||
@@ -523,7 +546,7 @@ export function useChatStream({
|
|||||||
});
|
});
|
||||||
activeUnsubscribeRef.current = unsubscribe;
|
activeUnsubscribeRef.current = unsubscribe;
|
||||||
},
|
},
|
||||||
[sessionId, addListener, onFinish, reloadConversation],
|
[sessionId, addListener, onFinish, reloadConversation]
|
||||||
);
|
);
|
||||||
doReattachRef.current = doReattach;
|
doReattachRef.current = doReattach;
|
||||||
|
|
||||||
@@ -582,7 +605,7 @@ export function useChatStream({
|
|||||||
targetSessionId: string,
|
targetSessionId: string,
|
||||||
userMessage: Message,
|
userMessage: Message,
|
||||||
currentMessages: Message[],
|
currentMessages: Message[],
|
||||||
overrideConversation?: Message[],
|
overrideConversation?: Message[]
|
||||||
) => {
|
) => {
|
||||||
const requestId = uuidv7();
|
const requestId = uuidv7();
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -596,7 +619,7 @@ export function useChatStream({
|
|||||||
dispatch,
|
dispatch,
|
||||||
onFinish,
|
onFinish,
|
||||||
targetSessionId,
|
targetSessionId,
|
||||||
reloadConversation,
|
reloadConversation
|
||||||
);
|
);
|
||||||
|
|
||||||
const unsubscribe = addListener(requestId, (event) => {
|
const unsubscribe = addListener(requestId, (event) => {
|
||||||
@@ -801,8 +824,6 @@ export function useChatStream({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastInteractionTimeRef.current = Date.now();
|
|
||||||
|
|
||||||
// Emit session-created event for first message in a new session
|
// Emit session-created event for first message in a new session
|
||||||
if (!hasExistingMessages && hasNewMessage) {
|
if (!hasExistingMessages && hasNewMessage) {
|
||||||
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
|
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
|
||||||
@@ -876,8 +897,6 @@ export function useChatStream({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastInteractionTimeRef.current = Date.now();
|
|
||||||
|
|
||||||
const responseMessage = createElicitationResponseMessage(elicitationId, userData);
|
const responseMessage = createElicitationResponseMessage(elicitationId, userData);
|
||||||
const currentMessages = [...currentState.messages, responseMessage];
|
const currentMessages = [...currentState.messages, responseMessage];
|
||||||
|
|
||||||
@@ -961,7 +980,6 @@ export function useChatStream({
|
|||||||
activeRequestSessionIdRef.current = null;
|
activeRequestSessionIdRef.current = null;
|
||||||
|
|
||||||
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });
|
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle });
|
||||||
lastInteractionTimeRef.current = Date.now();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onMessageUpdate = useCallback(
|
const onMessageUpdate = useCallback(
|
||||||
|
|||||||
@@ -113,6 +113,12 @@
|
|||||||
"cardButtons.launch": {
|
"cardButtons.launch": {
|
||||||
"defaultMessage": "Launch"
|
"defaultMessage": "Launch"
|
||||||
},
|
},
|
||||||
|
"chat.notification.taskComplete.body": {
|
||||||
|
"defaultMessage": "Click here to bring Goose back into focus."
|
||||||
|
},
|
||||||
|
"chat.notification.taskComplete.title": {
|
||||||
|
"defaultMessage": "Goose finished the task."
|
||||||
|
},
|
||||||
"chatInput.contextWindow": {
|
"chatInput.contextWindow": {
|
||||||
"defaultMessage": "Context window"
|
"defaultMessage": "Context window"
|
||||||
},
|
},
|
||||||
@@ -1449,7 +1455,7 @@
|
|||||||
"defaultMessage": "Downloaded"
|
"defaultMessage": "Downloaded"
|
||||||
},
|
},
|
||||||
"huggingFaceModelSearch.downloading": {
|
"huggingFaceModelSearch.downloading": {
|
||||||
"defaultMessage": "Downloading\u2026"
|
"defaultMessage": "Downloading…"
|
||||||
},
|
},
|
||||||
"huggingFaceModelSearch.loadingVariants": {
|
"huggingFaceModelSearch.loadingVariants": {
|
||||||
"defaultMessage": "Loading variants..."
|
"defaultMessage": "Loading variants..."
|
||||||
@@ -1845,7 +1851,7 @@
|
|||||||
"defaultMessage": "Vision"
|
"defaultMessage": "Vision"
|
||||||
},
|
},
|
||||||
"localInferenceSettings.visionEncoderDownloading": {
|
"localInferenceSettings.visionEncoderDownloading": {
|
||||||
"defaultMessage": "Vision encoder downloading\u2026"
|
"defaultMessage": "Vision encoder downloading…"
|
||||||
},
|
},
|
||||||
"localInferenceSettings.visionEncoderNotDownloaded": {
|
"localInferenceSettings.visionEncoderNotDownloaded": {
|
||||||
"defaultMessage": "Vision encoder not downloaded"
|
"defaultMessage": "Vision encoder not downloaded"
|
||||||
@@ -4052,6 +4058,12 @@
|
|||||||
"settings.notifications.openSettings": {
|
"settings.notifications.openSettings": {
|
||||||
"defaultMessage": "Open Settings"
|
"defaultMessage": "Open Settings"
|
||||||
},
|
},
|
||||||
|
"settings.notifications.task.description": {
|
||||||
|
"defaultMessage": "Notify when Goose finishes a task while the window is in the background"
|
||||||
|
},
|
||||||
|
"settings.notifications.task.title": {
|
||||||
|
"defaultMessage": "Task completion notifications"
|
||||||
|
},
|
||||||
"settings.notifications.title": {
|
"settings.notifications.title": {
|
||||||
"defaultMessage": "Notifications"
|
"defaultMessage": "Notifications"
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-2
@@ -749,11 +749,14 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
|||||||
// Nudge the user if mesh is their provider but isn't running.
|
// Nudge the user if mesh is their provider but isn't running.
|
||||||
// Delay to let the renderer mount before sending the IPC event.
|
// Delay to let the renderer mount before sending the IPC event.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
mesh.checkProviderRunning(goosedClient).then((ok) => {
|
mesh
|
||||||
|
.checkProviderRunning(goosedClient)
|
||||||
|
.then((ok) => {
|
||||||
if (!ok && !mainWindow.isDestroyed()) {
|
if (!ok && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send('mesh-not-running');
|
mainWindow.webContents.send('mesh-not-running');
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
})
|
||||||
|
.catch(() => {});
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
// Let windowStateKeeper manage the window
|
// Let windowStateKeeper manage the window
|
||||||
@@ -1359,6 +1362,7 @@ const validSettingKeys: Set<string> = new Set([
|
|||||||
'showMenuBarIcon',
|
'showMenuBarIcon',
|
||||||
'showDockIcon',
|
'showDockIcon',
|
||||||
'enableWakelock',
|
'enableWakelock',
|
||||||
|
'enableNotifications',
|
||||||
'spellcheckEnabled',
|
'spellcheckEnabled',
|
||||||
'externalGoosed',
|
'externalGoosed',
|
||||||
'globalShortcut',
|
'globalShortcut',
|
||||||
@@ -1572,6 +1576,10 @@ ipcMain.handle('get-spellcheck-state', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('is-any-window-focused', () => {
|
||||||
|
return BrowserWindow.getFocusedWindow() !== null;
|
||||||
|
});
|
||||||
|
|
||||||
// Add file/directory selection handler
|
// Add file/directory selection handler
|
||||||
ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => {
|
ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => {
|
||||||
const dialogOptions: OpenDialogOptions = {
|
const dialogOptions: OpenDialogOptions = {
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ type ElectronAPI = {
|
|||||||
setSpellcheck: (enable: boolean) => Promise<boolean>;
|
setSpellcheck: (enable: boolean) => Promise<boolean>;
|
||||||
getSpellcheckState: () => Promise<boolean>;
|
getSpellcheckState: () => Promise<boolean>;
|
||||||
openNotificationsSettings: () => Promise<boolean>;
|
openNotificationsSettings: () => Promise<boolean>;
|
||||||
|
isAnyWindowFocused: () => Promise<boolean>;
|
||||||
onMouseBackButtonClicked: (callback: () => void) => void;
|
onMouseBackButtonClicked: (callback: () => void) => void;
|
||||||
offMouseBackButtonClicked: (callback: () => void) => void;
|
offMouseBackButtonClicked: (callback: () => void) => void;
|
||||||
on: (
|
on: (
|
||||||
@@ -267,6 +268,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable),
|
setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable),
|
||||||
getSpellcheckState: () => ipcRenderer.invoke('get-spellcheck-state'),
|
getSpellcheckState: () => ipcRenderer.invoke('get-spellcheck-state'),
|
||||||
openNotificationsSettings: () => ipcRenderer.invoke('open-notifications-settings'),
|
openNotificationsSettings: () => ipcRenderer.invoke('open-notifications-settings'),
|
||||||
|
isAnyWindowFocused: () => ipcRenderer.invoke('is-any-window-focused'),
|
||||||
onMouseBackButtonClicked: (callback: () => void) => {
|
onMouseBackButtonClicked: (callback: () => void) => {
|
||||||
// Wrapper that ignores the event parameter.
|
// Wrapper that ignores the event parameter.
|
||||||
const wrappedCallback = (_event: Electron.IpcRendererEvent) => callback();
|
const wrappedCallback = (_event: Electron.IpcRendererEvent) => callback();
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface Settings {
|
|||||||
showMenuBarIcon: boolean;
|
showMenuBarIcon: boolean;
|
||||||
showDockIcon: boolean;
|
showDockIcon: boolean;
|
||||||
enableWakelock: boolean;
|
enableWakelock: boolean;
|
||||||
|
enableNotifications: boolean;
|
||||||
spellcheckEnabled: boolean;
|
spellcheckEnabled: boolean;
|
||||||
externalGoosed: ExternalGoosedConfig;
|
externalGoosed: ExternalGoosedConfig;
|
||||||
globalShortcut?: string | null;
|
globalShortcut?: string | null;
|
||||||
@@ -69,6 +70,7 @@ export const defaultSettings: Settings = {
|
|||||||
showMenuBarIcon: true,
|
showMenuBarIcon: true,
|
||||||
showDockIcon: true,
|
showDockIcon: true,
|
||||||
enableWakelock: false,
|
enableWakelock: false,
|
||||||
|
enableNotifications: true,
|
||||||
spellcheckEnabled: true,
|
spellcheckEnabled: true,
|
||||||
keyboardShortcuts: defaultKeyboardShortcuts,
|
keyboardShortcuts: defaultKeyboardShortcuts,
|
||||||
externalGoosed: {
|
externalGoosed: {
|
||||||
|
|||||||
Reference in New Issue
Block a user