refactor: format and lint codebase (#971)
This commit is contained in:
104
app/assets/javascripts/Services/AlertService.ts
Normal file
104
app/assets/javascripts/Services/AlertService.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/* eslint-disable prefer-promise-reject-errors */
|
||||
import { SNAlertService, ButtonType, sanitizeHtmlString } from '@standardnotes/snjs'
|
||||
import { SKAlert } from '@standardnotes/stylekit'
|
||||
|
||||
/** @returns a promise resolving to true if the user confirmed, false if they canceled */
|
||||
export function confirmDialog({
|
||||
text,
|
||||
title,
|
||||
confirmButtonText = 'Confirm',
|
||||
cancelButtonText = 'Cancel',
|
||||
confirmButtonStyle = 'info',
|
||||
}: {
|
||||
text: string
|
||||
title?: string
|
||||
confirmButtonText?: string
|
||||
cancelButtonText?: string
|
||||
confirmButtonStyle?: 'danger' | 'info'
|
||||
}) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const alert = new SKAlert({
|
||||
title: title && sanitizeHtmlString(title),
|
||||
text: sanitizeHtmlString(text),
|
||||
buttons: [
|
||||
{
|
||||
text: cancelButtonText,
|
||||
style: 'neutral',
|
||||
action() {
|
||||
resolve(false)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: confirmButtonText,
|
||||
style: confirmButtonStyle,
|
||||
action() {
|
||||
resolve(true)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
alert.present()
|
||||
})
|
||||
}
|
||||
|
||||
export function alertDialog({
|
||||
title,
|
||||
text,
|
||||
closeButtonText = 'OK',
|
||||
}: {
|
||||
title?: string
|
||||
text: string
|
||||
closeButtonText?: string
|
||||
}) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const alert = new SKAlert({
|
||||
title: title && sanitizeHtmlString(title),
|
||||
text: sanitizeHtmlString(text),
|
||||
buttons: [
|
||||
{
|
||||
text: closeButtonText,
|
||||
style: 'neutral',
|
||||
action: resolve,
|
||||
},
|
||||
],
|
||||
})
|
||||
alert.present()
|
||||
})
|
||||
}
|
||||
|
||||
export class AlertService implements SNAlertService {
|
||||
/**
|
||||
* @deprecated use the standalone `alertDialog` function instead
|
||||
*/
|
||||
alert(text: string, title?: string, closeButtonText?: string) {
|
||||
return alertDialog({ text, title, closeButtonText })
|
||||
}
|
||||
|
||||
confirm(
|
||||
text: string,
|
||||
title?: string,
|
||||
confirmButtonText?: string,
|
||||
confirmButtonType?: ButtonType,
|
||||
cancelButtonText?: string,
|
||||
): Promise<boolean> {
|
||||
return confirmDialog({
|
||||
text,
|
||||
title,
|
||||
confirmButtonText,
|
||||
cancelButtonText,
|
||||
confirmButtonStyle: confirmButtonType === ButtonType.Danger ? 'danger' : 'info',
|
||||
})
|
||||
}
|
||||
|
||||
blockingDialog(text: string, title?: string) {
|
||||
const alert = new SKAlert({
|
||||
title: title && sanitizeHtmlString(title),
|
||||
text: sanitizeHtmlString(text),
|
||||
buttons: [],
|
||||
})
|
||||
alert.present()
|
||||
return () => {
|
||||
alert.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
159
app/assets/javascripts/Services/ArchiveManager.ts
Normal file
159
app/assets/javascripts/Services/ArchiveManager.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { parseFileName } from '@standardnotes/filepicker'
|
||||
import {
|
||||
ContentType,
|
||||
BackupFile,
|
||||
BackupFileDecryptedContextualPayload,
|
||||
NoteContent,
|
||||
} from '@standardnotes/snjs'
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
return name.trim().replace(/[.\\/:"?*|<>]/g, '_')
|
||||
}
|
||||
|
||||
function zippableFileName(name: string, suffix = '', format = 'txt'): string {
|
||||
const sanitizedName = sanitizeFileName(name)
|
||||
const nameEnd = suffix + '.' + format
|
||||
const maxFileNameLength = 100
|
||||
return sanitizedName.slice(0, maxFileNameLength - nameEnd.length) + nameEnd
|
||||
}
|
||||
|
||||
type ZippableData = {
|
||||
filename: string
|
||||
content: Blob
|
||||
}[]
|
||||
|
||||
type ObjectURL = string
|
||||
|
||||
export class ArchiveManager {
|
||||
private readonly application: WebApplication
|
||||
private textFile?: string
|
||||
|
||||
constructor(application: WebApplication) {
|
||||
this.application = application
|
||||
}
|
||||
|
||||
public async getMimeType(ext: string) {
|
||||
return (await import('@zip.js/zip.js')).getMimeType(ext)
|
||||
}
|
||||
|
||||
public async downloadBackup(encrypted: boolean): Promise<void> {
|
||||
const data = encrypted
|
||||
? await this.application.createEncryptedBackupFile(true)
|
||||
: await this.application.createDecryptedBackupFile()
|
||||
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
const blobData = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'text/json',
|
||||
})
|
||||
|
||||
if (encrypted) {
|
||||
this.downloadData(
|
||||
blobData,
|
||||
`Standard Notes Encrypted Backup and Import File - ${this.formattedDate()}.txt`,
|
||||
)
|
||||
} else {
|
||||
this.downloadZippedDecryptedItems(data).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
private formattedDate() {
|
||||
const string = `${new Date()}`
|
||||
// Match up to the first parenthesis, i.e do not include '(Central Standard Time)'
|
||||
const matches = string.match(/^(.*?) \(/)
|
||||
if (matches && matches.length >= 2) {
|
||||
return matches[1]
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
private async downloadZippedDecryptedItems(data: BackupFile) {
|
||||
const zip = await import('@zip.js/zip.js')
|
||||
const zipWriter = new zip.ZipWriter(new zip.BlobWriter('application/zip'))
|
||||
const items = data.items
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'text/plain',
|
||||
})
|
||||
|
||||
const fileName = zippableFileName('Standard Notes Backup and Import File')
|
||||
await zipWriter.add(fileName, new zip.BlobReader(blob))
|
||||
|
||||
let index = 0
|
||||
const nextFile = async () => {
|
||||
const item = items[index]
|
||||
let name, contents
|
||||
|
||||
if (item.content_type === ContentType.Note) {
|
||||
const note = item as BackupFileDecryptedContextualPayload<NoteContent>
|
||||
name = note.content.title
|
||||
contents = note.content.text
|
||||
} else {
|
||||
name = item.content_type
|
||||
contents = JSON.stringify(item.content, null, 2)
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
name = ''
|
||||
}
|
||||
|
||||
const blob = new Blob([contents], { type: 'text/plain' })
|
||||
const fileName =
|
||||
`Items/${sanitizeFileName(item.content_type)}/` +
|
||||
zippableFileName(name, `-${item.uuid.split('-')[0]}`)
|
||||
await zipWriter.add(fileName, new zip.BlobReader(blob))
|
||||
|
||||
index++
|
||||
if (index < items.length) {
|
||||
await nextFile()
|
||||
} else {
|
||||
const finalBlob = await zipWriter.close()
|
||||
this.downloadData(finalBlob, `Standard Notes Backup - ${this.formattedDate()}.zip`)
|
||||
}
|
||||
}
|
||||
|
||||
await nextFile()
|
||||
}
|
||||
|
||||
async zipData(data: ZippableData): Promise<Blob> {
|
||||
const zip = await import('@zip.js/zip.js')
|
||||
const writer = new zip.ZipWriter(new zip.BlobWriter('application/zip'))
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const { name, ext } = parseFileName(data[i].filename)
|
||||
await writer.add(zippableFileName(name, '', ext), new zip.BlobReader(data[i].content))
|
||||
}
|
||||
|
||||
const zipFileAsBlob = await writer.close()
|
||||
|
||||
return zipFileAsBlob
|
||||
}
|
||||
|
||||
async downloadDataAsZip(data: ZippableData) {
|
||||
const zipFileAsBlob = await this.zipData(data)
|
||||
this.downloadData(zipFileAsBlob, `Standard Notes Export - ${this.formattedDate()}.zip`)
|
||||
}
|
||||
|
||||
private hrefForData(data: Blob) {
|
||||
// If we are replacing a previously generated file we need to
|
||||
// manually revoke the object URL to avoid memory leaks.
|
||||
if (this.textFile) {
|
||||
window.URL.revokeObjectURL(this.textFile)
|
||||
}
|
||||
this.textFile = window.URL.createObjectURL(data)
|
||||
// returns a URL you can use as a href
|
||||
return this.textFile
|
||||
}
|
||||
|
||||
downloadData(data: Blob | ObjectURL, fileName: string) {
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('download', fileName)
|
||||
link.href = typeof data === 'string' ? data : this.hrefForData(data)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
}
|
||||
129
app/assets/javascripts/Services/AutolockService.ts
Normal file
129
app/assets/javascripts/Services/AutolockService.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ApplicationService } from '@standardnotes/snjs'
|
||||
|
||||
const MILLISECONDS_PER_SECOND = 1000
|
||||
const POLL_INTERVAL = 50
|
||||
|
||||
const LockInterval = {
|
||||
None: 0,
|
||||
Immediate: 1,
|
||||
OneMinute: 60 * MILLISECONDS_PER_SECOND,
|
||||
FiveMinutes: 300 * MILLISECONDS_PER_SECOND,
|
||||
OneHour: 3600 * MILLISECONDS_PER_SECOND,
|
||||
}
|
||||
|
||||
const STORAGE_KEY_AUTOLOCK_INTERVAL = 'AutoLockIntervalKey'
|
||||
|
||||
export class AutolockService extends ApplicationService {
|
||||
private pollInterval: any
|
||||
private lastFocusState?: 'hidden' | 'visible'
|
||||
private lockAfterDate?: Date
|
||||
|
||||
override onAppLaunch() {
|
||||
this.beginPolling()
|
||||
return super.onAppLaunch()
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
this.cancelAutoLockTimer()
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval)
|
||||
}
|
||||
super.deinit()
|
||||
}
|
||||
|
||||
private lockApplication() {
|
||||
if (!this.application.hasPasscode()) {
|
||||
throw Error('Attempting to lock application with no passcode')
|
||||
}
|
||||
this.application.lock().catch(console.error)
|
||||
}
|
||||
|
||||
async setAutoLockInterval(interval: number) {
|
||||
return this.application.setValue(STORAGE_KEY_AUTOLOCK_INTERVAL, interval)
|
||||
}
|
||||
|
||||
async getAutoLockInterval() {
|
||||
const interval = (await this.application.getValue(STORAGE_KEY_AUTOLOCK_INTERVAL)) as number
|
||||
if (interval) {
|
||||
return interval
|
||||
} else {
|
||||
return LockInterval.None
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAutolockPreference() {
|
||||
await this.application.removeValue(STORAGE_KEY_AUTOLOCK_INTERVAL)
|
||||
this.cancelAutoLockTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify document is in focus every so often as visibilitychange event is
|
||||
* not triggered on a typical window blur event but rather on tab changes.
|
||||
*/
|
||||
beginPolling() {
|
||||
this.pollInterval = setInterval(async () => {
|
||||
const locked = await this.application.isLocked()
|
||||
if (!locked && this.lockAfterDate && new Date() > this.lockAfterDate) {
|
||||
this.lockApplication()
|
||||
}
|
||||
const hasFocus = document.hasFocus()
|
||||
if (hasFocus && this.lastFocusState === 'hidden') {
|
||||
this.documentVisibilityChanged(true).catch(console.error)
|
||||
} else if (!hasFocus && this.lastFocusState === 'visible') {
|
||||
this.documentVisibilityChanged(false).catch(console.error)
|
||||
}
|
||||
/* Save this to compare against next time around */
|
||||
this.lastFocusState = hasFocus ? 'visible' : 'hidden'
|
||||
}, POLL_INTERVAL)
|
||||
}
|
||||
|
||||
getAutoLockIntervalOptions() {
|
||||
return [
|
||||
{
|
||||
value: LockInterval.None,
|
||||
label: 'Off',
|
||||
},
|
||||
{
|
||||
value: LockInterval.Immediate,
|
||||
label: 'Immediately',
|
||||
},
|
||||
{
|
||||
value: LockInterval.OneMinute,
|
||||
label: '1m',
|
||||
},
|
||||
{
|
||||
value: LockInterval.FiveMinutes,
|
||||
label: '5m',
|
||||
},
|
||||
{
|
||||
value: LockInterval.OneHour,
|
||||
label: '1h',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async documentVisibilityChanged(visible: boolean) {
|
||||
if (visible) {
|
||||
this.cancelAutoLockTimer()
|
||||
} else {
|
||||
this.beginAutoLockTimer().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
async beginAutoLockTimer() {
|
||||
const interval = await this.getAutoLockInterval()
|
||||
if (interval === LockInterval.None) {
|
||||
return
|
||||
}
|
||||
const addToNow = (seconds: number) => {
|
||||
const date = new Date()
|
||||
date.setSeconds(date.getSeconds() + seconds)
|
||||
return date
|
||||
}
|
||||
this.lockAfterDate = addToNow(interval / MILLISECONDS_PER_SECOND)
|
||||
}
|
||||
|
||||
cancelAutoLockTimer() {
|
||||
this.lockAfterDate = undefined
|
||||
}
|
||||
}
|
||||
37
app/assets/javascripts/Services/Bridge.ts
Normal file
37
app/assets/javascripts/Services/Bridge.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* This file will be imported by desktop, so we make sure imports are carrying
|
||||
* as little extra code as possible with them.
|
||||
*/
|
||||
import { Environment } from '@standardnotes/snjs'
|
||||
|
||||
export interface ElectronDesktopCallbacks {
|
||||
desktop_updateAvailable(): void
|
||||
desktop_windowGainedFocus(): void
|
||||
desktop_windowLostFocus(): void
|
||||
desktop_onComponentInstallationComplete(componentData: any, error: any): Promise<void>
|
||||
desktop_requestBackupFile(): Promise<string | undefined>
|
||||
desktop_didBeginBackup(): void
|
||||
desktop_didFinishBackup(success: boolean): void
|
||||
}
|
||||
|
||||
/** Platform-specific (i-e Electron/browser) behavior is handled by a Bridge object. */
|
||||
export interface Bridge {
|
||||
readonly appVersion: string
|
||||
environment: Environment
|
||||
|
||||
getKeychainValue(): Promise<unknown>
|
||||
setKeychainValue(value: unknown): Promise<void>
|
||||
clearKeychainValue(): Promise<void>
|
||||
|
||||
localBackupsCount(): Promise<number>
|
||||
viewlocalBackups(): void
|
||||
deleteLocalBackups(): Promise<void>
|
||||
|
||||
extensionsServerHost?: string
|
||||
syncComponents(payloads: unknown[]): void
|
||||
onMajorDataChange(): void
|
||||
onInitialDataLoad(): void
|
||||
onSignOut(): void
|
||||
onSearch(text?: string): void
|
||||
downloadBackup(): void | Promise<void>
|
||||
}
|
||||
42
app/assets/javascripts/Services/BrowserBridge.ts
Normal file
42
app/assets/javascripts/Services/BrowserBridge.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Bridge } from './Bridge'
|
||||
import { Environment } from '@standardnotes/snjs'
|
||||
|
||||
const KEYCHAIN_STORAGE_KEY = 'keychain'
|
||||
|
||||
export class BrowserBridge implements Bridge {
|
||||
constructor(public appVersion: string) {}
|
||||
environment = Environment.Web
|
||||
|
||||
async getKeychainValue(): Promise<unknown> {
|
||||
const value = localStorage.getItem(KEYCHAIN_STORAGE_KEY)
|
||||
if (value) {
|
||||
return JSON.parse(value)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async setKeychainValue(value: unknown): Promise<void> {
|
||||
localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(value))
|
||||
}
|
||||
|
||||
async clearKeychainValue(): Promise<void> {
|
||||
localStorage.removeItem(KEYCHAIN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
async localBackupsCount(): Promise<number> {
|
||||
/** Browsers cannot save backups, only let you download one */
|
||||
return 0
|
||||
}
|
||||
|
||||
/** No-ops */
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
async deleteLocalBackups(): Promise<void> {}
|
||||
viewlocalBackups(): void {}
|
||||
syncComponents(): void {}
|
||||
onMajorDataChange(): void {}
|
||||
onInitialDataLoad(): void {}
|
||||
onSearch(): void {}
|
||||
downloadBackup(): void {}
|
||||
onSignOut(): void {}
|
||||
}
|
||||
171
app/assets/javascripts/Services/DesktopManager.ts
Normal file
171
app/assets/javascripts/Services/DesktopManager.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/* eslint-disable camelcase */
|
||||
import {
|
||||
SNComponent,
|
||||
ComponentMutator,
|
||||
AppDataField,
|
||||
ApplicationService,
|
||||
ApplicationEvent,
|
||||
removeFromArray,
|
||||
DesktopManagerInterface,
|
||||
PayloadSource,
|
||||
InternalEventBus,
|
||||
} from '@standardnotes/snjs'
|
||||
import { WebAppEvent, WebApplication } from '@/UIModels/Application'
|
||||
import { isDesktopApplication } from '@/Utils'
|
||||
import { Bridge, ElectronDesktopCallbacks } from './Bridge'
|
||||
|
||||
/**
|
||||
* An interface used by the Desktop application to interact with SN
|
||||
*/
|
||||
export class DesktopManager
|
||||
extends ApplicationService
|
||||
implements DesktopManagerInterface, ElectronDesktopCallbacks
|
||||
{
|
||||
updateObservers: {
|
||||
callback: (component: SNComponent) => void
|
||||
}[] = []
|
||||
|
||||
isDesktop = isDesktopApplication()
|
||||
dataLoaded = false
|
||||
lastSearchedText?: string
|
||||
|
||||
constructor(application: WebApplication, private bridge: Bridge) {
|
||||
super(application, new InternalEventBus())
|
||||
}
|
||||
|
||||
get webApplication() {
|
||||
return this.application as WebApplication
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
this.updateObservers.length = 0
|
||||
super.deinit()
|
||||
}
|
||||
|
||||
override async onAppEvent(eventName: ApplicationEvent) {
|
||||
super.onAppEvent(eventName).catch(console.error)
|
||||
if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
this.dataLoaded = true
|
||||
this.bridge.onInitialDataLoad()
|
||||
} else if (eventName === ApplicationEvent.MajorDataChange) {
|
||||
this.bridge.onMajorDataChange()
|
||||
}
|
||||
}
|
||||
|
||||
saveBackup() {
|
||||
this.bridge.onMajorDataChange()
|
||||
}
|
||||
|
||||
getExtServerHost(): string {
|
||||
console.assert(!!this.bridge.extensionsServerHost, 'extServerHost is null')
|
||||
return this.bridge.extensionsServerHost as string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sending a component in its raw state is really slow for the desktop app
|
||||
* Keys are not passed into ItemParams, so the result is not encrypted
|
||||
*/
|
||||
convertComponentForTransmission(component: SNComponent) {
|
||||
return component.payloadRepresentation().ejected()
|
||||
}
|
||||
|
||||
// All `components` should be installed
|
||||
syncComponentsInstallation(components: SNComponent[]) {
|
||||
if (!this.isDesktop) {
|
||||
return
|
||||
}
|
||||
|
||||
Promise.all(
|
||||
components.map((component) => {
|
||||
return this.convertComponentForTransmission(component)
|
||||
}),
|
||||
)
|
||||
.then((payloads) => {
|
||||
this.bridge.syncComponents(payloads)
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
registerUpdateObserver(callback: (component: SNComponent) => void) {
|
||||
const observer = {
|
||||
callback: callback,
|
||||
}
|
||||
this.updateObservers.push(observer)
|
||||
return () => {
|
||||
removeFromArray(this.updateObservers, observer)
|
||||
}
|
||||
}
|
||||
|
||||
searchText(text?: string) {
|
||||
if (!this.isDesktop) {
|
||||
return
|
||||
}
|
||||
this.lastSearchedText = text
|
||||
this.bridge.onSearch(text)
|
||||
}
|
||||
|
||||
redoSearch() {
|
||||
if (this.lastSearchedText) {
|
||||
this.searchText(this.lastSearchedText)
|
||||
}
|
||||
}
|
||||
|
||||
desktop_updateAvailable(): void {
|
||||
this.webApplication.notifyWebEvent(WebAppEvent.NewUpdateAvailable)
|
||||
}
|
||||
|
||||
desktop_windowGainedFocus(): void {
|
||||
this.webApplication.notifyWebEvent(WebAppEvent.DesktopWindowGainedFocus)
|
||||
}
|
||||
|
||||
desktop_windowLostFocus(): void {
|
||||
this.webApplication.notifyWebEvent(WebAppEvent.DesktopWindowLostFocus)
|
||||
}
|
||||
|
||||
async desktop_onComponentInstallationComplete(componentData: any, error: any) {
|
||||
const component = this.application.items.findItem(componentData.uuid)
|
||||
if (!component) {
|
||||
return
|
||||
}
|
||||
const updatedComponent = await this.application.mutator.changeAndSaveItem(
|
||||
component,
|
||||
(m) => {
|
||||
const mutator = m as ComponentMutator
|
||||
if (error) {
|
||||
mutator.setAppDataItem(AppDataField.ComponentInstallError, error)
|
||||
} else {
|
||||
mutator.local_url = componentData.content.local_url
|
||||
mutator.package_info = componentData.content.package_info
|
||||
mutator.setAppDataItem(AppDataField.ComponentInstallError, undefined)
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
PayloadSource.DesktopInstalled,
|
||||
)
|
||||
|
||||
for (const observer of this.updateObservers) {
|
||||
observer.callback(updatedComponent as SNComponent)
|
||||
}
|
||||
}
|
||||
|
||||
async desktop_requestBackupFile(): Promise<string | undefined> {
|
||||
const encrypted = this.application.hasProtectionSources()
|
||||
const data = encrypted
|
||||
? await this.application.createEncryptedBackupFile(false)
|
||||
: await this.application.createDecryptedBackupFile()
|
||||
|
||||
if (data) {
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
desktop_didBeginBackup() {
|
||||
this.webApplication.getAppState().beganBackupDownload()
|
||||
}
|
||||
|
||||
desktop_didFinishBackup(success: boolean) {
|
||||
this.webApplication.getAppState().endedBackupDownload(success)
|
||||
}
|
||||
}
|
||||
209
app/assets/javascripts/Services/IOService.ts
Normal file
209
app/assets/javascripts/Services/IOService.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { removeFromArray } from '@standardnotes/snjs'
|
||||
|
||||
export enum KeyboardKey {
|
||||
Tab = 'Tab',
|
||||
Backspace = 'Backspace',
|
||||
Up = 'ArrowUp',
|
||||
Down = 'ArrowDown',
|
||||
Enter = 'Enter',
|
||||
Escape = 'Escape',
|
||||
Home = 'Home',
|
||||
End = 'End',
|
||||
}
|
||||
|
||||
export enum KeyboardModifier {
|
||||
Shift = 'Shift',
|
||||
Ctrl = 'Control',
|
||||
/** ⌘ key on Mac, ⊞ key on Windows */
|
||||
Meta = 'Meta',
|
||||
Alt = 'Alt',
|
||||
}
|
||||
|
||||
enum KeyboardKeyEvent {
|
||||
Down = 'KeyEventDown',
|
||||
Up = 'KeyEventUp',
|
||||
}
|
||||
|
||||
type KeyboardObserver = {
|
||||
key?: KeyboardKey | string
|
||||
modifiers?: KeyboardModifier[]
|
||||
onKeyDown?: (event: KeyboardEvent) => void
|
||||
onKeyUp?: (event: KeyboardEvent) => void
|
||||
element?: HTMLElement
|
||||
elements?: HTMLElement[]
|
||||
notElement?: HTMLElement
|
||||
notElementIds?: string[]
|
||||
notTags?: string[]
|
||||
}
|
||||
|
||||
export class IOService {
|
||||
readonly activeModifiers = new Set<KeyboardModifier>()
|
||||
private observers: KeyboardObserver[] = []
|
||||
|
||||
constructor(private isMac: boolean) {
|
||||
window.addEventListener('keydown', this.handleKeyDown)
|
||||
window.addEventListener('keyup', this.handleKeyUp)
|
||||
window.addEventListener('blur', this.handleWindowBlur)
|
||||
}
|
||||
|
||||
public deinit() {
|
||||
this.observers.length = 0
|
||||
window.removeEventListener('keydown', this.handleKeyDown)
|
||||
window.removeEventListener('keyup', this.handleKeyUp)
|
||||
window.removeEventListener('blur', this.handleWindowBlur)
|
||||
;(this.handleKeyDown as unknown) = undefined
|
||||
;(this.handleKeyUp as unknown) = undefined
|
||||
;(this.handleWindowBlur as unknown) = undefined
|
||||
}
|
||||
|
||||
private addActiveModifier = (modifier: KeyboardModifier | undefined): void => {
|
||||
if (!modifier) {
|
||||
return
|
||||
}
|
||||
switch (modifier) {
|
||||
case KeyboardModifier.Meta: {
|
||||
if (this.isMac) {
|
||||
this.activeModifiers.add(modifier)
|
||||
}
|
||||
break
|
||||
}
|
||||
case KeyboardModifier.Ctrl: {
|
||||
if (!this.isMac) {
|
||||
this.activeModifiers.add(modifier)
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
this.activeModifiers.add(modifier)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeActiveModifier = (modifier: KeyboardModifier | undefined): void => {
|
||||
if (!modifier) {
|
||||
return
|
||||
}
|
||||
this.activeModifiers.delete(modifier)
|
||||
}
|
||||
|
||||
public handleComponentKeyDown = (modifier: KeyboardModifier | undefined): void => {
|
||||
this.addActiveModifier(modifier)
|
||||
}
|
||||
|
||||
public handleComponentKeyUp = (modifier: KeyboardModifier | undefined): void => {
|
||||
this.removeActiveModifier(modifier)
|
||||
}
|
||||
|
||||
private handleKeyDown = (event: KeyboardEvent): void => {
|
||||
this.updateAllModifiersFromEvent(event)
|
||||
this.notifyObserver(event, KeyboardKeyEvent.Down)
|
||||
}
|
||||
|
||||
private handleKeyUp = (event: KeyboardEvent): void => {
|
||||
this.updateAllModifiersFromEvent(event)
|
||||
this.notifyObserver(event, KeyboardKeyEvent.Up)
|
||||
}
|
||||
|
||||
private updateAllModifiersFromEvent(event: KeyboardEvent): void {
|
||||
for (const modifier of Object.values(KeyboardModifier)) {
|
||||
if (event.getModifierState(modifier)) {
|
||||
this.addActiveModifier(modifier)
|
||||
} else {
|
||||
this.removeActiveModifier(modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleWindowBlur = (): void => {
|
||||
for (const modifier of this.activeModifiers) {
|
||||
this.activeModifiers.delete(modifier)
|
||||
}
|
||||
}
|
||||
|
||||
modifiersForEvent(event: KeyboardEvent): KeyboardModifier[] {
|
||||
const allModifiers = Object.values(KeyboardModifier)
|
||||
const eventModifiers = allModifiers.filter((modifier) => {
|
||||
// For a modifier like ctrlKey, must check both event.ctrlKey and event.key.
|
||||
// That's because on keyup, event.ctrlKey would be false, but event.key == Control would be true.
|
||||
const matches =
|
||||
((event.ctrlKey || event.key === KeyboardModifier.Ctrl) &&
|
||||
modifier === KeyboardModifier.Ctrl) ||
|
||||
((event.metaKey || event.key === KeyboardModifier.Meta) &&
|
||||
modifier === KeyboardModifier.Meta) ||
|
||||
((event.altKey || event.key === KeyboardModifier.Alt) &&
|
||||
modifier === KeyboardModifier.Alt) ||
|
||||
((event.shiftKey || event.key === KeyboardModifier.Shift) &&
|
||||
modifier === KeyboardModifier.Shift)
|
||||
|
||||
return matches
|
||||
})
|
||||
|
||||
return eventModifiers
|
||||
}
|
||||
|
||||
private eventMatchesKeyAndModifiers(
|
||||
event: KeyboardEvent,
|
||||
key: KeyboardKey | string,
|
||||
modifiers: KeyboardModifier[] = [],
|
||||
): boolean {
|
||||
const eventModifiers = this.modifiersForEvent(event)
|
||||
if (eventModifiers.length !== modifiers.length) {
|
||||
return false
|
||||
}
|
||||
for (const modifier of modifiers) {
|
||||
if (!eventModifiers.includes(modifier)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Modifers match, check key
|
||||
if (!key) {
|
||||
return true
|
||||
}
|
||||
// In the browser, shift + f results in key 'f', but in Electron, shift + f results in 'F'
|
||||
// In our case we don't differentiate between the two.
|
||||
return key.toLowerCase() === event.key.toLowerCase()
|
||||
}
|
||||
|
||||
notifyObserver(event: KeyboardEvent, keyEvent: KeyboardKeyEvent): void {
|
||||
const target = event.target as HTMLElement
|
||||
for (const observer of this.observers) {
|
||||
if (observer.element && event.target !== observer.element) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (observer.elements && !observer.elements.includes(target)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (observer.notElement && observer.notElement === event.target) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (observer.notElementIds && observer.notElementIds.includes(target.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (observer.notTags && observer.notTags.includes(target.tagName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
observer.key &&
|
||||
this.eventMatchesKeyAndModifiers(event, observer.key, observer.modifiers)
|
||||
) {
|
||||
const callback = keyEvent === KeyboardKeyEvent.Down ? observer.onKeyDown : observer.onKeyUp
|
||||
if (callback) {
|
||||
callback(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addKeyObserver(observer: KeyboardObserver): () => void {
|
||||
this.observers.push(observer)
|
||||
return () => {
|
||||
removeFromArray(this.observers, observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/assets/javascripts/Services/LocalStorage.ts
Normal file
24
app/assets/javascripts/Services/LocalStorage.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export enum StorageKey {
|
||||
AnonymousUserId = 'AnonymousUserId',
|
||||
ShowBetaWarning = 'ShowBetaWarning',
|
||||
ShowNoAccountWarning = 'ShowNoAccountWarning',
|
||||
}
|
||||
|
||||
export type StorageValue = {
|
||||
[StorageKey.AnonymousUserId]: string
|
||||
[StorageKey.ShowBetaWarning]: boolean
|
||||
[StorageKey.ShowNoAccountWarning]: boolean
|
||||
}
|
||||
|
||||
export const storage = {
|
||||
get<K extends StorageKey>(key: K): StorageValue[K] | null {
|
||||
const value = localStorage.getItem(key)
|
||||
return value ? JSON.parse(value) : null
|
||||
},
|
||||
set<K extends StorageKey>(key: K, value: StorageValue[K]): void {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
},
|
||||
remove(key: StorageKey): void {
|
||||
localStorage.removeItem(key)
|
||||
},
|
||||
}
|
||||
30
app/assets/javascripts/Services/StatusManager.ts
Normal file
30
app/assets/javascripts/Services/StatusManager.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { removeFromArray } from '@standardnotes/snjs'
|
||||
|
||||
type StatusCallback = (string: string) => void
|
||||
|
||||
export class StatusManager {
|
||||
private _message = ''
|
||||
private observers: StatusCallback[] = []
|
||||
|
||||
get message(): string {
|
||||
return this._message
|
||||
}
|
||||
|
||||
setMessage(message: string) {
|
||||
this._message = message
|
||||
this.notifyObservers()
|
||||
}
|
||||
|
||||
onStatusChange(callback: StatusCallback) {
|
||||
this.observers.push(callback)
|
||||
return () => {
|
||||
removeFromArray(this.observers, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyObservers() {
|
||||
for (const observer of this.observers) {
|
||||
observer(this._message)
|
||||
}
|
||||
}
|
||||
}
|
||||
318
app/assets/javascripts/Services/ThemeManager.ts
Normal file
318
app/assets/javascripts/Services/ThemeManager.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import {
|
||||
StorageValueModes,
|
||||
ApplicationService,
|
||||
SNTheme,
|
||||
removeFromArray,
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
UuidString,
|
||||
FeatureStatus,
|
||||
PayloadSource,
|
||||
PrefKey,
|
||||
CreateDecryptedLocalStorageContextPayload,
|
||||
InternalEventBus,
|
||||
} from '@standardnotes/snjs'
|
||||
import { dismissToast, ToastType, addTimedToast } from '@standardnotes/stylekit'
|
||||
|
||||
const CachedThemesKey = 'cachedThemes'
|
||||
const TimeBeforeApplyingColorScheme = 5
|
||||
const DefaultThemeIdentifier = 'Default'
|
||||
|
||||
export class ThemeManager extends ApplicationService {
|
||||
private activeThemes: UuidString[] = []
|
||||
private unregisterDesktop!: () => void
|
||||
private unregisterStream!: () => void
|
||||
private lastUseDeviceThemeSettings = false
|
||||
|
||||
constructor(application: WebApplication) {
|
||||
super(application, new InternalEventBus())
|
||||
this.colorSchemeEventHandler = this.colorSchemeEventHandler.bind(this)
|
||||
}
|
||||
|
||||
override async onAppStart() {
|
||||
super.onAppStart().catch(console.error)
|
||||
this.registerObservers()
|
||||
}
|
||||
|
||||
override async onAppEvent(event: ApplicationEvent) {
|
||||
super.onAppEvent(event).catch(console.error)
|
||||
switch (event) {
|
||||
case ApplicationEvent.SignedOut: {
|
||||
this.deactivateAllThemes()
|
||||
this.activeThemes = []
|
||||
this.application
|
||||
?.removeValue(CachedThemesKey, StorageValueModes.Nonwrapped)
|
||||
.catch(console.error)
|
||||
break
|
||||
}
|
||||
case ApplicationEvent.StorageReady: {
|
||||
await this.activateCachedThemes()
|
||||
break
|
||||
}
|
||||
case ApplicationEvent.FeaturesUpdated: {
|
||||
this.handleFeaturesUpdated()
|
||||
break
|
||||
}
|
||||
case ApplicationEvent.Launched: {
|
||||
window
|
||||
.matchMedia('(prefers-color-scheme: dark)')
|
||||
.addEventListener('change', this.colorSchemeEventHandler)
|
||||
break
|
||||
}
|
||||
case ApplicationEvent.PreferencesChanged: {
|
||||
this.handlePreferencesChangeEvent()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handlePreferencesChangeEvent(): void {
|
||||
const useDeviceThemeSettings = this.application.getPreference(
|
||||
PrefKey.UseSystemColorScheme,
|
||||
false,
|
||||
)
|
||||
|
||||
if (useDeviceThemeSettings !== this.lastUseDeviceThemeSettings) {
|
||||
this.lastUseDeviceThemeSettings = useDeviceThemeSettings
|
||||
}
|
||||
|
||||
if (useDeviceThemeSettings) {
|
||||
const prefersDarkColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
this.setThemeAsPerColorScheme(prefersDarkColorScheme.matches)
|
||||
}
|
||||
}
|
||||
|
||||
get webApplication() {
|
||||
return this.application as WebApplication
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
this.activeThemes.length = 0
|
||||
this.unregisterDesktop()
|
||||
this.unregisterStream()
|
||||
;(this.unregisterDesktop as unknown) = undefined
|
||||
;(this.unregisterStream as unknown) = undefined
|
||||
window
|
||||
.matchMedia('(prefers-color-scheme: dark)')
|
||||
.removeEventListener('change', this.colorSchemeEventHandler)
|
||||
super.deinit()
|
||||
}
|
||||
|
||||
private handleFeaturesUpdated(): void {
|
||||
let hasChange = false
|
||||
for (const themeUuid of this.activeThemes) {
|
||||
const theme = this.application.items.findItem(themeUuid) as SNTheme
|
||||
if (!theme) {
|
||||
this.deactivateTheme(themeUuid)
|
||||
hasChange = true
|
||||
} else {
|
||||
const status = this.application.features.getFeatureStatus(theme.identifier)
|
||||
if (status !== FeatureStatus.Entitled) {
|
||||
if (theme.active) {
|
||||
this.application.mutator.toggleTheme(theme).catch(console.error)
|
||||
} else {
|
||||
this.deactivateTheme(theme.uuid)
|
||||
}
|
||||
hasChange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeThemes = (this.application.items.getItems(ContentType.Theme) as SNTheme[]).filter(
|
||||
(theme) => theme.active,
|
||||
)
|
||||
|
||||
for (const theme of activeThemes) {
|
||||
if (!this.activeThemes.includes(theme.uuid)) {
|
||||
this.activateTheme(theme)
|
||||
hasChange = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChange) {
|
||||
this.cacheThemeState().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
private colorSchemeEventHandler(event: MediaQueryListEvent) {
|
||||
this.setThemeAsPerColorScheme(event.matches)
|
||||
}
|
||||
|
||||
private showColorSchemeToast(setThemeCallback: () => void) {
|
||||
const [toastId, intervalId] = addTimedToast(
|
||||
{
|
||||
type: ToastType.Regular,
|
||||
message: (timeRemaining) => `Applying system color scheme in ${timeRemaining}s...`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Keep current theme',
|
||||
handler: () => {
|
||||
dismissToast(toastId)
|
||||
clearInterval(intervalId)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Apply now',
|
||||
handler: () => {
|
||||
dismissToast(toastId)
|
||||
clearInterval(intervalId)
|
||||
setThemeCallback()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
setThemeCallback,
|
||||
TimeBeforeApplyingColorScheme,
|
||||
)
|
||||
}
|
||||
|
||||
private setThemeAsPerColorScheme(prefersDarkColorScheme: boolean) {
|
||||
const preference = prefersDarkColorScheme
|
||||
? PrefKey.AutoDarkThemeIdentifier
|
||||
: PrefKey.AutoLightThemeIdentifier
|
||||
|
||||
const themes = this.application.items.getDisplayableItems(ContentType.Theme) as SNTheme[]
|
||||
|
||||
const activeTheme = themes.find((theme) => theme.active && !theme.isLayerable())
|
||||
|
||||
const themeIdentifier = this.application.getPreference(
|
||||
preference,
|
||||
DefaultThemeIdentifier,
|
||||
) as string
|
||||
|
||||
const setTheme = () => {
|
||||
if (themeIdentifier === DefaultThemeIdentifier && activeTheme) {
|
||||
this.application.mutator.toggleTheme(activeTheme).catch(console.error)
|
||||
} else {
|
||||
const theme = themes.find((theme) => theme.package_info.identifier === themeIdentifier)
|
||||
if (theme && !theme.active) {
|
||||
this.application.mutator.toggleTheme(theme).catch(console.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isPreferredThemeNotActive = activeTheme?.identifier !== themeIdentifier
|
||||
|
||||
const isDefaultThemePreferredAndNotActive =
|
||||
themeIdentifier === DefaultThemeIdentifier && activeTheme
|
||||
|
||||
if (isPreferredThemeNotActive || isDefaultThemePreferredAndNotActive) {
|
||||
this.showColorSchemeToast(setTheme)
|
||||
}
|
||||
}
|
||||
|
||||
private async activateCachedThemes() {
|
||||
const cachedThemes = await this.getCachedThemes()
|
||||
for (const theme of cachedThemes) {
|
||||
this.activateTheme(theme, true)
|
||||
}
|
||||
}
|
||||
|
||||
private registerObservers() {
|
||||
this.unregisterDesktop = this.webApplication
|
||||
.getDesktopService()
|
||||
.registerUpdateObserver((component) => {
|
||||
if (component.active && component.isTheme()) {
|
||||
this.deactivateTheme(component.uuid)
|
||||
setTimeout(() => {
|
||||
this.activateTheme(component as SNTheme)
|
||||
this.cacheThemeState().catch(console.error)
|
||||
}, 10)
|
||||
}
|
||||
})
|
||||
|
||||
this.unregisterStream = this.application.streamItems(
|
||||
ContentType.Theme,
|
||||
({ changed, inserted, source }) => {
|
||||
const items = changed.concat(inserted)
|
||||
const themes = items as SNTheme[]
|
||||
for (const theme of themes) {
|
||||
if (theme.active) {
|
||||
this.activateTheme(theme)
|
||||
} else {
|
||||
this.deactivateTheme(theme.uuid)
|
||||
}
|
||||
}
|
||||
if (source !== PayloadSource.LocalRetrieved) {
|
||||
this.cacheThemeState().catch(console.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public deactivateAllThemes() {
|
||||
const activeThemes = this.activeThemes.slice()
|
||||
for (const uuid of activeThemes) {
|
||||
this.deactivateTheme(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
private activateTheme(theme: SNTheme, skipEntitlementCheck = false) {
|
||||
if (this.activeThemes.find((uuid) => uuid === theme.uuid)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!skipEntitlementCheck &&
|
||||
this.application.features.getFeatureStatus(theme.identifier) !== FeatureStatus.Entitled
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = this.application.componentManager.urlForComponent(theme)
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
this.activeThemes.push(theme.uuid)
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.href = url
|
||||
link.type = 'text/css'
|
||||
link.rel = 'stylesheet'
|
||||
link.media = 'screen,print'
|
||||
link.id = theme.uuid
|
||||
document.getElementsByTagName('head')[0].appendChild(link)
|
||||
}
|
||||
|
||||
private deactivateTheme(uuid: string) {
|
||||
const element = document.getElementById(uuid) as HTMLLinkElement
|
||||
if (element) {
|
||||
element.disabled = true
|
||||
element.parentNode?.removeChild(element)
|
||||
}
|
||||
|
||||
removeFromArray(this.activeThemes, uuid)
|
||||
}
|
||||
|
||||
private async cacheThemeState() {
|
||||
const themes = this.application.items.findItems(this.activeThemes) as SNTheme[]
|
||||
|
||||
const mapped = themes.map((theme) => {
|
||||
const payload = theme.payloadRepresentation()
|
||||
return CreateDecryptedLocalStorageContextPayload(payload)
|
||||
})
|
||||
|
||||
return this.application.setValue(CachedThemesKey, mapped, StorageValueModes.Nonwrapped)
|
||||
}
|
||||
|
||||
private async getCachedThemes() {
|
||||
const cachedThemes = (await this.application.getValue(
|
||||
CachedThemesKey,
|
||||
StorageValueModes.Nonwrapped,
|
||||
)) as SNTheme[]
|
||||
if (cachedThemes) {
|
||||
const themes = []
|
||||
for (const cachedTheme of cachedThemes) {
|
||||
const payload = this.application.items.createPayloadFromObject(cachedTheme)
|
||||
const theme = this.application.items.createItemFromPayload(payload) as SNTheme
|
||||
themes.push(theme)
|
||||
}
|
||||
return themes
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user