diff --git a/app/assets/icons/ic-accessibility.svg b/app/assets/icons/ic-accessibility.svg new file mode 100644 index 000000000..de1e6249e --- /dev/null +++ b/app/assets/icons/ic-accessibility.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-help.svg b/app/assets/icons/ic-help.svg new file mode 100644 index 000000000..c312b7255 --- /dev/null +++ b/app/assets/icons/ic-help.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-keyboard.svg b/app/assets/icons/ic-keyboard.svg new file mode 100644 index 000000000..9a18af39c --- /dev/null +++ b/app/assets/icons/ic-keyboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-listed.svg b/app/assets/icons/ic-listed.svg new file mode 100644 index 000000000..03e347717 --- /dev/null +++ b/app/assets/icons/ic-listed.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-security.svg b/app/assets/icons/ic-security.svg new file mode 100644 index 000000000..badc9d1ad --- /dev/null +++ b/app/assets/icons/ic-security.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-settings-filled.svg b/app/assets/icons/ic-settings-filled.svg new file mode 100644 index 000000000..0a7c8b835 --- /dev/null +++ b/app/assets/icons/ic-settings-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-star.svg b/app/assets/icons/ic-star.svg new file mode 100644 index 000000000..638dae331 --- /dev/null +++ b/app/assets/icons/ic-star.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-themes.svg b/app/assets/icons/ic-themes.svg new file mode 100644 index 000000000..88606ca76 --- /dev/null +++ b/app/assets/icons/ic-themes.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-user.svg b/app/assets/icons/ic-user.svg new file mode 100644 index 000000000..65ac58800 --- /dev/null +++ b/app/assets/icons/ic-user.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index 9cc5b8d46..01898de3c 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -65,6 +65,7 @@ import { NotesContextMenuDirective } from './components/NotesContextMenu'; import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel'; import { IconDirective } from './components/Icon'; import { NoteTagsContainerDirective } from './components/NoteTagsContainer'; +import { PreferencesDirective } from './components/preferences'; function reloadHiddenFirefoxTab(): boolean { /** @@ -90,7 +91,7 @@ function reloadHiddenFirefoxTab(): boolean { const startApplication: StartApplication = async function startApplication( defaultSyncServerHost: string, bridge: Bridge, - nextVersionSyncServerHost: string, + nextVersionSyncServerHost: string ) { if (reloadHiddenFirefoxTab()) { return; @@ -161,7 +162,8 @@ const startApplication: StartApplication = async function startApplication( .directive('notesContextMenu', NotesContextMenuDirective) .directive('notesOptionsPanel', NotesOptionsPanelDirective) .directive('icon', IconDirective) - .directive('noteTagsContainer', NoteTagsContainerDirective); + .directive('noteTagsContainer', NoteTagsContainerDirective) + .directive('preferences', PreferencesDirective); // Filters angular.module('app').filter('trusted', ['$sce', trusted]); @@ -174,10 +176,12 @@ const startApplication: StartApplication = async function startApplication( Object.defineProperties(window, { application: { get: () => - (angular - .element(document) - .injector() - .get('mainApplicationGroup') as any).primaryApplication, + ( + angular + .element(document) + .injector() + .get('mainApplicationGroup') as any + ).primaryApplication, }, }); } diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index 78a874ab4..721c1cec5 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -13,40 +13,59 @@ import PasswordIcon from '../../icons/ic-textbox-password.svg'; import TrashSweepIcon from '../../icons/ic-trash-sweep.svg'; import MoreIcon from '../../icons/ic-more.svg'; import TuneIcon from '../../icons/ic-tune.svg'; + +import AccessibilityIcon from '../../icons/ic-accessibility.svg'; +import HelpIcon from '../../icons/ic-help.svg'; +import KeyboardIcon from '../../icons/ic-keyboard.svg'; +import ListedIcon from '../../icons/ic-listed.svg'; +import SecurityIcon from '../../icons/ic-security.svg'; +import SettingsFilledIcon from '../../icons/ic-settings-filled.svg'; +import StarIcon from '../../icons/ic-star.svg'; +import ThemesIcon from '../../icons/ic-themes.svg'; +import UserIcon from '../../icons/ic-user.svg'; + import { toDirective } from './utils'; const ICONS = { 'pencil-off': PencilOffIcon, 'rich-text': RichTextIcon, - 'trash': TrashIcon, - 'pin': PinIcon, - 'unpin': UnpinIcon, - 'archive': ArchiveIcon, - 'unarchive': UnarchiveIcon, - 'hashtag': HashtagIcon, + trash: TrashIcon, + pin: PinIcon, + unpin: UnpinIcon, + archive: ArchiveIcon, + unarchive: UnarchiveIcon, + hashtag: HashtagIcon, 'chevron-right': ChevronRightIcon, - 'restore': RestoreIcon, - 'close': CloseIcon, - 'password': PasswordIcon, + restore: RestoreIcon, + close: CloseIcon, + password: PasswordIcon, 'trash-sweep': TrashSweepIcon, - 'more': MoreIcon, - 'tune': TuneIcon, + more: MoreIcon, + tune: TuneIcon, + accessibility: AccessibilityIcon, + help: HelpIcon, + keyboard: KeyboardIcon, + listed: ListedIcon, + security: SecurityIcon, + 'settings-filled': SettingsFilledIcon, + star: StarIcon, + themes: ThemesIcon, + user: UserIcon, }; +export type IconType = keyof typeof ICONS; + type Props = { - type: keyof (typeof ICONS); - className: string; -} + type: IconType; + className?: string; +}; export const Icon: React.FC = ({ type, className }) => { const IconComponent = ICONS[type]; return ; }; -export const IconDirective = toDirective( - Icon, - { - type: '@', - className: '@', - } -); +export const IconDirective = toDirective(Icon, { + type: '@', + className: '@', +}); diff --git a/app/assets/javascripts/components/IconButton.tsx b/app/assets/javascripts/components/IconButton.tsx new file mode 100644 index 000000000..93d826c4e --- /dev/null +++ b/app/assets/javascripts/components/IconButton.tsx @@ -0,0 +1,53 @@ +import { FunctionComponent } from 'preact'; +import { Icon, IconType } from './Icon'; + +const ICON_BUTTON_TYPES: { + [type: string]: { className: string }; +} = { + normal: { + className: '', + }, + primary: { + className: 'info', + }, +}; + +export type IconButtonType = keyof typeof ICON_BUTTON_TYPES; + +interface IconButtonProps { + /** + * onClick - preventDefault is handled within the component + */ + onClick: () => void; + + type: IconButtonType; + + className?: string; + + iconType: IconType; +} + +/** + * CircleButton component with an icon for SPA + * preventDefault is already handled within the component + */ +export const IconButton: FunctionComponent = ({ + onClick, + type, + className, + iconType, +}) => { + const click = (e: MouseEvent) => { + e.preventDefault(); + onClick(); + }; + const typeProps = ICON_BUTTON_TYPES[type]; + return ( + + ); +}; diff --git a/app/assets/javascripts/components/PreferencesMenuItem.tsx b/app/assets/javascripts/components/PreferencesMenuItem.tsx new file mode 100644 index 000000000..eb872053a --- /dev/null +++ b/app/assets/javascripts/components/PreferencesMenuItem.tsx @@ -0,0 +1,23 @@ +import { Icon, IconType } from '@/components/Icon'; +import { FunctionComponent } from 'preact'; + +interface PreferencesMenuItemProps { + iconType: IconType; + label: string; + selected: boolean; + onClick: () => void; +} + +export const PreferencesMenuItem: FunctionComponent = + ({ iconType, label, selected, onClick }) => ( +
{ + e.preventDefault(); + onClick(); + }} + > + + {label} +
+ ); diff --git a/app/assets/javascripts/components/TitleBar.tsx b/app/assets/javascripts/components/TitleBar.tsx new file mode 100644 index 000000000..8cf9e9208 --- /dev/null +++ b/app/assets/javascripts/components/TitleBar.tsx @@ -0,0 +1,13 @@ +import { FunctionComponent } from 'preact'; + +export const TitleBar: FunctionComponent<{ className?: string }> = ({ + children, + className, +}) =>
{children}
; + +export const Title: FunctionComponent<{ className?: string }> = ({ + children, + className, +}) => { + return
{children}
; +}; diff --git a/app/assets/javascripts/components/preferences/index.tsx b/app/assets/javascripts/components/preferences/index.tsx new file mode 100644 index 000000000..7ad6bb318 --- /dev/null +++ b/app/assets/javascripts/components/preferences/index.tsx @@ -0,0 +1,22 @@ +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; + +import { toDirective } from '../utils'; +import { PreferencesView } from './view'; + +interface WrapperProps { + appState: { preferences: { isOpen: boolean; closePreferences: () => void } }; +} + +const PreferencesViewWrapper: FunctionComponent = observer( + ({ appState }) => { + if (!appState.preferences.isOpen) return null; + return ( + appState.preferences.closePreferences()} /> + ); + } +); + +export const PreferencesDirective = toDirective( + PreferencesViewWrapper +); diff --git a/app/assets/javascripts/components/preferences/mock-state.ts b/app/assets/javascripts/components/preferences/mock-state.ts new file mode 100644 index 000000000..dd018f243 --- /dev/null +++ b/app/assets/javascripts/components/preferences/mock-state.ts @@ -0,0 +1,50 @@ +import { IconType } from '@/components/Icon'; +import { action, computed, makeObservable, observable } from 'mobx'; + +interface PreferenceItem { + icon: IconType; + label: string; +} + +interface PreferenceListItem extends PreferenceItem { + id: number; +} + +const predefinedItems: PreferenceItem[] = [ + { label: 'General', icon: 'settings-filled' }, + { label: 'Account', icon: 'user' }, + { label: 'Appearance', icon: 'themes' }, + { label: 'Security', icon: 'security' }, + { label: 'Listed', icon: 'listed' }, + { label: 'Shortcuts', icon: 'keyboard' }, + { label: 'Accessibility', icon: 'accessibility' }, + { label: 'Get a free month', icon: 'star' }, + { label: 'Help & feedback', icon: 'help' }, +]; + +export class MockState { + private readonly _items: PreferenceListItem[]; + private _selectedId = 0; + + constructor(items: PreferenceItem[] = predefinedItems) { + makeObservable(this, { + _selectedId: observable, + items: computed, + select: action, + }); + + this._items = items.map((p, idx) => ({ ...p, id: idx })); + this._selectedId = this._items[0].id; + } + + select(id: number) { + this._selectedId = id; + } + + get items(): (PreferenceListItem & { selected: boolean })[] { + return this._items.map((p) => ({ + ...p, + selected: p.id === this._selectedId, + })); + } +} diff --git a/app/assets/javascripts/components/preferences/pane.tsx b/app/assets/javascripts/components/preferences/pane.tsx new file mode 100644 index 000000000..2738239ee --- /dev/null +++ b/app/assets/javascripts/components/preferences/pane.tsx @@ -0,0 +1,33 @@ +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { PreferencesMenuItem } from '../PreferencesMenuItem'; +import { MockState } from './mock-state'; + +interface PreferencesMenuProps { + store: MockState; +} + +const PreferencesMenu: FunctionComponent = observer( + ({ store }) => ( +
+ {store.items.map((pref) => ( + store.select(pref.id)} + /> + ))} +
+ ) +); + +export const PreferencesPane: FunctionComponent = () => { + const store = new MockState(); + return ( +
+ +
+ ); +}; diff --git a/app/assets/javascripts/components/preferences/view.tsx b/app/assets/javascripts/components/preferences/view.tsx new file mode 100644 index 000000000..01ceb8773 --- /dev/null +++ b/app/assets/javascripts/components/preferences/view.tsx @@ -0,0 +1,28 @@ +import { IconButton } from '@/components/IconButton'; +import { TitleBar, Title } from '@/components/TitleBar'; +import { FunctionComponent } from 'preact'; +import { PreferencesPane } from './pane'; + +interface PreferencesViewProps { + close: () => void; +} + +export const PreferencesView: FunctionComponent = ({ + close, +}) => ( +
+ + {/* div is added so flex justify-between can center the title */} +
+ Your preferences for Standard Notes + { + close(); + }} + type="normal" + iconType="close" + /> + + +
+); diff --git a/app/assets/javascripts/ui_models/app_state/app_state.ts b/app/assets/javascripts/ui_models/app_state/app_state.ts index 1c8191bd7..55851ede7 100644 --- a/app/assets/javascripts/ui_models/app_state/app_state.ts +++ b/app/assets/javascripts/ui_models/app_state/app_state.ts @@ -22,6 +22,7 @@ import { SyncState } from './sync_state'; import { SearchOptionsState } from './search_options_state'; import { NotesState } from './notes_state'; import { TagsState } from './tags_state'; +import { PreferencesState } from './preferences_state'; export enum AppStateEvent { TagChanged, @@ -63,6 +64,7 @@ export class AppState { showBetaWarning: boolean; readonly accountMenu = new AccountMenuState(); readonly actionsMenu = new ActionsMenuState(); + readonly preferences = new PreferencesState(); readonly noAccountWarning: NoAccountWarningState; readonly noteTags: NoteTagsState; readonly sync = new SyncState(); @@ -89,21 +91,18 @@ export class AppState { async () => { await this.notifyEvent(AppStateEvent.ActiveEditorChanged); }, - this.appEventObserverRemovers, + this.appEventObserverRemovers ); this.noteTags = new NoteTagsState( application, this, this.appEventObserverRemovers ); - this.tags = new TagsState( - application, - this.appEventObserverRemovers, - ), - this.noAccountWarning = new NoAccountWarningState( - application, - this.appEventObserverRemovers - ); + (this.tags = new TagsState(application, this.appEventObserverRemovers)), + (this.noAccountWarning = new NoAccountWarningState( + application, + this.appEventObserverRemovers + )); this.searchOptions = new SearchOptionsState( application, this.appEventObserverRemovers @@ -128,6 +127,7 @@ export class AppState { makeObservable(this, { showBetaWarning: observable, isSessionsModalVisible: observable, + preferences: observable, enableBetaWarning: action, disableBetaWarning: action, diff --git a/app/assets/javascripts/ui_models/app_state/preferences_state.ts b/app/assets/javascripts/ui_models/app_state/preferences_state.ts new file mode 100644 index 000000000..607cd23e3 --- /dev/null +++ b/app/assets/javascripts/ui_models/app_state/preferences_state.ts @@ -0,0 +1,26 @@ +import { action, computed, makeObservable, observable } from 'mobx'; + +export class PreferencesState { + private _open = false; + + constructor() { + makeObservable(this, { + _open: observable, + openPreferences: action, + closePreferences: action, + isOpen: computed, + }); + } + + openPreferences = (): void => { + this._open = true; + }; + + closePreferences = (): void => { + this._open = false; + }; + + get isOpen() { + return this._open; + } +} diff --git a/app/assets/javascripts/views/application/application-view.pug b/app/assets/javascripts/views/application/application-view.pug index a1c08a28d..0f473bbea 100644 --- a/app/assets/javascripts/views/application/application-view.pug +++ b/app/assets/javascripts/views/application/application-view.pug @@ -26,6 +26,9 @@ application='self.application' app-state='self.appState' ) + preferences( + app-state='self.appState' + ) challenge-modal( ng-repeat="challenge in self.challenges track by challenge.id" class="sk-modal" @@ -36,3 +39,4 @@ notes-context-menu( app-state='self.appState' ) + diff --git a/app/assets/javascripts/views/footer/footer_view.ts b/app/assets/javascripts/views/footer/footer_view.ts index 64a1b2703..9764d3c2a 100644 --- a/app/assets/javascripts/views/footer/footer_view.ts +++ b/app/assets/javascripts/views/footer/footer_view.ts @@ -33,44 +33,47 @@ const ACCOUNT_SWITCHER_ENABLED = false; const ACCOUNT_SWITCHER_FEATURE_KEY = 'account_switcher'; type DockShortcut = { - name: string, - component: SNComponent, + name: string; + component: SNComponent; icon: { - type: string - background_color: string - border_color: string - } -} + type: string; + background_color: string; + border_color: string; + }; +}; -class FooterViewCtrl extends PureViewCtrl { - private $rootScope: ng.IRootScopeService - private rooms: SNComponent[] = [] - private themesWithIcons: SNTheme[] = [] - private showSyncResolution = false - private unregisterComponent: any - private rootScopeListener1: any - private rootScopeListener2: any - public arbitraryStatusMessage?: string - public user?: any - private offline = true - public showAccountMenu = false - private didCheckForOffline = false - private queueExtReload = false - private reloadInProgress = false - public hasError = false - public isRefreshing = false - public lastSyncDate?: string - public newUpdateAvailable = false - public dockShortcuts: DockShortcut[] = [] - public roomShowState: Partial> = {} +class FooterViewCtrl extends PureViewCtrl< + unknown, + { + outOfSync: boolean; + hasPasscode: boolean; + dataUpgradeAvailable: boolean; + dockShortcuts: DockShortcut[]; + hasAccountSwitcher: boolean; + showBetaWarning: boolean; + showDataUpgrade: boolean; + } +> { + private $rootScope: ng.IRootScopeService; + private rooms: SNComponent[] = []; + private themesWithIcons: SNTheme[] = []; + private showSyncResolution = false; + private unregisterComponent: any; + private rootScopeListener1: any; + private rootScopeListener2: any; + public arbitraryStatusMessage?: string; + public user?: any; + private offline = true; + public showAccountMenu = false; + private didCheckForOffline = false; + private queueExtReload = false; + private reloadInProgress = false; + public hasError = false; + public isRefreshing = false; + public lastSyncDate?: string; + public newUpdateAvailable = false; + public dockShortcuts: DockShortcut[] = []; + public roomShowState: Partial> = {}; private observerRemovers: Array<() => void> = []; private completedInitialSync = false; private showingDownloadStatus = false; @@ -117,7 +120,7 @@ class FooterViewCtrl extends PureViewCtrl { this.setState({ - dataUpgradeAvailable: available + dataUpgradeAvailable: available, }); }); } @@ -176,19 +181,25 @@ class FooterViewCtrl extends PureViewCtrl { - this.reloadExtendedData(); - }); - this.rootScopeListener2 = this.$rootScope.$on(RootScopeMessages.NewUpdateAvailable, () => { - this.$timeout(() => { - this.onNewUpdateAvailable(); - }); - }); + this.rootScopeListener1 = this.$rootScope.$on( + RootScopeMessages.ReloadExtendedData, + () => { + this.reloadExtendedData(); + } + ); + this.rootScopeListener2 = this.$rootScope.$on( + RootScopeMessages.NewUpdateAvailable, + () => { + this.$timeout(() => { + this.onNewUpdateAvailable(); + }); + } + ); } /** @override */ @@ -202,11 +213,11 @@ class FooterViewCtrl extends PureViewCtrl { - return ( - theme.package_info && - theme.package_info.dock_icon - ); + return theme.package_info && theme.package_info.dock_icon; } ); - this.observerRemovers.push(this.application.streamItems( - ContentType.Component, - async () => { - const components = this.application.getItems(ContentType.Component) as SNComponent[]; + this.observerRemovers.push( + this.application.streamItems(ContentType.Component, async () => { + const components = this.application.getItems( + ContentType.Component + ) as SNComponent[]; this.rooms = components.filter((candidate) => { return candidate.area === ComponentArea.Rooms && !candidate.deleted; }); @@ -308,33 +317,38 @@ class FooterViewCtrl extends PureViewCtrl { - const themes = this.application.getDisplayableItems(ContentType.Theme) as SNTheme[]; + this.observerRemovers.push( + this.application.streamItems(ContentType.Theme, async () => { + const themes = this.application.getDisplayableItems( + ContentType.Theme + ) as SNTheme[]; this.themesWithIcons = themes; this.reloadDockShortcuts(); - } - )); + }) + ); } registerComponentHandler() { - this.unregisterComponent = this.application.componentManager!.registerHandler({ - identifier: 'room-bar', - areas: [ComponentArea.Rooms, ComponentArea.Modal], - focusHandler: (component, focused) => { - if (component.isEditor() && focused) { - if (component.package_info?.identifier === 'org.standardnotes.standard-sheets') { - return; + this.unregisterComponent = + this.application.componentManager!.registerHandler({ + identifier: 'room-bar', + areas: [ComponentArea.Rooms, ComponentArea.Modal], + focusHandler: (component, focused) => { + if (component.isEditor() && focused) { + if ( + component.package_info?.identifier === + 'org.standardnotes.standard-sheets' + ) { + return; + } + this.closeAllRooms(); + this.closeAccountMenu(); } - this.closeAllRooms(); - this.closeAccountMenu(); - } - } - }); + }, + }); } updateSyncStatus() { @@ -354,17 +368,17 @@ class FooterViewCtrl extends PureViewCtrl 20) { - const completionPercentage = stats.uploadCompletionCount === 0 - ? 0 - : stats.uploadCompletionCount / stats.uploadTotalCount; + const completionPercentage = + stats.uploadCompletionCount === 0 + ? 0 + : stats.uploadCompletionCount / stats.uploadTotalCount; - const stringPercentage = completionPercentage.toLocaleString( - undefined, - { style: 'percent' } - ); + const stringPercentage = completionPercentage.toLocaleString(undefined, { + style: 'percent', + }); statusManager.setMessage( - `Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`, + `Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)` ); } else { statusManager.setMessage(''); @@ -398,8 +412,10 @@ class FooterViewCtrl extends PureViewCtrl { - return room.package_info.identifier === this.application - .getNativeExtService().extManagerId; + return ( + room.package_info.identifier === + this.application.getNativeExtService().extManagerId + ); }); if (!extWindow) { this.queueExtReload = true; @@ -419,11 +435,13 @@ class FooterViewCtrl extends PureViewCtrl { await this.application.upgradeProtocolVersion(); }); @@ -453,25 +471,27 @@ class FooterViewCtrl extends PureViewCtrl { - this.$timeout(() => { - this.isRefreshing = false; - }, 200); - if (response && response.error) { - this.application.alertService!.alert( - STRING_GENERIC_SYNC_ERROR - ); - } else { - this.syncUpdated(); - } - }); + this.application + .sync({ + queueStrategy: SyncQueueStrategy.ForceSpawnNew, + checkIntegrity: true, + }) + .then((response) => { + this.$timeout(() => { + this.isRefreshing = false; + }, 200); + if (response && response.error) { + this.application.alertService!.alert(STRING_GENERIC_SYNC_ERROR); + } else { + this.syncUpdated(); + } + }); } syncUpdated() { - this.lastSyncDate = dateToLocalizedString(this.application.getLastSyncDate()!); + this.lastSyncDate = dateToLocalizedString( + this.application.getLastSyncDate()! + ); } onNewUpdateAvailable() { @@ -480,9 +500,7 @@ class FooterViewCtrl extends PureViewCtrlYou can silence this warning from the ' + - 'Account menu.' + 'Account menu.', }); } @@ -563,6 +581,10 @@ class FooterViewCtrl extends PureViewCtrl