refactor: format and lint codebase (#971)

This commit is contained in:
Aman Harwara
2022-04-13 22:02:34 +05:30
committed by GitHub
parent dc9c1ea0fc
commit 8e467f9e6d
367 changed files with 13778 additions and 16093 deletions

View 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()
}
}
}

View 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()
}
}

View 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
}
}

View 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>
}

View 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 {}
}

View 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)
}
}

View 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)
}
}
}

View 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)
},
}

View 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)
}
}
}

View 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 []
}
}
}