Merge branch 'release/3.20.2'

This commit is contained in:
Mo
2022-06-02 11:11:46 -05:00
359 changed files with 10039 additions and 8735 deletions

View File

@@ -1,12 +1,4 @@
{ {
"presets": [ "presets": ["@babel/preset-typescript", "@babel/preset-env"],
"@babel/preset-typescript", "plugins": [["@babel/plugin-transform-react-jsx"]]
"@babel/preset-env"
],
"plugins": [
["@babel/plugin-transform-react-jsx", {
"pragma": "h",
"pragmaFrag": "Fragment"
}]
]
} }

View File

@@ -6,23 +6,23 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
tsc: test:
name: Check types & lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Install dependencies - name: Install dependencies
run: yarn install --pure-lockfile run: yarn install --pure-lockfile
- name: Typescript - name: Bundle
run: yarn tsc run: yarn bundle
- name: ESLint - name: ESLint
run: yarn lint --quiet run: yarn lint
- name: Test
run: yarn test
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: tsc needs: test
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

View File

@@ -10,31 +10,26 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
test:
tsc:
name: Check types & lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Install dependencies - name: Install dependencies
run: yarn install --pure-lockfile run: yarn install --pure-lockfile
- name: Bundle
- name: Typescript run: yarn bundle
run: yarn tsc
- name: ESLint - name: ESLint
run: yarn lint --quiet run: yarn lint
- name: Test
run: yarn test
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: tsc needs: test
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

View File

@@ -7,21 +7,16 @@ on:
- main - main
jobs: jobs:
test:
tsc:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Install dependencies - name: Install dependencies
run: yarn install --pure-lockfile run: yarn install --pure-lockfile
- name: Bundle
- name: Typescript run: yarn bundle
run: yarn tsc
- name: ESLint - name: ESLint
run: yarn lint --quiet run: yarn lint
- name: Test
run: yarn test

View File

@@ -9,32 +9,25 @@ on:
branches: [ main ] branches: [ main ]
jobs: jobs:
test:
tsc:
name: Check types & lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Install dependencies - name: Install dependencies
run: yarn install --pure-lockfile run: yarn install --pure-lockfile
- name: Bundle
- name: Typescript run: yarn bundle
run: yarn tsc
- name: ESLint - name: ESLint
run: yarn lint --quiet run: yarn lint
- name: Test
run: yarn test
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: tsc needs: test
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

View File

@@ -20,16 +20,15 @@ declare global {
} }
} }
import { IsWebPlatform, WebAppVersion } from '@/Version' import { IsWebPlatform, WebAppVersion } from '@/Constants/Version'
import { DesktopManagerInterface, SNLog } from '@standardnotes/snjs' import { DesktopManagerInterface, SNLog } from '@standardnotes/snjs'
import { render } from 'preact' import ApplicationGroupView from './Components/ApplicationGroupView/ApplicationGroupView'
import { ApplicationGroupView } from './Components/ApplicationGroupView' import { WebDevice } from './Application/Device/WebDevice'
import { WebDevice } from './Device/WebDevice' import { StartApplication } from './Application/Device/StartApplication'
import { StartApplication } from './Device/StartApplication' import { ApplicationGroup } from './Application/ApplicationGroup'
import { ApplicationGroup } from './UIModels/ApplicationGroup' import { WebOrDesktopDevice } from './Application/Device/WebOrDesktopDevice'
import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice' import { WebApplication } from './Application/Application'
import { WebApplication } from './UIModels/Application' import { createRoot, Root } from 'react-dom/client'
import { unmountComponentAtRoot } from './Utils/PreactUtils'
let keyCount = 0 let keyCount = 0
const getKey = () => { const getKey = () => {
@@ -46,21 +45,22 @@ const startApplication: StartApplication = async function startApplication(
) { ) {
SNLog.onLog = console.log SNLog.onLog = console.log
SNLog.onError = console.error SNLog.onError = console.error
let root: Root
const onDestroy = () => { const onDestroy = () => {
const root = document.getElementById(RootId) as HTMLElement const rootElement = document.getElementById(RootId) as HTMLElement
unmountComponentAtRoot(root) root.unmount()
root.remove() rootElement.remove()
renderApp() renderApp()
} }
const renderApp = () => { const renderApp = () => {
const root = document.createElement('div') const rootElement = document.createElement('div')
root.id = RootId rootElement.id = RootId
const appendedRootNode = document.body.appendChild(rootElement)
root = createRoot(appendedRootNode)
const parentNode = document.body.appendChild(root) root.render(
render(
<ApplicationGroupView <ApplicationGroupView
key={getKey()} key={getKey()}
server={defaultSyncServerHost} server={defaultSyncServerHost}
@@ -69,7 +69,6 @@ const startApplication: StartApplication = async function startApplication(
websocketUrl={webSocketUrl} websocketUrl={webSocketUrl}
onDestroy={onDestroy} onDestroy={onDestroy}
/>, />,
parentNode,
) )
} }

View File

@@ -1,12 +1,12 @@
import { WebCrypto } from '@/Crypto' import { WebCrypto } from '@/Application/Crypto'
import { WebAlertService } from '@/Services/AlertService' import { WebAlertService } from '@/Services/AlertService'
import { ArchiveManager } from '@/Services/ArchiveManager' import { ArchiveManager } from '@/Services/ArchiveManager'
import { AutolockService } from '@/Services/AutolockService' import { AutolockService } from '@/Services/AutolockService'
import { DesktopManager } from '@/Services/DesktopManager' import { DesktopManager } from '@/Services/DesktopManager'
import { IOService } from '@/Services/IOService' import { IOService } from '@/Services/IOService'
import { ThemeManager } from '@/Services/ThemeManager' import { ThemeManager } from '@/Services/ThemeManager'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice' import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice'
import { import {
DeinitSource, DeinitSource,
Platform, Platform,
@@ -14,14 +14,21 @@ import {
NoteGroupController, NoteGroupController,
removeFromArray, removeFromArray,
IconsController, IconsController,
Runtime,
DesktopDeviceInterface, DesktopDeviceInterface,
isDesktopDevice, isDesktopDevice,
DeinitMode, DeinitMode,
PrefKey,
SNTag,
ContentType,
DecryptedItemInterface,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { makeObservable, observable } from 'mobx'
import { PanelResizedData } from '@/Types/PanelResizedData'
import { WebAppEvent } from './WebAppEvent'
import { isDesktopApplication } from '@/Utils'
type WebServices = { type WebServices = {
appState: AppState viewControllerManager: ViewControllerManager
desktopService?: DesktopManager desktopService?: DesktopManager
autolockService: AutolockService autolockService: AutolockService
archiveService: ArchiveManager archiveService: ArchiveManager
@@ -29,19 +36,14 @@ type WebServices = {
io: IOService io: IOService
} }
export enum WebAppEvent { export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
NewUpdateAvailable = 'NewUpdateAvailable',
DesktopWindowGainedFocus = 'DesktopWindowGainedFocus',
DesktopWindowLostFocus = 'DesktopWindowLostFocus',
}
export type WebEventObserver = (event: WebAppEvent) => void
export class WebApplication extends SNApplication { export class WebApplication extends SNApplication {
private webServices!: WebServices private webServices!: WebServices
private webEventObservers: WebEventObserver[] = [] private webEventObservers: WebEventObserver[] = []
public noteControllerGroup: NoteGroupController public noteControllerGroup: NoteGroupController
public iconsController: IconsController public iconsController: IconsController
private onVisibilityChange: () => void
constructor( constructor(
deviceInterface: WebOrDesktopDevice, deviceInterface: WebOrDesktopDevice,
@@ -49,7 +51,6 @@ export class WebApplication extends SNApplication {
identifier: string, identifier: string,
defaultSyncServerHost: string, defaultSyncServerHost: string,
webSocketUrl: string, webSocketUrl: string,
runtime: Runtime,
) { ) {
super({ super({
environment: deviceInterface.environment, environment: deviceInterface.environment,
@@ -61,12 +62,26 @@ export class WebApplication extends SNApplication {
defaultHost: defaultSyncServerHost, defaultHost: defaultSyncServerHost,
appVersion: deviceInterface.appVersion, appVersion: deviceInterface.appVersion,
webSocketUrl: webSocketUrl, webSocketUrl: webSocketUrl,
runtime, supportsFileNavigation: window.enabledUnfinishedFeatures || false,
})
makeObservable(this, {
dealloced: observable,
}) })
deviceInterface.setApplication(this) deviceInterface.setApplication(this)
this.noteControllerGroup = new NoteGroupController(this) this.noteControllerGroup = new NoteGroupController(this)
this.iconsController = new IconsController() this.iconsController = new IconsController()
this.onVisibilityChange = () => {
const visible = document.visibilityState === 'visible'
const event = visible ? WebAppEvent.WindowDidFocus : WebAppEvent.WindowDidBlur
this.notifyWebEvent(event)
}
if (!isDesktopApplication()) {
document.addEventListener('visibilitychange', this.onVisibilityChange)
}
} }
override deinit(mode: DeinitMode, source: DeinitSource): void { override deinit(mode: DeinitMode, source: DeinitSource): void {
@@ -91,6 +106,9 @@ export class WebApplication extends SNApplication {
;(this.noteControllerGroup as unknown) = undefined ;(this.noteControllerGroup as unknown) = undefined
this.webEventObservers.length = 0 this.webEventObservers.length = 0
document.removeEventListener('visibilitychange', this.onVisibilityChange)
;(this.onVisibilityChange as unknown) = undefined
} catch (error) { } catch (error) {
console.error('Error while deiniting application', error) console.error('Error while deiniting application', error)
} }
@@ -102,19 +120,28 @@ export class WebApplication extends SNApplication {
public addWebEventObserver(observer: WebEventObserver): () => void { public addWebEventObserver(observer: WebEventObserver): () => void {
this.webEventObservers.push(observer) this.webEventObservers.push(observer)
return () => { return () => {
removeFromArray(this.webEventObservers, observer) removeFromArray(this.webEventObservers, observer)
} }
} }
public notifyWebEvent(event: WebAppEvent): void { public notifyWebEvent(event: WebAppEvent, data?: unknown): void {
for (const observer of this.webEventObservers) { for (const observer of this.webEventObservers) {
observer(event) observer(event, data)
} }
} }
public getAppState(): AppState { publishPanelDidResizeEvent(name: string, collapsed: boolean) {
return this.webServices.appState const data: PanelResizedData = {
panel: name,
collapsed: collapsed,
}
this.notifyWebEvent(WebAppEvent.PanelResized, data)
}
public getViewControllerManager(): ViewControllerManager {
return this.webServices.viewControllerManager
} }
public getDesktopService(): DesktopManager | undefined { public getDesktopService(): DesktopManager | undefined {
@@ -160,4 +187,23 @@ export class WebApplication extends SNApplication {
return this.user.signOut() return this.user.signOut()
} }
isGlobalSpellcheckEnabled(): boolean {
return this.getPreference(PrefKey.EditorSpellcheck, true)
}
public getItemTags(item: DecryptedItemInterface) {
return this.items.itemsReferencingItem(item).filter((ref) => {
return ref.content_type === ContentType.Tag
}) as SNTag[]
}
public get version(): string {
return (this.deviceInterface as WebOrDesktopDevice).appVersion
}
async toggleGlobalSpellcheck() {
const currentValue = this.isGlobalSpellcheckEnabled()
return this.setPreference(PrefKey.EditorSpellcheck, !currentValue)
}
} }

View File

@@ -3,25 +3,23 @@ import {
ApplicationDescriptor, ApplicationDescriptor,
SNApplicationGroup, SNApplicationGroup,
Platform, Platform,
Runtime,
InternalEventBus, InternalEventBus,
isDesktopDevice, isDesktopDevice,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { getPlatform, isDesktopApplication } from '@/Utils' import { getPlatform, isDesktopApplication } from '@/Utils'
import { ArchiveManager } from '@/Services/ArchiveManager' import { ArchiveManager } from '@/Services/ArchiveManager'
import { DesktopManager } from '@/Services/DesktopManager' import { DesktopManager } from '@/Services/DesktopManager'
import { IOService } from '@/Services/IOService' import { IOService } from '@/Services/IOService'
import { AutolockService } from '@/Services/AutolockService' import { AutolockService } from '@/Services/AutolockService'
import { ThemeManager } from '@/Services/ThemeManager' import { ThemeManager } from '@/Services/ThemeManager'
import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice' import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice'
const createApplication = ( const createApplication = (
descriptor: ApplicationDescriptor, descriptor: ApplicationDescriptor,
deviceInterface: WebOrDesktopDevice, deviceInterface: WebOrDesktopDevice,
defaultSyncServerHost: string, defaultSyncServerHost: string,
device: WebOrDesktopDevice, device: WebOrDesktopDevice,
runtime: Runtime,
webSocketUrl: string, webSocketUrl: string,
) => { ) => {
const platform = getPlatform() const platform = getPlatform()
@@ -32,17 +30,16 @@ const createApplication = (
descriptor.identifier, descriptor.identifier,
defaultSyncServerHost, defaultSyncServerHost,
webSocketUrl, webSocketUrl,
runtime,
) )
const appState = new AppState(application, device) const viewControllerManager = new ViewControllerManager(application, device)
const archiveService = new ArchiveManager(application) const archiveService = new ArchiveManager(application)
const io = new IOService(platform === Platform.MacWeb || platform === Platform.MacDesktop) const io = new IOService(platform === Platform.MacWeb || platform === Platform.MacDesktop)
const autolockService = new AutolockService(application, new InternalEventBus()) const autolockService = new AutolockService(application, new InternalEventBus())
const themeService = new ThemeManager(application) const themeService = new ThemeManager(application)
application.setWebServices({ application.setWebServices({
appState, viewControllerManager,
archiveService, archiveService,
desktopService: isDesktopDevice(device) ? new DesktopManager(application, device) : undefined, desktopService: isDesktopDevice(device) ? new DesktopManager(application, device) : undefined,
io, io,
@@ -54,23 +51,17 @@ const createApplication = (
} }
export class ApplicationGroup extends SNApplicationGroup<WebOrDesktopDevice> { export class ApplicationGroup extends SNApplicationGroup<WebOrDesktopDevice> {
constructor( constructor(private defaultSyncServerHost: string, device: WebOrDesktopDevice, private webSocketUrl: string) {
private defaultSyncServerHost: string,
device: WebOrDesktopDevice,
private runtime: Runtime,
private webSocketUrl: string,
) {
super(device) super(device)
} }
override async initialize(): Promise<void> { override async initialize(): Promise<void> {
const defaultSyncServerHost = this.defaultSyncServerHost const defaultSyncServerHost = this.defaultSyncServerHost
const runtime = this.runtime
const webSocketUrl = this.webSocketUrl const webSocketUrl = this.webSocketUrl
await super.initialize({ await super.initialize({
applicationCreator: async (descriptor, device) => { applicationCreator: async (descriptor, device) => {
return createApplication(descriptor, device, defaultSyncServerHost, device, runtime, webSocketUrl) return createApplication(descriptor, device, defaultSyncServerHost, device, webSocketUrl)
}, },
}) })

View File

@@ -0,0 +1,9 @@
export enum WebAppEvent {
NewUpdateAvailable = 'NewUpdateAvailable',
EditorFocused = 'EditorFocused',
BeganBackupDownload = 'BeganBackupDownload',
EndedBackupDownload = 'EndedBackupDownload',
PanelResized = 'PanelResized',
WindowDidFocus = 'WindowDidFocus',
WindowDidBlur = 'WindowDidBlur',
}

View File

@@ -1,16 +1,14 @@
import { ApplicationEvent } from '@standardnotes/snjs' import { ApplicationEvent } from '@standardnotes/snjs'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState, AppStateEvent } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx' import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'
import { Component } from 'preact' import { Component } from 'react'
import { findDOMNode, unmountComponentAtNode } from 'preact/compat'
export type PureComponentState = Partial<Record<string, any>> export type PureComponentState = Partial<Record<string, any>>
export type PureComponentProps = Partial<Record<string, any>> export type PureComponentProps = Partial<Record<string, any>>
export abstract class PureComponent<P = PureComponentProps, S = PureComponentState> extends Component<P, S> { export abstract class PureComponent<P = PureComponentProps, S = PureComponentState> extends Component<P, S> {
private unsubApp!: () => void private unsubApp!: () => void
private unsubState!: () => void
private reactionDisposers: IReactionDisposer[] = [] private reactionDisposers: IReactionDisposer[] = []
constructor(props: P, protected application: WebApplication) { constructor(props: P, protected application: WebApplication) {
@@ -19,63 +17,34 @@ export abstract class PureComponent<P = PureComponentProps, S = PureComponentSta
override componentDidMount() { override componentDidMount() {
this.addAppEventObserver() this.addAppEventObserver()
this.addAppStateObserver()
} }
deinit(): void { deinit(): void {
this.unsubApp?.() this.unsubApp?.()
this.unsubState?.()
for (const disposer of this.reactionDisposers) { for (const disposer of this.reactionDisposers) {
disposer() disposer()
} }
this.reactionDisposers.length = 0 this.reactionDisposers.length = 0
;(this.unsubApp as unknown) = undefined ;(this.unsubApp as unknown) = undefined
;(this.unsubState as unknown) = undefined
;(this.application as unknown) = undefined ;(this.application as unknown) = undefined
;(this.props as unknown) = undefined ;(this.props as unknown) = undefined
;(this.state as unknown) = undefined ;(this.state as unknown) = undefined
} }
protected dismissModal(): void {
const elem = this.getElement()
if (!elem) {
return
}
const parent = elem.parentElement
if (!parent) {
return
}
parent.remove()
unmountComponentAtNode(parent)
}
override componentWillUnmount(): void { override componentWillUnmount(): void {
this.deinit() this.deinit()
} }
public get appState(): AppState { public get viewControllerManager(): ViewControllerManager {
return this.application.getAppState() return this.application.getViewControllerManager()
}
protected getElement(): Element | null {
return findDOMNode(this)
} }
autorun(view: (r: IReactionPublic) => void): void { autorun(view: (r: IReactionPublic) => void): void {
this.reactionDisposers.push(autorun(view)) this.reactionDisposers.push(autorun(view))
} }
addAppStateObserver() {
this.unsubState = this.application.getAppState().addObserver(async (eventName, data) => {
this.onAppStateEvent(eventName, data)
})
}
onAppStateEvent(_eventName: AppStateEvent, _data: unknown) {
/** Optional override */
}
addAppEventObserver() { addAppEventObserver() {
if (this.application.isStarted()) { if (this.application.isStarted()) {
this.onAppStart().catch(console.error) this.onAppStart().catch(console.error)

View File

@@ -0,0 +1,79 @@
import { observer } from 'mobx-react-lite'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { WebApplication } from '@/Application/Application'
import { useCallback, useRef, FunctionComponent, KeyboardEventHandler } from 'react'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { AccountMenuPane } from './AccountMenuPane'
import MenuPaneSelector from './MenuPaneSelector'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
onClickOutside: () => void
mainApplicationGroup: ApplicationGroup
}
const AccountMenu: FunctionComponent<Props> = ({
application,
viewControllerManager,
onClickOutside,
mainApplicationGroup,
}) => {
const { currentPane, shouldAnimateCloseMenu } = viewControllerManager.accountMenuController
const closeAccountMenu = useCallback(() => {
viewControllerManager.accountMenuController.closeAccountMenu()
}, [viewControllerManager])
const setCurrentPane = useCallback(
(pane: AccountMenuPane) => {
viewControllerManager.accountMenuController.setCurrentPane(pane)
},
[viewControllerManager],
)
const ref = useRef<HTMLDivElement>(null)
useCloseOnClickOutside(ref, () => {
onClickOutside()
})
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = useCallback(
(event) => {
switch (event.key) {
case 'Escape':
if (currentPane === AccountMenuPane.GeneralMenu) {
closeAccountMenu()
} else if (currentPane === AccountMenuPane.ConfirmPassword) {
setCurrentPane(AccountMenuPane.Register)
} else {
setCurrentPane(AccountMenuPane.GeneralMenu)
}
break
}
},
[closeAccountMenu, currentPane, setCurrentPane],
)
return (
<div ref={ref} id="account-menu" className="sn-component">
<div
className={`sn-account-menu sn-dropdown ${
shouldAnimateCloseMenu ? 'slide-up-animation' : 'sn-dropdown--animated'
} min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`}
onKeyDown={handleKeyDown}
>
<MenuPaneSelector
viewControllerManager={viewControllerManager}
application={application}
mainApplicationGroup={mainApplicationGroup}
menuPane={currentPane}
setMenuPane={setCurrentPane}
closeMenu={closeAccountMenu}
/>
</div>
</div>
)
}
export default observer(AccountMenu)

View File

@@ -0,0 +1,6 @@
export enum AccountMenuPane {
GeneralMenu,
SignIn,
Register,
ConfirmPassword,
}

View File

@@ -1,184 +1,190 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { ChangeEventHandler, FunctionComponent, useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'preact/hooks' import Checkbox from '@/Components/Checkbox/Checkbox'
import { Checkbox } from '@/Components/Checkbox' import DecoratedInput from '@/Components/Input/DecoratedInput'
import { DecoratedInput } from '@/Components/Input/DecoratedInput' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon'
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState viewControllerManager: ViewControllerManager
disabled?: boolean disabled?: boolean
onPrivateWorkspaceChange?: (isPrivate: boolean, identifier?: string) => void onPrivateWorkspaceChange?: (isPrivate: boolean, identifier?: string) => void
onStrictSignInChange?: (isStrictSignIn: boolean) => void onStrictSignInChange?: (isStrictSignIn: boolean) => void
} }
export const AdvancedOptions: FunctionComponent<Props> = observer( const AdvancedOptions: FunctionComponent<Props> = ({
({ appState, application, disabled = false, onPrivateWorkspaceChange, onStrictSignInChange, children }) => { viewControllerManager,
const { server, setServer, enableServerOption, setEnableServerOption } = appState.accountMenu application,
const [showAdvanced, setShowAdvanced] = useState(false) disabled = false,
onPrivateWorkspaceChange,
onStrictSignInChange,
children,
}) => {
const { server, setServer, enableServerOption, setEnableServerOption } = viewControllerManager.accountMenuController
const [showAdvanced, setShowAdvanced] = useState(false)
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false) const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
const [privateWorkspaceName, setPrivateWorkspaceName] = useState('') const [privateWorkspaceName, setPrivateWorkspaceName] = useState('')
const [privateWorkspaceUserphrase, setPrivateWorkspaceUserphrase] = useState('') const [privateWorkspaceUserphrase, setPrivateWorkspaceUserphrase] = useState('')
const [isStrictSignin, setIsStrictSignin] = useState(false) const [isStrictSignin, setIsStrictSignin] = useState(false)
useEffect(() => { useEffect(() => {
const recomputePrivateWorkspaceIdentifier = async () => { const recomputePrivateWorkspaceIdentifier = async () => {
const identifier = await application.computePrivateWorkspaceIdentifier( const identifier = await application.computePrivateWorkspaceIdentifier(
privateWorkspaceName, privateWorkspaceName,
privateWorkspaceUserphrase, privateWorkspaceUserphrase,
) )
if (!identifier) { if (!identifier) {
if (privateWorkspaceName?.length > 0 && privateWorkspaceUserphrase?.length > 0) { if (privateWorkspaceName?.length > 0 && privateWorkspaceUserphrase?.length > 0) {
application.alertService.alert('Unable to compute private workspace name.').catch(console.error) application.alertService.alert('Unable to compute private workspace name.').catch(console.error)
}
return
} }
onPrivateWorkspaceChange?.(true, identifier) return
} }
onPrivateWorkspaceChange?.(true, identifier)
}
if (privateWorkspaceName && privateWorkspaceUserphrase) { if (privateWorkspaceName && privateWorkspaceUserphrase) {
recomputePrivateWorkspaceIdentifier().catch(console.error) recomputePrivateWorkspaceIdentifier().catch(console.error)
}
}, [privateWorkspaceName, privateWorkspaceUserphrase, application, onPrivateWorkspaceChange])
useEffect(() => {
onPrivateWorkspaceChange?.(isPrivateWorkspace)
}, [isPrivateWorkspace, onPrivateWorkspaceChange])
const handleIsPrivateWorkspaceChange = useCallback(() => {
setIsPrivateWorkspace(!isPrivateWorkspace)
}, [isPrivateWorkspace])
const handlePrivateWorkspaceNameChange = useCallback((name: string) => {
setPrivateWorkspaceName(name)
}, [])
const handlePrivateWorkspaceUserphraseChange = useCallback((userphrase: string) => {
setPrivateWorkspaceUserphrase(userphrase)
}, [])
const handleServerOptionChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
if (e.target instanceof HTMLInputElement) {
setEnableServerOption(e.target.checked)
} }
}, [privateWorkspaceName, privateWorkspaceUserphrase, application, onPrivateWorkspaceChange]) },
[setEnableServerOption],
)
useEffect(() => { const handleSyncServerChange = useCallback(
onPrivateWorkspaceChange?.(isPrivateWorkspace) (server: string) => {
}, [isPrivateWorkspace, onPrivateWorkspaceChange]) setServer(server)
application.setCustomHost(server).catch(console.error)
},
[application, setServer],
)
const handleIsPrivateWorkspaceChange = useCallback(() => { const handleStrictSigninChange = useCallback(() => {
setIsPrivateWorkspace(!isPrivateWorkspace) const newValue = !isStrictSignin
}, [isPrivateWorkspace]) setIsStrictSignin(newValue)
onStrictSignInChange?.(newValue)
}, [isStrictSignin, onStrictSignInChange])
const handlePrivateWorkspaceNameChange = useCallback((name: string) => { const toggleShowAdvanced = useCallback(() => {
setPrivateWorkspaceName(name) setShowAdvanced(!showAdvanced)
}, []) }, [showAdvanced])
const handlePrivateWorkspaceUserphraseChange = useCallback((userphrase: string) => { return (
setPrivateWorkspaceUserphrase(userphrase) <>
}, []) <button
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none font-bold"
onClick={toggleShowAdvanced}
>
<div className="flex items-center">
Advanced options
<Icon type="chevron-down" className="color-passive-1 ml-1" />
</div>
</button>
{showAdvanced ? (
<div className="px-3 my-2">
{children}
const handleServerOptionChange = useCallback( <div className="flex justify-between items-center mb-1">
(e: Event) => { <Checkbox
if (e.target instanceof HTMLInputElement) { name="private-workspace"
setEnableServerOption(e.target.checked) label="Private workspace"
} checked={isPrivateWorkspace}
}, disabled={disabled}
[setEnableServerOption], onChange={handleIsPrivateWorkspaceChange}
) />
<a href="https://standardnotes.com/help/80" target="_blank" rel="noopener noreferrer" title="Learn more">
const handleSyncServerChange = useCallback( <Icon type="info" className="color-neutral" />
(server: string) => { </a>
setServer(server)
application.setCustomHost(server).catch(console.error)
},
[application, setServer],
)
const handleStrictSigninChange = useCallback(() => {
const newValue = !isStrictSignin
setIsStrictSignin(newValue)
onStrictSignInChange?.(newValue)
}, [isStrictSignin, onStrictSignInChange])
const toggleShowAdvanced = useCallback(() => {
setShowAdvanced(!showAdvanced)
}, [showAdvanced])
return (
<>
<button
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none font-bold"
onClick={toggleShowAdvanced}
>
<div className="flex items-center">
Advanced options
<Icon type="chevron-down" className="color-grey-1 ml-1" />
</div> </div>
</button>
{showAdvanced ? (
<div className="px-3 my-2">
{children}
{isPrivateWorkspace && (
<>
<DecoratedInput
className={'mb-2'}
left={[<Icon type="server" className="color-neutral" />]}
type="text"
placeholder="Userphrase"
value={privateWorkspaceUserphrase}
onChange={handlePrivateWorkspaceUserphraseChange}
disabled={disabled}
/>
<DecoratedInput
className={'mb-2'}
left={[<Icon type="folder" className="color-neutral" />]}
type="text"
placeholder="Name"
value={privateWorkspaceName}
onChange={handlePrivateWorkspaceNameChange}
disabled={disabled}
/>
</>
)}
{onStrictSignInChange && (
<div className="flex justify-between items-center mb-1"> <div className="flex justify-between items-center mb-1">
<Checkbox <Checkbox
name="private-workspace" name="use-strict-signin"
label="Private workspace" label="Use strict sign-in"
checked={isPrivateWorkspace} checked={isStrictSignin}
disabled={disabled} disabled={disabled}
onChange={handleIsPrivateWorkspaceChange} onChange={handleStrictSigninChange}
/> />
<a href="https://standardnotes.com/help/80" target="_blank" rel="noopener noreferrer" title="Learn more"> <a
href="https://standardnotes.com/help/security"
target="_blank"
rel="noopener noreferrer"
title="Learn more"
>
<Icon type="info" className="color-neutral" /> <Icon type="info" className="color-neutral" />
</a> </a>
</div> </div>
)}
{isPrivateWorkspace && ( <Checkbox
<> name="custom-sync-server"
<DecoratedInput label="Custom sync server"
className={'mb-2'} checked={enableServerOption}
left={[<Icon type="server" className="color-neutral" />]} onChange={handleServerOptionChange}
type="text" disabled={disabled}
placeholder="Userphrase" />
value={privateWorkspaceUserphrase} <DecoratedInput
onChange={handlePrivateWorkspaceUserphraseChange} type="text"
disabled={disabled} left={[<Icon type="server" className="color-neutral" />]}
/> placeholder="https://api.standardnotes.com"
<DecoratedInput value={server}
className={'mb-2'} onChange={handleSyncServerChange}
left={[<Icon type="folder" className="color-neutral" />]} disabled={!enableServerOption && !disabled}
type="text" />
placeholder="Name" </div>
value={privateWorkspaceName} ) : null}
onChange={handlePrivateWorkspaceNameChange} </>
disabled={disabled} )
/> }
</>
)}
{onStrictSignInChange && ( export default observer(AdvancedOptions)
<div className="flex justify-between items-center mb-1">
<Checkbox
name="use-strict-signin"
label="Use strict sign-in"
checked={isStrictSignin}
disabled={disabled}
onChange={handleStrictSigninChange}
/>
<a
href="https://standardnotes.com/help/security"
target="_blank"
rel="noopener noreferrer"
title="Learn more"
>
<Icon type="info" className="color-neutral" />
</a>
</div>
)}
<Checkbox
name="custom-sync-server"
label="Custom sync server"
checked={enableServerOption}
onChange={handleServerOptionChange}
disabled={disabled}
/>
<DecoratedInput
type="text"
left={[<Icon type="server" className="color-neutral" />]}
placeholder="https://api.standardnotes.com"
value={server}
onChange={handleSyncServerChange}
disabled={!enableServerOption && !disabled}
/>
</div>
) : null}
</>
)
},
)

View File

@@ -1,158 +1,163 @@
import { STRING_NON_MATCHING_PASSWORDS } from '@/Strings' import { STRING_NON_MATCHING_PASSWORDS } from '@/Constants/Strings'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import { AccountMenuPane } from './AccountMenuPane'
import { AccountMenuPane } from '.' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import Checkbox from '@/Components/Checkbox/Checkbox'
import { Checkbox } from '@/Components/Checkbox' import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import IconButton from '@/Components/Button/IconButton'
import { IconButton } from '@/Components/Button/IconButton'
type Props = { type Props = {
appState: AppState viewControllerManager: ViewControllerManager
application: WebApplication application: WebApplication
setMenuPane: (pane: AccountMenuPane) => void setMenuPane: (pane: AccountMenuPane) => void
email: string email: string
password: string password: string
} }
export const ConfirmPassword: FunctionComponent<Props> = observer( const ConfirmPassword: FunctionComponent<Props> = ({
({ application, appState, setMenuPane, email, password }) => { application,
const { notesAndTagsCount } = appState.accountMenu viewControllerManager,
const [confirmPassword, setConfirmPassword] = useState('') setMenuPane,
const [isRegistering, setIsRegistering] = useState(false) email,
const [isEphemeral, setIsEphemeral] = useState(false) password,
const [shouldMergeLocal, setShouldMergeLocal] = useState(true) }) => {
const [error, setError] = useState('') const { notesAndTagsCount } = viewControllerManager.accountMenuController
const [confirmPassword, setConfirmPassword] = useState('')
const [isRegistering, setIsRegistering] = useState(false)
const [isEphemeral, setIsEphemeral] = useState(false)
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
const [error, setError] = useState('')
const passwordInputRef = useRef<HTMLInputElement>(null) const passwordInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
passwordInputRef.current?.focus() passwordInputRef.current?.focus()
}, []) }, [])
const handlePasswordChange = useCallback((text: string) => { const handlePasswordChange = useCallback((text: string) => {
setConfirmPassword(text) setConfirmPassword(text)
}, []) }, [])
const handleEphemeralChange = useCallback(() => { const handleEphemeralChange = useCallback(() => {
setIsEphemeral(!isEphemeral) setIsEphemeral(!isEphemeral)
}, [isEphemeral]) }, [isEphemeral])
const handleShouldMergeChange = useCallback(() => { const handleShouldMergeChange = useCallback(() => {
setShouldMergeLocal(!shouldMergeLocal) setShouldMergeLocal(!shouldMergeLocal)
}, [shouldMergeLocal]) }, [shouldMergeLocal])
const handleConfirmFormSubmit = useCallback( const handleConfirmFormSubmit = useCallback(
(e: Event) => { (e) => {
e.preventDefault() e.preventDefault()
if (!password) { if (!password) {
passwordInputRef.current?.focus() passwordInputRef.current?.focus()
return return
} }
if (password === confirmPassword) { if (password === confirmPassword) {
setIsRegistering(true) setIsRegistering(true)
application application
.register(email, password, isEphemeral, shouldMergeLocal) .register(email, password, isEphemeral, shouldMergeLocal)
.then((res) => { .then((res) => {
if (res.error) { if (res.error) {
throw new Error(res.error.message) throw new Error(res.error.message)
} }
appState.accountMenu.closeAccountMenu() viewControllerManager.accountMenuController.closeAccountMenu()
appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu) viewControllerManager.accountMenuController.setCurrentPane(AccountMenuPane.GeneralMenu)
}) })
.catch((err) => { .catch((err) => {
console.error(err) console.error(err)
setError(err.message) setError(err.message)
}) })
.finally(() => { .finally(() => {
setIsRegistering(false) setIsRegistering(false)
}) })
} else { } else {
setError(STRING_NON_MATCHING_PASSWORDS) setError(STRING_NON_MATCHING_PASSWORDS)
setConfirmPassword('') setConfirmPassword('')
passwordInputRef.current?.focus() passwordInputRef.current?.focus()
} }
}, },
[appState, application, confirmPassword, email, isEphemeral, password, shouldMergeLocal], [viewControllerManager, application, confirmPassword, email, isEphemeral, password, shouldMergeLocal],
) )
const handleKeyDown = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
(e: KeyboardEvent) => { (e) => {
if (error.length) { if (error.length) {
setError('') setError('')
} }
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleConfirmFormSubmit(e) handleConfirmFormSubmit(e)
} }
}, },
[handleConfirmFormSubmit, error], [handleConfirmFormSubmit, error],
) )
const handleGoBack = useCallback(() => { const handleGoBack = useCallback(() => {
setMenuPane(AccountMenuPane.Register) setMenuPane(AccountMenuPane.Register)
}, [setMenuPane]) }, [setMenuPane])
return ( return (
<> <>
<div className="flex items-center px-3 mt-1 mb-3"> <div className="flex items-center px-3 mt-1 mb-3">
<IconButton <IconButton
icon="arrow-left" icon="arrow-left"
title="Go back" title="Go back"
className="flex mr-2 color-neutral p-0" className="flex mr-2 color-neutral p-0"
onClick={handleGoBack} onClick={handleGoBack}
focusable={true} focusable={true}
disabled={isRegistering} disabled={isRegistering}
/> />
<div className="sn-account-menu-headline">Confirm password</div> <div className="sn-account-menu-headline">Confirm password</div>
</div> </div>
<div className="px-3 mb-3 text-sm"> <div className="px-3 mb-3 text-sm">
Because your notes are encrypted using your password,{' '} Because your notes are encrypted using your password,{' '}
<span className="color-dark-red">Standard Notes does not have a password reset option</span>. If you forget <span className="color-danger">Standard Notes does not have a password reset option</span>. If you forget your
your password, you will permanently lose access to your data. password, you will permanently lose access to your data.
</div> </div>
<form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1"> <form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1">
<DecoratedPasswordInput <DecoratedPasswordInput
className="mb-2" className="mb-2"
disabled={isRegistering} disabled={isRegistering}
left={[<Icon type="password" className="color-neutral" />]} left={[<Icon type="password" className="color-neutral" />]}
onChange={handlePasswordChange} onChange={handlePasswordChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Confirm password" placeholder="Confirm password"
ref={passwordInputRef} ref={passwordInputRef}
value={confirmPassword} value={confirmPassword}
/> />
{error ? <div className="color-dark-red my-2">{error}</div> : null} {error ? <div className="color-danger my-2">{error}</div> : null}
<Button <Button
className="btn-w-full mt-1 mb-3" className="btn-w-full mt-1 mb-3"
label={isRegistering ? 'Creating account...' : 'Create account & sign in'} label={isRegistering ? 'Creating account...' : 'Create account & sign in'}
variant="primary" variant="primary"
onClick={handleConfirmFormSubmit} onClick={handleConfirmFormSubmit}
disabled={isRegistering} disabled={isRegistering}
/> />
<Checkbox
name="is-ephemeral"
label="Stay signed in"
checked={!isEphemeral}
onChange={handleEphemeralChange}
disabled={isRegistering}
/>
{notesAndTagsCount > 0 ? (
<Checkbox <Checkbox
name="is-ephemeral" name="should-merge-local"
label="Stay signed in" label={`Merge local data (${notesAndTagsCount} notes and tags)`}
checked={!isEphemeral} checked={shouldMergeLocal}
onChange={handleEphemeralChange} onChange={handleShouldMergeChange}
disabled={isRegistering} disabled={isRegistering}
/> />
{notesAndTagsCount > 0 ? ( ) : null}
<Checkbox </form>
name="should-merge-local" </>
label={`Merge local data (${notesAndTagsCount} notes and tags)`} )
checked={shouldMergeLocal} }
onChange={handleShouldMergeChange}
disabled={isRegistering} export default observer(ConfirmPassword)
/>
) : null}
</form>
</>
)
},
)

View File

@@ -1,140 +1,147 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { StateUpdater, useCallback, useEffect, useRef, useState } from 'preact/hooks' import { AccountMenuPane } from './AccountMenuPane'
import { AccountMenuPane } from '.' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import DecoratedInput from '@/Components/Input/DecoratedInput'
import { DecoratedInput } from '@/Components/Input/DecoratedInput' import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import IconButton from '@/Components/Button/IconButton'
import { IconButton } from '@/Components/Button/IconButton' import AdvancedOptions from './AdvancedOptions'
import { AdvancedOptions } from './AdvancedOptions'
type Props = { type Props = {
appState: AppState viewControllerManager: ViewControllerManager
application: WebApplication application: WebApplication
setMenuPane: (pane: AccountMenuPane) => void setMenuPane: (pane: AccountMenuPane) => void
email: string email: string
setEmail: StateUpdater<string> setEmail: React.Dispatch<React.SetStateAction<string>>
password: string password: string
setPassword: StateUpdater<string> setPassword: React.Dispatch<React.SetStateAction<string>>
} }
export const CreateAccount: FunctionComponent<Props> = observer( const CreateAccount: FunctionComponent<Props> = ({
({ appState, application, setMenuPane, email, setEmail, password, setPassword }) => { viewControllerManager,
const emailInputRef = useRef<HTMLInputElement>(null) application,
const passwordInputRef = useRef<HTMLInputElement>(null) setMenuPane,
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false) email,
setEmail,
password,
setPassword,
}) => {
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
useEffect(() => { useEffect(() => {
if (emailInputRef.current) { if (emailInputRef.current) {
emailInputRef.current?.focus()
}
}, [])
const handleEmailChange = useCallback(
(text: string) => {
setEmail(text)
},
[setEmail],
)
const handlePasswordChange = useCallback(
(text: string) => {
setPassword(text)
},
[setPassword],
)
const handleRegisterFormSubmit = useCallback(
(e) => {
e.preventDefault()
if (!email || email.length === 0) {
emailInputRef.current?.focus() emailInputRef.current?.focus()
return
} }
}, [])
const handleEmailChange = useCallback( if (!password || password.length === 0) {
(text: string) => { passwordInputRef.current?.focus()
setEmail(text) return
}, }
[setEmail],
)
const handlePasswordChange = useCallback( setEmail(email)
(text: string) => { setPassword(password)
setPassword(text) setMenuPane(AccountMenuPane.ConfirmPassword)
}, },
[setPassword], [email, password, setPassword, setMenuPane, setEmail],
) )
const handleRegisterFormSubmit = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
(e: Event) => { (e) => {
e.preventDefault() if (e.key === 'Enter') {
handleRegisterFormSubmit(e)
}
},
[handleRegisterFormSubmit],
)
if (!email || email.length === 0) { const handleClose = useCallback(() => {
emailInputRef.current?.focus() setMenuPane(AccountMenuPane.GeneralMenu)
return setEmail('')
} setPassword('')
}, [setEmail, setMenuPane, setPassword])
if (!password || password.length === 0) { const onPrivateWorkspaceChange = useCallback(
passwordInputRef.current?.focus() (isPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
return setIsPrivateWorkspace(isPrivateWorkspace)
} if (isPrivateWorkspace && privateWorkspaceIdentifier) {
setEmail(privateWorkspaceIdentifier)
}
},
[setEmail],
)
setEmail(email) return (
setPassword(password) <>
setMenuPane(AccountMenuPane.ConfirmPassword) <div className="flex items-center px-3 mt-1 mb-3">
}, <IconButton
[email, password, setPassword, setMenuPane, setEmail], icon="arrow-left"
) title="Go back"
className="flex mr-2 color-neutral p-0"
const handleKeyDown = useCallback( onClick={handleClose}
(e: KeyboardEvent) => { focusable={true}
if (e.key === 'Enter') {
handleRegisterFormSubmit(e)
}
},
[handleRegisterFormSubmit],
)
const handleClose = useCallback(() => {
setMenuPane(AccountMenuPane.GeneralMenu)
setEmail('')
setPassword('')
}, [setEmail, setMenuPane, setPassword])
const onPrivateWorkspaceChange = useCallback(
(isPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
setIsPrivateWorkspace(isPrivateWorkspace)
if (isPrivateWorkspace && privateWorkspaceIdentifier) {
setEmail(privateWorkspaceIdentifier)
}
},
[setEmail],
)
return (
<>
<div className="flex items-center px-3 mt-1 mb-3">
<IconButton
icon="arrow-left"
title="Go back"
className="flex mr-2 color-neutral p-0"
onClick={handleClose}
focusable={true}
/>
<div className="sn-account-menu-headline">Create account</div>
</div>
<form onSubmit={handleRegisterFormSubmit} className="px-3 mb-1">
<DecoratedInput
className="mb-2"
disabled={isPrivateWorkspace}
left={[<Icon type="email" className="color-neutral" />]}
onChange={handleEmailChange}
onKeyDown={handleKeyDown}
placeholder="Email"
ref={emailInputRef}
type="email"
value={email}
/>
<DecoratedPasswordInput
className="mb-2"
left={[<Icon type="password" className="color-neutral" />]}
onChange={handlePasswordChange}
onKeyDown={handleKeyDown}
placeholder="Password"
ref={passwordInputRef}
value={password}
/>
<Button className="btn-w-full mt-1" label="Next" variant="primary" onClick={handleRegisterFormSubmit} />
</form>
<div className="h-1px my-2 bg-border"></div>
<AdvancedOptions
application={application}
appState={appState}
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
/> />
</> <div className="sn-account-menu-headline">Create account</div>
) </div>
}, <form onSubmit={handleRegisterFormSubmit} className="px-3 mb-1">
) <DecoratedInput
className="mb-2"
disabled={isPrivateWorkspace}
left={[<Icon type="email" className="color-neutral" />]}
onChange={handleEmailChange}
onKeyDown={handleKeyDown}
placeholder="Email"
ref={emailInputRef}
type="email"
value={email}
/>
<DecoratedPasswordInput
className="mb-2"
left={[<Icon type="password" className="color-neutral" />]}
onChange={handlePasswordChange}
onKeyDown={handleKeyDown}
placeholder="Password"
ref={passwordInputRef}
value={password}
/>
<Button className="btn-w-full mt-1" label="Next" variant="primary" onClick={handleRegisterFormSubmit} />
</form>
<div className="h-1px my-2 bg-border"></div>
<AdvancedOptions
application={application}
viewControllerManager={viewControllerManager}
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
/>
</>
)
}
export default observer(CreateAccount)

View File

@@ -1,20 +1,21 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { Icon } from '@/Components/Icon' import Icon from '@/Components/Icon/Icon'
import { formatLastSyncDate } from '@/Components/Preferences/Panes/Account/Sync'
import { SyncQueueStrategy } from '@standardnotes/snjs' import { SyncQueueStrategy } from '@standardnotes/snjs'
import { STRING_GENERIC_SYNC_ERROR } from '@/Strings' import { STRING_GENERIC_SYNC_ERROR } from '@/Constants/Strings'
import { useCallback, useMemo, useState } from 'preact/hooks' import { useCallback, useMemo, useState, FunctionComponent } from 'react'
import { AccountMenuPane } from '.' import { AccountMenuPane } from './AccountMenuPane'
import { FunctionComponent } from 'preact' import Menu from '@/Components/Menu/Menu'
import { Menu } from '@/Components/Menu/Menu' import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem' import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
import { WorkspaceSwitcherOption } from './WorkspaceSwitcher/WorkspaceSwitcherOption' import { MenuItemType } from '@/Components/Menu/MenuItemType'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import WorkspaceSwitcherOption from './WorkspaceSwitcher/WorkspaceSwitcherOption'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { formatLastSyncDate } from '@/Utils/FormatLastSyncDate'
type Props = { type Props = {
appState: AppState viewControllerManager: ViewControllerManager
application: WebApplication application: WebApplication
mainApplicationGroup: ApplicationGroup mainApplicationGroup: ApplicationGroup
setMenuPane: (pane: AccountMenuPane) => void setMenuPane: (pane: AccountMenuPane) => void
@@ -23,156 +24,165 @@ type Props = {
const iconClassName = 'color-neutral mr-2' const iconClassName = 'color-neutral mr-2'
export const GeneralAccountMenu: FunctionComponent<Props> = observer( const GeneralAccountMenu: FunctionComponent<Props> = ({
({ application, appState, setMenuPane, closeMenu, mainApplicationGroup }) => { application,
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false) viewControllerManager,
const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date)) setMenuPane,
closeMenu,
mainApplicationGroup,
}) => {
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
const doSynchronization = useCallback(async () => { const doSynchronization = useCallback(async () => {
setIsSyncingInProgress(true) setIsSyncingInProgress(true)
application.sync application.sync
.sync({ .sync({
queueStrategy: SyncQueueStrategy.ForceSpawnNew, queueStrategy: SyncQueueStrategy.ForceSpawnNew,
checkIntegrity: true, checkIntegrity: true,
}) })
.then((res) => { .then((res) => {
if (res && (res as any).error) { if (res && (res as any).error) {
throw new Error() throw new Error()
} else { } else {
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date)) setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
} }
}) })
.catch(() => { .catch(() => {
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error) application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
}) })
.finally(() => { .finally(() => {
setIsSyncingInProgress(false) setIsSyncingInProgress(false)
}) })
}, [application]) }, [application])
const user = useMemo(() => application.getUser(), [application]) const user = useMemo(() => application.getUser(), [application])
const openPreferences = useCallback(() => { const openPreferences = useCallback(() => {
appState.accountMenu.closeAccountMenu() viewControllerManager.accountMenuController.closeAccountMenu()
appState.preferences.setCurrentPane('account') viewControllerManager.preferencesController.setCurrentPane('account')
appState.preferences.openPreferences() viewControllerManager.preferencesController.openPreferences()
}, [appState]) }, [viewControllerManager])
const openHelp = useCallback(() => { const openHelp = useCallback(() => {
appState.accountMenu.closeAccountMenu() viewControllerManager.accountMenuController.closeAccountMenu()
appState.preferences.setCurrentPane('help-feedback') viewControllerManager.preferencesController.setCurrentPane('help-feedback')
appState.preferences.openPreferences() viewControllerManager.preferencesController.openPreferences()
}, [appState]) }, [viewControllerManager])
const signOut = useCallback(() => { const signOut = useCallback(() => {
appState.accountMenu.setSigningOut(true) viewControllerManager.accountMenuController.setSigningOut(true)
}, [appState]) }, [viewControllerManager])
const activateRegisterPane = useCallback(() => { const activateRegisterPane = useCallback(() => {
setMenuPane(AccountMenuPane.Register) setMenuPane(AccountMenuPane.Register)
}, [setMenuPane]) }, [setMenuPane])
const activateSignInPane = useCallback(() => { const activateSignInPane = useCallback(() => {
setMenuPane(AccountMenuPane.SignIn) setMenuPane(AccountMenuPane.SignIn)
}, [setMenuPane]) }, [setMenuPane])
const CREATE_ACCOUNT_INDEX = 1 const CREATE_ACCOUNT_INDEX = 1
const SWITCHER_INDEX = 0 const SWITCHER_INDEX = 0
return ( return (
<> <>
<div className="flex items-center justify-between px-3 mt-1 mb-1"> <div className="flex items-center justify-between px-3 mt-1 mb-1">
<div className="sn-account-menu-headline">Account</div> <div className="sn-account-menu-headline">Account</div>
<div className="flex cursor-pointer" onClick={closeMenu}> <div className="flex cursor-pointer" onClick={closeMenu}>
<Icon type="close" className="color-neutral" /> <Icon type="close" className="color-neutral" />
</div>
</div> </div>
{user ? ( </div>
<> {user ? (
<div className="px-3 mb-3 color-foreground text-sm"> <>
<div>You're signed in as:</div> <div className="px-3 mb-3 color-foreground text-sm">
<div className="my-0.5 font-bold wrap">{user.email}</div> <div>You're signed in as:</div>
<span className="color-neutral">{application.getHost()}</span> <div className="my-0.5 font-bold wrap">{user.email}</div>
</div> <span className="color-neutral">{application.getHost()}</span>
<div className="flex items-start justify-between px-3 mb-3"> </div>
{isSyncingInProgress ? ( <div className="flex items-start justify-between px-3 mb-3">
<div className="flex items-center color-info font-semibold"> {isSyncingInProgress ? (
<div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div> <div className="flex items-center color-info font-semibold">
Syncing... <div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div>
</div> Syncing...
) : (
<div className="flex items-start">
<Icon type="check-circle" className="mr-2 success" />
<div>
<div class="font-semibold success">Last synced:</div>
<div class="color-text">{lastSyncDate}</div>
</div>
</div>
)}
<div className="flex cursor-pointer color-grey-1" onClick={doSynchronization}>
<Icon type="sync" />
</div> </div>
) : (
<div className="flex items-start">
<Icon type="check-circle" className="mr-2 success" />
<div>
<div className="font-semibold success">Last synced:</div>
<div className="color-text">{lastSyncDate}</div>
</div>
</div>
)}
<div className="flex cursor-pointer color-passive-1" onClick={doSynchronization}>
<Icon type="sync" />
</div> </div>
</> </div>
</>
) : (
<>
<div className="px-3 mb-1">
<div className="mb-3 color-foreground">
Youre offline. Sign in to sync your notes and preferences across all your devices and enable end-to-end
encryption.
</div>
<div className="flex items-center color-passive-1">
<Icon type="cloud-off" className="mr-2" />
<span className="font-semibold">Offline</span>
</div>
</div>
</>
)}
<Menu
isOpen={viewControllerManager.accountMenuController.show}
a11yLabel="General account menu"
closeMenu={closeMenu}
initialFocus={!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX}
>
<MenuItemSeparator />
<WorkspaceSwitcherOption
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
/>
<MenuItemSeparator />
{user ? (
<MenuItem type={MenuItemType.IconButton} onClick={openPreferences}>
<Icon type="user" className={iconClassName} />
Account settings
</MenuItem>
) : ( ) : (
<> <>
<div className="px-3 mb-1"> <MenuItem type={MenuItemType.IconButton} onClick={activateRegisterPane}>
<div className="mb-3 color-foreground"> <Icon type="user" className={iconClassName} />
Youre offline. Sign in to sync your notes and preferences across all your devices and enable end-to-end Create free account
encryption. </MenuItem>
</div> <MenuItem type={MenuItemType.IconButton} onClick={activateSignInPane}>
<div className="flex items-center color-grey-1"> <Icon type="signIn" className={iconClassName} />
<Icon type="cloud-off" className="mr-2" /> Sign in
<span className="font-semibold">Offline</span> </MenuItem>
</div>
</div>
</> </>
)} )}
<Menu <MenuItem className="justify-between" type={MenuItemType.IconButton} onClick={openHelp}>
isOpen={appState.accountMenu.show} <div className="flex items-center">
a11yLabel="General account menu" <Icon type="help" className={iconClassName} />
closeMenu={closeMenu} Help &amp; feedback
initialFocus={!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX} </div>
> <span className="color-neutral">v{application.version}</span>
<MenuItemSeparator /> </MenuItem>
<WorkspaceSwitcherOption mainApplicationGroup={mainApplicationGroup} appState={appState} /> {user ? (
<MenuItemSeparator /> <>
{user ? ( <MenuItemSeparator />
<MenuItem type={MenuItemType.IconButton} onClick={openPreferences}> <MenuItem type={MenuItemType.IconButton} onClick={signOut}>
<Icon type="user" className={iconClassName} /> <Icon type="signOut" className={iconClassName} />
Account settings Sign out workspace
</MenuItem> </MenuItem>
) : ( </>
<> ) : null}
<MenuItem type={MenuItemType.IconButton} onClick={activateRegisterPane}> </Menu>
<Icon type="user" className={iconClassName} /> </>
Create free account )
</MenuItem> }
<MenuItem type={MenuItemType.IconButton} onClick={activateSignInPane}>
<Icon type="signIn" className={iconClassName} /> export default observer(GeneralAccountMenu)
Sign in
</MenuItem>
</>
)}
<MenuItem className="justify-between" type={MenuItemType.IconButton} onClick={openHelp}>
<div className="flex items-center">
<Icon type="help" className={iconClassName} />
Help &amp; feedback
</div>
<span className="color-neutral">v{appState.version}</span>
</MenuItem>
{user ? (
<>
<MenuItemSeparator />
<MenuItem type={MenuItemType.IconButton} onClick={signOut}>
<Icon type="signOut" className={iconClassName} />
Sign out workspace
</MenuItem>
</>
) : null}
</Menu>
</>
)
},
)

View File

@@ -0,0 +1,72 @@
import { WebApplication } from '@/Application/Application'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useState } from 'react'
import { AccountMenuPane } from './AccountMenuPane'
import ConfirmPassword from './ConfirmPassword'
import CreateAccount from './CreateAccount'
import GeneralAccountMenu from './GeneralAccountMenu'
import SignInPane from './SignIn'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
mainApplicationGroup: ApplicationGroup
menuPane: AccountMenuPane
setMenuPane: (pane: AccountMenuPane) => void
closeMenu: () => void
}
const MenuPaneSelector: FunctionComponent<Props> = ({
application,
viewControllerManager,
menuPane,
setMenuPane,
closeMenu,
mainApplicationGroup,
}) => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
switch (menuPane) {
case AccountMenuPane.GeneralMenu:
return (
<GeneralAccountMenu
viewControllerManager={viewControllerManager}
application={application}
mainApplicationGroup={mainApplicationGroup}
setMenuPane={setMenuPane}
closeMenu={closeMenu}
/>
)
case AccountMenuPane.SignIn:
return (
<SignInPane viewControllerManager={viewControllerManager} application={application} setMenuPane={setMenuPane} />
)
case AccountMenuPane.Register:
return (
<CreateAccount
viewControllerManager={viewControllerManager}
application={application}
setMenuPane={setMenuPane}
email={email}
setEmail={setEmail}
password={password}
setPassword={setPassword}
/>
)
case AccountMenuPane.ConfirmPassword:
return (
<ConfirmPassword
viewControllerManager={viewControllerManager}
application={application}
setMenuPane={setMenuPane}
email={email}
password={password}
/>
)
}
}
export default observer(MenuPaneSelector)

View File

@@ -1,26 +1,25 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { isDev } from '@/Utils' import { isDev } from '@/Utils'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import React, { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import { AccountMenuPane } from './AccountMenuPane'
import { AccountMenuPane } from '.' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import Checkbox from '@/Components/Checkbox/Checkbox'
import { Checkbox } from '@/Components/Checkbox' import DecoratedInput from '@/Components/Input/DecoratedInput'
import { DecoratedInput } from '@/Components/Input/DecoratedInput' import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import IconButton from '@/Components/Button/IconButton'
import { IconButton } from '@/Components/Button/IconButton' import AdvancedOptions from './AdvancedOptions'
import { AdvancedOptions } from './AdvancedOptions'
type Props = { type Props = {
appState: AppState viewControllerManager: ViewControllerManager
application: WebApplication application: WebApplication
setMenuPane: (pane: AccountMenuPane) => void setMenuPane: (pane: AccountMenuPane) => void
} }
export const SignInPane: FunctionComponent<Props> = observer(({ application, appState, setMenuPane }) => { const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManager, setMenuPane }) => {
const { notesAndTagsCount } = appState.accountMenu const { notesAndTagsCount } = viewControllerManager.accountMenuController
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -87,7 +86,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
if (res.error) { if (res.error) {
throw new Error(res.error.message) throw new Error(res.error.message)
} }
appState.accountMenu.closeAccountMenu() viewControllerManager.accountMenuController.closeAccountMenu()
}) })
.catch((err) => { .catch((err) => {
console.error(err) console.error(err)
@@ -98,7 +97,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
.finally(() => { .finally(() => {
setIsSigningIn(false) setIsSigningIn(false)
}) })
}, [appState, application, email, isEphemeral, isStrictSignin, password, shouldMergeLocal]) }, [viewControllerManager, application, email, isEphemeral, isStrictSignin, password, shouldMergeLocal])
const onPrivateWorkspaceChange = useCallback( const onPrivateWorkspaceChange = useCallback(
(newIsPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => { (newIsPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
@@ -111,7 +110,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
) )
const handleSignInFormSubmit = useCallback( const handleSignInFormSubmit = useCallback(
(e: Event) => { (e: React.SyntheticEvent) => {
e.preventDefault() e.preventDefault()
if (!email || email.length === 0) { if (!email || email.length === 0) {
@@ -129,8 +128,8 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
[email, password, signIn], [email, password, signIn],
) )
const handleKeyDown = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
(e: KeyboardEvent) => { (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleSignInFormSubmit(e) handleSignInFormSubmit(e)
} }
@@ -153,7 +152,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
</div> </div>
<div className="px-3 mb-1"> <div className="px-3 mb-1">
<DecoratedInput <DecoratedInput
className={`mb-2 ${error ? 'border-dark-red' : null}`} className={`mb-2 ${error ? 'border-danger' : null}`}
left={[<Icon type="email" className="color-neutral" />]} left={[<Icon type="email" className="color-neutral" />]}
type="email" type="email"
placeholder="Email" placeholder="Email"
@@ -165,7 +164,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
ref={emailInputRef} ref={emailInputRef}
/> />
<DecoratedPasswordInput <DecoratedPasswordInput
className={`mb-2 ${error ? 'border-dark-red' : null}`} className={`mb-2 ${error ? 'border-danger' : null}`}
disabled={isSigningIn} disabled={isSigningIn}
left={[<Icon type="password" className="color-neutral" />]} left={[<Icon type="password" className="color-neutral" />]}
onChange={handlePasswordChange} onChange={handlePasswordChange}
@@ -175,7 +174,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
ref={passwordInputRef} ref={passwordInputRef}
value={password} value={password}
/> />
{error ? <div className="color-dark-red my-2">{error}</div> : null} {error ? <div className="color-danger my-2">{error}</div> : null}
<Button <Button
className="btn-w-full mt-1 mb-3" className="btn-w-full mt-1 mb-3"
label={isSigningIn ? 'Signing in...' : 'Sign in'} label={isSigningIn ? 'Signing in...' : 'Sign in'}
@@ -202,7 +201,7 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
</div> </div>
<div className="h-1px my-2 bg-border"></div> <div className="h-1px my-2 bg-border"></div>
<AdvancedOptions <AdvancedOptions
appState={appState} viewControllerManager={viewControllerManager}
application={application} application={application}
disabled={isSigningIn} disabled={isSigningIn}
onPrivateWorkspaceChange={onPrivateWorkspaceChange} onPrivateWorkspaceChange={onPrivateWorkspaceChange}
@@ -210,4 +209,6 @@ export const SignInPane: FunctionComponent<Props> = observer(({ application, app
/> />
</> </>
) )
}) }
export default observer(SignInPane)

View File

@@ -1,24 +1,25 @@
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { User as UserType } from '@standardnotes/snjs' import { User as UserType } from '@standardnotes/snjs'
type Props = { type Props = {
appState: AppState viewControllerManager: ViewControllerManager
application: WebApplication application: WebApplication
} }
const User = observer(({ appState, application }: Props) => { const User = ({ viewControllerManager, application }: Props) => {
const { server } = appState.accountMenu const { server } = viewControllerManager.accountMenuController
const user = application.getUser() as UserType const user = application.getUser() as UserType
return ( return (
<div className="sk-panel-section"> <div className="sk-panel-section">
{appState.sync.errorMessage && ( {viewControllerManager.syncStatusController.errorMessage && (
<div className="sk-notification danger"> <div className="sk-notification danger">
<div className="sk-notification-title">Sync Unreachable</div> <div className="sk-notification-title">Sync Unreachable</div>
<div className="sk-notification-text"> <div className="sk-notification-text">
Hmm...we can't seem to sync your account. The reason: {appState.sync.errorMessage} Hmm...we can't seem to sync your account. The reason:{' '}
{viewControllerManager.syncStatusController.errorMessage}
</div> </div>
<a <a
className="sk-a info-contrast sk-bold sk-panel-row" className="sk-a info-contrast sk-bold sk-panel-row"
@@ -39,6 +40,6 @@ const User = observer(({ appState, application }: Props) => {
<div className="sk-panel-row" /> <div className="sk-panel-row" />
</div> </div>
) )
}) }
export default User export default observer(User)

View File

@@ -1,9 +1,18 @@
import { Icon } from '@/Components/Icon' import Icon from '@/Components/Icon/Icon'
import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem' import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItemType } from '@/Components/Menu/MenuItemType'
import { KeyboardKey } from '@/Services/IOService' import { KeyboardKey } from '@/Services/IOService'
import { ApplicationDescriptor } from '@standardnotes/snjs' import { ApplicationDescriptor } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import {
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' ChangeEventHandler,
FocusEventHandler,
FunctionComponent,
KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type Props = { type Props = {
descriptor: ApplicationDescriptor descriptor: ApplicationDescriptor
@@ -13,7 +22,7 @@ type Props = {
hideOptions: boolean hideOptions: boolean
} }
export const WorkspaceMenuItem: FunctionComponent<Props> = ({ const WorkspaceMenuItem: FunctionComponent<Props> = ({
descriptor, descriptor,
onClick, onClick,
onDelete, onDelete,
@@ -21,6 +30,7 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
hideOptions, hideOptions,
}) => { }) => {
const [isRenaming, setIsRenaming] = useState(false) const [isRenaming, setIsRenaming] = useState(false)
const [inputValue, setInputValue] = useState(descriptor.label)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
@@ -29,20 +39,21 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
} }
}, [isRenaming]) }, [isRenaming])
const handleInputKeyDown = useCallback((event: KeyboardEvent) => { const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback((event) => {
setInputValue(event.target.value)
}, [])
const handleInputKeyDown: KeyboardEventHandler = useCallback((event) => {
if (event.key === KeyboardKey.Enter) { if (event.key === KeyboardKey.Enter) {
inputRef.current?.blur() inputRef.current?.blur()
} }
}, []) }, [])
const handleInputBlur = useCallback( const handleInputBlur: FocusEventHandler<HTMLInputElement> = useCallback(() => {
(event: FocusEvent) => { renameDescriptor(inputValue)
const name = (event.target as HTMLInputElement).value setIsRenaming(false)
renameDescriptor(name) setInputValue('')
setIsRenaming(false) }, [inputValue, renameDescriptor])
},
[renameDescriptor],
)
return ( return (
<MenuItem <MenuItem
@@ -51,12 +62,13 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
onClick={onClick} onClick={onClick}
checked={descriptor.primary} checked={descriptor.primary}
> >
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full ml-2">
{isRenaming ? ( {isRenaming ? (
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={descriptor.label} value={inputValue}
onChange={handleChange}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur} onBlur={handleInputBlur}
/> />
@@ -65,7 +77,8 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
)} )}
{descriptor.primary && !hideOptions && ( {descriptor.primary && !hideOptions && (
<div> <div>
<button <a
role="button"
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer" className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -73,8 +86,9 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
}} }}
> >
<Icon type="pencil" className="sn-icon--mid color-neutral" /> <Icon type="pencil" className="sn-icon--mid color-neutral" />
</button> </a>
<button <a
role="button"
className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer" className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -82,10 +96,12 @@ export const WorkspaceMenuItem: FunctionComponent<Props> = ({
}} }}
> >
<Icon type="trash" className="sn-icon--mid color-danger" /> <Icon type="trash" className="sn-icon--mid color-danger" />
</button> </a>
</div> </div>
)} )}
</div> </div>
</MenuItem> </MenuItem>
) )
} }
export default WorkspaceMenuItem

View File

@@ -1,89 +1,95 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { ApplicationDescriptor, ApplicationGroupEvent, ButtonType } from '@standardnotes/snjs' import { ApplicationDescriptor, ApplicationGroupEvent, ButtonType } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import Menu from '@/Components/Menu/Menu'
import { Menu } from '@/Components/Menu/Menu' import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem' import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
import { WorkspaceMenuItem } from './WorkspaceMenuItem' import { MenuItemType } from '@/Components/Menu/MenuItemType'
import WorkspaceMenuItem from './WorkspaceMenuItem'
type Props = { type Props = {
mainApplicationGroup: ApplicationGroup mainApplicationGroup: ApplicationGroup
appState: AppState viewControllerManager: ViewControllerManager
isOpen: boolean isOpen: boolean
hideWorkspaceOptions?: boolean hideWorkspaceOptions?: boolean
} }
export const WorkspaceSwitcherMenu: FunctionComponent<Props> = observer( const WorkspaceSwitcherMenu: FunctionComponent<Props> = ({
({ mainApplicationGroup, appState, isOpen, hideWorkspaceOptions = false }: Props) => { mainApplicationGroup,
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>([]) viewControllerManager,
isOpen,
hideWorkspaceOptions = false,
}: Props) => {
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>([])
useEffect(() => { useEffect(() => {
const applicationDescriptors = mainApplicationGroup.getDescriptors() const applicationDescriptors = mainApplicationGroup.getDescriptors()
setApplicationDescriptors(applicationDescriptors) setApplicationDescriptors(applicationDescriptors)
const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => { const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => {
if (event === ApplicationGroupEvent.DescriptorsDataChanged) { if (event === ApplicationGroupEvent.DescriptorsDataChanged) {
const applicationDescriptors = mainApplicationGroup.getDescriptors() const applicationDescriptors = mainApplicationGroup.getDescriptors()
setApplicationDescriptors(applicationDescriptors) setApplicationDescriptors(applicationDescriptors)
}
})
return () => {
removeAppGroupObserver()
} }
}, [mainApplicationGroup]) })
const signoutAll = useCallback(async () => { return () => {
const confirmed = await appState.application.alertService.confirm( removeAppGroupObserver()
'Are you sure you want to sign out of all workspaces on this device?', }
undefined, }, [mainApplicationGroup])
'Sign out all',
ButtonType.Danger,
)
if (!confirmed) {
return
}
mainApplicationGroup.signOutAllWorkspaces().catch(console.error)
}, [mainApplicationGroup, appState])
const destroyWorkspace = useCallback(() => { const signoutAll = useCallback(async () => {
appState.accountMenu.setSigningOut(true) const confirmed = await viewControllerManager.application.alertService.confirm(
}, [appState]) 'Are you sure you want to sign out of all workspaces on this device?',
undefined,
return ( 'Sign out all',
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}> ButtonType.Danger,
{applicationDescriptors.map((descriptor) => (
<WorkspaceMenuItem
key={descriptor.identifier}
descriptor={descriptor}
hideOptions={hideWorkspaceOptions}
onDelete={destroyWorkspace}
onClick={() => void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)}
renameDescriptor={(label: string) => mainApplicationGroup.renameDescriptor(descriptor, label)}
/>
))}
<MenuItemSeparator />
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
}}
>
<Icon type="user-add" className="color-neutral mr-2" />
Add another workspace
</MenuItem>
{!hideWorkspaceOptions && (
<MenuItem type={MenuItemType.IconButton} onClick={signoutAll}>
<Icon type="signOut" className="color-neutral mr-2" />
Sign out all workspaces
</MenuItem>
)}
</Menu>
) )
}, if (!confirmed) {
) return
}
mainApplicationGroup.signOutAllWorkspaces().catch(console.error)
}, [mainApplicationGroup, viewControllerManager])
const destroyWorkspace = useCallback(() => {
viewControllerManager.accountMenuController.setSigningOut(true)
}, [viewControllerManager])
return (
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}>
{applicationDescriptors.map((descriptor) => (
<WorkspaceMenuItem
key={descriptor.identifier}
descriptor={descriptor}
hideOptions={hideWorkspaceOptions}
onDelete={destroyWorkspace}
onClick={() => void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)}
renameDescriptor={(label: string) => mainApplicationGroup.renameDescriptor(descriptor, label)}
/>
))}
<MenuItemSeparator />
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
}}
>
<Icon type="user-add" className="color-neutral mr-2" />
Add another workspace
</MenuItem>
{!hideWorkspaceOptions && (
<MenuItem type={MenuItemType.IconButton} onClick={signoutAll}>
<Icon type="signOut" className="color-neutral mr-2" />
Sign out all workspaces
</MenuItem>
)}
</Menu>
)
}
export default observer(WorkspaceSwitcherMenu)

View File

@@ -1,19 +1,18 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import WorkspaceSwitcherMenu from './WorkspaceSwitcherMenu'
import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu'
type Props = { type Props = {
mainApplicationGroup: ApplicationGroup mainApplicationGroup: ApplicationGroup
appState: AppState viewControllerManager: ViewControllerManager
} }
export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(({ mainApplicationGroup, appState }) => { const WorkspaceSwitcherOption: FunctionComponent<Props> = ({ mainApplicationGroup, viewControllerManager }) => {
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@@ -59,9 +58,15 @@ export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(({ mai
</button> </button>
{isOpen && ( {isOpen && (
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}> <div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
<WorkspaceSwitcherMenu mainApplicationGroup={mainApplicationGroup} appState={appState} isOpen={isOpen} /> <WorkspaceSwitcherMenu
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
isOpen={isOpen}
/>
</div> </div>
)} )}
</> </>
) )
}) }
export default observer(WorkspaceSwitcherOption)

View File

@@ -1,138 +0,0 @@
import { observer } from 'mobx-react-lite'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { AppState } from '@/UIModels/AppState'
import { WebApplication } from '@/UIModels/Application'
import { useCallback, useRef, useState } from 'preact/hooks'
import { GeneralAccountMenu } from './GeneralAccountMenu'
import { FunctionComponent } from 'preact'
import { SignInPane } from './SignIn'
import { CreateAccount } from './CreateAccount'
import { ConfirmPassword } from './ConfirmPassword'
import { JSXInternal } from 'preact/src/jsx'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup'
export enum AccountMenuPane {
GeneralMenu,
SignIn,
Register,
ConfirmPassword,
}
type Props = {
appState: AppState
application: WebApplication
onClickOutside: () => void
mainApplicationGroup: ApplicationGroup
}
type PaneSelectorProps = {
appState: AppState
application: WebApplication
mainApplicationGroup: ApplicationGroup
menuPane: AccountMenuPane
setMenuPane: (pane: AccountMenuPane) => void
closeMenu: () => void
}
const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
({ application, appState, menuPane, setMenuPane, closeMenu, mainApplicationGroup }) => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
switch (menuPane) {
case AccountMenuPane.GeneralMenu:
return (
<GeneralAccountMenu
appState={appState}
application={application}
mainApplicationGroup={mainApplicationGroup}
setMenuPane={setMenuPane}
closeMenu={closeMenu}
/>
)
case AccountMenuPane.SignIn:
return <SignInPane appState={appState} application={application} setMenuPane={setMenuPane} />
case AccountMenuPane.Register:
return (
<CreateAccount
appState={appState}
application={application}
setMenuPane={setMenuPane}
email={email}
setEmail={setEmail}
password={password}
setPassword={setPassword}
/>
)
case AccountMenuPane.ConfirmPassword:
return (
<ConfirmPassword
appState={appState}
application={application}
setMenuPane={setMenuPane}
email={email}
password={password}
/>
)
}
},
)
export const AccountMenu: FunctionComponent<Props> = observer(
({ application, appState, onClickOutside, mainApplicationGroup }) => {
const { currentPane, shouldAnimateCloseMenu } = appState.accountMenu
const closeAccountMenu = useCallback(() => {
appState.accountMenu.closeAccountMenu()
}, [appState])
const setCurrentPane = useCallback(
(pane: AccountMenuPane) => {
appState.accountMenu.setCurrentPane(pane)
},
[appState],
)
const ref = useRef<HTMLDivElement>(null)
useCloseOnClickOutside(ref, () => {
onClickOutside()
})
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = useCallback(
(event) => {
switch (event.key) {
case 'Escape':
if (currentPane === AccountMenuPane.GeneralMenu) {
closeAccountMenu()
} else if (currentPane === AccountMenuPane.ConfirmPassword) {
setCurrentPane(AccountMenuPane.Register)
} else {
setCurrentPane(AccountMenuPane.GeneralMenu)
}
break
}
},
[closeAccountMenu, currentPane, setCurrentPane],
)
return (
<div ref={ref} id="account-menu" className="sn-component">
<div
className={`sn-menu-border sn-account-menu sn-dropdown ${
shouldAnimateCloseMenu ? 'slide-up-animation' : 'sn-dropdown--animated'
} min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`}
onKeyDown={handleKeyDown}
>
<MenuPaneSelector
appState={appState}
application={application}
mainApplicationGroup={mainApplicationGroup}
menuPane={currentPane}
setMenuPane={setCurrentPane}
closeMenu={closeAccountMenu}
/>
</div>
</div>
)
},
)

View File

@@ -1,12 +1,12 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { Component } from 'preact' import { Component } from 'react'
import { ApplicationView } from '@/Components/ApplicationView' import ApplicationView from '@/Components/ApplicationView/ApplicationView'
import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice' import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice'
import { ApplicationGroupEvent, Runtime, ApplicationGroupEventData, DeinitSource } from '@standardnotes/snjs' import { ApplicationGroupEvent, ApplicationGroupEventData, DeinitSource } from '@standardnotes/snjs'
import { unmountComponentAtNode, findDOMNode } from 'preact/compat'
import { DialogContent, DialogOverlay } from '@reach/dialog' import { DialogContent, DialogOverlay } from '@reach/dialog'
import { isDesktopApplication } from '@/Utils' import { isDesktopApplication } from '@/Utils'
import DeallocateHandler from '../DeallocateHandler/DeallocateHandler'
type Props = { type Props = {
server: string server: string
@@ -23,7 +23,7 @@ type State = {
deviceDestroyed?: boolean deviceDestroyed?: boolean
} }
export class ApplicationGroupView extends Component<Props, State> { class ApplicationGroupView extends Component<Props, State> {
applicationObserverRemover?: () => void applicationObserverRemover?: () => void
private group?: ApplicationGroup private group?: ApplicationGroup
private application?: WebApplication private application?: WebApplication
@@ -39,12 +39,7 @@ export class ApplicationGroupView extends Component<Props, State> {
return return
} }
this.group = new ApplicationGroup( this.group = new ApplicationGroup(props.server, props.device, props.websocketUrl)
props.server,
props.device,
props.enableUnfinished ? Runtime.Dev : Runtime.Prod,
props.websocketUrl,
)
window.mainApplicationGroup = this.group window.mainApplicationGroup = this.group
@@ -79,17 +74,15 @@ export class ApplicationGroupView extends Component<Props, State> {
const onDestroy = this.props.onDestroy const onDestroy = this.props.onDestroy
const node = findDOMNode(this) as Element
unmountComponentAtNode(node)
onDestroy() onDestroy()
} }
render() { override render() {
const renderDialog = (message: string) => { const renderDialog = (message: string) => {
return ( return (
<DialogOverlay className={'sn-component challenge-modal-overlay'}> <DialogOverlay className={'sn-component challenge-modal-overlay'}>
<DialogContent <DialogContent
aria-label="Switching workspace"
className={ className={
'challenge-modal flex flex-col items-center bg-default p-8 rounded relative shadow-overlay-light border-1 border-solid border-main' 'challenge-modal flex flex-col items-center bg-default p-8 rounded relative shadow-overlay-light border-1 border-solid border-main'
} }
@@ -121,12 +114,16 @@ export class ApplicationGroupView extends Component<Props, State> {
return ( return (
<div id={this.state.activeApplication.identifier} key={this.state.activeApplication.ephemeralIdentifier}> <div id={this.state.activeApplication.identifier} key={this.state.activeApplication.ephemeralIdentifier}>
<ApplicationView <DeallocateHandler application={this.state.activeApplication}>
key={this.state.activeApplication.ephemeralIdentifier} <ApplicationView
mainApplicationGroup={this.group} key={this.state.activeApplication.ephemeralIdentifier}
application={this.state.activeApplication} mainApplicationGroup={this.group}
/> application={this.state.activeApplication}
/>
</DeallocateHandler>
</div> </div>
) )
} }
} }
export default ApplicationGroupView

View File

@@ -1,55 +1,45 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { getPlatformString, getWindowUrlParams } from '@/Utils' import { getPlatformString, getWindowUrlParams } from '@/Utils'
import { AppStateEvent, PanelResizedData } from '@/UIModels/AppState' import { ApplicationEvent, Challenge, removeFromArray } from '@standardnotes/snjs'
import { ApplicationEvent, Challenge, PermissionDialog, removeFromArray } from '@standardnotes/snjs' import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants/Constants'
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants'
import { alertDialog } from '@/Services/AlertService' import { alertDialog } from '@/Services/AlertService'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { Navigation } from '@/Components/Navigation' import { WebAppEvent } from '@/Application/WebAppEvent'
import { NotesView } from '@/Components/NotesView' import Navigation from '@/Components/Navigation/Navigation'
import { NoteGroupView } from '@/Components/NoteGroupView' import NoteGroupView from '@/Components/NoteGroupView/NoteGroupView'
import { Footer } from '@/Components/Footer' import Footer from '@/Components/Footer/Footer'
import { SessionsModal } from '@/Components/SessionsModal' import SessionsModal from '@/Components/SessionsModal/SessionsModal'
import { PreferencesViewWrapper } from '@/Components/Preferences/PreferencesViewWrapper' import PreferencesViewWrapper from '@/Components/Preferences/PreferencesViewWrapper'
import { ChallengeModal } from '@/Components/ChallengeModal/ChallengeModal' import ChallengeModal from '@/Components/ChallengeModal/ChallengeModal'
import { NotesContextMenu } from '@/Components/NotesContextMenu' import NotesContextMenu from '@/Components/NotesContextMenu/NotesContextMenu'
import { PurchaseFlowWrapper } from '@/Components/PurchaseFlow/PurchaseFlowWrapper' import PurchaseFlowWrapper from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
import { render, FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import { PermissionsModal } from '@/Components/PermissionsModal' import RevisionHistoryModalWrapper from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper'
import { RevisionHistoryModalWrapper } from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper' import PremiumModalProvider from '@/Hooks/usePremiumModal'
import { PremiumModalProvider } from '@/Hooks/usePremiumModal' import ConfirmSignoutContainer from '@/Components/ConfirmSignoutModal/ConfirmSignoutModal'
import { ConfirmSignoutContainer } from '@/Components/ConfirmSignoutModal' import TagsContextMenuWrapper from '@/Components/Tags/TagContextMenu'
import { TagsContextMenu } from '@/Components/Tags/TagContextMenu'
import { ToastContainer } from '@standardnotes/stylekit' import { ToastContainer } from '@standardnotes/stylekit'
import { FilePreviewModal } from '../Files/FilePreviewModal' import FilePreviewModalWrapper from '@/Components/Files/FilePreviewModal'
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks' import ContentListView from '@/Components/ContentListView/ContentListView'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState' import FileContextMenuWrapper from '@/Components/FileContextMenu/FileContextMenu'
import PermissionsModalWrapper from '@/Components/PermissionsModal/PermissionsModalWrapper'
import { PanelResizedData } from '@/Types/PanelResizedData'
type Props = { type Props = {
application: WebApplication application: WebApplication
mainApplicationGroup: ApplicationGroup mainApplicationGroup: ApplicationGroup
} }
export const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => { const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => {
const platformString = getPlatformString() const platformString = getPlatformString()
const [appClass, setAppClass] = useState('') const [appClass, setAppClass] = useState('')
const [launched, setLaunched] = useState(false) const [launched, setLaunched] = useState(false)
const [needsUnlock, setNeedsUnlock] = useState(true) const [needsUnlock, setNeedsUnlock] = useState(true)
const [challenges, setChallenges] = useState<Challenge[]>([]) const [challenges, setChallenges] = useState<Challenge[]>([])
const [dealloced, setDealloced] = useState(false)
const componentManager = application.componentManager const viewControllerManager = application.getViewControllerManager()
const appState = application.getAppState()
useEffect(() => { useEffect(() => {
setDealloced(application.dealloced)
}, [application.dealloced])
useEffect(() => {
if (dealloced) {
return
}
const desktopService = application.getDesktopService() const desktopService = application.getDesktopService()
if (desktopService) { if (desktopService) {
@@ -69,7 +59,7 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
}) })
.catch(console.error) .catch(console.error)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [application, dealloced]) }, [application])
const removeChallenge = useCallback( const removeChallenge = useCallback(
(challenge: Challenge) => { (challenge: Challenge) => {
@@ -80,29 +70,9 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
[challenges], [challenges],
) )
const presentPermissionsDialog = useCallback(
(dialog: PermissionDialog) => {
render(
<PermissionsModal
application={application}
callback={dialog.callback}
component={dialog.component}
permissionsString={dialog.permissionsString}
/>,
document.body.appendChild(document.createElement('div')),
)
},
[application],
)
const onAppStart = useCallback(() => { const onAppStart = useCallback(() => {
setNeedsUnlock(application.hasPasscode()) setNeedsUnlock(application.hasPasscode())
componentManager.presentPermissionsDialog = presentPermissionsDialog }, [application])
return () => {
;(componentManager.presentPermissionsDialog as unknown) = undefined
}
}, [application, componentManager, presentPermissionsDialog])
const handleDemoSignInFromParams = useCallback(() => { const handleDemoSignInFromParams = useCallback(() => {
const token = getWindowUrlParams().get('demo-token') const token = getWindowUrlParams().get('demo-token')
@@ -150,8 +120,8 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
}, [application, onAppLaunch, onAppStart]) }, [application, onAppLaunch, onAppStart])
useEffect(() => { useEffect(() => {
const removeObserver = application.getAppState().addObserver(async (eventName, data) => { const removeObserver = application.addWebEventObserver(async (eventName, data) => {
if (eventName === AppStateEvent.PanelResized) { if (eventName === WebAppEvent.PanelResized) {
const { panel, collapsed } = data as PanelResizedData const { panel, collapsed } = data as PanelResizedData
let appClass = '' let appClass = ''
if (panel === PANEL_NAME_NOTES && collapsed) { if (panel === PANEL_NAME_NOTES && collapsed) {
@@ -161,7 +131,7 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
appClass += ' collapsed-navigation' appClass += ' collapsed-navigation'
} }
setAppClass(appClass) setAppClass(appClass)
} else if (eventName === AppStateEvent.WindowDidFocus) { } else if (eventName === WebAppEvent.WindowDidFocus) {
if (!(await application.isLocked())) { if (!(await application.isLocked())) {
application.sync.sync().catch(console.error) application.sync.sync().catch(console.error)
} }
@@ -182,11 +152,11 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
<> <>
{challenges.map((challenge) => { {challenges.map((challenge) => {
return ( return (
<div className="sk-modal"> <div className="sk-modal" key={`${challenge.id}${application.ephemeralIdentifier}`}>
<ChallengeModal <ChallengeModal
key={`${challenge.id}${application.ephemeralIdentifier}`} key={`${challenge.id}${application.ephemeralIdentifier}`}
application={application} application={application}
appState={appState} viewControllerManager={viewControllerManager}
mainApplicationGroup={mainApplicationGroup} mainApplicationGroup={mainApplicationGroup}
challenge={challenge} challenge={challenge}
onDismiss={removeChallenge} onDismiss={removeChallenge}
@@ -196,47 +166,47 @@ export const ApplicationView: FunctionComponent<Props> = ({ application, mainApp
})} })}
</> </>
) )
}, [appState, challenges, mainApplicationGroup, removeChallenge, application]) }, [viewControllerManager, challenges, mainApplicationGroup, removeChallenge, application])
if (dealloced || isStateDealloced(appState)) {
return null
}
if (!renderAppContents) { if (!renderAppContents) {
return renderChallenges() return renderChallenges()
} }
return ( return (
<PremiumModalProvider application={application} appState={appState}> <PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
<div className={platformString + ' main-ui-view sn-component'}> <div className={platformString + ' main-ui-view sn-component'}>
<div id="app" className={appClass + ' app app-column-container'}> <div id="app" className={appClass + ' app app-column-container'}>
<Navigation application={application} /> <Navigation application={application} />
<NotesView application={application} appState={appState} /> <ContentListView application={application} viewControllerManager={viewControllerManager} />
<NoteGroupView application={application} /> <NoteGroupView application={application} />
</div> </div>
<> <>
<Footer application={application} applicationGroup={mainApplicationGroup} /> <Footer application={application} applicationGroup={mainApplicationGroup} />
<SessionsModal application={application} appState={appState} /> <SessionsModal application={application} viewControllerManager={viewControllerManager} />
<PreferencesViewWrapper appState={appState} application={application} /> <PreferencesViewWrapper viewControllerManager={viewControllerManager} application={application} />
<RevisionHistoryModalWrapper application={application} appState={appState} /> <RevisionHistoryModalWrapper application={application} viewControllerManager={viewControllerManager} />
</> </>
{renderChallenges()} {renderChallenges()}
<> <>
<NotesContextMenu application={application} appState={appState} /> <NotesContextMenu application={application} viewControllerManager={viewControllerManager} />
<TagsContextMenu appState={appState} /> <TagsContextMenuWrapper viewControllerManager={viewControllerManager} />
<PurchaseFlowWrapper application={application} appState={appState} /> <FileContextMenuWrapper viewControllerManager={viewControllerManager} />
<PurchaseFlowWrapper application={application} viewControllerManager={viewControllerManager} />
<ConfirmSignoutContainer <ConfirmSignoutContainer
applicationGroup={mainApplicationGroup} applicationGroup={mainApplicationGroup}
appState={appState} viewControllerManager={viewControllerManager}
application={application} application={application}
/> />
<ToastContainer /> <ToastContainer />
<FilePreviewModal application={application} appState={appState} /> <FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
<PermissionsModalWrapper application={application} />
</> </>
</div> </div>
</PremiumModalProvider> </PremiumModalProvider>
) )
} }
export default ApplicationView

View File

@@ -1,154 +1,135 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants' import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import VisuallyHidden from '@reach/visually-hidden' import VisuallyHidden from '@reach/visually-hidden'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { ChallengeReason, CollectionSort, ContentType, FileItem, SNNote } from '@standardnotes/snjs' import { ChallengeReason, ContentType, FileItem, SNNote } from '@standardnotes/snjs'
import { confirmDialog } from '@/Services/AlertService' import { confirmDialog } from '@/Services/AlertService'
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit' import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'
import { StreamingFileReader } from '@standardnotes/filepicker' import { StreamingFileReader } from '@standardnotes/filepicker'
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction' import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
import { AttachedFilesPopover } from './AttachedFilesPopover' import AttachedFilesPopover from './AttachedFilesPopover'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { PopoverTabs } from './PopoverTabs' import { PopoverTabs } from './PopoverTabs'
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck' import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState viewControllerManager: ViewControllerManager
onClickPreprocessing?: () => Promise<void> onClickPreprocessing?: () => Promise<void>
} }
export const AttachedFilesButton: FunctionComponent<Props> = observer( const AttachedFilesButton: FunctionComponent<Props> = ({
({ application, appState, onClickPreprocessing }: Props) => { application,
if (isStateDealloced(appState)) { viewControllerManager,
return null onClickPreprocessing,
}: Props) => {
const premiumModal = usePremiumModal()
const note: SNNote | undefined = viewControllerManager.notesController.firstSelectedNote
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({
top: 0,
right: 0,
})
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen)
useEffect(() => {
if (viewControllerManager.filePreviewModalController.isOpen) {
keepMenuOpen(true)
} else {
keepMenuOpen(false)
} }
}, [viewControllerManager.filePreviewModalController.isOpen, keepMenuOpen])
const premiumModal = usePremiumModal() const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles)
const note: SNNote | undefined = Object.values(appState.notes.selectedNotes)[0] const [allFiles, setAllFiles] = useState<FileItem[]>([])
const [attachedFiles, setAttachedFiles] = useState<FileItem[]>([])
const attachedFilesCount = attachedFiles.length
const [open, setOpen] = useState(false) useEffect(() => {
const [position, setPosition] = useState({ const unregisterFileStream = application.streamItems(ContentType.File, () => {
top: 0, setAllFiles(application.items.getDisplayableFiles())
right: 0, if (note) {
setAttachedFiles(application.items.getFilesForNote(note))
}
}) })
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen)
useEffect(() => { return () => {
if (appState.filePreviewModal.isOpen) { unregisterFileStream()
keepMenuOpen(true) }
} else { }, [application, note])
keepMenuOpen(false)
const toggleAttachedFilesMenu = useCallback(async () => {
const rect = buttonRef.current?.getBoundingClientRect()
if (rect) {
const { clientHeight } = document.documentElement
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
if (footerHeightInPx) {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
} }
}, [appState.filePreviewModal.isOpen, keepMenuOpen])
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles) setPosition({
const [allFiles, setAllFiles] = useState<FileItem[]>([]) top: rect.bottom,
const [attachedFiles, setAttachedFiles] = useState<FileItem[]>([]) right: document.body.clientWidth - rect.right,
const attachedFilesCount = attachedFiles.length
useEffect(() => {
application.items.setDisplayOptions(ContentType.File, CollectionSort.Title, 'dsc')
const unregisterFileStream = application.streamItems(ContentType.File, () => {
setAllFiles(application.items.getDisplayableItems<FileItem>(ContentType.File))
if (note) {
setAttachedFiles(application.items.getFilesForNote(note))
}
}) })
return () => { const newOpenState = !open
unregisterFileStream() if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
} }
}, [application, note])
const toggleAttachedFilesMenu = useCallback(async () => { setOpen(newOpenState)
const rect = buttonRef.current?.getBoundingClientRect() }
if (rect) { }, [onClickPreprocessing, open])
const { clientHeight } = document.documentElement
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
if (footerHeightInPx) { const prospectivelyShowFilesPremiumModal = useCallback(() => {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER) if (!viewControllerManager.featuresController.hasFiles) {
} premiumModal.activate('Files')
}
}, [viewControllerManager.featuresController.hasFiles, premiumModal])
setPosition({ const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => {
top: rect.bottom, prospectivelyShowFilesPremiumModal()
right: document.body.clientWidth - rect.right,
})
const newOpenState = !open await toggleAttachedFilesMenu()
if (newOpenState && onClickPreprocessing) { }, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal])
await onClickPreprocessing()
}
setOpen(newOpenState) const deleteFile = async (file: FileItem) => {
} const shouldDelete = await confirmDialog({
}, [onClickPreprocessing, open]) text: `Are you sure you want to permanently delete "${file.name}"?`,
confirmButtonStyle: 'danger',
const prospectivelyShowFilesPremiumModal = useCallback(() => { })
if (!appState.features.hasFiles) { if (shouldDelete) {
premiumModal.activate('Files') const deletingToastId = addToast({
} type: ToastType.Loading,
}, [appState.features.hasFiles, premiumModal]) message: `Deleting file "${file.name}"...`,
const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => {
prospectivelyShowFilesPremiumModal()
await toggleAttachedFilesMenu()
}, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal])
const deleteFile = async (file: FileItem) => {
const shouldDelete = await confirmDialog({
text: `Are you sure you want to permanently delete "${file.name}"?`,
confirmButtonStyle: 'danger',
}) })
if (shouldDelete) { await application.files.deleteFile(file)
const deletingToastId = addToast({ addToast({
type: ToastType.Loading, type: ToastType.Success,
message: `Deleting file "${file.name}"...`, message: `Deleted file "${file.name}"`,
}) })
await application.files.deleteFile(file) dismissToast(deletingToastId)
addToast({
type: ToastType.Success,
message: `Deleted file "${file.name}"`,
})
dismissToast(deletingToastId)
}
} }
}
const downloadFile = async (file: FileItem) => { const downloadFile = async (file: FileItem) => {
appState.files.downloadFile(file).catch(console.error) viewControllerManager.filesController.downloadFile(file).catch(console.error)
} }
const attachFileToNote = useCallback( const attachFileToNote = useCallback(
async (file: FileItem) => { async (file: FileItem) => {
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was deleted',
})
return
}
await application.items.associateFileWithNote(file, note)
},
[application.items, note],
)
const detachFileFromNote = async (file: FileItem) => {
if (!note) { if (!note) {
addToast({ addToast({
type: ToastType.Error, type: ToastType.Error,
@@ -156,268 +137,283 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
}) })
return return
} }
await application.items.disassociateFileWithNote(file, note)
await application.items.associateFileWithNote(file, note)
},
[application.items, note],
)
const detachFileFromNote = async (file: FileItem) => {
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was deleted',
})
return
}
await application.items.disassociateFileWithNote(file, note)
}
const toggleFileProtection = async (file: FileItem) => {
let result: FileItem | undefined
if (file.protected) {
keepMenuOpen(true)
result = await application.mutator.unprotectFile(file)
keepMenuOpen(false)
buttonRef.current?.focus()
} else {
result = await application.mutator.protectFile(file)
}
const isProtected = result ? result.protected : file.protected
return isProtected
}
const authorizeProtectedActionForFile = async (file: FileItem, challengeReason: ChallengeReason) => {
const authorizedFiles = await application.protections.authorizeProtectedActionForItems([file], challengeReason)
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file)
return isAuthorized
}
const renameFile = async (file: FileItem, fileName: string) => {
await application.items.renameFile(file, fileName)
}
const handleFileAction = async (action: PopoverFileItemAction) => {
const file = action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file
let isAuthorizedForAction = true
if (file.protected && action.type !== PopoverFileItemActionType.ToggleFileProtection) {
keepMenuOpen(true)
isAuthorizedForAction = await authorizeProtectedActionForFile(file, ChallengeReason.AccessProtectedFile)
keepMenuOpen(false)
buttonRef.current?.focus()
} }
const toggleFileProtection = async (file: FileItem) => { if (!isAuthorizedForAction) {
let result: FileItem | undefined return false
if (file.protected) { }
switch (action.type) {
case PopoverFileItemActionType.AttachFileToNote:
await attachFileToNote(file)
break
case PopoverFileItemActionType.DetachFileToNote:
await detachFileFromNote(file)
break
case PopoverFileItemActionType.DeleteFile:
await deleteFile(file)
break
case PopoverFileItemActionType.DownloadFile:
await downloadFile(file)
break
case PopoverFileItemActionType.ToggleFileProtection: {
const isProtected = await toggleFileProtection(file)
action.callback(isProtected)
break
}
case PopoverFileItemActionType.RenameFile:
await renameFile(file, action.payload.name)
break
case PopoverFileItemActionType.PreviewFile: {
keepMenuOpen(true) keepMenuOpen(true)
result = await application.mutator.unprotectFile(file) const otherFiles = currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles
keepMenuOpen(false) viewControllerManager.filePreviewModalController.activate(
buttonRef.current?.focus() file,
} else { otherFiles.filter((file) => !file.protected),
result = await application.mutator.protectFile(file) )
break
} }
const isProtected = result ? result.protected : file.protected
return isProtected
} }
const authorizeProtectedActionForFile = async (file: FileItem, challengeReason: ChallengeReason) => { if (
const authorizedFiles = await application.protections.authorizeProtectedActionForFiles([file], challengeReason) action.type !== PopoverFileItemActionType.DownloadFile &&
const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file) action.type !== PopoverFileItemActionType.PreviewFile
return isAuthorized ) {
application.sync.sync().catch(console.error)
} }
const renameFile = async (file: FileItem, fileName: string) => { return true
await application.items.renameFile(file, fileName) }
}
const handleFileAction = async (action: PopoverFileItemAction) => { const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const file = action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file const dragCounter = useRef(0)
let isAuthorizedForAction = true
if (file.protected && action.type !== PopoverFileItemActionType.ToggleFileProtection) {
keepMenuOpen(true)
isAuthorizedForAction = await authorizeProtectedActionForFile(file, ChallengeReason.AccessProtectedFile)
keepMenuOpen(false)
buttonRef.current?.focus()
}
if (!isAuthorizedForAction) {
return false
}
switch (action.type) {
case PopoverFileItemActionType.AttachFileToNote:
await attachFileToNote(file)
break
case PopoverFileItemActionType.DetachFileToNote:
await detachFileFromNote(file)
break
case PopoverFileItemActionType.DeleteFile:
await deleteFile(file)
break
case PopoverFileItemActionType.DownloadFile:
await downloadFile(file)
break
case PopoverFileItemActionType.ToggleFileProtection: {
const isProtected = await toggleFileProtection(file)
action.callback(isProtected)
break
}
case PopoverFileItemActionType.RenameFile:
await renameFile(file, action.payload.name)
break
case PopoverFileItemActionType.PreviewFile: {
keepMenuOpen(true)
const otherFiles = currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles
appState.filePreviewModal.activate(
file,
otherFiles.filter((file) => !file.protected),
)
break
}
}
if (
action.type !== PopoverFileItemActionType.DownloadFile &&
action.type !== PopoverFileItemActionType.PreviewFile
) {
application.sync.sync().catch(console.error)
}
return true
}
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const dragCounter = useRef(0)
const handleDrag = useCallback(
(event: DragEvent) => {
if (isHandlingFileDrag(event, application)) {
event.preventDefault()
event.stopPropagation()
}
},
[application],
)
const handleDragIn = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
const handleDrag = useCallback(
(event: DragEvent) => {
if (isHandlingFileDrag(event, application)) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
}
},
[application],
)
switch ((event.target as HTMLElement).id) { const handleDragIn = useCallback(
case PopoverTabs.AllFiles: (event: DragEvent) => {
setCurrentTab(PopoverTabs.AllFiles) if (!isHandlingFileDrag(event, application)) {
break return
case PopoverTabs.AttachedFiles: }
setCurrentTab(PopoverTabs.AttachedFiles)
break event.preventDefault()
event.stopPropagation()
switch ((event.target as HTMLElement).id) {
case PopoverTabs.AllFiles:
setCurrentTab(PopoverTabs.AllFiles)
break
case PopoverTabs.AttachedFiles:
setCurrentTab(PopoverTabs.AttachedFiles)
break
}
dragCounter.current = dragCounter.current + 1
if (event.dataTransfer?.items.length) {
setIsDraggingFiles(true)
if (!open) {
toggleAttachedFilesMenu().catch(console.error)
} }
}
},
[open, toggleAttachedFilesMenu, application],
)
dragCounter.current = dragCounter.current + 1 const handleDragOut = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
if (event.dataTransfer?.items.length) { event.preventDefault()
setIsDraggingFiles(true) event.stopPropagation()
if (!open) {
toggleAttachedFilesMenu().catch(console.error) dragCounter.current = dragCounter.current - 1
if (dragCounter.current > 0) {
return
}
setIsDraggingFiles(false)
},
[application],
)
const handleDrop = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
event.preventDefault()
event.stopPropagation()
setIsDraggingFiles(false)
if (!viewControllerManager.featuresController.hasFiles) {
prospectivelyShowFilesPremiumModal()
return
}
if (event.dataTransfer?.items.length) {
Array.from(event.dataTransfer.items).forEach(async (item) => {
const fileOrHandle = StreamingFileReader.available()
? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle)
: item.getAsFile()
if (!fileOrHandle) {
return
} }
}
},
[open, toggleAttachedFilesMenu, application],
)
const handleDragOut = useCallback( const uploadedFiles = await viewControllerManager.filesController.uploadNewFile(fileOrHandle)
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
event.preventDefault() if (!uploadedFiles) {
event.stopPropagation() return
}
dragCounter.current = dragCounter.current - 1 if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
attachFileToNote(file).catch(console.error)
})
}
})
if (dragCounter.current > 0) { event.dataTransfer.clearData()
return dragCounter.current = 0
}
setIsDraggingFiles(false)
},
[application],
)
const handleDrop = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
return
}
event.preventDefault()
event.stopPropagation()
setIsDraggingFiles(false)
if (!appState.features.hasFiles) {
prospectivelyShowFilesPremiumModal()
return
}
if (event.dataTransfer?.items.length) {
Array.from(event.dataTransfer.items).forEach(async (item) => {
const fileOrHandle = StreamingFileReader.available()
? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle)
: item.getAsFile()
if (!fileOrHandle) {
return
}
const uploadedFiles = await appState.files.uploadNewFile(fileOrHandle)
if (!uploadedFiles) {
return
}
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
attachFileToNote(file).catch(console.error)
})
}
})
event.dataTransfer.clearData()
dragCounter.current = 0
}
},
[
appState.files,
appState.features.hasFiles,
attachFileToNote,
currentTab,
application,
prospectivelyShowFilesPremiumModal,
],
)
useEffect(() => {
window.addEventListener('dragenter', handleDragIn)
window.addEventListener('dragleave', handleDragOut)
window.addEventListener('dragover', handleDrag)
window.addEventListener('drop', handleDrop)
return () => {
window.removeEventListener('dragenter', handleDragIn)
window.removeEventListener('dragleave', handleDragOut)
window.removeEventListener('dragover', handleDrag)
window.removeEventListener('drop', handleDrop)
} }
}, [handleDragIn, handleDrop, handleDrag, handleDragOut]) },
[
viewControllerManager.filesController,
viewControllerManager.featuresController.hasFiles,
attachFileToNote,
currentTab,
application,
prospectivelyShowFilesPremiumModal,
],
)
return ( useEffect(() => {
<div ref={containerRef}> window.addEventListener('dragenter', handleDragIn)
<Disclosure open={open} onChange={toggleAttachedFilesMenuWithEntitlementCheck}> window.addEventListener('dragleave', handleDragOut)
<DisclosureButton window.addEventListener('dragover', handleDrag)
onKeyDown={(event) => { window.addEventListener('drop', handleDrop)
if (event.key === 'Escape') {
setOpen(false) return () => {
} window.removeEventListener('dragenter', handleDragIn)
}} window.removeEventListener('dragleave', handleDragOut)
ref={buttonRef} window.removeEventListener('dragover', handleDrag)
className={`sn-icon-button border-contrast ${attachedFilesCount > 0 ? 'py-1 px-3' : ''}`} window.removeEventListener('drop', handleDrop)
onBlur={closeOnBlur} }
> }, [handleDragIn, handleDrop, handleDrag, handleDragOut])
<VisuallyHidden>Attached files</VisuallyHidden>
<Icon type="attachment-file" className="block" /> return (
{attachedFilesCount > 0 && <span className="ml-2">{attachedFilesCount}</span>} <div ref={containerRef}>
</DisclosureButton> <Disclosure open={open} onChange={toggleAttachedFilesMenuWithEntitlementCheck}>
<DisclosurePanel <DisclosureButton
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setOpen(false) setOpen(false)
buttonRef.current?.focus() }
} }}
}} ref={buttonRef}
ref={panelRef} className={`sn-icon-button border-contrast ${attachedFilesCount > 0 ? 'py-1 px-3' : ''}`}
style={{ onBlur={closeOnBlur}
...position, >
maxHeight, <VisuallyHidden>Attached files</VisuallyHidden>
}} <Icon type="attachment-file" className="block" />
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed" {attachedFilesCount > 0 && <span className="ml-2">{attachedFilesCount}</span>}
onBlur={closeOnBlur} </DisclosureButton>
> <DisclosurePanel
{open && ( onKeyDown={(event) => {
<AttachedFilesPopover if (event.key === 'Escape') {
application={application} setOpen(false)
appState={appState} buttonRef.current?.focus()
attachedFiles={attachedFiles} }
allFiles={allFiles} }}
closeOnBlur={closeOnBlur} ref={panelRef}
currentTab={currentTab} style={{
handleFileAction={handleFileAction} ...position,
isDraggingFiles={isDraggingFiles} maxHeight,
setCurrentTab={setCurrentTab} }}
/> className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
)} onBlur={closeOnBlur}
</DisclosurePanel> >
</Disclosure> {open && (
</div> <AttachedFilesPopover
) application={application}
}, viewControllerManager={viewControllerManager}
) attachedFiles={attachedFiles}
allFiles={allFiles}
closeOnBlur={closeOnBlur}
currentTab={currentTab}
handleFileAction={handleFileAction}
isDraggingFiles={isDraggingFiles}
setCurrentTab={setCurrentTab}
/>
)}
</DisclosurePanel>
</Disclosure>
</div>
)
}
export default observer(AttachedFilesButton)

View File

@@ -1,173 +1,172 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { FilesIllustration } from '@standardnotes/icons' import { FilesIllustration } from '@standardnotes/icons'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { Dispatch, FunctionComponent, SetStateAction, useRef, useState } from 'react'
import { StateUpdater, useRef, useState } from 'preact/hooks' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import PopoverFileItem from './PopoverFileItem'
import { PopoverFileItem } from './PopoverFileItem'
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction' import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction'
import { PopoverTabs } from './PopoverTabs' import { PopoverTabs } from './PopoverTabs'
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState viewControllerManager: ViewControllerManager
allFiles: FileItem[] allFiles: FileItem[]
attachedFiles: FileItem[] attachedFiles: FileItem[]
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
currentTab: PopoverTabs currentTab: PopoverTabs
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean> handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
isDraggingFiles: boolean isDraggingFiles: boolean
setCurrentTab: StateUpdater<PopoverTabs> setCurrentTab: Dispatch<SetStateAction<PopoverTabs>>
} }
export const AttachedFilesPopover: FunctionComponent<Props> = observer( const AttachedFilesPopover: FunctionComponent<Props> = ({
({ application,
application, viewControllerManager,
appState, allFiles,
allFiles, attachedFiles,
attachedFiles, closeOnBlur,
closeOnBlur, currentTab,
currentTab, handleFileAction,
handleFileAction, isDraggingFiles,
isDraggingFiles, setCurrentTab,
setCurrentTab, }) => {
}) => { const [searchQuery, setSearchQuery] = useState('')
const [searchQuery, setSearchQuery] = useState('') const searchInputRef = useRef<HTMLInputElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles
const filteredList = const filteredList =
searchQuery.length > 0 searchQuery.length > 0
? filesList.filter((file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1) ? filesList.filter((file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1)
: filesList : filesList
const handleAttachFilesClick = async () => { const handleAttachFilesClick = async () => {
const uploadedFiles = await appState.files.uploadNewFile() const uploadedFiles = await viewControllerManager.filesController.uploadNewFile()
if (!uploadedFiles) { if (!uploadedFiles) {
return return
}
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
payload: file,
}).catch(console.error)
})
}
} }
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
payload: file,
}).catch(console.error)
})
}
}
return ( return (
<div <div
className="flex flex-col" className="flex flex-col"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
style={{ style={{
border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '', border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '',
}} }}
> >
<div className="flex border-0 border-b-1 border-solid border-main"> <div className="flex border-0 border-b-1 border-solid border-main">
<button <button
id={PopoverTabs.AttachedFiles} id={PopoverTabs.AttachedFiles}
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${ className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
currentTab === PopoverTabs.AttachedFiles ? 'color-info font-medium shadow-bottom' : 'color-text' currentTab === PopoverTabs.AttachedFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
}`} }`}
onClick={() => { onClick={() => {
setCurrentTab(PopoverTabs.AttachedFiles) setCurrentTab(PopoverTabs.AttachedFiles)
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
Attached Attached
</button> </button>
<button <button
id={PopoverTabs.AllFiles} id={PopoverTabs.AllFiles}
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${ className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
currentTab === PopoverTabs.AllFiles ? 'color-info font-medium shadow-bottom' : 'color-text' currentTab === PopoverTabs.AllFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
}`} }`}
onClick={() => { onClick={() => {
setCurrentTab(PopoverTabs.AllFiles) setCurrentTab(PopoverTabs.AllFiles)
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
> >
All files All files
</button> </button>
</div> </div>
<div className="min-h-0 max-h-110 overflow-y-auto"> <div className="min-h-0 max-h-110 overflow-y-auto">
{filteredList.length > 0 || searchQuery.length > 0 ? ( {filteredList.length > 0 || searchQuery.length > 0 ? (
<div className="sticky top-0 left-0 p-3 bg-default border-0 border-b-1 border-solid border-main"> <div className="sticky top-0 left-0 p-3 bg-default border-0 border-b-1 border-solid border-main">
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
className="color-text w-full rounded py-1.5 px-3 text-input bg-default border-solid border-1 border-main" className="color-text w-full rounded py-1.5 px-3 text-input bg-default border-solid border-1 border-main"
placeholder="Search files..." placeholder="Search files..."
value={searchQuery} value={searchQuery}
onInput={(e) => { onInput={(e) => {
setSearchQuery((e.target as HTMLInputElement).value) setSearchQuery((e.target as HTMLInputElement).value)
}}
onBlur={closeOnBlur}
ref={searchInputRef}
/>
{searchQuery.length > 0 && (
<button
className="flex absolute right-2 p-0 bg-transparent border-0 top-1/2 -translate-y-1/2 cursor-pointer"
onClick={() => {
setSearchQuery('')
searchInputRef.current?.focus()
}} }}
onBlur={closeOnBlur} onBlur={closeOnBlur}
ref={searchInputRef} >
/> <Icon type="clear-circle-filled" className="color-neutral" />
{searchQuery.length > 0 && ( </button>
<button )}
className="flex absolute right-2 p-0 bg-transparent border-0 top-1/2 -translate-y-1/2 cursor-pointer"
onClick={() => {
setSearchQuery('')
searchInputRef.current?.focus()
}}
onBlur={closeOnBlur}
>
<Icon type="clear-circle-filled" className="color-neutral" />
</button>
)}
</div>
</div> </div>
) : null} </div>
{filteredList.length > 0 ? ( ) : null}
filteredList.map((file: FileItem) => { {filteredList.length > 0 ? (
return ( filteredList.map((file: FileItem) => {
<PopoverFileItem return (
key={file.uuid} <PopoverFileItem
file={file} key={file.uuid}
isAttachedToNote={attachedFiles.includes(file)} file={file}
handleFileAction={handleFileAction} isAttachedToNote={attachedFiles.includes(file)}
getIconType={application.iconsController.getIconForFileType} handleFileAction={handleFileAction}
closeOnBlur={closeOnBlur} getIconType={application.iconsController.getIconForFileType}
/> closeOnBlur={closeOnBlur}
) />
}) )
) : ( })
<div className="flex flex-col items-center justify-center w-full py-8"> ) : (
<div className="w-18 h-18 mb-2"> <div className="flex flex-col items-center justify-center w-full py-8">
<FilesIllustration /> <div className="w-18 h-18 mb-2">
</div> <FilesIllustration />
<div className="text-sm font-medium mb-3">
{searchQuery.length > 0
? 'No result found'
: currentTab === PopoverTabs.AttachedFiles
? 'No files attached to this note'
: 'No files found in this account'}
</div>
<Button variant="normal" onClick={handleAttachFilesClick} onBlur={closeOnBlur}>
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
</Button>
<div className="text-xs color-grey-0 mt-3">Or drop your files here</div>
</div> </div>
)} <div className="text-sm font-medium mb-3">
</div> {searchQuery.length > 0
{filteredList.length > 0 && ( ? 'No result found'
<button : currentTab === PopoverTabs.AttachedFiles
className="sn-dropdown-item py-3 border-0 border-t-1px border-solid border-main focus:bg-info-backdrop" ? 'No files attached to this note'
onClick={handleAttachFilesClick} : 'No files found in this account'}
onBlur={closeOnBlur} </div>
> <Button variant="normal" onClick={handleAttachFilesClick} onBlur={closeOnBlur}>
<Icon type="add" className="mr-2 color-neutral" /> {currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files </Button>
</button> <div className="text-xs color-passive-0 mt-3">Or drop your files here</div>
</div>
)} )}
</div> </div>
) {filteredList.length > 0 && (
}, <button
) className="sn-dropdown-item py-3 border-0 border-t-1px border-solid border-main focus:bg-info-backdrop"
onClick={handleAttachFilesClick}
onBlur={closeOnBlur}
>
<Icon type="add" className="mr-2 color-neutral" />
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
</button>
)}
</div>
)
}
export default observer(AttachedFilesPopover)

View File

@@ -1,28 +1,15 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { KeyboardKey } from '@/Services/IOService' import { KeyboardKey } from '@/Services/IOService'
import { formatSizeToReadableString } from '@standardnotes/filepicker' import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { IconType, FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FormEventHandler, FunctionComponent, KeyboardEventHandler, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon, ICONS } from '@/Components/Icon' import { PopoverFileItemActionType } from './PopoverFileItemAction'
import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction' import PopoverFileSubmenu from './PopoverFileSubmenu'
import { PopoverFileSubmenu } from './PopoverFileSubmenu' import { getFileIconComponent } from './getFileIconComponent'
import { PopoverFileItemProps } from './PopoverFileItemProps'
export const getFileIconComponent = (iconType: string, className: string) => { const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
const IconComponent = ICONS[iconType as keyof typeof ICONS]
return <IconComponent className={className} />
}
export type PopoverFileItemProps = {
file: FileItem
isAttachedToNote: boolean
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
getIconType(type: string): IconType
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}
export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
file, file,
isAttachedToNote, isAttachedToNote,
handleFileAction, handleFileAction,
@@ -51,11 +38,11 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
setIsRenamingFile(false) setIsRenamingFile(false)
} }
const handleFileNameInput = (event: Event) => { const handleFileNameInput: FormEventHandler<HTMLInputElement> = (event) => {
setFileName((event.target as HTMLInputElement).value) setFileName((event.target as HTMLInputElement).value)
} }
const handleFileNameInputKeyDown = (event: KeyboardEvent) => { const handleFileNameInputKeyDown: KeyboardEventHandler = (event) => {
if (event.key === KeyboardKey.Enter) { if (event.key === KeyboardKey.Enter) {
itemRef.current?.focus() itemRef.current?.focus()
} }
@@ -99,7 +86,7 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
)} )}
</div> </div>
)} )}
<div className="text-xs color-grey-0"> <div className="text-xs color-passive-0">
{file.created_at.toLocaleString()} · {formatSizeToReadableString(file.decryptedSize)} {file.created_at.toLocaleString()} · {formatSizeToReadableString(file.decryptedSize)}
</div> </div>
</div> </div>
@@ -115,3 +102,5 @@ export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
</div> </div>
) )
} }
export default PopoverFileItem

View File

@@ -0,0 +1,10 @@
import { IconType, FileItem } from '@standardnotes/snjs'
import { PopoverFileItemAction } from './PopoverFileItemAction'
export type PopoverFileItemProps = {
file: FileItem
isAttachedToNote: boolean
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
getIconType(type: string): IconType
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}

View File

@@ -1,20 +1,19 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { FunctionComponent } from 'preact' import { Dispatch, FunctionComponent, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'
import { StateUpdater, useCallback, useEffect, useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import Switch from '@/Components/Switch/Switch'
import { Switch } from '@/Components/Switch'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { PopoverFileItemProps } from './PopoverFileItem' import { PopoverFileItemProps } from './PopoverFileItemProps'
import { PopoverFileItemActionType } from './PopoverFileItemAction' import { PopoverFileItemActionType } from './PopoverFileItemAction'
type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & { type Props = Omit<PopoverFileItemProps, 'renameFile' | 'getIconType'> & {
setIsRenamingFile: StateUpdater<boolean> setIsRenamingFile: Dispatch<SetStateAction<boolean>>
previewHandler: () => void previewHandler: () => void
} }
export const PopoverFileSubmenu: FunctionComponent<Props> = ({ const PopoverFileSubmenu: FunctionComponent<Props> = ({
file, file,
isAttachedToNote, isAttachedToNote,
handleFileAction, handleFileAction,
@@ -197,3 +196,5 @@ export const PopoverFileSubmenu: FunctionComponent<Props> = ({
</div> </div>
) )
} }
export default PopoverFileSubmenu

View File

@@ -0,0 +1,7 @@
import { ICONS } from '@/Components/Icon/Icon'
export const getFileIconComponent = (iconType: string, className: string) => {
const IconComponent = ICONS[iconType as keyof typeof ICONS]
return <IconComponent className={className} />
}

View File

@@ -1,4 +1,6 @@
interface BubbleProperties { import { FunctionComponent } from 'react'
type Props = {
label: string label: string
selected: boolean selected: boolean
onSelect: () => void onSelect: () => void
@@ -10,7 +12,7 @@ const styles = {
selected: 'border-info bg-info color-neutral-contrast', selected: 'border-info bg-info color-neutral-contrast',
} }
const Bubble = ({ label, selected, onSelect }: BubbleProperties) => ( const Bubble: FunctionComponent<Props> = ({ label, selected, onSelect }) => (
<span <span
role="tab" role="tab"
className={`bubble ${styles.base} ${selected ? styles.selected : styles.unselected}`} className={`bubble ${styles.base} ${selected ? styles.selected : styles.unselected}`}

View File

@@ -1,6 +1,4 @@
import { JSXInternal } from 'preact/src/jsx' import { Ref, forwardRef, ReactNode, ComponentPropsWithoutRef } from 'react'
import { ComponentChildren, FunctionComponent, Ref } from 'preact'
import { forwardRef } from 'preact/compat'
const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content' const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content'
@@ -22,7 +20,7 @@ const getClassName = (variant: ButtonVariant, danger: boolean, disabled: boolean
} }
if (disabled) { if (disabled) {
colors = variant === 'normal' ? 'bg-default color-grey-2' : 'bg-grey-2 color-info-contrast' colors = variant === 'normal' ? 'bg-default color-passive-2' : 'bg-passive-2 color-info-contrast'
focusHoverStates = focusHoverStates =
variant === 'normal' variant === 'normal'
? 'focus:bg-default focus:outline-none hover:bg-default' ? 'focus:bg-default focus:outline-none hover:bg-default'
@@ -32,15 +30,15 @@ const getClassName = (variant: ButtonVariant, danger: boolean, disabled: boolean
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}` return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`
} }
type ButtonProps = JSXInternal.HTMLAttributes<HTMLButtonElement> & { interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
children?: ComponentChildren children?: ReactNode
className?: string className?: string
variant?: ButtonVariant variant?: ButtonVariant
dangerStyle?: boolean dangerStyle?: boolean
label?: string label?: string
} }
export const Button: FunctionComponent<ButtonProps> = forwardRef( const Button = forwardRef(
( (
{ {
variant = 'normal', variant = 'normal',
@@ -66,3 +64,5 @@ export const Button: FunctionComponent<ButtonProps> = forwardRef(
) )
}, },
) )
export default Button

View File

@@ -1,34 +1,18 @@
import { FunctionComponent } from 'preact' import { FunctionComponent, MouseEventHandler } from 'react'
import { Icon } from '@/Components/Icon' import Icon from '@/Components/Icon/Icon'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
interface Props { type Props = {
/**
* onClick - preventDefault is handled within the component
*/
onClick: () => void onClick: () => void
className?: string className?: string
icon: IconType icon: IconType
iconClassName?: string iconClassName?: string
/**
* Button tooltip
*/
title: string title: string
focusable: boolean focusable: boolean
disabled?: boolean disabled?: boolean
} }
/** const IconButton: FunctionComponent<Props> = ({
* IconButton component with an icon
* preventDefault is already handled within the component
*/
export const IconButton: FunctionComponent<Props> = ({
onClick, onClick,
className = '', className = '',
icon, icon,
@@ -37,7 +21,7 @@ export const IconButton: FunctionComponent<Props> = ({
iconClassName = '', iconClassName = '',
disabled = false, disabled = false,
}) => { }) => {
const click = (e: MouseEvent) => { const click: MouseEventHandler = (e) => {
e.preventDefault() e.preventDefault()
onClick() onClick()
} }
@@ -55,3 +39,5 @@ export const IconButton: FunctionComponent<Props> = ({
</button> </button>
) )
} }
export default IconButton

View File

@@ -1,28 +1,18 @@
import { FunctionComponent } from 'preact' import { FunctionComponent, MouseEventHandler } from 'react'
import { Icon } from '@/Components/Icon' import Icon from '@/Components/Icon/Icon'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
type ButtonType = 'normal' | 'primary' type ButtonType = 'normal' | 'primary'
interface Props { type Props = {
/**
* onClick - preventDefault is handled within the component
*/
onClick: () => void onClick: () => void
type: ButtonType type: ButtonType
className?: string className?: string
icon: IconType icon: IconType
} }
/** const RoundIconButton: FunctionComponent<Props> = ({ onClick, type, className, icon: iconType }) => {
* IconButton component with an icon const click: MouseEventHandler = (e) => {
* preventDefault is already handled within the component
*/
export const RoundIconButton: FunctionComponent<Props> = ({ onClick, type, className, icon: iconType }) => {
const click = (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
onClick() onClick()
} }
@@ -33,3 +23,5 @@ export const RoundIconButton: FunctionComponent<Props> = ({ onClick, type, class
</button> </button>
) )
} }
export default RoundIconButton

View File

@@ -1,4 +1,4 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { DialogContent, DialogOverlay } from '@reach/dialog' import { DialogContent, DialogOverlay } from '@reach/dialog'
import { import {
ButtonType, ButtonType,
@@ -9,26 +9,18 @@ import {
removeFromArray, removeFromArray,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { ProtectedIllustration } from '@standardnotes/icons' import { ProtectedIllustration } from '@standardnotes/icons'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'preact/hooks' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import ChallengeModalPrompt from './ChallengePrompt'
import { ChallengeModalPrompt } from './ChallengePrompt' import LockscreenWorkspaceSwitcher from './LockscreenWorkspaceSwitcher'
import { LockscreenWorkspaceSwitcher } from './LockscreenWorkspaceSwitcher' import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { AppState } from '@/UIModels/AppState' import { ChallengeModalValues } from './ChallengeModalValues'
type InputValue = {
prompt: ChallengePrompt
value: string | number | boolean
invalid: boolean
}
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState viewControllerManager: ViewControllerManager
mainApplicationGroup: ApplicationGroup mainApplicationGroup: ApplicationGroup
challenge: Challenge challenge: Challenge
onDismiss?: (challenge: Challenge) => void onDismiss?: (challenge: Challenge) => void
@@ -50,9 +42,9 @@ const validateValues = (values: ChallengeModalValues, prompts: ChallengePrompt[]
return undefined return undefined
} }
export const ChallengeModal: FunctionComponent<Props> = ({ const ChallengeModal: FunctionComponent<Props> = ({
application, application,
appState, viewControllerManager,
mainApplicationGroup, mainApplicationGroup,
challenge, challenge,
onDismiss, onDismiss,
@@ -191,6 +183,7 @@ export const ChallengeModal: FunctionComponent<Props> = ({
key={challenge.id} key={challenge.id}
> >
<DialogContent <DialogContent
aria-label="Challenge modal"
className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${ className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${
challenge.reason !== ChallengeReason.ApplicationUnlock challenge.reason !== ChallengeReason.ApplicationUnlock
? 'shadow-overlay-light border-1 border-solid border-main' ? 'shadow-overlay-light border-1 border-solid border-main'
@@ -262,9 +255,14 @@ export const ChallengeModal: FunctionComponent<Props> = ({
</Button> </Button>
)} )}
{shouldShowWorkspaceSwitcher && ( {shouldShowWorkspaceSwitcher && (
<LockscreenWorkspaceSwitcher mainApplicationGroup={mainApplicationGroup} appState={appState} /> <LockscreenWorkspaceSwitcher
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
/>
)} )}
</DialogContent> </DialogContent>
</DialogOverlay> </DialogOverlay>
) )
} }
export default ChallengeModal

View File

@@ -0,0 +1,4 @@
import { ChallengePrompt } from '@standardnotes/snjs'
import { InputValue } from './InputValue'
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>

View File

@@ -1,9 +1,8 @@
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs' import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent, useEffect, useRef } from 'react'
import { useEffect, useRef } from 'preact/hooks' import DecoratedInput from '@/Components/Input/DecoratedInput'
import { DecoratedInput } from '@/Components/Input/DecoratedInput' import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput' import { ChallengeModalValues } from './ChallengeModalValues'
import { ChallengeModalValues } from './ChallengeModal'
type Props = { type Props = {
prompt: ChallengePrompt prompt: ChallengePrompt
@@ -13,7 +12,7 @@ type Props = {
isInvalid: boolean isInvalid: boolean
} }
export const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => { const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
@@ -33,13 +32,14 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values,
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? ( {prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
<div className="min-w-76"> <div className="min-w-76">
<div className="text-sm font-medium mb-2">Allow protected access for</div> <div className="text-sm font-medium mb-2">Allow protected access for</div>
<div className="flex items-center justify-between bg-grey-4 rounded p-1"> <div className="flex items-center justify-between bg-passive-4 rounded p-1">
{ProtectionSessionDurations.map((option) => { {ProtectionSessionDurations.map((option) => {
const selected = option.valueInSeconds === values[prompt.id].value const selected = option.valueInSeconds === values[prompt.id].value
return ( return (
<label <label
key={option.label}
className={`cursor-pointer px-2 py-1.5 rounded ${ className={`cursor-pointer px-2 py-1.5 rounded ${
selected ? 'bg-default color-foreground font-semibold' : 'color-grey-0 hover:bg-grey-3' selected ? 'bg-default color-foreground font-semibold' : 'color-passive-0 hover:bg-passive-3'
}`} }`}
> >
<input <input
@@ -80,3 +80,5 @@ export const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values,
</div> </div>
) )
} }
export default ChallengeModalPrompt

View File

@@ -0,0 +1,7 @@
import { ChallengePrompt } from '@standardnotes/snjs'
export type InputValue = {
prompt: ChallengePrompt
value: string | number | boolean
invalid: boolean
}

View File

@@ -1,19 +1,18 @@
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import WorkspaceSwitcherMenu from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu'
import { WorkspaceSwitcherMenu } from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
type Props = { type Props = {
mainApplicationGroup: ApplicationGroup mainApplicationGroup: ApplicationGroup
appState: AppState viewControllerManager: ViewControllerManager
} }
export const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplicationGroup, appState }) => { const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplicationGroup, viewControllerManager }) => {
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -56,7 +55,7 @@ export const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainAppl
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}> <div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
<WorkspaceSwitcherMenu <WorkspaceSwitcherMenu
mainApplicationGroup={mainApplicationGroup} mainApplicationGroup={mainApplicationGroup}
appState={appState} viewControllerManager={viewControllerManager}
isOpen={isOpen} isOpen={isOpen}
hideWorkspaceOptions={true} hideWorkspaceOptions={true}
/> />
@@ -65,3 +64,5 @@ export const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainAppl
</div> </div>
) )
} }
export default LockscreenWorkspaceSwitcher

View File

@@ -1,114 +1,112 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants' import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import VisuallyHidden from '@reach/visually-hidden' import VisuallyHidden from '@reach/visually-hidden'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useRef, useState } from 'react'
import { useRef, useState } from 'preact/hooks' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import ChangeEditorMenu from './ChangeEditorMenu'
import { ChangeEditorMenu } from './ChangeEditorMenu'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState viewControllerManager: ViewControllerManager
onClickPreprocessing?: () => Promise<void> onClickPreprocessing?: () => Promise<void>
} }
export const ChangeEditorButton: FunctionComponent<Props> = observer( const ChangeEditorButton: FunctionComponent<Props> = ({
({ application, appState, onClickPreprocessing }: Props) => { application,
if (isStateDealloced(appState)) { viewControllerManager,
return null onClickPreprocessing,
} }: Props) => {
const note = viewControllerManager.notesController.firstSelectedNote
const [isOpen, setIsOpen] = useState(false)
const [isVisible, setIsVisible] = useState(false)
const [position, setPosition] = useState({
top: 0,
right: 0,
})
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen)
const note = Object.values(appState.notes.selectedNotes)[0] const toggleChangeEditorMenu = async () => {
const [isOpen, setIsOpen] = useState(false) const rect = buttonRef.current?.getBoundingClientRect()
const [isVisible, setIsVisible] = useState(false) if (rect) {
const [position, setPosition] = useState({ const { clientHeight } = document.documentElement
top: 0, const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
right: 0, const footerHeightInPx = footerElementRect?.height
})
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen)
const toggleChangeEditorMenu = async () => { if (footerHeightInPx) {
const rect = buttonRef.current?.getBoundingClientRect() setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
if (rect) {
const { clientHeight } = document.documentElement
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
if (footerHeightInPx) {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
}
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
})
const newOpenState = !isOpen
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
setIsOpen(newOpenState)
setTimeout(() => {
setIsVisible(newOpenState)
})
} }
}
return ( setPosition({
<div ref={containerRef}> top: rect.bottom,
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}> right: document.body.clientWidth - rect.right,
<DisclosureButton })
onKeyDown={(event) => {
if (event.key === 'Escape') { const newOpenState = !isOpen
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
setIsOpen(newOpenState)
setTimeout(() => {
setIsVisible(newOpenState)
})
}
}
return (
<div ref={containerRef}>
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}}
onBlur={closeOnBlur}
ref={buttonRef}
className="sn-icon-button border-contrast"
>
<VisuallyHidden>Change note type</VisuallyHidden>
<Icon type="dashboard" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsOpen(false)
buttonRef.current?.focus()
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="sn-dropdown sn-dropdown--animated min-w-68 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
onBlur={closeOnBlur}
>
{isOpen && (
<ChangeEditorMenu
closeOnBlur={closeOnBlur}
application={application}
isVisible={isVisible}
note={note}
closeMenu={() => {
setIsOpen(false) setIsOpen(false)
} }}
}} />
onBlur={closeOnBlur} )}
ref={buttonRef} </DisclosurePanel>
className="sn-icon-button border-contrast" </Disclosure>
> </div>
<VisuallyHidden>Change note type</VisuallyHidden> )
<Icon type="dashboard" className="block" /> }
</DisclosureButton>
<DisclosurePanel export default observer(ChangeEditorButton)
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsOpen(false)
buttonRef.current?.focus()
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="sn-dropdown sn-dropdown--animated min-w-68 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
onBlur={closeOnBlur}
>
{isOpen && (
<ChangeEditorMenu
closeOnBlur={closeOnBlur}
application={application}
isVisible={isVisible}
note={note}
closeMenu={() => {
setIsOpen(false)
}}
/>
)}
</DisclosurePanel>
</Disclosure>
</div>
)
},
)

View File

@@ -1,14 +1,10 @@
import { Icon } from '@/Components/Icon' import Icon from '@/Components/Icon/Icon'
import { Menu } from '@/Components/Menu/Menu' import Menu from '@/Components/Menu/Menu'
import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem' import MenuItem from '@/Components/Menu/MenuItem'
import { import { MenuItemType } from '@/Components/Menu/MenuItemType'
reloadFont,
transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote,
} from '@/Components/NoteView/NoteView'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Strings' import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { import {
ComponentArea, ComponentArea,
ItemMutator, ItemMutator,
@@ -18,23 +14,28 @@ import {
SNNote, SNNote,
TransactionalMutation, TransactionalMutation,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { Fragment, FunctionComponent } from 'preact' import { Fragment, FunctionComponent, useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'preact/hooks' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup'
import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption' import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
import { createEditorMenuGroups } from './createEditorMenuGroups' import { createEditorMenuGroups } from './createEditorMenuGroups'
import { PLAIN_EDITOR_NAME } from '@/Constants' import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
import {
transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote,
} from '../NoteView/TransactionFunctions'
import { reloadFont } from '../NoteView/FontFunctions'
type ChangeEditorMenuProps = { type ChangeEditorMenuProps = {
application: WebApplication application: WebApplication
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
closeMenu: () => void closeMenu: () => void
isVisible: boolean isVisible: boolean
note: SNNote note: SNNote | undefined
} }
const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-') const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-')
export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
application, application,
closeOnBlur, closeOnBlur,
closeMenu, closeMenu,
@@ -43,7 +44,7 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
}) => { }) => {
const [editors] = useState<SNComponent[]>(() => const [editors] = useState<SNComponent[]>(() =>
application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => { application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1 return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1
}), }),
) )
const [groups, setGroups] = useState<EditorMenuGroup[]>([]) const [groups, setGroups] = useState<EditorMenuGroup[]>([])
@@ -75,97 +76,103 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
[currentEditor], [currentEditor],
) )
const selectComponent = async (component: SNComponent | null, note: SNNote) => { const selectComponent = useCallback(
if (component) { async (component: SNComponent | null, note: SNNote) => {
if (component.conflictOf) { if (component) {
application.mutator if (component.conflictOf) {
.changeAndSaveItem(component, (mutator) => { application.mutator
mutator.conflictOf = undefined .changeAndSaveItem(component, (mutator) => {
mutator.conflictOf = undefined
})
.catch(console.error)
}
}
const transactions: TransactionalMutation[] = []
await application.getViewControllerManager().itemListController.insertCurrentIfTemplate()
if (note.locked) {
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT).catch(console.error)
return
}
if (!component) {
if (!note.prefersPlainEditor) {
transactions.push({
itemUuid: note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator
noteMutator.prefersPlainEditor = true
},
}) })
.catch(console.error) }
const currentEditor = application.componentManager.editorForNote(note)
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
}
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled))
} else if (component.area === ComponentArea.Editor) {
const currentEditor = application.componentManager.editorForNote(note)
if (currentEditor && component.uuid !== currentEditor.uuid) {
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
}
const prefersPlain = note.prefersPlainEditor
if (prefersPlain) {
transactions.push({
itemUuid: note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator
noteMutator.prefersPlainEditor = false
},
})
}
transactions.push(transactionForAssociateComponentWithCurrentNote(component, note))
} }
}
const transactions: TransactionalMutation[] = [] await application.mutator.runTransactionalMutations(transactions)
/** Dirtying can happen above */
application.sync.sync().catch(console.error)
await application.getAppState().notesView.insertCurrentIfTemplate() setCurrentEditor(application.componentManager.editorForNote(note))
},
[application],
)
if (note.locked) { const selectEditor = useCallback(
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT).catch(console.error) async (itemToBeSelected: EditorMenuItem) => {
return if (!itemToBeSelected.isEntitled) {
} premiumModal.activate(itemToBeSelected.name)
return
if (!component) {
if (!note.prefersPlainEditor) {
transactions.push({
itemUuid: note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator
noteMutator.prefersPlainEditor = true
},
})
} }
const currentEditor = application.componentManager.editorForNote(note)
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) { const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
if (areBothEditorsPlain) {
return
} }
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled))
} else if (component.area === ComponentArea.Editor) { let shouldSelectEditor = true
const currentEditor = application.componentManager.editorForNote(note)
if (currentEditor && component.uuid !== currentEditor.uuid) { if (itemToBeSelected.component) {
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note)) const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert(
currentEditor,
itemToBeSelected.component,
)
if (changeRequiresAlert) {
shouldSelectEditor = await application.componentManager.showEditorChangeAlert()
}
} }
const prefersPlain = note.prefersPlainEditor
if (prefersPlain) { if (shouldSelectEditor && note) {
transactions.push({ selectComponent(itemToBeSelected.component ?? null, note).catch(console.error)
itemUuid: note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator
noteMutator.prefersPlainEditor = false
},
})
} }
transactions.push(transactionForAssociateComponentWithCurrentNote(component, note))
}
await application.mutator.runTransactionalMutations(transactions) closeMenu()
/** Dirtying can happen above */ },
application.sync.sync().catch(console.error) [application.componentManager, closeMenu, currentEditor, note, premiumModal, selectComponent],
)
setCurrentEditor(application.componentManager.editorForNote(note))
}
const selectEditor = async (itemToBeSelected: EditorMenuItem) => {
if (!itemToBeSelected.isEntitled) {
premiumModal.activate(itemToBeSelected.name)
return
}
const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component
if (areBothEditorsPlain) {
return
}
let shouldSelectEditor = true
if (itemToBeSelected.component) {
const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert(
currentEditor,
itemToBeSelected.component,
)
if (changeRequiresAlert) {
shouldSelectEditor = await application.componentManager.showEditorChangeAlert()
}
}
if (shouldSelectEditor) {
selectComponent(itemToBeSelected.component ?? null, note).catch(console.error)
}
closeMenu()
}
return ( return (
<Menu className="pt-0.5 pb-1" a11yLabel="Change note type menu" isOpen={isVisible}> <Menu className="pt-0.5 pb-1" a11yLabel="Change note type menu" isOpen={isVisible}>
@@ -176,37 +183,38 @@ export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
return ( return (
<Fragment key={groupId}> <Fragment key={groupId}>
<div <div className={`py-1 border-0 border-t-1px border-solid border-main ${index === 0 ? 'border-t-0' : ''}`}>
className={`flex items-center px-2.5 py-2 text-xs font-semibold color-text border-0 border-y-1px border-solid border-main ${ {group.items.map((item) => {
index === 0 ? 'border-t-0 mb-2' : 'my-2' const onClickEditorItem = () => {
}`} selectEditor(item).catch(console.error)
> }
{group.icon && <Icon type={group.icon} className={`mr-2 ${group.iconClassName}`} />} return (
<div className="font-semibold text-input">{group.title}</div> <MenuItem
key={item.name}
type={MenuItemType.RadioButton}
onClick={onClickEditorItem}
className={
'sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none flex-row-reverse'
}
onBlur={closeOnBlur}
checked={isSelectedEditor(item)}
>
<div className="flex flex-grow items-center justify-between">
<div className="flex items-center">
{group.icon && <Icon type={group.icon} className={`mr-2 ${group.iconClassName}`} />}
{item.name}
</div>
{!item.isEntitled && <Icon type="premium-feature" />}
</div>
</MenuItem>
)
})}
</div> </div>
{group.items.map((item) => {
const onClickEditorItem = () => {
selectEditor(item).catch(console.error)
}
return (
<MenuItem
type={MenuItemType.RadioButton}
onClick={onClickEditorItem}
className={'sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none'}
onBlur={closeOnBlur}
checked={isSelectedEditor(item)}
>
<div className="flex flex-grow items-center justify-between">
{item.name}
{!item.isEntitled && <Icon type="premium-feature" />}
</div>
</MenuItem>
)
})}
</Fragment> </Fragment>
) )
})} })}
</Menu> </Menu>
) )
} }
export default ChangeEditorMenu

View File

@@ -1,4 +1,4 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { import {
ContentType, ContentType,
FeatureStatus, FeatureStatus,
@@ -8,8 +8,9 @@ import {
GetFeatures, GetFeatures,
NoteType, NoteType,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup'
import { PLAIN_EDITOR_NAME } from '@/Constants' import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
type EditorGroup = NoteType | 'plain' | 'others' type EditorGroup = NoteType | 'plain' | 'others'
@@ -63,7 +64,7 @@ export const createEditorMenuGroups = (application: WebApplication, editors: SNC
editors.forEach((editor) => { editors.forEach((editor) => {
const editorItem: EditorMenuItem = { const editorItem: EditorMenuItem = {
name: editor.name, name: editor.displayName,
component: editor, component: editor,
isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled, isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled,
} }

View File

@@ -1,14 +1,14 @@
import { FunctionComponent } from 'preact' import { ChangeEventHandler, FunctionComponent } from 'react'
type CheckboxProps = { type CheckboxProps = {
name: string name: string
checked: boolean checked: boolean
onChange: (e: Event) => void onChange: ChangeEventHandler<HTMLInputElement>
disabled?: boolean disabled?: boolean
label: string label: string
} }
export const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onChange, disabled, label }) => { const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onChange, disabled, label }) => {
return ( return (
<label htmlFor={name} className="flex items-center fit-content mb-2"> <label htmlFor={name} className="flex items-center fit-content mb-2">
<input <input
@@ -24,3 +24,5 @@ export const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onCh
</label> </label>
) )
} }
export default Checkbox

View File

@@ -0,0 +1,220 @@
import {
ComponentAction,
FeatureStatus,
SNComponent,
dateToLocalizedString,
ComponentViewer,
ComponentViewerEvent,
ComponentViewerError,
} from '@standardnotes/snjs'
import { WebApplication } from '@/Application/Application'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { observer } from 'mobx-react-lite'
import OfflineRestricted from '@/Components/ComponentView/OfflineRestricted'
import UrlMissing from '@/Components/ComponentView/UrlMissing'
import IsDeprecated from '@/Components/ComponentView/IsDeprecated'
import IsExpired from '@/Components/ComponentView/IsExpired'
import IssueOnLoading from '@/Components/ComponentView/IssueOnLoading'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
interface IProps {
application: WebApplication
viewControllerManager: ViewControllerManager
componentViewer: ComponentViewer
requestReload?: (viewer: ComponentViewer, force?: boolean) => void
onLoad?: (component: SNComponent) => void
}
/**
* The maximum amount of time we'll wait for a component
* to load before displaying error
*/
const MaxLoadThreshold = 4000
const VisibilityChangeKey = 'visibilitychange'
const MSToWaitAfterIframeLoadToAvoidFlicker = 35
const ComponentView: FunctionComponent<IProps> = ({ application, onLoad, componentViewer, requestReload }) => {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [loadTimeout, setLoadTimeout] = useState<ReturnType<typeof setTimeout> | undefined>(undefined)
const [hasIssueLoading, setHasIssueLoading] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(componentViewer.getFeatureStatus())
const [isComponentValid, setIsComponentValid] = useState(true)
const [error, setError] = useState<ComponentViewerError | undefined>(undefined)
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined)
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false)
const [didAttemptReload, setDidAttemptReload] = useState(false)
const component: SNComponent = componentViewer.component
const manageSubscription = useCallback(() => {
openSubscriptionDashboard(application)
}, [application])
const reloadValidityStatus = useCallback(() => {
setFeatureStatus(componentViewer.getFeatureStatus())
if (!componentViewer.lockReadonly) {
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled)
}
setIsComponentValid(componentViewer.shouldRender())
if (isLoading && !isComponentValid) {
setIsLoading(false)
}
setError(componentViewer.getError())
setDeprecationMessage(component.deprecationMessage)
}, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading])
useEffect(() => {
reloadValidityStatus()
}, [reloadValidityStatus])
const dismissDeprecationMessage = () => {
setIsDeprecationMessageDismissed(true)
}
const onVisibilityChange = useCallback(() => {
if (document.visibilityState === 'hidden') {
return
}
if (hasIssueLoading) {
requestReload?.(componentViewer)
}
}, [hasIssueLoading, componentViewer, requestReload])
useEffect(() => {
const loadTimeout = setTimeout(() => {
setIsLoading(false)
setHasIssueLoading(true)
if (!didAttemptReload) {
setDidAttemptReload(true)
requestReload?.(componentViewer)
} else {
document.addEventListener(VisibilityChangeKey, onVisibilityChange)
}
}, MaxLoadThreshold)
setLoadTimeout(loadTimeout)
return () => {
if (loadTimeout) {
clearTimeout(loadTimeout)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentViewer])
const onIframeLoad = useCallback(() => {
const iframe = iframeRef.current as HTMLIFrameElement
const contentWindow = iframe.contentWindow as Window
if (loadTimeout) {
clearTimeout(loadTimeout)
}
try {
componentViewer.setWindow(contentWindow)
} catch (error) {
console.error(error)
}
setTimeout(() => {
setIsLoading(false)
setHasIssueLoading(false)
onLoad?.(component)
}, MSToWaitAfterIframeLoadToAvoidFlicker)
}, [componentViewer, onLoad, component, loadTimeout])
useEffect(() => {
const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => {
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
setFeatureStatus(componentViewer.getFeatureStatus())
}
})
return () => {
removeFeaturesChangedObserver()
}
}, [componentViewer])
useEffect(() => {
const removeActionObserver = componentViewer.addActionObserver((action, data) => {
switch (action) {
case ComponentAction.KeyDown:
application.io.handleComponentKeyDown(data.keyboardModifier)
break
case ComponentAction.KeyUp:
application.io.handleComponentKeyUp(data.keyboardModifier)
break
case ComponentAction.Click:
application.getViewControllerManager().notesController.setContextMenuOpen(false)
break
default:
return
}
})
return () => {
removeActionObserver()
}
}, [componentViewer, application])
useEffect(() => {
const unregisterDesktopObserver = application
.getDesktopService()
?.registerUpdateObserver((updatedComponent: SNComponent) => {
if (updatedComponent.uuid === component.uuid && updatedComponent.active) {
requestReload?.(componentViewer)
}
})
return () => {
unregisterDesktopObserver?.()
}
}, [application, requestReload, componentViewer, component.uuid])
return (
<>
{hasIssueLoading && (
<IssueOnLoading
componentName={component.displayName}
reloadIframe={() => {
reloadValidityStatus(), requestReload?.(componentViewer, true)
}}
/>
)}
{featureStatus !== FeatureStatus.Entitled && (
<IsExpired
expiredDate={dateToLocalizedString(component.valid_until)}
featureStatus={featureStatus}
componentName={component.displayName}
manageSubscription={manageSubscription}
/>
)}
{deprecationMessage && !isDeprecationMessageDismissed && (
<IsDeprecated deprecationMessage={deprecationMessage} dismissDeprecationMessage={dismissDeprecationMessage} />
)}
{error === ComponentViewerError.OfflineRestricted && <OfflineRestricted />}
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.displayName} />}
{component.uuid && isComponentValid && (
<iframe
ref={iframeRef}
onLoad={onIframeLoad}
data-component-viewer-id={componentViewer.identifier}
frameBorder={0}
src={componentViewer.url || ''}
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
>
Loading
</iframe>
)}
{isLoading && <div className={'loading-overlay'} />}
</>
)
}
export default observer(ComponentView)

View File

@@ -1,11 +1,11 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
interface IProps { type Props = {
deprecationMessage: string | undefined deprecationMessage: string | undefined
dismissDeprecationMessage: () => void dismissDeprecationMessage: () => void
} }
export const IsDeprecated: FunctionalComponent<IProps> = ({ deprecationMessage, dismissDeprecationMessage }) => { const IsDeprecated: FunctionComponent<Props> = ({ deprecationMessage, dismissDeprecationMessage }) => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}> <div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
@@ -23,3 +23,5 @@ export const IsDeprecated: FunctionalComponent<IProps> = ({ deprecationMessage,
</div> </div>
) )
} }
export default IsDeprecated

View File

@@ -1,7 +1,7 @@
import { FeatureStatus } from '@standardnotes/snjs' import { FeatureStatus } from '@standardnotes/snjs'
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
interface IProps { type Props = {
expiredDate: string expiredDate: string
componentName: string componentName: string
featureStatus: FeatureStatus featureStatus: FeatureStatus
@@ -21,12 +21,7 @@ const statusString = (featureStatus: FeatureStatus, expiredDate: string, compone
} }
} }
export const IsExpired: FunctionalComponent<IProps> = ({ const IsExpired: FunctionComponent<Props> = ({ expiredDate, featureStatus, componentName, manageSubscription }) => {
expiredDate,
featureStatus,
componentName,
manageSubscription,
}) => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}> <div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
@@ -52,3 +47,5 @@ export const IsExpired: FunctionalComponent<IProps> = ({
</div> </div>
) )
} }
export default IsExpired

View File

@@ -1,11 +1,11 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
interface IProps { type Props = {
componentName: string componentName: string
reloadIframe: () => void reloadIframe: () => void
} }
export const IssueOnLoading: FunctionalComponent<IProps> = ({ componentName, reloadIframe }) => { const IssueOnLoading: FunctionComponent<Props> = ({ componentName, reloadIframe }) => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}> <div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
@@ -23,3 +23,5 @@ export const IssueOnLoading: FunctionalComponent<IProps> = ({ componentName, rel
</div> </div>
) )
} }
export default IssueOnLoading

View File

@@ -1,6 +1,6 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
export const OfflineRestricted: FunctionalComponent = () => { const OfflineRestricted: FunctionComponent = () => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-panel static'}> <div className={'sk-panel static'}>
@@ -29,3 +29,5 @@ export const OfflineRestricted: FunctionalComponent = () => {
</div> </div>
) )
} }
export default OfflineRestricted

View File

@@ -1,10 +1,10 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
interface IProps { type Props = {
componentName: string componentName: string
} }
export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => { const UrlMissing: FunctionComponent<Props> = ({ componentName }) => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-panel static'}> <div className={'sk-panel static'}>
@@ -20,3 +20,5 @@ export const UrlMissing: FunctionalComponent<IProps> = ({ componentName }) => {
</div> </div>
) )
} }
export default UrlMissing

View File

@@ -1,221 +0,0 @@
import {
ComponentAction,
FeatureStatus,
SNComponent,
dateToLocalizedString,
ComponentViewer,
ComponentViewerEvent,
ComponentViewerError,
} from '@standardnotes/snjs'
import { WebApplication } from '@/UIModels/Application'
import { FunctionalComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import { observer } from 'mobx-react-lite'
import { OfflineRestricted } from '@/Components/ComponentView/OfflineRestricted'
import { UrlMissing } from '@/Components/ComponentView/UrlMissing'
import { IsDeprecated } from '@/Components/ComponentView/IsDeprecated'
import { IsExpired } from '@/Components/ComponentView/IsExpired'
import { IssueOnLoading } from '@/Components/ComponentView/IssueOnLoading'
import { AppState } from '@/UIModels/AppState'
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
interface IProps {
application: WebApplication
appState: AppState
componentViewer: ComponentViewer
requestReload?: (viewer: ComponentViewer, force?: boolean) => void
onLoad?: (component: SNComponent) => void
}
/**
* The maximum amount of time we'll wait for a component
* to load before displaying error
*/
const MaxLoadThreshold = 4000
const VisibilityChangeKey = 'visibilitychange'
const MSToWaitAfterIframeLoadToAvoidFlicker = 35
export const ComponentView: FunctionalComponent<IProps> = observer(
({ application, onLoad, componentViewer, requestReload }) => {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [loadTimeout, setLoadTimeout] = useState<ReturnType<typeof setTimeout> | undefined>(undefined)
const [hasIssueLoading, setHasIssueLoading] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(componentViewer.getFeatureStatus())
const [isComponentValid, setIsComponentValid] = useState(true)
const [error, setError] = useState<ComponentViewerError | undefined>(undefined)
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined)
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false)
const [didAttemptReload, setDidAttemptReload] = useState(false)
const component = componentViewer.component
const manageSubscription = useCallback(() => {
openSubscriptionDashboard(application)
}, [application])
const reloadValidityStatus = useCallback(() => {
setFeatureStatus(componentViewer.getFeatureStatus())
if (!componentViewer.lockReadonly) {
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled)
}
setIsComponentValid(componentViewer.shouldRender())
if (isLoading && !isComponentValid) {
setIsLoading(false)
}
setError(componentViewer.getError())
setDeprecationMessage(component.deprecationMessage)
}, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading])
useEffect(() => {
reloadValidityStatus()
}, [reloadValidityStatus])
const dismissDeprecationMessage = () => {
setIsDeprecationMessageDismissed(true)
}
const onVisibilityChange = useCallback(() => {
if (document.visibilityState === 'hidden') {
return
}
if (hasIssueLoading) {
requestReload?.(componentViewer)
}
}, [hasIssueLoading, componentViewer, requestReload])
useEffect(() => {
const loadTimeout = setTimeout(() => {
setIsLoading(false)
setHasIssueLoading(true)
if (!didAttemptReload) {
setDidAttemptReload(true)
requestReload?.(componentViewer)
} else {
document.addEventListener(VisibilityChangeKey, onVisibilityChange)
}
}, MaxLoadThreshold)
setLoadTimeout(loadTimeout)
return () => {
if (loadTimeout) {
clearTimeout(loadTimeout)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentViewer])
const onIframeLoad = useCallback(() => {
const iframe = iframeRef.current as HTMLIFrameElement
const contentWindow = iframe.contentWindow as Window
if (loadTimeout) {
clearTimeout(loadTimeout)
}
try {
componentViewer.setWindow(contentWindow)
} catch (error) {
console.error(error)
}
setTimeout(() => {
setIsLoading(false)
setHasIssueLoading(false)
onLoad?.(component)
}, MSToWaitAfterIframeLoadToAvoidFlicker)
}, [componentViewer, onLoad, component, loadTimeout])
useEffect(() => {
const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => {
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
setFeatureStatus(componentViewer.getFeatureStatus())
}
})
return () => {
removeFeaturesChangedObserver()
}
}, [componentViewer])
useEffect(() => {
const removeActionObserver = componentViewer.addActionObserver((action, data) => {
switch (action) {
case ComponentAction.KeyDown:
application.io.handleComponentKeyDown(data.keyboardModifier)
break
case ComponentAction.KeyUp:
application.io.handleComponentKeyUp(data.keyboardModifier)
break
case ComponentAction.Click:
application.getAppState().notes.setContextMenuOpen(false)
break
default:
return
}
})
return () => {
removeActionObserver()
}
}, [componentViewer, application])
useEffect(() => {
const unregisterDesktopObserver = application
.getDesktopService()
?.registerUpdateObserver((updatedComponent: SNComponent) => {
if (updatedComponent.uuid === component.uuid && updatedComponent.active) {
requestReload?.(componentViewer)
}
})
return () => {
unregisterDesktopObserver?.()
}
}, [application, requestReload, componentViewer, component.uuid])
return (
<>
{hasIssueLoading && (
<IssueOnLoading
componentName={component.name}
reloadIframe={() => {
reloadValidityStatus(), requestReload?.(componentViewer, true)
}}
/>
)}
{featureStatus !== FeatureStatus.Entitled && (
<IsExpired
expiredDate={dateToLocalizedString(component.valid_until)}
featureStatus={featureStatus}
componentName={component.name}
manageSubscription={manageSubscription}
/>
)}
{deprecationMessage && !isDeprecationMessageDismissed && (
<IsDeprecated deprecationMessage={deprecationMessage} dismissDeprecationMessage={dismissDeprecationMessage} />
)}
{error === ComponentViewerError.OfflineRestricted && <OfflineRestricted />}
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.name} />}
{component.uuid && isComponentValid && (
<iframe
ref={iframeRef}
onLoad={onIframeLoad}
data-component-viewer-id={componentViewer.identifier}
frameBorder={0}
src={componentViewer.url || ''}
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
>
Loading
</iframe>
)}
{isLoading && <div className={'loading-overlay'} />}
</>
)
},
)

View File

@@ -1,38 +1,31 @@
import { useEffect, useRef, useState } from 'preact/hooks' import { FunctionComponent, useEffect, useRef, useState } from 'react'
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog' import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
import { STRING_SIGN_OUT_CONFIRMATION } from '@/Strings' import { STRING_SIGN_OUT_CONFIRMATION } from '@/Constants/Strings'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { isDesktopApplication } from '@/Utils' import { isDesktopApplication } from '@/Utils'
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState viewControllerManager: ViewControllerManager
applicationGroup: ApplicationGroup applicationGroup: ApplicationGroup
} }
export const ConfirmSignoutContainer = observer((props: Props) => { const ConfirmSignoutModal: FunctionComponent<Props> = ({ application, viewControllerManager, applicationGroup }) => {
if (!props.appState.accountMenu.signingOut) {
return null
}
return <ConfirmSignoutModal {...props} />
})
export const ConfirmSignoutModal = observer(({ application, appState, applicationGroup }: Props) => {
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false) const [deleteLocalBackups, setDeleteLocalBackups] = useState(false)
const cancelRef = useRef<HTMLButtonElement>(null) const cancelRef = useRef<HTMLButtonElement>(null)
function closeDialog() { function closeDialog() {
appState.accountMenu.setSigningOut(false) viewControllerManager.accountMenuController.setSigningOut(false)
} }
const [localBackupsCount, setLocalBackupsCount] = useState(0) const [localBackupsCount, setLocalBackupsCount] = useState(0)
useEffect(() => { useEffect(() => {
application.desktopDevice?.localBackupsCount().then(setLocalBackupsCount).catch(console.error) application.desktopDevice?.localBackupsCount().then(setLocalBackupsCount).catch(console.error)
}, [appState.accountMenu.signingOut, application.desktopDevice]) }, [viewControllerManager.accountMenuController.signingOut, application.desktopDevice])
const workspaces = applicationGroup.getDescriptors() const workspaces = applicationGroup.getDescriptors()
const showWorkspaceWarning = workspaces.length > 1 && isDesktopApplication() const showWorkspaceWarning = workspaces.length > 1 && isDesktopApplication()
@@ -114,4 +107,15 @@ export const ConfirmSignoutModal = observer(({ application, appState, applicatio
</div> </div>
</AlertDialog> </AlertDialog>
) )
}) }
ConfirmSignoutModal.displayName = 'ConfirmSignoutModal'
const ConfirmSignoutContainer = (props: Props) => {
if (!props.viewControllerManager.accountMenuController.signingOut) {
return null
}
return <ConfirmSignoutModal {...props} />
}
export default observer(ConfirmSignoutContainer)

View File

@@ -0,0 +1,81 @@
import { WebApplication } from '@/Application/Application'
import { KeyboardKey } from '@/Services/IOService'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { UuidString } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, KeyboardEventHandler, UIEventHandler, useCallback } from 'react'
import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants/Constants'
import { ListableContentItem } from './Types/ListableContentItem'
import ContentListItem from './ContentListItem'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
items: ListableContentItem[]
selectedItems: Record<UuidString, ListableContentItem>
paginate: () => void
}
const ContentList: FunctionComponent<Props> = ({
application,
viewControllerManager,
items,
selectedItems,
paginate,
}) => {
const { selectPreviousItem, selectNextItem } = viewControllerManager.itemListController
const { hideTags, hideDate, hideNotePreview, hideEditorIcon } =
viewControllerManager.itemListController.webDisplayOptions
const { sortBy } = viewControllerManager.itemListController.displayOptions
const onScroll: UIEventHandler = useCallback(
(e) => {
const offset = NOTES_LIST_SCROLL_THRESHOLD
const element = e.target as HTMLElement
if (element.scrollTop + element.offsetHeight >= element.scrollHeight - offset) {
paginate()
}
},
[paginate],
)
const onKeyDown: KeyboardEventHandler = useCallback(
(e) => {
if (e.key === KeyboardKey.Up) {
e.preventDefault()
selectPreviousItem()
} else if (e.key === KeyboardKey.Down) {
e.preventDefault()
selectNextItem()
}
},
[selectNextItem, selectPreviousItem],
)
return (
<div
className="infinite-scroll focus:shadow-none focus:outline-none"
id="notes-scrollable"
onScroll={onScroll}
onKeyDown={onKeyDown}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
{items.map((item) => (
<ContentListItem
key={item.uuid}
application={application}
viewControllerManager={viewControllerManager}
item={item}
selected={!!selectedItems[item.uuid]}
hideDate={hideDate}
hidePreview={hideNotePreview}
hideTags={hideTags}
hideIcon={hideEditorIcon}
sortBy={sortBy}
/>
))}
</div>
)
}
export default observer(ContentList)

View File

@@ -0,0 +1,38 @@
import { ContentType, SNTag } from '@standardnotes/snjs'
import { FunctionComponent } from 'react'
import FileListItem from './FileListItem'
import NoteListItem from './NoteListItem'
import { AbstractListItemProps } from './Types/AbstractListItemProps'
const ContentListItem: FunctionComponent<AbstractListItemProps> = (props) => {
const getTags = () => {
if (props.hideTags) {
return []
}
const selectedTag = props.viewControllerManager.navigationController.selected
if (!selectedTag) {
return []
}
const tags = props.application.getItemTags(props.item)
const isNavigatingOnlyTag = selectedTag instanceof SNTag && tags.length === 1
if (isNavigatingOnlyTag) {
return []
}
return tags
}
switch (props.item.content_type) {
case ContentType.Note:
return <NoteListItem tags={getTags()} {...props} />
case ContentType.File:
return <FileListItem tags={getTags()} {...props} />
default:
return null
}
}
export default ContentListItem

View File

@@ -0,0 +1,257 @@
import { WebApplication } from '@/Application/Application'
import { CollectionSort, CollectionSortProperty, PrefKey, SystemViewId } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import Menu from '@/Components/Menu/Menu'
import MenuItem from '@/Components/Menu/MenuItem'
import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
import { MenuItemType } from '@/Components/Menu/MenuItemType'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
closeDisplayOptionsMenu: () => void
isOpen: boolean
}
const ContentListOptionsMenu: FunctionComponent<Props> = ({
closeDisplayOptionsMenu,
closeOnBlur,
application,
viewControllerManager,
isOpen,
}) => {
const [sortBy, setSortBy] = useState(() => application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt))
const [sortReverse, setSortReverse] = useState(() => application.getPreference(PrefKey.SortNotesReverse, false))
const [hidePreview, setHidePreview] = useState(() => application.getPreference(PrefKey.NotesHideNotePreview, false))
const [hideDate, setHideDate] = useState(() => application.getPreference(PrefKey.NotesHideDate, false))
const [hideTags, setHideTags] = useState(() => application.getPreference(PrefKey.NotesHideTags, true))
const [hidePinned, setHidePinned] = useState(() => application.getPreference(PrefKey.NotesHidePinned, false))
const [showArchived, setShowArchived] = useState(() => application.getPreference(PrefKey.NotesShowArchived, false))
const [showTrashed, setShowTrashed] = useState(() => application.getPreference(PrefKey.NotesShowTrashed, false))
const [hideProtected, setHideProtected] = useState(() => application.getPreference(PrefKey.NotesHideProtected, false))
const [hideEditorIcon, setHideEditorIcon] = useState(() =>
application.getPreference(PrefKey.NotesHideEditorIcon, false),
)
const toggleSortReverse = useCallback(() => {
application.setPreference(PrefKey.SortNotesReverse, !sortReverse).catch(console.error)
setSortReverse(!sortReverse)
}, [application, sortReverse])
const toggleSortBy = useCallback(
(sort: CollectionSortProperty) => {
if (sortBy === sort) {
toggleSortReverse()
} else {
setSortBy(sort)
application.setPreference(PrefKey.SortNotesBy, sort).catch(console.error)
}
},
[application, sortBy, toggleSortReverse],
)
const toggleSortByDateModified = useCallback(() => {
toggleSortBy(CollectionSort.UpdatedAt)
}, [toggleSortBy])
const toggleSortByCreationDate = useCallback(() => {
toggleSortBy(CollectionSort.CreatedAt)
}, [toggleSortBy])
const toggleSortByTitle = useCallback(() => {
toggleSortBy(CollectionSort.Title)
}, [toggleSortBy])
const toggleHidePreview = useCallback(() => {
setHidePreview(!hidePreview)
application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview).catch(console.error)
}, [application, hidePreview])
const toggleHideDate = useCallback(() => {
setHideDate(!hideDate)
application.setPreference(PrefKey.NotesHideDate, !hideDate).catch(console.error)
}, [application, hideDate])
const toggleHideTags = useCallback(() => {
setHideTags(!hideTags)
application.setPreference(PrefKey.NotesHideTags, !hideTags).catch(console.error)
}, [application, hideTags])
const toggleHidePinned = useCallback(() => {
setHidePinned(!hidePinned)
application.setPreference(PrefKey.NotesHidePinned, !hidePinned).catch(console.error)
}, [application, hidePinned])
const toggleShowArchived = useCallback(() => {
setShowArchived(!showArchived)
application.setPreference(PrefKey.NotesShowArchived, !showArchived).catch(console.error)
}, [application, showArchived])
const toggleShowTrashed = useCallback(() => {
setShowTrashed(!showTrashed)
application.setPreference(PrefKey.NotesShowTrashed, !showTrashed).catch(console.error)
}, [application, showTrashed])
const toggleHideProtected = useCallback(() => {
setHideProtected(!hideProtected)
application.setPreference(PrefKey.NotesHideProtected, !hideProtected).catch(console.error)
}, [application, hideProtected])
const toggleEditorIcon = useCallback(() => {
setHideEditorIcon(!hideEditorIcon)
application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon).catch(console.error)
}, [application, hideEditorIcon])
return (
<Menu
className={
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
border-1 border-solid border-main text-sm z-index-dropdown-menu \
flex flex-col py-2 top-full left-2 absolute'
}
a11yLabel="Notes list options menu"
closeMenu={closeDisplayOptionsMenu}
isOpen={isOpen}
>
<div className="px-3 my-1 text-xs font-semibold color-text uppercase">Sort by</div>
<MenuItem
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByDateModified}
checked={sortBy === CollectionSort.UpdatedAt}
onBlur={closeOnBlur}
>
<div className="flex flex-grow items-center justify-between ml-2">
<span>Date modified</span>
{sortBy === CollectionSort.UpdatedAt ? (
sortReverse ? (
<Icon type="arrows-sort-up" className="color-neutral" />
) : (
<Icon type="arrows-sort-down" className="color-neutral" />
)
) : null}
</div>
</MenuItem>
<MenuItem
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByCreationDate}
checked={sortBy === CollectionSort.CreatedAt}
onBlur={closeOnBlur}
>
<div className="flex flex-grow items-center justify-between ml-2">
<span>Creation date</span>
{sortBy === CollectionSort.CreatedAt ? (
sortReverse ? (
<Icon type="arrows-sort-up" className="color-neutral" />
) : (
<Icon type="arrows-sort-down" className="color-neutral" />
)
) : null}
</div>
</MenuItem>
<MenuItem
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByTitle}
checked={sortBy === CollectionSort.Title}
onBlur={closeOnBlur}
>
<div className="flex flex-grow items-center justify-between ml-2">
<span>Title</span>
{sortBy === CollectionSort.Title ? (
sortReverse ? (
<Icon type="arrows-sort-up" className="color-neutral" />
) : (
<Icon type="arrows-sort-down" className="color-neutral" />
)
) : null}
</div>
</MenuItem>
<MenuItemSeparator />
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">View</div>
{viewControllerManager.navigationController.selectedUuid !== SystemViewId.Files && (
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hidePreview}
onChange={toggleHidePreview}
onBlur={closeOnBlur}
>
<div className="flex flex-col max-w-3/4">Show note preview</div>
</MenuItem>
)}
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideDate}
onChange={toggleHideDate}
onBlur={closeOnBlur}
>
Show date
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideTags}
onChange={toggleHideTags}
onBlur={closeOnBlur}
>
Show tags
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideEditorIcon}
onChange={toggleEditorIcon}
onBlur={closeOnBlur}
>
Show icon
</MenuItem>
<div className="h-1px my-2 bg-border"></div>
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">Other</div>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hidePinned}
onChange={toggleHidePinned}
onBlur={closeOnBlur}
>
Show pinned
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideProtected}
onChange={toggleHideProtected}
onBlur={closeOnBlur}
>
Show protected
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={showArchived}
onChange={toggleShowArchived}
onBlur={closeOnBlur}
>
Show archived
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={showTrashed}
onChange={toggleShowTrashed}
onBlur={closeOnBlur}
>
Show trashed
</MenuItem>
</Menu>
)
}
export default observer(ContentListOptionsMenu)

View File

@@ -1,59 +1,73 @@
import { KeyboardKey, KeyboardModifier } from '@/Services/IOService' import { KeyboardKey, KeyboardModifier } from '@/Services/IOService'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { PANEL_NAME_NOTES } from '@/Constants' import { PANEL_NAME_NOTES } from '@/Constants/Constants'
import { PrefKey } from '@standardnotes/snjs' import { PrefKey, SystemViewId } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import {
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' ChangeEventHandler,
import { NoAccountWarning } from '@/Components/NoAccountWarning' FunctionComponent,
import { NotesList } from '@/Components/NotesList' KeyboardEventHandler,
import { NotesListOptionsMenu } from '@/Components/NotesList/NotesListOptionsMenu' useCallback,
import { SearchOptions } from '@/Components/SearchOptions' useEffect,
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer' useMemo,
useRef,
useState,
} from 'react'
import ContentList from '@/Components/ContentListView/ContentList'
import NoAccountWarningWrapper from '@/Components/NoAccountWarning/NoAccountWarning'
import SearchOptions from '@/Components/SearchOptions/SearchOptions'
import PanelResizer, { PanelSide, ResizeFinishCallback, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState' import ContentListOptionsMenu from './ContentListOptionsMenu'
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState viewControllerManager: ViewControllerManager
} }
export const NotesView: FunctionComponent<Props> = observer(({ application, appState }: Props) => { const ContentListView: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
if (isStateDealloced(appState)) { const itemsViewPanelRef = useRef<HTMLDivElement>(null)
return null
}
const notesViewPanelRef = useRef<HTMLDivElement>(null)
const displayOptionsMenuRef = useRef<HTMLDivElement>(null) const displayOptionsMenuRef = useRef<HTMLDivElement>(null)
const { const {
completedFullSync, completedFullSync,
displayOptions,
noteFilterText, noteFilterText,
optionsSubtitle, optionsSubtitle,
panelTitle, panelTitle,
renderedNotes, renderedItems,
setNoteFilterText,
searchBarElement, searchBarElement,
selectNextItem,
selectPreviousItem,
onFilterEnter,
clearFilterText,
paginate, paginate,
panelWidth, panelWidth,
} = appState.notesView createNewNote,
} = viewControllerManager.itemListController
const { selectedNotes } = appState.notes const { selectedItems } = viewControllerManager.selectionController
const createNewNote = useCallback(() => appState.notesView.createNewNote(), [appState])
const onFilterEnter = useCallback(() => appState.notesView.onFilterEnter(), [appState])
const clearFilterText = useCallback(() => appState.notesView.clearFilterText(), [appState])
const setNoteFilterText = useCallback((text: string) => appState.notesView.setNoteFilterText(text), [appState])
const selectNextNote = useCallback(() => appState.notesView.selectNextNote(), [appState])
const selectPreviousNote = useCallback(() => appState.notesView.selectPreviousNote(), [appState])
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false) const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false)
const [focusedSearch, setFocusedSearch] = useState(false) const [focusedSearch, setFocusedSearch] = useState(false)
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(displayOptionsMenuRef, setShowDisplayOptionsMenu) const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(displayOptionsMenuRef, setShowDisplayOptionsMenu)
const isFilesSmartView = useMemo(
() => viewControllerManager.navigationController.selected?.uuid === SystemViewId.Files,
[viewControllerManager.navigationController.selected?.uuid],
)
const addNewItem = useCallback(() => {
if (isFilesSmartView) {
void viewControllerManager.filesController.uploadNewFile()
} else {
void createNewNote()
}
}, [viewControllerManager.filesController, createNewNote, isFilesSmartView])
useEffect(() => { useEffect(() => {
/** /**
* In the browser we're not allowed to override cmd/ctrl + n, so we have to * In the browser we're not allowed to override cmd/ctrl + n, so we have to
@@ -65,7 +79,7 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl], modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl],
onKeyDown: (event) => { onKeyDown: (event) => {
event.preventDefault() event.preventDefault()
void createNewNote() addNewItem()
}, },
}) })
@@ -76,7 +90,7 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
if (searchBarElement === document.activeElement) { if (searchBarElement === document.activeElement) {
searchBarElement?.blur() searchBarElement?.blur()
} }
selectNextNote() selectNextItem()
}, },
}) })
@@ -84,7 +98,7 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
key: KeyboardKey.Up, key: KeyboardKey.Up,
element: document.body, element: document.body,
onKeyDown: () => { onKeyDown: () => {
selectPreviousNote() selectPreviousItem()
}, },
}) })
@@ -104,11 +118,11 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
previousNoteKeyObserver() previousNoteKeyObserver()
searchKeyObserver() searchKeyObserver()
} }
}, [application, createNewNote, selectPreviousNote, searchBarElement, selectNextNote]) }, [addNewItem, application.io, createNewNote, searchBarElement, selectNextItem, selectPreviousItem])
const onNoteFilterTextChange = useCallback( const onNoteFilterTextChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(e: Event) => { (e) => {
setNoteFilterText((e.target as HTMLInputElement).value) setNoteFilterText(e.target.value)
}, },
[setNoteFilterText], [setNoteFilterText],
) )
@@ -116,8 +130,8 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
const onSearchFocused = useCallback(() => setFocusedSearch(true), []) const onSearchFocused = useCallback(() => setFocusedSearch(true), [])
const onSearchBlurred = useCallback(() => setFocusedSearch(false), []) const onSearchBlurred = useCallback(() => setFocusedSearch(false), [])
const onNoteFilterKeyUp = useCallback( const onNoteFilterKeyUp: KeyboardEventHandler = useCallback(
(e: KeyboardEvent) => { (e) => {
if (e.key === KeyboardKey.Enter) { if (e.key === KeyboardKey.Enter) {
onFilterEnter() onFilterEnter()
} }
@@ -128,37 +142,42 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
const panelResizeFinishCallback: ResizeFinishCallback = useCallback( const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
(width, _lastLeft, _isMaxWidth, isCollapsed) => { (width, _lastLeft, _isMaxWidth, isCollapsed) => {
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error) application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
appState.noteTags.reloadTagsContainerMaxWidth() viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed) application.publishPanelDidResizeEvent(PANEL_NAME_NOTES, isCollapsed)
}, },
[appState, application], [viewControllerManager, application],
) )
const panelWidthEventCallback = useCallback(() => { const panelWidthEventCallback = useCallback(() => {
appState.noteTags.reloadTagsContainerMaxWidth() viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
}, [appState]) }, [viewControllerManager])
const toggleDisplayOptionsMenu = useCallback(() => { const toggleDisplayOptionsMenu = useCallback(() => {
setShowDisplayOptionsMenu(!showDisplayOptionsMenu) setShowDisplayOptionsMenu(!showDisplayOptionsMenu)
}, [showDisplayOptionsMenu]) }, [showDisplayOptionsMenu])
const addButtonLabel = useMemo(
() => (isFilesSmartView ? 'Upload file' : 'Create a new note in the selected tag'),
[isFilesSmartView],
)
return ( return (
<div <div
id="notes-column" id="items-column"
className="sn-component section notes app-column app-column-second" className="sn-component section app-column app-column-second"
aria-label="Notes" aria-label={'Notes & Files'}
ref={notesViewPanelRef} ref={itemsViewPanelRef}
> >
<div className="content"> <div className="content">
<div id="notes-title-bar" className="section-title-bar"> <div id="items-title-bar" className="section-title-bar">
<div id="notes-title-bar-container"> <div id="items-title-bar-container">
<div className="section-title-bar-header"> <div className="section-title-bar-header">
<div className="sk-h2 font-semibold title">{panelTitle}</div> <div className="sk-h2 font-semibold title">{panelTitle}</div>
<button <button
className="sk-button contrast wide" className="sk-button contrast wide"
title="Create a new note in the selected tag" title={addButtonLabel}
aria-label="Create new note" aria-label={addButtonLabel}
onClick={() => createNewNote()} onClick={addNewItem}
> >
<div className="sk-label"> <div className="sk-label">
<i className="ion-plus add-button" aria-hidden></i> <i className="ion-plus add-button" aria-hidden></i>
@@ -172,16 +191,16 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
id="search-bar" id="search-bar"
className="filter-bar" className="filter-bar"
placeholder="Search" placeholder="Search"
title="Searches notes in the currently selected tag" title="Searches notes and files in the currently selected tag"
value={noteFilterText} value={noteFilterText}
onChange={onNoteFilterTextChange} onChange={onNoteFilterTextChange}
onKeyUp={onNoteFilterKeyUp} onKeyUp={onNoteFilterKeyUp}
onFocus={onSearchFocused} onFocus={onSearchFocused}
onBlur={onSearchBlurred} onBlur={onSearchBlurred}
autocomplete="off" autoComplete="off"
/> />
{noteFilterText && ( {noteFilterText && (
<button onClick={clearFilterText} aria-role="button" id="search-clear-button"> <button onClick={clearFilterText} id="search-clear-button">
</button> </button>
)} )}
@@ -189,13 +208,13 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
{(focusedSearch || noteFilterText) && ( {(focusedSearch || noteFilterText) && (
<div className="animate-fade-from-top"> <div className="animate-fade-from-top">
<SearchOptions application={application} appState={appState} /> <SearchOptions application={application} viewControllerManager={viewControllerManager} />
</div> </div>
)} )}
</div> </div>
<NoAccountWarning appState={appState} /> <NoAccountWarningWrapper viewControllerManager={viewControllerManager} />
</div> </div>
<div id="notes-menu-bar" className="sn-component" ref={displayOptionsMenuRef}> <div id="items-menu-bar" className="sn-component" ref={displayOptionsMenuRef}>
<div className="sk-app-bar no-edges"> <div className="sk-app-bar no-edges">
<div className="left"> <div className="left">
<Disclosure open={showDisplayOptionsMenu} onChange={toggleDisplayOptionsMenu}> <Disclosure open={showDisplayOptionsMenu} onChange={toggleDisplayOptionsMenu}>
@@ -214,8 +233,9 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
</DisclosureButton> </DisclosureButton>
<DisclosurePanel onBlur={closeDisplayOptMenuOnBlur}> <DisclosurePanel onBlur={closeDisplayOptMenuOnBlur}>
{showDisplayOptionsMenu && ( {showDisplayOptionsMenu && (
<NotesListOptionsMenu <ContentListOptionsMenu
application={application} application={application}
viewControllerManager={viewControllerManager}
closeDisplayOptionsMenu={toggleDisplayOptionsMenu} closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
closeOnBlur={closeDisplayOptMenuOnBlur} closeOnBlur={closeDisplayOptMenuOnBlur}
isOpen={showDisplayOptionsMenu} isOpen={showDisplayOptionsMenu}
@@ -227,27 +247,24 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
</div> </div>
</div> </div>
</div> </div>
{completedFullSync && !renderedNotes.length ? <p className="empty-notes-list faded">No notes.</p> : null} {completedFullSync && !renderedItems.length ? <p className="empty-items-list faded">No items.</p> : null}
{!completedFullSync && !renderedNotes.length ? ( {!completedFullSync && !renderedItems.length ? <p className="empty-items-list faded">Loading...</p> : null}
<p className="empty-notes-list faded">Loading notes...</p> {renderedItems.length ? (
) : null} <ContentList
{renderedNotes.length ? ( items={renderedItems}
<NotesList selectedItems={selectedItems}
notes={renderedNotes}
selectedNotes={selectedNotes}
application={application} application={application}
appState={appState} viewControllerManager={viewControllerManager}
displayOptions={displayOptions}
paginate={paginate} paginate={paginate}
/> />
) : null} ) : null}
</div> </div>
{notesViewPanelRef.current && ( {itemsViewPanelRef.current && (
<PanelResizer <PanelResizer
collapsable={true} collapsable={true}
hoverable={true} hoverable={true}
defaultWidth={300} defaultWidth={300}
panel={notesViewPanelRef.current} panel={itemsViewPanelRef.current}
side={PanelSide.Right} side={PanelSide.Right}
type={PanelResizeType.WidthOnly} type={PanelResizeType.WidthOnly}
resizeFinishCallback={panelResizeFinishCallback} resizeFinishCallback={panelResizeFinishCallback}
@@ -258,4 +275,6 @@ export const NotesView: FunctionComponent<Props> = observer(({ application, appS
)} )}
</div> </div>
) )
}) }
export default observer(ContentListView)

View File

@@ -0,0 +1,97 @@
import { FileItem } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback } from 'react'
import { getFileIconComponent } from '../AttachedFilesPopover/getFileIconComponent'
import ListItemConflictIndicator from './ListItemConflictIndicator'
import ListItemFlagIcons from './ListItemFlagIcons'
import ListItemTags from './ListItemTags'
import ListItemMetadata from './ListItemMetadata'
import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
const FileListItem: FunctionComponent<DisplayableListItemProps> = ({
application,
viewControllerManager,
hideDate,
hideIcon,
hideTags,
item,
selected,
sortBy,
tags,
}) => {
const openFileContextMenu = useCallback(
(posX: number, posY: number) => {
viewControllerManager.filesController.setFileContextMenuLocation({
x: posX,
y: posY,
})
viewControllerManager.filesController.setShowFileContextMenu(true)
},
[viewControllerManager.filesController],
)
const openContextMenu = useCallback(
async (posX: number, posY: number) => {
const { didSelect } = await viewControllerManager.selectionController.selectItem(item.uuid)
if (didSelect) {
openFileContextMenu(posX, posY)
}
},
[viewControllerManager.selectionController, item.uuid, openFileContextMenu],
)
const onClick = useCallback(() => {
void viewControllerManager.selectionController.selectItem(item.uuid, true).then(({ didSelect }) => {
if (didSelect && viewControllerManager.selectionController.selectedItemsCount < 2) {
viewControllerManager.filePreviewModalController.activate(
item as FileItem,
viewControllerManager.filesController.allFiles,
)
}
})
}, [
viewControllerManager.filePreviewModalController,
viewControllerManager.filesController.allFiles,
viewControllerManager.selectionController,
item,
])
const IconComponent = () =>
getFileIconComponent(
application.iconsController.getIconForFileType((item as FileItem).mimeType),
'w-5 h-5 flex-shrink-0',
)
return (
<div
className={`content-list-item flex items-stretch w-full cursor-pointer ${
selected && 'selected border-0 border-l-2px border-solid border-info'
}`}
id={item.uuid}
onClick={onClick}
onContextMenu={(event) => {
event.preventDefault()
void openContextMenu(event.clientX, event.clientY)
}}
>
{!hideIcon ? (
<div className="flex flex-col items-center justify-between p-4.5 pr-3 mr-0">
<IconComponent />
</div>
) : (
<div className="pr-4" />
)}
<div className="flex-grow min-w-0 py-4 px-0 border-0 border-b-1 border-solid border-main">
<div className="flex items-start justify-between font-semibold text-base leading-1.3 overflow-hidden">
<div className="break-word mr-2">{item.title}</div>
</div>
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={item} />
</div>
<ListItemFlagIcons item={item} />
</div>
)
}
export default observer(FileListItem)

View File

@@ -0,0 +1,20 @@
import { FunctionComponent } from 'react'
import { ListableContentItem } from './Types/ListableContentItem'
type Props = {
item: {
conflictOf?: ListableContentItem['conflictOf']
}
}
const ListItemConflictIndicator: FunctionComponent<Props> = ({ item }) => {
return item.conflictOf ? (
<div className="flex flex-wrap items-center mt-0.5">
<div className={'py-1 px-1.5 rounded mr-1 mt-2 bg-danger color-danger-contrast'}>
<div className="text-xs font-bold text-center">Conflicted Copy</div>
</div>
</div>
) : null
}
export default ListItemConflictIndicator

View File

@@ -0,0 +1,47 @@
import { FunctionComponent } from 'react'
import Icon from '@/Components/Icon/Icon'
import { ListableContentItem } from './Types/ListableContentItem'
type Props = {
item: {
locked: ListableContentItem['locked']
trashed: ListableContentItem['trashed']
archived: ListableContentItem['archived']
pinned: ListableContentItem['pinned']
}
hasFiles?: boolean
}
const ListItemFlagIcons: FunctionComponent<Props> = ({ item, hasFiles = false }) => {
return (
<div className="flex items-start p-4 pl-0 border-0 border-b-1 border-solid border-main">
{item.locked && (
<span className="flex items-center" title="Editing Disabled">
<Icon ariaLabel="Editing Disabled" type="pencil-off" className="sn-icon--small color-info" />
</span>
)}
{item.trashed && (
<span className="flex items-center ml-1.5" title="Trashed">
<Icon ariaLabel="Trashed" type="trash-filled" className="sn-icon--small color-danger" />
</span>
)}
{item.archived && (
<span className="flex items-center ml-1.5" title="Archived">
<Icon ariaLabel="Archived" type="archive" className="sn-icon--mid color-accessory-tint-3" />
</span>
)}
{item.pinned && (
<span className="flex items-center ml-1.5" title="Pinned">
<Icon ariaLabel="Pinned" type="pin-filled" className="sn-icon--small color-info" />
</span>
)}
{hasFiles && (
<span className="flex items-center ml-1.5" title="Files">
<Icon ariaLabel="Files" type="attachment-file" className="sn-icon--small color-info" />
</span>
)}
</div>
)
}
export default ListItemFlagIcons

View File

@@ -0,0 +1,31 @@
import { CollectionSort, SortableItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'react'
import { ListableContentItem } from './Types/ListableContentItem'
type Props = {
item: {
protected: ListableContentItem['protected']
updatedAtString?: ListableContentItem['updatedAtString']
createdAtString?: ListableContentItem['createdAtString']
}
hideDate: boolean
sortBy: keyof SortableItem | undefined
}
const ListItemMetadata: FunctionComponent<Props> = ({ item, hideDate, sortBy }) => {
const showModifiedDate = sortBy === CollectionSort.UpdatedAt
if (hideDate && !item.protected) {
return null
}
return (
<div className="text-xs leading-1.4 mt-1 faded">
{item.protected && <span>Protected {hideDate ? '' : ' • '}</span>}
{!hideDate && showModifiedDate && <span>Modified {item.updatedAtString || 'Now'}</span>}
{!hideDate && !showModifiedDate && <span>{item.createdAtString || 'Now'}</span>}
</div>
)
}
export default ListItemMetadata

View File

@@ -0,0 +1,30 @@
import { FunctionComponent } from 'react'
import Icon from '@/Components/Icon/Icon'
import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
type Props = {
hideTags: boolean
tags: DisplayableListItemProps['tags']
}
const ListItemTags: FunctionComponent<Props> = ({ hideTags, tags }) => {
if (hideTags || !tags.length) {
return null
}
return (
<div className="flex flex-wrap mt-1.5 text-xs gap-2">
{tags.map((tag) => (
<span
className="inline-flex items-center py-1 px-1.5 bg-passive-4-opacity-variant color-foreground rounded-0.5"
key={tag.uuid}
>
<Icon type="hashtag" className="sn-icon--small color-passive-1 mr-1" />
<span>{tag.title}</span>
</span>
))}
</div>
)
}
export default ListItemTags

View File

@@ -0,0 +1,97 @@
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
import { sanitizeHtmlString, SNNote } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import Icon from '@/Components/Icon/Icon'
import ListItemConflictIndicator from './ListItemConflictIndicator'
import ListItemFlagIcons from './ListItemFlagIcons'
import ListItemTags from './ListItemTags'
import ListItemMetadata from './ListItemMetadata'
import { DisplayableListItemProps } from './Types/DisplayableListItemProps'
const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
application,
viewControllerManager,
hideDate,
hideIcon,
hideTags,
hidePreview,
item,
selected,
sortBy,
tags,
}) => {
const editorForNote = application.componentManager.editorForNote(item as SNNote)
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type)
const hasFiles = application.items.getFilesForNote(item as SNNote).length > 0
const openNoteContextMenu = (posX: number, posY: number) => {
viewControllerManager.notesController.setContextMenuClickLocation({
x: posX,
y: posY,
})
viewControllerManager.notesController.reloadContextMenuLayout()
viewControllerManager.notesController.setContextMenuOpen(true)
}
const openContextMenu = async (posX: number, posY: number) => {
const { didSelect } = await viewControllerManager.selectionController.selectItem(item.uuid, true)
if (didSelect) {
openNoteContextMenu(posX, posY)
}
}
return (
<div
className={`content-list-item flex items-stretch w-full cursor-pointer ${
selected && 'selected border-0 border-l-2px border-solid border-info'
}`}
id={item.uuid}
onClick={() => {
void viewControllerManager.selectionController.selectItem(item.uuid, true)
}}
onContextMenu={(event) => {
event.preventDefault()
void openContextMenu(event.clientX, event.clientY)
}}
>
{!hideIcon ? (
<div className="flex flex-col items-center justify-between p-4 pr-3 mr-0">
<Icon ariaLabel={`Icon for ${editorName}`} type={icon} className={`color-accessory-tint-${tint}`} />
</div>
) : (
<div className="pr-4" />
)}
<div className="flex-grow min-w-0 py-4 px-0 border-0 border-b-1 border-solid border-main">
<div className="flex items-start justify-between font-semibold text-base leading-1.3 overflow-hidden">
<div className="break-word mr-2">{item.title}</div>
</div>
{!hidePreview && !item.hidePreview && !item.protected && (
<div className="overflow-hidden overflow-ellipsis text-sm">
{item.preview_html && (
<div
className="my-1"
dangerouslySetInnerHTML={{
__html: sanitizeHtmlString(item.preview_html),
}}
></div>
)}
{!item.preview_html && item.preview_plain && (
<div className="leading-1.3 overflow-hidden line-clamp-1 mt-1">{item.preview_plain}</div>
)}
{!item.preview_html && !item.preview_plain && item.text && (
<div className="leading-1.3 overflow-hidden line-clamp-1 mt-1">{item.text}</div>
)}
</div>
)}
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={item} />
</div>
<ListItemFlagIcons item={item} hasFiles={hasFiles} />
</div>
)
}
export default observer(NoteListItem)

View File

@@ -0,0 +1,16 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { SortableItem } from '@standardnotes/snjs'
import { ListableContentItem } from './ListableContentItem'
export type AbstractListItemProps = {
application: WebApplication
viewControllerManager: ViewControllerManager
hideDate: boolean
hideIcon: boolean
hideTags: boolean
hidePreview: boolean
item: ListableContentItem
selected: boolean
sortBy: keyof SortableItem | undefined
}

View File

@@ -0,0 +1,9 @@
import { SNTag } from '@standardnotes/snjs'
import { AbstractListItemProps } from './AbstractListItemProps'
export type DisplayableListItemProps = AbstractListItemProps & {
tags: {
uuid: SNTag['uuid']
title: SNTag['title']
}[]
}

View File

@@ -0,0 +1,14 @@
import { ContentType, DecryptedItem, ItemContent } from '@standardnotes/snjs'
export type ListableContentItem = DecryptedItem<ItemContent> & {
title: string
protected: boolean
uuid: string
content_type: ContentType
updatedAtString?: string
createdAtString?: string
hidePreview?: boolean
preview_html?: string
preview_plain?: string
text?: string
}

View File

@@ -0,0 +1,17 @@
import { WebApplication } from '@/Application/Application'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
type Props = {
application: WebApplication
}
const DeallocateHandler: FunctionComponent<Props> = ({ application, children }) => {
if (application.dealloced) {
return null
}
return <>{children}</>
}
export default observer(DeallocateHandler)

View File

@@ -1,16 +1,8 @@
import { ListboxArrow, ListboxButton, ListboxInput, ListboxList, ListboxOption, ListboxPopover } from '@reach/listbox' import { ListboxArrow, ListboxButton, ListboxInput, ListboxList, ListboxOption, ListboxPopover } from '@reach/listbox'
import VisuallyHidden from '@reach/visually-hidden' import VisuallyHidden from '@reach/visually-hidden'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { Icon } from '@/Components/Icon' import Icon from '@/Components/Icon/Icon'
import { IconType } from '@standardnotes/snjs' import { DropdownItem } from './DropdownItem'
export type DropdownItem = {
icon?: IconType
iconClassName?: string
label: string
value: string
disabled?: boolean
}
type DropdownProps = { type DropdownProps = {
id: string id: string
@@ -41,12 +33,12 @@ const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
<div className="dropdown-selected-label">{label}</div> <div className="dropdown-selected-label">{label}</div>
</div> </div>
<ListboxArrow className={`sn-dropdown-arrow ${isExpanded ? 'sn-dropdown-arrow-flipped' : ''}`}> <ListboxArrow className={`sn-dropdown-arrow ${isExpanded ? 'sn-dropdown-arrow-flipped' : ''}`}>
<Icon type="menu-arrow-down" className="sn-icon--small color-grey-1" /> <Icon type="menu-arrow-down" className="sn-icon--small color-passive-1" />
</ListboxArrow> </ListboxArrow>
</> </>
) )
export const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled }) => { const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled }) => {
const labelId = `${id}-label` const labelId = `${id}-label`
const handleChange = (value: string) => { const handleChange = (value: string) => {
@@ -79,6 +71,7 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, v
<ListboxList> <ListboxList>
{items.map((item) => ( {items.map((item) => (
<ListboxOption <ListboxOption
key={item.value}
className="sn-dropdown-item" className="sn-dropdown-item"
value={item.value} value={item.value}
label={item.label} label={item.label}
@@ -99,3 +92,5 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, v
</> </>
) )
} }
export default Dropdown

View File

@@ -0,0 +1,9 @@
import { IconType } from '@standardnotes/snjs'
export type DropdownItem = {
icon?: IconType
iconClassName?: string
label: string
value: string
disabled?: boolean
}

View File

@@ -0,0 +1,135 @@
import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { PopoverFileItemAction } from '../AttachedFilesPopover/PopoverFileItemAction'
import { PopoverTabs } from '../AttachedFilesPopover/PopoverTabs'
import FileMenuOptions from './FileMenuOptions'
type Props = {
viewControllerManager: ViewControllerManager
}
const FileContextMenu: FunctionComponent<Props> = observer(({ viewControllerManager }) => {
const { selectedFiles, showFileContextMenu, setShowFileContextMenu, fileContextMenuLocation } =
viewControllerManager.filesController
const [contextMenuStyle, setContextMenuStyle] = useState<React.CSSProperties>({
top: 0,
left: 0,
visibility: 'hidden',
})
const [contextMenuMaxHeight, setContextMenuMaxHeight] = useState<number | 'auto'>('auto')
const contextMenuRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) => setShowFileContextMenu(open))
useCloseOnClickOutside(contextMenuRef, () => viewControllerManager.filesController.setShowFileContextMenu(false))
const selectedFile = selectedFiles[0]
const reloadContextMenuLayout = useCallback(() => {
const { clientHeight } = document.documentElement
const defaultFontSize = window.getComputedStyle(document.documentElement).fontSize
const maxContextMenuHeight = parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
let openUpBottom = true
if (footerHeightInPx) {
const bottomSpace = clientHeight - footerHeightInPx - fileContextMenuLocation.y
const upSpace = fileContextMenuLocation.y
if (maxContextMenuHeight > bottomSpace) {
if (upSpace > maxContextMenuHeight) {
openUpBottom = false
setContextMenuMaxHeight('auto')
} else {
if (upSpace > bottomSpace) {
setContextMenuMaxHeight(upSpace - MENU_MARGIN_FROM_APP_BORDER)
openUpBottom = false
} else {
setContextMenuMaxHeight(bottomSpace - MENU_MARGIN_FROM_APP_BORDER)
}
}
} else {
setContextMenuMaxHeight('auto')
}
}
if (openUpBottom) {
setContextMenuStyle({
top: fileContextMenuLocation.y,
left: fileContextMenuLocation.x,
visibility: 'visible',
})
} else {
setContextMenuStyle({
bottom: clientHeight - fileContextMenuLocation.y,
left: fileContextMenuLocation.x,
visibility: 'visible',
})
}
}, [fileContextMenuLocation.x, fileContextMenuLocation.y])
useEffect(() => {
if (showFileContextMenu) {
reloadContextMenuLayout()
}
}, [reloadContextMenuLayout, showFileContextMenu])
useEffect(() => {
window.addEventListener('resize', reloadContextMenuLayout)
return () => {
window.removeEventListener('resize', reloadContextMenuLayout)
}
}, [reloadContextMenuLayout])
const handleFileAction = useCallback(
async (action: PopoverFileItemAction) => {
const { didHandleAction } = await viewControllerManager.filesController.handleFileAction(
action,
PopoverTabs.AllFiles,
)
return didHandleAction
},
[viewControllerManager.filesController],
)
return (
<div
ref={contextMenuRef}
className="sn-dropdown min-w-60 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed"
style={{
...contextMenuStyle,
maxHeight: contextMenuMaxHeight,
}}
>
<FileMenuOptions
file={selectedFile}
handleFileAction={handleFileAction}
closeOnBlur={closeOnBlur}
closeMenu={() => setShowFileContextMenu(false)}
shouldShowRenameOption={false}
shouldShowAttachOption={false}
/>
</div>
)
})
FileContextMenu.displayName = 'FileContextMenu'
const FileContextMenuWrapper: FunctionComponent<Props> = ({ viewControllerManager }) => {
const { selectedFiles, showFileContextMenu } = viewControllerManager.filesController
const selectedFile = selectedFiles[0]
if (!showFileContextMenu || !selectedFile) {
return null
}
return <FileContextMenu viewControllerManager={viewControllerManager} />
}
export default observer(FileContextMenuWrapper)

View File

@@ -0,0 +1,143 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'react'
import { PopoverFileItemAction, PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import Icon from '@/Components/Icon/Icon'
import Switch from '@/Components/Switch/Switch'
type Props = {
closeMenu: () => void
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
file: FileItem
fileProtectionToggleCallback?: (isProtected: boolean) => void
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>
isFileAttachedToNote?: boolean
renameToggleCallback?: (isRenamingFile: boolean) => void
shouldShowRenameOption: boolean
shouldShowAttachOption: boolean
}
const FileMenuOptions: FunctionComponent<Props> = ({
closeMenu,
closeOnBlur,
file,
fileProtectionToggleCallback,
handleFileAction,
isFileAttachedToNote,
renameToggleCallback,
shouldShowRenameOption,
shouldShowAttachOption,
}) => {
return (
<>
<button
onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.PreviewFile,
payload: file,
}).catch(console.error)
closeMenu()
}}
>
<Icon type="file" className="mr-2 color-neutral" />
Preview file
</button>
{isFileAttachedToNote ? (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DetachFileToNote,
payload: file,
}).catch(console.error)
closeMenu()
}}
>
<Icon type="link-off" className="mr-2 color-neutral" />
Detach from note
</button>
) : shouldShowAttachOption ? (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
payload: file,
}).catch(console.error)
closeMenu()
}}
>
<Icon type="link" className="mr-2 color-neutral" />
Attach to note
</button>
) : null}
<div className="min-h-1px my-1 bg-border"></div>
<button
className="sn-dropdown-item justify-between focus:bg-info-backdrop"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.ToggleFileProtection,
payload: file,
callback: (isProtected: boolean) => {
fileProtectionToggleCallback?.(isProtected)
},
}).catch(console.error)
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="password" className="mr-2 color-neutral" />
Password protection
</span>
<Switch className="px-0 pointer-events-none" tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} checked={file.protected} />
</button>
<div className="min-h-1px my-1 bg-border"></div>
<button
onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DownloadFile,
payload: file,
}).catch(console.error)
closeMenu()
}}
>
<Icon type="download" className="mr-2 color-neutral" />
Download
</button>
{shouldShowRenameOption && (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => {
renameToggleCallback?.(true)
}}
>
<Icon type="pencil" className="mr-2 color-neutral" />
Rename
</button>
)}
<button
onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DeleteFile,
payload: file,
}).catch(console.error)
closeMenu()
}}
>
<Icon type="trash" className="mr-2 color-danger" />
<span className="color-danger">Delete permanently</span>
</button>
</>
)
}
export default FileMenuOptions

View File

@@ -1,13 +1,13 @@
import { formatSizeToReadableString } from '@standardnotes/filepicker' import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { Icon } from '@/Components/Icon' import Icon from '@/Components/Icon/Icon'
type Props = { type Props = {
file: FileItem file: FileItem
} }
export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => { const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
return ( return (
<div className="flex flex-col min-w-70 p-4 border-0 border-l-1px border-solid border-main"> <div className="flex flex-col min-w-70 p-4 border-0 border-l-1px border-solid border-main">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
@@ -35,3 +35,5 @@ export const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
</div> </div>
) )
} }
export default FilePreviewInfoPanel

View File

@@ -1,30 +1,29 @@
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays' import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays'
import { DialogContent, DialogOverlay } from '@reach/dialog' import { DialogContent, DialogOverlay } from '@reach/dialog'
import { addToast, ToastType } from '@standardnotes/stylekit' import { addToast, ToastType } from '@standardnotes/stylekit'
import { NoPreviewIllustration } from '@standardnotes/icons' import { NoPreviewIllustration } from '@standardnotes/icons'
import { FunctionComponent } from 'preact' import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import { getFileIconComponent } from '@/Components/AttachedFilesPopover/getFileIconComponent'
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/PopoverFileItem' import Button from '@/Components/Button/Button'
import { Button } from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import FilePreviewInfoPanel from './FilePreviewInfoPanel'
import { FilePreviewInfoPanel } from './FilePreviewInfoPanel'
import { isFileTypePreviewable } from './isFilePreviewable' import { isFileTypePreviewable } from './isFilePreviewable'
import { PreviewComponent } from './PreviewComponent' import PreviewComponent from './PreviewComponent'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { KeyboardKey } from '@/Services/IOService' import { KeyboardKey } from '@/Services/IOService'
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
type Props = { type Props = {
application: WebApplication application: WebApplication
appState: AppState viewControllerManager: ViewControllerManager
} }
export const FilePreviewModal: FunctionComponent<Props> = observer(({ application, appState }) => { const FilePreviewModal: FunctionComponent<Props> = observer(({ application, viewControllerManager }) => {
const { currentFile, setCurrentFile, otherFiles, dismiss, isOpen } = appState.filePreviewModal const { currentFile, setCurrentFile, otherFiles, dismiss } = viewControllerManager.filePreviewModalController
if (!currentFile || !isOpen) { if (!currentFile) {
return null return null
} }
@@ -87,34 +86,46 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
} }
}, [currentFile, getObjectUrl, objectUrl]) }, [currentFile, getObjectUrl, objectUrl])
const keyDownHandler = (event: KeyboardEvent) => { const keyDownHandler: KeyboardEventHandler = useCallback(
if (event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right) { (event) => {
return if (event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right) {
} return
event.preventDefault()
const currentFileIndex = otherFiles.findIndex((fileFromArray) => fileFromArray.uuid === currentFile.uuid)
switch (event.key) {
case KeyboardKey.Left: {
const previousFileIndex = currentFileIndex - 1 >= 0 ? currentFileIndex - 1 : otherFiles.length - 1
const previousFile = otherFiles[previousFileIndex]
if (previousFile) {
setCurrentFile(previousFile)
}
break
} }
case KeyboardKey.Right: {
const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0 event.preventDefault()
const nextFile = otherFiles[nextFileIndex]
if (nextFile) { const currentFileIndex = otherFiles.findIndex((fileFromArray) => fileFromArray.uuid === currentFile.uuid)
setCurrentFile(nextFile)
switch (event.key) {
case KeyboardKey.Left: {
const previousFileIndex = currentFileIndex - 1 >= 0 ? currentFileIndex - 1 : otherFiles.length - 1
const previousFile = otherFiles[previousFileIndex]
if (previousFile) {
setCurrentFile(previousFile)
}
break
}
case KeyboardKey.Right: {
const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0
const nextFile = otherFiles[nextFileIndex]
if (nextFile) {
setCurrentFile(nextFile)
}
break
} }
break
} }
} },
} [currentFile.uuid, otherFiles, setCurrentFile],
)
const IconComponent = useMemo(
() =>
getFileIconComponent(
application.iconsController.getIconForFileType(currentFile.mimeType),
'w-6 h-6 flex-shrink-0',
),
[application.iconsController, currentFile.mimeType],
)
return ( return (
<DialogOverlay <DialogOverlay
@@ -125,12 +136,13 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
dangerouslyBypassScrollLock dangerouslyBypassScrollLock
> >
<DialogContent <DialogContent
aria-label="File preview modal"
className="flex flex-col rounded shadow-overlay" className="flex flex-col rounded shadow-overlay"
style={{ style={{
width: '90%', width: '90%',
maxWidth: '90%', maxWidth: '90%',
minHeight: '90%', minHeight: '90%',
background: 'var(--sn-stylekit-background-color)', background: 'var(--modal-background-color)',
}} }}
> >
<div <div
@@ -139,12 +151,7 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
onKeyDown={keyDownHandler} onKeyDown={keyDownHandler}
> >
<div className="flex items-center"> <div className="flex items-center">
<div className="w-6 h-6"> <div className="w-6 h-6">{IconComponent}</div>
{getFileIconComponent(
application.iconsController.getIconForFileType(currentFile.mimeType),
'w-6 h-6 flex-shrink-0',
)}
</div>
<span className="ml-3 font-medium">{currentFile.name}</span> <span className="ml-3 font-medium">{currentFile.name}</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
@@ -197,7 +204,7 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
<div className="font-bold text-base mb-2">This file can't be previewed.</div> <div className="font-bold text-base mb-2">This file can't be previewed.</div>
{isFilePreviewable ? ( {isFilePreviewable ? (
<> <>
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch"> <div className="text-sm text-center color-passive-0 mb-4 max-w-35ch">
There was an error loading the file. Try again, or download the file and open it using another There was an error loading the file. Try again, or download the file and open it using another
application. application.
</div> </div>
@@ -214,7 +221,10 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
<Button <Button
variant="normal" variant="normal"
onClick={() => { onClick={() => {
application.getAppState().files.downloadFile(currentFile).catch(console.error) application
.getViewControllerManager()
.filesController.downloadFile(currentFile)
.catch(console.error)
}} }}
> >
Download Download
@@ -223,13 +233,16 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
</> </>
) : ( ) : (
<> <>
<div className="text-sm text-center color-grey-0 mb-4 max-w-35ch"> <div className="text-sm text-center color-passive-0 mb-4 max-w-35ch">
To view this file, download it and open it using another application. To view this file, download it and open it using another application.
</div> </div>
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
application.getAppState().files.downloadFile(currentFile).catch(console.error) application
.getViewControllerManager()
.filesController.downloadFile(currentFile)
.catch(console.error)
}} }}
> >
Download Download
@@ -245,3 +258,13 @@ export const FilePreviewModal: FunctionComponent<Props> = observer(({ applicatio
</DialogOverlay> </DialogOverlay>
) )
}) })
FilePreviewModal.displayName = 'FilePreviewModal'
const FilePreviewModalWrapper: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
return viewControllerManager.filePreviewModalController.isOpen ? (
<FilePreviewModal application={application} viewControllerManager={viewControllerManager} />
) : null
}
export default observer(FilePreviewModalWrapper)

View File

@@ -1,13 +1,12 @@
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent, useRef, useState } from 'react'
import { useRef, useState } from 'preact/hooks' import IconButton from '../Button/IconButton'
import { IconButton } from '../Button/IconButton'
type Props = { type Props = {
objectUrl: string objectUrl: string
} }
export const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => { const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
const initialImgHeightRef = useRef<number>() const initialImgHeightRef = useRef<number>()
const [imageZoomPercent, setImageZoomPercent] = useState(100) const [imageZoomPercent, setImageZoomPercent] = useState(100)
@@ -21,12 +20,12 @@ export const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
height: `${imageZoomPercent}%`, height: `${imageZoomPercent}%`,
...(imageZoomPercent <= 100 ...(imageZoomPercent <= 100
? { ? {
'min-width': '100%', minWidth: '100%',
'object-fit': 'contain', objectFit: 'contain',
} }
: { : {
position: 'absolute', position: 'absolute',
inset: 0, top: 0,
margin: 'auto', margin: 'auto',
}), }),
}} }}
@@ -69,3 +68,5 @@ export const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
</div> </div>
) )
} }
export default ImagePreview

View File

@@ -1,19 +1,19 @@
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { FunctionComponent } from 'preact' import { FunctionComponent } from 'react'
import { ImagePreview } from './ImagePreview' import ImagePreview from './ImagePreview'
type Props = { type Props = {
file: FileItem file: FileItem
objectUrl: string objectUrl: string
} }
export const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl }) => { const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl }) => {
if (file.mimeType.startsWith('image/')) { if (file.mimeType.startsWith('image/')) {
return <ImagePreview objectUrl={objectUrl} /> return <ImagePreview objectUrl={objectUrl} />
} }
if (file.mimeType.startsWith('video/')) { if (file.mimeType.startsWith('video/')) {
return <video className="w-full h-full" src={objectUrl} controls /> return <video className="w-full h-full" src={objectUrl} controls autoPlay />
} }
if (file.mimeType.startsWith('audio/')) { if (file.mimeType.startsWith('audio/')) {
@@ -22,3 +22,5 @@ export const PreviewComponent: FunctionComponent<Props> = ({ file, objectUrl })
return <object className="w-full h-full" data={objectUrl} /> return <object className="w-full h-full" data={objectUrl} />
} }
export default PreviewComponent

View File

@@ -1,22 +1,24 @@
import { WebAppEvent, WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { ApplicationGroup } from '@/UIModels/ApplicationGroup' import { WebAppEvent } from '@/Application/WebAppEvent'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { PureComponent } from '@/Components/Abstract/PureComponent' import { PureComponent } from '@/Components/Abstract/PureComponent'
import { destroyAllObjectProperties, preventRefreshing } from '@/Utils' import { destroyAllObjectProperties, preventRefreshing } from '@/Utils'
import { ApplicationEvent, ContentType, CollectionSort, ApplicationDescriptor } from '@standardnotes/snjs' import { ApplicationEvent, ApplicationDescriptor } from '@standardnotes/snjs'
import { import {
STRING_NEW_UPDATE_READY, STRING_NEW_UPDATE_READY,
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT, STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE, STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON, STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
} from '@/Strings' } from '@/Constants/Strings'
import { alertDialog, confirmDialog } from '@/Services/AlertService' import { alertDialog, confirmDialog } from '@/Services/AlertService'
import { AccountMenu, AccountMenuPane } from '@/Components/AccountMenu' import AccountMenu from '@/Components/AccountMenu/AccountMenu'
import { AppStateEvent, EventSource } from '@/UIModels/AppState' import Icon from '@/Components/Icon/Icon'
import { Icon } from '@/Components/Icon' import QuickSettingsMenu from '@/Components/QuickSettingsMenu/QuickSettingsMenu'
import { QuickSettingsMenu } from '@/Components/QuickSettingsMenu' import SyncResolutionMenu from '@/Components/SyncResolutionMenu/SyncResolutionMenu'
import { SyncResolutionMenu } from '@/Components/SyncResolutionMenu' import { Fragment } from 'react'
import { Fragment } from 'preact' import { AccountMenuPane } from '../AccountMenu/AccountMenuPane'
import { EditorEventSource } from '@/Types/EditorEventSource'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -38,7 +40,7 @@ type State = {
arbitraryStatusMessage?: string arbitraryStatusMessage?: string
} }
export class Footer extends PureComponent<Props, State> { class Footer extends PureComponent<Props, State> {
public user?: unknown public user?: unknown
private didCheckForOffline = false private didCheckForOffline = false
private completedInitialSync = false private completedInitialSync = false
@@ -62,9 +64,34 @@ export class Footer extends PureComponent<Props, State> {
showQuickSettingsMenu: false, showQuickSettingsMenu: false,
} }
this.webEventListenerDestroyer = props.application.addWebEventObserver((event) => { this.webEventListenerDestroyer = props.application.addWebEventObserver((event, data) => {
if (event === WebAppEvent.NewUpdateAvailable) { const statusService = this.application.status
this.onNewUpdateAvailable()
switch (event) {
case WebAppEvent.NewUpdateAvailable:
this.onNewUpdateAvailable()
break
case WebAppEvent.EditorFocused:
if ((data as any).eventSource === EditorEventSource.UserInteraction) {
this.closeAccountMenu()
}
break
case WebAppEvent.BeganBackupDownload:
statusService.setMessage('Saving local backup…')
break
case WebAppEvent.EndedBackupDownload: {
const successMessage = 'Successfully saved backup.'
const errorMessage = 'Unable to save local backup.'
statusService.setMessage((data as any).success ? successMessage : errorMessage)
const twoSeconds = 2000
setTimeout(() => {
if (statusService.message === successMessage || statusService.message === errorMessage) {
statusService.setMessage('')
}
}, twoSeconds)
break
}
} }
}) })
} }
@@ -91,11 +118,11 @@ export class Footer extends PureComponent<Props, State> {
}) })
this.autorun(() => { this.autorun(() => {
const showBetaWarning = this.appState.showBetaWarning const showBetaWarning = this.viewControllerManager.showBetaWarning
this.setState({ this.setState({
showBetaWarning: showBetaWarning, showBetaWarning: showBetaWarning,
showAccountMenu: this.appState.accountMenu.show, showAccountMenu: this.viewControllerManager.accountMenuController.show,
showQuickSettingsMenu: this.appState.quickSettingsMenu.open, showQuickSettingsMenu: this.viewControllerManager.quickSettingsMenuController.open,
}) })
}) })
} }
@@ -118,7 +145,6 @@ export class Footer extends PureComponent<Props, State> {
this.reloadUpgradeStatus() this.reloadUpgradeStatus()
this.updateOfflineStatus() this.updateOfflineStatus()
this.findErrors() this.findErrors()
this.streamItems()
} }
reloadUser() { reloadUser() {
@@ -132,33 +158,6 @@ export class Footer extends PureComponent<Props, State> {
}) })
} }
override onAppStateEvent(eventName: AppStateEvent, data: any) {
const statusService = this.application.status
switch (eventName) {
case AppStateEvent.EditorFocused:
if (data.eventSource === EventSource.UserInteraction) {
this.closeAccountMenu()
}
break
case AppStateEvent.BeganBackupDownload:
statusService.setMessage('Saving local backup…')
break
case AppStateEvent.EndedBackupDownload: {
const successMessage = 'Successfully saved backup.'
const errorMessage = 'Unable to save local backup.'
statusService.setMessage(data.success ? successMessage : errorMessage)
const twoSeconds = 2000
setTimeout(() => {
if (statusService.message === successMessage || statusService.message === errorMessage) {
statusService.setMessage('')
}
}, twoSeconds)
break
}
}
}
override async onAppKeyChange() { override async onAppKeyChange() {
super.onAppKeyChange().catch(console.error) super.onAppKeyChange().catch(console.error)
this.reloadPasscodeStatus().catch(console.error) this.reloadPasscodeStatus().catch(console.error)
@@ -187,7 +186,7 @@ export class Footer extends PureComponent<Props, State> {
if (!this.didCheckForOffline) { if (!this.didCheckForOffline) {
this.didCheckForOffline = true this.didCheckForOffline = true
if (this.state.offline && this.application.items.getNoteCount() === 0) { if (this.state.offline && this.application.items.getNoteCount() === 0) {
this.appState.accountMenu.setShow(true) this.viewControllerManager.accountMenuController.setShow(true)
} }
} }
this.findErrors() this.findErrors()
@@ -217,10 +216,6 @@ export class Footer extends PureComponent<Props, State> {
} }
} }
streamItems() {
this.application.items.setDisplayOptions(ContentType.Theme, CollectionSort.Title, 'asc')
}
updateSyncStatus() { updateSyncStatus() {
const statusManager = this.application.status const statusManager = this.application.status
const syncStatus = this.application.sync.getSyncStatus() const syncStatus = this.application.sync.getSyncStatus()
@@ -292,13 +287,13 @@ export class Footer extends PureComponent<Props, State> {
} }
accountMenuClickHandler = () => { accountMenuClickHandler = () => {
this.appState.quickSettingsMenu.closeQuickSettingsMenu() this.viewControllerManager.quickSettingsMenuController.closeQuickSettingsMenu()
this.appState.accountMenu.toggleShow() this.viewControllerManager.accountMenuController.toggleShow()
} }
quickSettingsClickHandler = () => { quickSettingsClickHandler = () => {
this.appState.accountMenu.closeAccountMenu() this.viewControllerManager.accountMenuController.closeAccountMenu()
this.appState.quickSettingsMenu.toggle() this.viewControllerManager.quickSettingsMenuController.toggle()
} }
syncResolutionClickHandler = () => { syncResolutionClickHandler = () => {
@@ -308,8 +303,8 @@ export class Footer extends PureComponent<Props, State> {
} }
closeAccountMenu = () => { closeAccountMenu = () => {
this.appState.accountMenu.setShow(false) this.viewControllerManager.accountMenuController.setShow(false)
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu) this.viewControllerManager.accountMenuController.setCurrentPane(AccountMenuPane.GeneralMenu)
} }
lockClickHandler = () => { lockClickHandler = () => {
@@ -337,11 +332,11 @@ export class Footer extends PureComponent<Props, State> {
} }
clickOutsideAccountMenu = () => { clickOutsideAccountMenu = () => {
this.appState.accountMenu.closeAccountMenu() this.viewControllerManager.accountMenuController.closeAccountMenu()
} }
clickOutsideQuickSettingsMenu = () => { clickOutsideQuickSettingsMenu = () => {
this.appState.quickSettingsMenu.closeQuickSettingsMenu() this.viewControllerManager.quickSettingsMenuController.closeQuickSettingsMenu()
} }
override render() { override render() {
@@ -364,7 +359,7 @@ export class Footer extends PureComponent<Props, State> {
{this.state.showAccountMenu && ( {this.state.showAccountMenu && (
<AccountMenu <AccountMenu
onClickOutside={this.clickOutsideAccountMenu} onClickOutside={this.clickOutsideAccountMenu}
appState={this.appState} viewControllerManager={this.viewControllerManager}
application={this.application} application={this.application}
mainApplicationGroup={this.props.applicationGroup} mainApplicationGroup={this.props.applicationGroup}
/> />
@@ -385,7 +380,7 @@ export class Footer extends PureComponent<Props, State> {
{this.state.showQuickSettingsMenu && ( {this.state.showQuickSettingsMenu && (
<QuickSettingsMenu <QuickSettingsMenu
onClickOutside={this.clickOutsideQuickSettingsMenu} onClickOutside={this.clickOutsideQuickSettingsMenu}
appState={this.appState} viewControllerManager={this.viewControllerManager}
application={this.application} application={this.application}
/> />
)} )}
@@ -459,3 +454,5 @@ export class Footer extends PureComponent<Props, State> {
) )
} }
} }
export default Footer

View File

@@ -1,4 +1,4 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
import { import {
@@ -187,7 +187,7 @@ type Props = {
ariaLabel?: string ariaLabel?: string
} }
export const Icon: FunctionalComponent<Props> = ({ type, className = '', ariaLabel }) => { const Icon: FunctionComponent<Props> = ({ type, className = '', ariaLabel }) => {
const IconComponent = ICONS[type as keyof typeof ICONS] const IconComponent = ICONS[type as keyof typeof ICONS]
if (!IconComponent) { if (!IconComponent) {
return null return null
@@ -200,3 +200,5 @@ export const Icon: FunctionalComponent<Props> = ({ type, className = '', ariaLab
/> />
) )
} }
export default Icon

View File

@@ -1,5 +1,4 @@
import { FunctionalComponent, Ref } from 'preact' import { forwardRef, Fragment, Ref } from 'react'
import { forwardRef } from 'preact/compat'
import { DecoratedInputProps } from './DecoratedInputProps' import { DecoratedInputProps } from './DecoratedInputProps'
const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean) => { const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean) => {
@@ -10,14 +9,14 @@ const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean
input: `w-full border-0 focus:shadow-none bg-transparent color-text ${ input: `w-full border-0 focus:shadow-none bg-transparent color-text ${
!hasLeftDecorations && hasRightDecorations ? 'pl-2' : '' !hasLeftDecorations && hasRightDecorations ? 'pl-2' : ''
} ${hasRightDecorations ? 'pr-2' : ''}`, } ${hasRightDecorations ? 'pr-2' : ''}`,
disabled: 'bg-grey-5 cursor-not-allowed', disabled: 'bg-passive-5 cursor-not-allowed',
} }
} }
/** /**
* Input that can be decorated on the left and right side * Input that can be decorated on the left and right side
*/ */
export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardRef( const DecoratedInput = forwardRef(
( (
{ {
type = 'text', type = 'text',
@@ -42,8 +41,8 @@ export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardR
<div className={`${classNames.container} ${disabled ? classNames.disabled : ''} ${className}`}> <div className={`${classNames.container} ${disabled ? classNames.disabled : ''} ${className}`}>
{left && ( {left && (
<div className="flex items-center px-2 py-1.5"> <div className="flex items-center px-2 py-1.5">
{left.map((leftChild) => ( {left.map((leftChild, index) => (
<>{leftChild}</> <Fragment key={index}>{leftChild}</Fragment>
))} ))}
</div> </div>
)} )}
@@ -58,14 +57,16 @@ export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardR
onFocus={onFocus} onFocus={onFocus}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
data-lpignore={type !== 'password' ? true : false} data-lpignore={type !== 'password' ? true : false}
autocomplete={autocomplete ? 'on' : 'off'} autoComplete={autocomplete ? 'on' : 'off'}
ref={ref} ref={ref}
/> />
{right && ( {right && (
<div className="flex items-center px-2 py-1.5"> <div className="flex items-center px-2 py-1.5">
{right.map((rightChild, index) => ( {right.map((rightChild, index) => (
<div className={index > 0 ? 'ml-3' : ''}>{rightChild}</div> <div className={index > 0 ? 'ml-3' : ''} key={index}>
{rightChild}
</div>
))} ))}
</div> </div>
)} )}
@@ -73,3 +74,5 @@ export const DecoratedInput: FunctionalComponent<DecoratedInputProps> = forwardR
) )
}, },
) )
export default DecoratedInput

View File

@@ -1,15 +1,15 @@
import { ComponentChild } from 'preact' import { FocusEventHandler, KeyboardEventHandler, ReactNode } from 'react'
export type DecoratedInputProps = { export type DecoratedInputProps = {
type?: 'text' | 'email' | 'password' type?: 'text' | 'email' | 'password'
className?: string className?: string
disabled?: boolean disabled?: boolean
left?: ComponentChild[] left?: ReactNode[]
right?: ComponentChild[] right?: ReactNode[]
value?: string value?: string
placeholder?: string placeholder?: string
onChange?: (text: string) => void onChange?: (text: string) => void
onFocus?: (event: FocusEvent) => void onFocus?: FocusEventHandler
onKeyDown?: (event: KeyboardEvent) => void onKeyDown?: KeyboardEventHandler
autocomplete?: boolean autocomplete?: boolean
} }

View File

@@ -1,16 +1,14 @@
import { FunctionComponent, Ref } from 'preact' import { Dispatch, FunctionComponent, Ref, SetStateAction, forwardRef, useState } from 'react'
import { forwardRef } from 'preact/compat' import DecoratedInput from './DecoratedInput'
import { StateUpdater, useState } from 'preact/hooks' import IconButton from '@/Components/Button/IconButton'
import { DecoratedInput } from './DecoratedInput'
import { IconButton } from '@/Components/Button/IconButton'
import { DecoratedInputProps } from './DecoratedInputProps' import { DecoratedInputProps } from './DecoratedInputProps'
const Toggle: FunctionComponent<{ const Toggle: FunctionComponent<{
isToggled: boolean isToggled: boolean
setIsToggled: StateUpdater<boolean> setIsToggled: Dispatch<SetStateAction<boolean>>
}> = ({ isToggled, setIsToggled }) => ( }> = ({ isToggled, setIsToggled }) => (
<IconButton <IconButton
className="w-5 h-5 p-0 justify-center sk-circle hover:bg-grey-4 color-neutral" className="w-5 h-5 p-0 justify-center sk-circle hover:bg-passive-4 color-neutral"
icon={isToggled ? 'eye-off' : 'eye'} icon={isToggled ? 'eye-off' : 'eye'}
iconClassName="sn-icon--small" iconClassName="sn-icon--small"
title="Show/hide password" title="Show/hide password"
@@ -22,19 +20,19 @@ const Toggle: FunctionComponent<{
/** /**
* Password input that has a toggle to show/hide password and can be decorated on the left and right side * Password input that has a toggle to show/hide password and can be decorated on the left and right side
*/ */
export const DecoratedPasswordInput: FunctionComponent<Omit<DecoratedInputProps, 'type'>> = forwardRef( const DecoratedPasswordInput = forwardRef((props: DecoratedInputProps, ref: Ref<HTMLInputElement>) => {
(props, ref: Ref<HTMLInputElement>) => { const [isToggled, setIsToggled] = useState(false)
const [isToggled, setIsToggled] = useState(false)
const rightSideDecorations = props.right ? [...props.right] : [] const rightSideDecorations = props.right ? [...props.right] : []
return ( return (
<DecoratedInput <DecoratedInput
{...props} {...props}
ref={ref} ref={ref}
type={isToggled ? 'text' : 'password'} type={isToggled ? 'text' : 'password'}
right={[...rightSideDecorations, <Toggle isToggled={isToggled} setIsToggled={setIsToggled} />]} right={[...rightSideDecorations, <Toggle isToggled={isToggled} setIsToggled={setIsToggled} />]}
/> />
) )
}, })
)
export default DecoratedPasswordInput

View File

@@ -1,14 +1,11 @@
import { FunctionComponent, Ref } from 'preact' import { ChangeEventHandler, Ref, forwardRef, useState } from 'react'
import { JSXInternal } from 'preact/src/jsx'
import { forwardRef } from 'preact/compat'
import { useState } from 'preact/hooks'
type Props = { type Props = {
id: string id: string
type: 'text' | 'email' | 'password' type: 'text' | 'email' | 'password'
label: string label: string
value: string value: string
onChange: JSXInternal.GenericEventHandler<HTMLInputElement> onChange: ChangeEventHandler<HTMLInputElement>
disabled?: boolean disabled?: boolean
className?: string className?: string
labelClassName?: string labelClassName?: string
@@ -16,7 +13,7 @@ type Props = {
isInvalid?: boolean isInvalid?: boolean
} }
export const FloatingLabelInput: FunctionComponent<Props> = forwardRef( const FloatingLabelInput = forwardRef(
( (
{ {
id, id,
@@ -38,12 +35,12 @@ export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
const LABEL_CLASSNAME = `hidden absolute ${!focused ? 'color-neutral' : 'color-info'} ${ const LABEL_CLASSNAME = `hidden absolute ${!focused ? 'color-neutral' : 'color-info'} ${
focused || value ? 'flex top-0 left-2 pt-1.5 px-1' : '' focused || value ? 'flex top-0 left-2 pt-1.5 px-1' : ''
} ${isInvalid ? 'color-dark-red' : ''} ${labelClassName}` } ${isInvalid ? 'color-danger' : ''} ${labelClassName}`
const INPUT_CLASSNAME = `w-full h-full ${ const INPUT_CLASSNAME = `w-full h-full ${
focused || value ? 'pt-6 pb-2' : 'py-2.5' focused || value ? 'pt-6 pb-2' : 'py-2.5'
} px-3 text-input border-1 border-solid border-main rounded placeholder-medium text-input focus:ring-info ${ } px-3 text-input border-1 border-solid border-main rounded placeholder-medium text-input focus:ring-info ${
isInvalid ? 'border-dark-red placeholder-dark-red' : '' isInvalid ? 'border-danger placeholder-dark-red' : ''
} ${inputClassName}` } ${inputClassName}`
const handleFocus = () => setFocused(true) const handleFocus = () => setFocused(true)
@@ -71,3 +68,5 @@ export const FloatingLabelInput: FunctionComponent<Props> = forwardRef(
) )
}, },
) )
export default FloatingLabelInput

View File

@@ -1,4 +1,4 @@
import { FunctionalComponent } from 'preact' import { FunctionComponent } from 'react'
interface Props { interface Props {
text?: string text?: string
@@ -6,9 +6,11 @@ interface Props {
className?: string className?: string
} }
export const Input: FunctionalComponent<Props> = ({ className = '', disabled = false, text }) => { const Input: FunctionComponent<Props> = ({ className = '', disabled = false, text }) => {
const base = 'rounded py-1.5 px-3 text-input my-1 h-8 bg-contrast' const base = 'rounded py-1.5 px-3 text-input my-1 h-8 bg-contrast'
const stateClasses = disabled ? 'no-border' : 'border-solid border-1 border-main' const stateClasses = disabled ? 'no-border' : 'border-solid border-1 border-main'
const classes = `${base} ${stateClasses} ${className}` const classes = `${base} ${stateClasses} ${className}`
return <input type="text" className={classes} disabled={disabled} value={text} /> return <input type="text" className={classes} disabled={disabled} value={text} />
} }
export default Input

View File

@@ -1,21 +1,26 @@
import { JSX, FunctionComponent, ComponentChildren, VNode, RefCallback, ComponentChild, toChildArray } from 'preact' import {
import { useCallback, useEffect, useRef } from 'preact/hooks' CSSProperties,
import { JSXInternal } from 'preact/src/jsx' FunctionComponent,
import { MenuItem, MenuItemListElement } from './MenuItem' KeyboardEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
} from 'react'
import { KeyboardKey } from '@/Services/IOService' import { KeyboardKey } from '@/Services/IOService'
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation' import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
type MenuProps = { type MenuProps = {
className?: string className?: string
style?: string | JSX.CSSProperties | undefined style?: CSSProperties | undefined
a11yLabel: string a11yLabel: string
children: ComponentChildren children: ReactNode
closeMenu?: () => void closeMenu?: () => void
isOpen: boolean isOpen: boolean
initialFocus?: number initialFocus?: number
} }
export const Menu: FunctionComponent<MenuProps> = ({ const Menu: FunctionComponent<MenuProps> = ({
children, children,
className = '', className = '',
style, style,
@@ -24,16 +29,10 @@ export const Menu: FunctionComponent<MenuProps> = ({
isOpen, isOpen,
initialFocus, initialFocus,
}: MenuProps) => { }: MenuProps) => {
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([])
const menuElementRef = useRef<HTMLMenuElement>(null) const menuElementRef = useRef<HTMLMenuElement>(null)
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = useCallback( const handleKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback(
(event) => { (event) => {
if (!menuItemRefs.current) {
return
}
if (event.key === KeyboardKey.Escape) { if (event.key === KeyboardKey.Escape) {
closeMenu?.() closeMenu?.()
return return
@@ -45,67 +44,24 @@ export const Menu: FunctionComponent<MenuProps> = ({
useListKeyboardNavigation(menuElementRef, initialFocus) useListKeyboardNavigation(menuElementRef, initialFocus)
useEffect(() => { useEffect(() => {
if (isOpen && menuItemRefs.current.length > 0) { if (isOpen) {
setTimeout(() => { setTimeout(() => {
menuElementRef.current?.focus() menuElementRef.current?.focus()
}) })
} }
}, [isOpen]) }, [isOpen])
const pushRefToArray: RefCallback<HTMLLIElement> = useCallback((instance) => {
if (instance && instance.children) {
Array.from(instance.children).forEach((child) => {
if (
child.getAttribute('role')?.includes('menuitem') &&
!menuItemRefs.current.includes(child as HTMLButtonElement)
) {
menuItemRefs.current.push(child as HTMLButtonElement)
}
})
}
}, [])
const mapMenuItems = useCallback(
(child: ComponentChild, index: number, array: ComponentChild[]): ComponentChild => {
if (!child || (Array.isArray(child) && child.length < 1)) {
return
}
if (Array.isArray(child)) {
return child.map(mapMenuItems)
}
const _child = child as VNode<unknown>
const isFirstMenuItem = index === array.findIndex((child) => (child as VNode<unknown>).type === MenuItem)
const hasMultipleItems = Array.isArray(_child.props.children)
? Array.from(_child.props.children as ComponentChild[]).some(
(child) => (child as VNode<unknown>).type === MenuItem,
)
: false
const items = hasMultipleItems ? [...(_child.props.children as ComponentChild[])] : [_child]
return items.map((child) => {
return (
<MenuItemListElement isFirstMenuItem={isFirstMenuItem} ref={pushRefToArray}>
{child}
</MenuItemListElement>
)
})
},
[pushRefToArray],
)
return ( return (
<menu <menu
className={`m-0 p-0 list-style-none focus:shadow-none ${className}`} className={`m-0 pl-0 list-style-none focus:shadow-none ${className}`}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
ref={menuElementRef} ref={menuElementRef}
style={style} style={style}
aria-label={a11yLabel} aria-label={a11yLabel}
> >
{toChildArray(children).map(mapMenuItems)} {children}
</menu> </menu>
) )
} }
export default Menu

View File

@@ -1,21 +1,15 @@
import { ComponentChildren, FunctionComponent, VNode } from 'preact' import { forwardRef, MouseEventHandler, ReactNode, Ref } from 'react'
import { forwardRef, Ref } from 'preact/compat' import Icon from '@/Components/Icon/Icon'
import { JSXInternal } from 'preact/src/jsx' import Switch from '@/Components/Switch/Switch'
import { Icon } from '@/Components/Icon' import { SwitchProps } from '@/Components/Switch/SwitchProps'
import { Switch, SwitchProps } from '@/Components/Switch'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { MenuItemType } from './MenuItemType'
export enum MenuItemType {
IconButton,
RadioButton,
SwitchButton,
}
type MenuItemProps = { type MenuItemProps = {
type: MenuItemType type: MenuItemType
children: ComponentChildren children: ReactNode
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement> onClick?: MouseEventHandler<HTMLButtonElement>
onChange?: SwitchProps['onChange'] onChange?: SwitchProps['onChange']
onBlur?: (event: { relatedTarget: EventTarget | null }) => void onBlur?: (event: { relatedTarget: EventTarget | null }) => void
className?: string className?: string
@@ -25,7 +19,7 @@ type MenuItemProps = {
tabIndex?: number tabIndex?: number
} }
export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef( const MenuItem = forwardRef(
( (
{ {
children, children,
@@ -42,63 +36,42 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
ref: Ref<HTMLButtonElement>, ref: Ref<HTMLButtonElement>,
) => { ) => {
return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? ( return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
<button <li className="list-style-none" role="none">
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between" <button
onClick={() => { ref={ref}
onChange(!checked) className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
}} onClick={() => {
onBlur={onBlur} onChange(!checked)
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE} }}
role="menuitemcheckbox" onBlur={onBlur}
aria-checked={checked} tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
> role="menuitemcheckbox"
<span className="flex flex-grow items-center">{children}</span> aria-checked={checked}
<Switch className="px-0" checked={checked} /> >
</button> <span className="flex flex-grow items-center">{children}</span>
<Switch className="px-0" checked={checked} />
</button>
</li>
) : ( ) : (
<button <li className="list-style-none" role="none">
ref={ref} <button
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'} ref={ref}
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE} role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`} tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
onClick={onClick} className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
onBlur={onBlur} onClick={onClick}
{...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})} onBlur={onBlur}
> {...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})}
{type === MenuItemType.IconButton && icon ? <Icon type={icon} className={iconClassName} /> : null} >
{type === MenuItemType.RadioButton && typeof checked === 'boolean' ? ( {type === MenuItemType.IconButton && icon ? <Icon type={icon} className={iconClassName} /> : null}
<div className={`pseudo-radio-btn ${checked ? 'pseudo-radio-btn--checked' : ''} mr-2 flex-shrink-0`}></div> {type === MenuItemType.RadioButton && typeof checked === 'boolean' ? (
) : null} <div className={`pseudo-radio-btn ${checked ? 'pseudo-radio-btn--checked' : ''} flex-shrink-0`}></div>
{children} ) : null}
</button> {children}
) </button>
},
)
export const MenuItemSeparator: FunctionComponent = () => <div role="separator" className="h-1px my-2 bg-border"></div>
type ListElementProps = {
isFirstMenuItem: boolean
children: ComponentChildren
}
export const MenuItemListElement: FunctionComponent<ListElementProps> = forwardRef(
({ children, isFirstMenuItem }: ListElementProps, ref: Ref<HTMLLIElement>) => {
const child = children as VNode<unknown>
return (
<li className="list-style-none" role="none" ref={ref}>
{{
...child,
props: {
...(child.props ? { ...child.props } : {}),
...(child.type === MenuItem
? {
tabIndex: isFirstMenuItem ? 0 : -1,
}
: {}),
},
}}
</li> </li>
) )
}, },
) )
export default MenuItem

View File

@@ -0,0 +1,9 @@
import { FunctionComponent } from 'react'
const MenuItemSeparator: FunctionComponent = () => (
<li className="list-style-none" role="none">
<div role="separator" className="h-1px my-2 bg-border" />
</li>
)
export default MenuItemSeparator

View File

@@ -0,0 +1,5 @@
export enum MenuItemType {
IconButton,
RadioButton,
SwitchButton,
}

View File

@@ -0,0 +1,45 @@
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { IlNotesIcon } from '@standardnotes/icons'
import { observer } from 'mobx-react-lite'
import NotesOptionsPanel from '@/Components/NotesOptions/NotesOptionsPanel'
import { WebApplication } from '@/Application/Application'
import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
import Button from '../Button/Button'
import { useCallback } from 'react'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const MultipleSelectedNotes = ({ application, viewControllerManager }: Props) => {
const count = viewControllerManager.notesController.selectedNotesCount
const cancelMultipleSelection = useCallback(() => {
viewControllerManager.selectionController.cancelMultipleSelection()
}, [viewControllerManager])
return (
<div className="flex flex-col h-full items-center">
<div className="flex items-center justify-between p-4 w-full">
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
<div className="flex">
<div className="mr-3">
<PinNoteButton viewControllerManager={viewControllerManager} />
</div>
<NotesOptionsPanel application={application} viewControllerManager={viewControllerManager} />
</div>
</div>
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
<IlNotesIcon className="block" />
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
<p className="text-sm mt-2 text-center max-w-60">Actions will be performed on all selected notes.</p>
<Button className="mt-2.5" onClick={cancelMultipleSelection}>
Cancel multiple selection
</Button>
</div>
</div>
)
}
export default observer(MultipleSelectedNotes)

View File

@@ -1,34 +0,0 @@
import { AppState } from '@/UIModels/AppState'
import { IlNotesIcon } from '@standardnotes/icons'
import { observer } from 'mobx-react-lite'
import { NotesOptionsPanel } from '@/Components/NotesOptions/NotesOptionsPanel'
import { WebApplication } from '@/UIModels/Application'
import { PinNoteButton } from '@/Components/PinNoteButton'
type Props = {
application: WebApplication
appState: AppState
}
export const MultipleSelectedNotes = observer(({ application, appState }: Props) => {
const count = appState.notes.selectedNotesCount
return (
<div className="flex flex-col h-full items-center">
<div className="flex items-center justify-between p-4 w-full">
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
<div className="flex">
<div className="mr-3">
<PinNoteButton appState={appState} />
</div>
<NotesOptionsPanel application={application} appState={appState} />
</div>
</div>
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
<IlNotesIcon className="block" />
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
<p className="text-sm mt-2 text-center max-w-60">Actions will be performed on all selected notes.</p>
</div>
</div>
)
})

View File

@@ -1,19 +1,18 @@
import { SmartViewsSection } from '@/Components/Tags/SmartViewsSection' import SmartViewsSection from '@/Components/Tags/SmartViewsSection'
import { TagsSection } from '@/Components/Tags/TagsSection' import TagsSection from '@/Components/Tags/TagsSection'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { PANEL_NAME_NAVIGATION } from '@/Constants' import { PANEL_NAME_NAVIGATION } from '@/Constants/Constants'
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs' import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'preact' import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks' import PanelResizer, { PanelSide, ResizeFinishCallback, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer'
type Props = { type Props = {
application: WebApplication application: WebApplication
} }
export const Navigation: FunctionComponent<Props> = observer(({ application }) => { const Navigation: FunctionComponent<Props> = ({ application }) => {
const appState = useMemo(() => application.getAppState(), [application]) const viewControllerManager = useMemo(() => application.getViewControllerManager(), [application])
const [ref, setRef] = useState<HTMLDivElement | null>() const [ref, setRef] = useState<HTMLDivElement | null>()
const [panelWidth, setPanelWidth] = useState<number>(0) const [panelWidth, setPanelWidth] = useState<number>(0)
@@ -33,15 +32,15 @@ export const Navigation: FunctionComponent<Props> = observer(({ application }) =
const panelResizeFinishCallback: ResizeFinishCallback = useCallback( const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
(width, _lastLeft, _isMaxWidth, isCollapsed) => { (width, _lastLeft, _isMaxWidth, isCollapsed) => {
application.setPreference(PrefKey.TagsPanelWidth, width).catch(console.error) application.setPreference(PrefKey.TagsPanelWidth, width).catch(console.error)
appState.noteTags.reloadTagsContainerMaxWidth() viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed) application.publishPanelDidResizeEvent(PANEL_NAME_NAVIGATION, isCollapsed)
}, },
[application, appState], [application, viewControllerManager],
) )
const panelWidthEventCallback = useCallback(() => { const panelWidthEventCallback = useCallback(() => {
appState.noteTags.reloadTagsContainerMaxWidth() viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
}, [appState]) }, [viewControllerManager])
return ( return (
<div <div
@@ -59,8 +58,8 @@ export const Navigation: FunctionComponent<Props> = observer(({ application }) =
</div> </div>
</div> </div>
<div className="scrollable"> <div className="scrollable">
<SmartViewsSection appState={appState} /> <SmartViewsSection viewControllerManager={viewControllerManager} />
<TagsSection appState={appState} /> <TagsSection viewControllerManager={viewControllerManager} />
</div> </div>
</div> </div>
{ref && ( {ref && (
@@ -79,4 +78,6 @@ export const Navigation: FunctionComponent<Props> = observer(({ application }) =
)} )}
</div> </div>
) )
}) }
export default observer(Navigation)

View File

@@ -0,0 +1,49 @@
import Icon from '@/Components/Icon/Icon'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { MouseEventHandler, useCallback } from 'react'
type Props = { viewControllerManager: ViewControllerManager }
const NoAccountWarning = observer(({ viewControllerManager }: Props) => {
const showAccountMenu: MouseEventHandler = useCallback(
(event) => {
event.stopPropagation()
viewControllerManager.accountMenuController.setShow(true)
},
[viewControllerManager],
)
const hideWarning = useCallback(() => {
viewControllerManager.noAccountWarningController.hide()
}, [viewControllerManager])
return (
<div className="mt-4 p-4 rounded-md shadow-sm grid grid-template-cols-1fr">
<h1 className="sk-h3 m-0 font-semibold">Data not backed up</h1>
<p className="m-0 mt-1 col-start-1 col-end-3">Sign in or register to back up your notes.</p>
<button className="sn-button small info mt-3 col-start-1 col-end-3 justify-self-start" onClick={showAccountMenu}>
Open Account menu
</button>
<button
onClick={hideWarning}
title="Ignore warning"
aria-label="Ignore warning"
style={{ height: '20px' }}
className="border-0 m-0 p-0 bg-transparent cursor-pointer rounded-md col-start-2 row-start-1 color-neutral hover:color-info"
>
<Icon type="close" className="block" />
</button>
</div>
)
})
NoAccountWarning.displayName = 'NoAccountWarning'
const NoAccountWarningWrapper = ({ viewControllerManager }: Props) => {
const canShow = viewControllerManager.noAccountWarningController.show
return canShow ? <NoAccountWarning viewControllerManager={viewControllerManager} /> : null
}
export default observer(NoAccountWarningWrapper)

View File

@@ -1,44 +0,0 @@
import { Icon } from '@/Components/Icon'
import { AppState } from '@/UIModels/AppState'
import { observer } from 'mobx-react-lite'
import { useCallback } from 'preact/hooks'
type Props = { appState: AppState }
export const NoAccountWarning = observer(({ appState }: Props) => {
const canShow = appState.noAccountWarning.show
if (!canShow) {
return null
}
const showAccountMenu = useCallback(
(event: Event) => {
event.stopPropagation()
appState.accountMenu.setShow(true)
},
[appState],
)
const hideWarning = useCallback(() => {
appState.noAccountWarning.hide()
}, [appState])
return (
<div className="mt-5 p-5 rounded-md shadow-sm grid grid-template-cols-1fr">
<h1 className="sk-h3 m-0 font-semibold">Data not backed up</h1>
<p className="m-0 mt-1 col-start-1 col-end-3">Sign in or register to back up your notes.</p>
<button className="sn-button small info mt-3 col-start-1 col-end-3 justify-self-start" onClick={showAccountMenu}>
Open Account menu
</button>
<button
onClick={hideWarning}
title="Ignore"
label="Ignore"
style="height: 20px"
className="border-0 m-0 p-0 bg-transparent cursor-pointer rounded-md col-start-2 row-start-1 color-neutral hover:color-info"
>
<Icon type="close" className="block" />
</button>
</div>
)
})

View File

@@ -1,9 +1,9 @@
import { NoteViewController } from '@standardnotes/snjs' import { NoteViewController } from '@standardnotes/snjs'
import { PureComponent } from '@/Components/Abstract/PureComponent' import { PureComponent } from '@/Components/Abstract/PureComponent'
import { WebApplication } from '@/UIModels/Application' import { WebApplication } from '@/Application/Application'
import { MultipleSelectedNotes } from '@/Components/MultipleSelectedNotes' import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
import { NoteView } from '@/Components/NoteView/NoteView' import NoteView from '@/Components/NoteView/NoteView'
import { ElementIds } from '@/ElementIDs' import { ElementIds } from '@/Constants/ElementIDs'
type State = { type State = {
showMultipleSelectedNotes: boolean showMultipleSelectedNotes: boolean
@@ -14,7 +14,7 @@ type Props = {
application: WebApplication application: WebApplication
} }
export class NoteGroupView extends PureComponent<Props, State> { class NoteGroupView extends PureComponent<Props, State> {
private removeChangeObserver!: () => void private removeChangeObserver!: () => void
constructor(props: Props) { constructor(props: Props) {
@@ -37,9 +37,9 @@ export class NoteGroupView extends PureComponent<Props, State> {
}) })
this.autorun(() => { this.autorun(() => {
if (this.appState && this.appState.notes) { if (this.viewControllerManager && this.viewControllerManager.notesController) {
this.setState({ this.setState({
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1, showMultipleSelectedNotes: this.viewControllerManager.notesController.selectedNotesCount > 1,
}) })
} }
}) })
@@ -56,7 +56,7 @@ export class NoteGroupView extends PureComponent<Props, State> {
return ( return (
<div id={ElementIds.EditorColumn} className="h-full app-column app-column-third"> <div id={ElementIds.EditorColumn} className="h-full app-column app-column-third">
{this.state.showMultipleSelectedNotes && ( {this.state.showMultipleSelectedNotes && (
<MultipleSelectedNotes application={this.application} appState={this.appState} /> <MultipleSelectedNotes application={this.application} viewControllerManager={this.viewControllerManager} />
)} )}
{!this.state.showMultipleSelectedNotes && ( {!this.state.showMultipleSelectedNotes && (
@@ -70,3 +70,5 @@ export class NoteGroupView extends PureComponent<Props, State> {
) )
} }
} }
export default NoteGroupView

View File

@@ -1,16 +1,24 @@
import { Icon } from '@/Components/Icon' import Icon from '@/Components/Icon/Icon'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks' import {
import { AppState } from '@/UIModels/AppState' FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { SNTag } from '@standardnotes/snjs' import { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
type Props = { type Props = {
appState: AppState viewControllerManager: ViewControllerManager
tag: SNTag tag: SNTag
} }
export const NoteTag = observer(({ appState, tag }: Props) => { const NoteTag = ({ viewControllerManager, tag }: Props) => {
const noteTags = appState.noteTags const noteTags = viewControllerManager.noteTagsController
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags
@@ -25,45 +33,44 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
const longTitle = noteTags.getLongTitle(tag) const longTitle = noteTags.getLongTitle(tag)
const deleteTag = useCallback(() => { const deleteTag = useCallback(() => {
appState.noteTags.focusPreviousTag(tag) viewControllerManager.noteTagsController.focusPreviousTag(tag)
appState.noteTags.removeTagFromActiveNote(tag).catch(console.error) viewControllerManager.noteTagsController.removeTagFromActiveNote(tag).catch(console.error)
}, [appState, tag]) }, [viewControllerManager, tag])
const onDeleteTagClick = useCallback( const onDeleteTagClick: MouseEventHandler = useCallback(
(event: MouseEvent) => { (event) => {
event.stopImmediatePropagation()
event.stopPropagation() event.stopPropagation()
deleteTag() deleteTag()
}, },
[deleteTag], [deleteTag],
) )
const onTagClick = useCallback( const onTagClick: MouseEventHandler = useCallback(
(event: MouseEvent) => { (event) => {
if (tagClicked && event.target !== deleteTagRef.current) { if (tagClicked && event.target !== deleteTagRef.current) {
setTagClicked(false) setTagClicked(false)
appState.tags.selected = tag void viewControllerManager.navigationController.setSelectedTag(tag)
} else { } else {
setTagClicked(true) setTagClicked(true)
} }
}, },
[appState, tagClicked, tag], [viewControllerManager, tagClicked, tag],
) )
const onFocus = useCallback(() => { const onFocus = useCallback(() => {
appState.noteTags.setFocusedTagUuid(tag.uuid) viewControllerManager.noteTagsController.setFocusedTagUuid(tag.uuid)
setShowDeleteButton(true) setShowDeleteButton(true)
}, [appState, tag]) }, [viewControllerManager, tag])
const onBlur = useCallback( const onBlur: FocusEventHandler = useCallback(
(event: FocusEvent) => { (event) => {
const relatedTarget = event.relatedTarget as Node const relatedTarget = event.relatedTarget as Node
if (relatedTarget !== deleteTagRef.current) { if (relatedTarget !== deleteTagRef.current) {
appState.noteTags.setFocusedTagUuid(undefined) viewControllerManager.noteTagsController.setFocusedTagUuid(undefined)
setShowDeleteButton(false) setShowDeleteButton(false)
} }
}, },
[appState], [viewControllerManager],
) )
const getTabIndex = useCallback(() => { const getTabIndex = useCallback(() => {
@@ -76,35 +83,35 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
return tags[0].uuid === tag.uuid ? 0 : -1 return tags[0].uuid === tag.uuid ? 0 : -1
}, [autocompleteInputFocused, tags, tag, focusedTagUuid]) }, [autocompleteInputFocused, tags, tag, focusedTagUuid])
const onKeyDown = useCallback( const onKeyDown: KeyboardEventHandler = useCallback(
(event: KeyboardEvent) => { (event) => {
const tagIndex = appState.noteTags.getTagIndex(tag, tags) const tagIndex = viewControllerManager.noteTagsController.getTagIndex(tag, tags)
switch (event.key) { switch (event.key) {
case 'Backspace': case 'Backspace':
deleteTag() deleteTag()
break break
case 'ArrowLeft': case 'ArrowLeft':
appState.noteTags.focusPreviousTag(tag) viewControllerManager.noteTagsController.focusPreviousTag(tag)
break break
case 'ArrowRight': case 'ArrowRight':
if (tagIndex === tags.length - 1) { if (tagIndex === tags.length - 1) {
appState.noteTags.setAutocompleteInputFocused(true) viewControllerManager.noteTagsController.setAutocompleteInputFocused(true)
} else { } else {
appState.noteTags.focusNextTag(tag) viewControllerManager.noteTagsController.focusNextTag(tag)
} }
break break
default: default:
return return
} }
}, },
[appState, deleteTag, tag, tags], [viewControllerManager, deleteTag, tag, tags],
) )
useEffect(() => { useEffect(() => {
if (focusedTagUuid === tag.uuid) { if (focusedTagUuid === tag.uuid) {
tagRef.current?.focus() tagRef.current?.focus()
} }
}, [appState, focusedTagUuid, tag]) }, [viewControllerManager, focusedTagUuid, tag])
return ( return (
<button <button
@@ -119,7 +126,7 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
> >
<Icon type="hashtag" className="sn-icon--small color-info mr-1" /> <Icon type="hashtag" className="sn-icon--small color-info mr-1" />
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis max-w-290px"> <span className="whitespace-nowrap overflow-hidden overflow-ellipsis max-w-290px">
{prefixTitle && <span className="color-grey-1">{prefixTitle}</span>} {prefixTitle && <span className="color-passive-1">{prefixTitle}</span>}
{title} {title}
</span> </span>
{showDeleteButton && ( {showDeleteButton && (
@@ -136,4 +143,6 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
)} )}
</button> </button>
) )
}) }
export default observer(NoteTag)

View File

@@ -1,24 +1,19 @@
import { AppState } from '@/UIModels/AppState' import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { AutocompleteTagInput } from '@/Components/TagAutocomplete/AutocompleteTagInput' import AutocompleteTagInput from '@/Components/TagAutocomplete/AutocompleteTagInput'
import { NoteTag } from './NoteTag' import NoteTag from './NoteTag'
import { useEffect } from 'preact/hooks' import { useEffect } from 'react'
import { isStateDealloced } from '@/UIModels/AppState/AbstractState'
type Props = { type Props = {
appState: AppState viewControllerManager: ViewControllerManager
} }
export const NoteTagsContainer = observer(({ appState }: Props) => { const NoteTagsContainer = ({ viewControllerManager }: Props) => {
if (isStateDealloced(appState)) { const { tags, tagsContainerMaxWidth } = viewControllerManager.noteTagsController
return null
}
const { tags, tagsContainerMaxWidth } = appState.noteTags
useEffect(() => { useEffect(() => {
appState.noteTags.reloadTagsContainerMaxWidth() viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
}, [appState]) }, [viewControllerManager])
return ( return (
<div <div
@@ -28,9 +23,11 @@ export const NoteTagsContainer = observer(({ appState }: Props) => {
}} }}
> >
{tags.map((tag) => ( {tags.map((tag) => (
<NoteTag key={tag.uuid} appState={appState} tag={tag} /> <NoteTag key={tag.uuid} viewControllerManager={viewControllerManager} tag={tag} />
))} ))}
<AutocompleteTagInput appState={appState} /> <AutocompleteTagInput viewControllerManager={viewControllerManager} />
</div> </div>
) )
}) }
export default observer(NoteTagsContainer)

View File

@@ -0,0 +1,40 @@
import { FunctionComponent } from 'react'
import Icon from '../Icon/Icon'
type Props = {
onMouseLeave: () => void
onMouseOver: () => void
onClick: () => void
showLockedIcon: boolean
lockText: string
}
const EditingDisabledBanner: FunctionComponent<Props> = ({
onMouseLeave,
onMouseOver,
onClick,
showLockedIcon,
lockText,
}) => {
const background = showLockedIcon ? 'bg-warning-faded' : 'bg-info-faded'
const iconColor = showLockedIcon ? 'color-accessory-tint-3' : 'color-accessory-tint-1'
const textColor = showLockedIcon ? 'color-warning' : 'color-accessory-tint-1'
return (
<div
className={`flex items-center relative ${background} px-3.5 py-2 cursor-pointer`}
onMouseLeave={onMouseLeave}
onMouseOver={onMouseOver}
onClick={onClick}
>
{showLockedIcon ? (
<Icon type="pencil-off" className={`${iconColor} flex fill-current mr-3`} />
) : (
<Icon type="pencil" className={`${iconColor} flex fill-current mr-3`} />
)}
<span className={textColor}>{lockText}</span>
</div>
)
}
export default EditingDisabledBanner

Some files were not shown because too many files have changed in this diff Show More