Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[GH-314] Export native app user settings on change #380

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 11 additions & 35 deletions mac/Focalboard/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ class ViewController:
self.view.window?.makeFirstResponder(self.webView)
}

override func viewWillDisappear() {
super.viewWillDisappear()
persistUserSettings()
}

override var representedObject: Any? {
didSet {
// Update the view, if already loaded.
Expand Down Expand Up @@ -76,34 +71,6 @@ class ViewController:
}
}

private func persistUserSettings() {
let semaphore = DispatchSemaphore(value: 0)

webView.evaluateJavaScript("Focalboard.exportUserSettingsBlob();") { result, error in
defer { semaphore.signal() }
guard let blob = result as? String else {
NSLog("Failed to export user settings: \(error?.localizedDescription ?? "?")")
return
}
UserDefaults.standard.set(blob, forKey: "localStorage")
NSLog("Persisted user settings: \(Data(base64Encoded: blob).flatMap { String(data: $0, encoding: .utf8) } ?? blob)")
}

// During shutdown the system grants us about 5 seconds to clean up and store user data
let timeout = DispatchTime.now() + .seconds(3)
var result: DispatchTimeoutResult?

// Busy wait because evaluateJavaScript can only be called from *and* signals on the main thread
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Busy wait be gone! 🎉

while (result != .success && .now() < timeout) {
result = semaphore.wait(timeout: .now())
RunLoop.current.run(mode: .default, before: Date())
}

if result == .timedOut {
NSLog("Timed out trying to persist user settings")
}
}

private func updateSessionTokenAndUserSettings() {
let appDelegate = NSApplication.shared.delegate as! AppDelegate
let sessionTokenScript = WKUserScript(
Expand Down Expand Up @@ -263,11 +230,20 @@ class ViewController:
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: String], let type = body["type"], let blob = body["settingsBlob"] else {
guard
let body = message.body as? [String: String],
let type = body["type"],
let blob = body["settingsBlob"],
let settings = Data(base64Encoded: blob).flatMap({ String(data: $0, encoding: .utf8) })
else {
NSLog("Received unexpected script message \(message.body)")
return
}
NSLog("Received script message \(type): \(Data(base64Encoded: blob).flatMap { String(data: $0, encoding: .utf8) } ?? blob)")
NSLog("Received script message \(type): \(settings)")
if type == "didChangeUserSettings" {
UserDefaults.standard.set(blob, forKey: "localStorage")
NSLog("Persisted user settings after change for key \(body["key"] ?? "?")")
}
}
}

3 changes: 0 additions & 3 deletions webapp/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,8 @@ import RegisterPage from './pages/registerPage'
import {IUser} from './user'
import {Utils} from './utils'
import CombinedProviders from './combinedProviders'
import {importNativeAppSettings} from './nativeApp'

const App = React.memo((): JSX.Element => {
importNativeAppSettings()

const [language, setLanguage] = useState(getCurrentLanguage())
const [user, setUser] = useState<IUser|undefined>(undefined)
const [initialLoad, setInitialLoad] = useState(false)
Expand Down
6 changes: 4 additions & 2 deletions webapp/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import messages_tr from '../i18n/tr.json'
import messages_zhHant from '../i18n/zh_Hant.json'
import messages_zhHans from '../i18n/zh_Hans.json'

import {UserSettings} from './userSettings'

const supportedLanguages = ['de', 'fr', 'ja', 'nl', 'ru', 'es', 'oc', 'tr', 'zh', 'zh_Hant', 'zh_Hans']

export function getMessages(lang: string): {[key: string]: string} {
Expand Down Expand Up @@ -44,7 +46,7 @@ export function getMessages(lang: string): {[key: string]: string} {
}

export function getCurrentLanguage(): string {
let lang = localStorage.getItem('language')
let lang = UserSettings.language
if (!lang) {
if (supportedLanguages.includes(navigator.language)) {
lang = navigator.language
Expand All @@ -58,5 +60,5 @@ export function getCurrentLanguage(): string {
}

export function storeLanguage(lang: string): void {
localStorage.setItem('language', lang)
UserSettings.language = lang
}
5 changes: 5 additions & 0 deletions webapp/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
// See LICENSE.txt for license information.
import React from 'react'
import ReactDOM from 'react-dom'
import {store} from 'emoji-mart'

import App from './app'
import {initThemes} from './theme'
import {importNativeAppSettings} from './nativeApp'
import {UserSettings} from './userSettings'

import './styles/variables.scss'
import './styles/main.scss'
import './styles/labels.scss'

store.setHandlers({getter: UserSettings.getEmojiMartSetting, setter: UserSettings.setEmojiMartSetting})
importNativeAppSettings()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved this here because the initThemes call below already reads from localStorage.

initThemes()
ReactDOM.render(<App/>, document.getElementById('main-app'))
16 changes: 6 additions & 10 deletions webapp/src/nativeApp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {importUserSettingsBlob} from './userSettings'
import {exportUserSettingsBlob, importUserSettingsBlob} from './userSettings'

declare interface INativeApp {
settingsBlob: string | null;
Expand All @@ -19,14 +19,10 @@ export function importNativeAppSettings() {
NativeApp.settingsBlob = null
}

export function notifySettingsChanged(key: string) {
postWebKitMessage({type: 'didChangeUserSettings', key, settingsBlob: exportUserSettingsBlob()})
}

function postWebKitMessage(message: any) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this construct - for extending webapp-to-native messaging in the future. We'll add a mental todo to also adapt this for Windows / Linux in the future.

const webkit = (window as any).webkit
if (typeof webkit === 'undefined') {
return
}
const handler = webkit.messageHandlers.nativeApp
if (typeof handler === 'undefined') {
return
}
handler.postMessage(message)
(window as any).webkit?.messageHandlers.nativeApp?.postMessage(message)
}
11 changes: 6 additions & 5 deletions webapp/src/pages/boardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {OctoListener} from '../octoListener'
import {Utils} from '../utils'
import {BoardTree, MutableBoardTree} from '../viewModel/boardTree'
import {MutableWorkspaceTree, WorkspaceTree} from '../viewModel/workspaceTree'
import {UserSettings} from '../userSettings'
import './boardPage.scss'

type Props = RouteComponentProps<{workspaceId?: string}> & {
Expand Down Expand Up @@ -43,8 +44,8 @@ class BoardPage extends React.Component<Props, State> {

if (!boardId) {
// Load last viewed boardView
boardId = localStorage.getItem('lastBoardId') || ''
viewId = localStorage.getItem('lastViewId') || ''
boardId = UserSettings.lastBoardId || ''
viewId = UserSettings.lastViewId || ''
if (boardId) {
Utils.replaceUrlQueryParam('id', boardId)
}
Expand Down Expand Up @@ -184,8 +185,8 @@ class BoardPage extends React.Component<Props, State> {

private async attachToBoard(boardId?: string, viewId = '') {
Utils.log(`attachToBoard: ${boardId}`)
localStorage.setItem('lastBoardId', boardId || '')
localStorage.setItem('lastViewId', viewId)
UserSettings.lastBoardId = boardId || ''
UserSettings.lastViewId = viewId

if (boardId) {
this.sync(boardId, viewId)
Expand Down Expand Up @@ -316,7 +317,7 @@ class BoardPage extends React.Component<Props, State> {
}

showView(viewId: string, boardId: string = this.state.boardId): void {
localStorage.setItem('lastViewId', viewId)
UserSettings.lastViewId = viewId

if (this.state.boardTree && this.state.boardId === boardId) {
const newBoardTree = this.state.boardTree.copyWithView(viewId)
Expand Down
10 changes: 6 additions & 4 deletions webapp/src/theme.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {UserSettings} from './userSettings'

export type Theme = {
mainBg: string,
mainFg: string,
Expand Down Expand Up @@ -91,9 +93,9 @@ export function setTheme(theme: Theme | null): Theme {
let consolidatedTheme = defaultTheme
if (theme) {
consolidatedTheme = {...defaultTheme, ...theme}
localStorage.setItem('theme', JSON.stringify(consolidatedTheme))
UserSettings.theme = JSON.stringify(consolidatedTheme)
} else {
localStorage.setItem('theme', '')
UserSettings.theme = ''
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
consolidatedTheme = {...defaultTheme, ...darkTheme}
Expand Down Expand Up @@ -127,7 +129,7 @@ export function setTheme(theme: Theme | null): Theme {
}

export function loadTheme(): Theme {
const themeStr = localStorage.getItem('theme')
const themeStr = UserSettings.theme
if (themeStr) {
try {
const theme = JSON.parse(themeStr)
Expand All @@ -143,7 +145,7 @@ export function loadTheme(): Theme {
export function initThemes(): void {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
const changeHandler = () => {
const themeStr = localStorage.getItem('theme')
const themeStr = UserSettings.theme
if (!themeStr) {
setTheme(null)
}
Expand Down
79 changes: 75 additions & 4 deletions webapp/src/userSettings.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {notifySettingsChanged} from './nativeApp'
import {Utils} from './utils'

enum UserSettingKey {
Language = 'language',
Theme = 'theme',
LastBoardId = 'lastBoardId',
LastViewId = 'lastViewId',
EmojiMartSkin = 'emoji-mart.skin',
EmojiMartLast = 'emoji-mart.last',
EmojiMartFrequently = 'emoji-mart.frequently',
RandomIcons = 'randomIcons'
}

export class UserSettings {
static get(key: UserSettingKey): string | null {
return localStorage.getItem(key)
}

static set(key: UserSettingKey, value: string | null) {
Johennes marked this conversation as resolved.
Show resolved Hide resolved
if (value === null) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, value)
}
notifySettingsChanged(key)
}

static get language(): string | null {
return UserSettings.get(UserSettingKey.Language)
}

static set language(newValue: string | null) {
UserSettings.set(UserSettingKey.Language, newValue)
}

static get theme(): string | null {
return UserSettings.get(UserSettingKey.Theme)
}

static set theme(newValue: string | null) {
UserSettings.set(UserSettingKey.Theme, newValue)
}

static get lastBoardId(): string | null {
return UserSettings.get(UserSettingKey.LastBoardId)
}

static set lastBoardId(newValue: string | null) {
UserSettings.set(UserSettingKey.LastBoardId, newValue)
}

static get lastViewId(): string | null {
return UserSettings.get(UserSettingKey.LastViewId)
}

static set lastViewId(newValue: string | null) {
UserSettings.set(UserSettingKey.LastViewId, newValue)
}

static get prefillRandomIcons(): boolean {
return localStorage.getItem('randomIcons') !== 'false'
return UserSettings.get(UserSettingKey.RandomIcons) !== 'false'
}

static set prefillRandomIcons(newValue: boolean) {
localStorage.setItem('randomIcons', JSON.stringify(newValue))
UserSettings.set(UserSettingKey.RandomIcons, JSON.stringify(newValue))
}
}

const keys = ['language', 'theme', 'lastBoardId', 'lastViewId', 'emoji-mart.last', 'emoji-mart.frequently']
static getEmojiMartSetting(key: string): any {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method and the one below are a bit hairy. There's some documentation about the emoji-mart settings keys and values in the upstream README (see the "Storage" section).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, thanks for parsing the Emojimart settings! We expect to refactor the emoji picker in the future, so this will likely change (not for a while though).

const prefixed = `emoji-mart.${key}`
Utils.assert((Object as any).values(UserSettingKey).includes(prefixed))
const json = UserSettings.get(prefixed as UserSettingKey)
return json ? JSON.parse(json) : null
}

static setEmojiMartSetting(key: string, value: any) {
const prefixed = `emoji-mart.${key}`
Utils.assert((Object as any).values(UserSettingKey).includes(prefixed))
UserSettings.set(prefixed as UserSettingKey, JSON.stringify(value))
}
}

export function exportUserSettingsBlob(): string {
return window.btoa(exportUserSettings())
}

function exportUserSettings(): string {
const keys = Object.values(UserSettingKey)
const settings = Object.fromEntries(keys.map((key) => [key, localStorage.getItem(key)]))
settings.timestamp = `${Date.now()}`
return JSON.stringify(settings)
Expand Down