feat: Moments: your personal photo journal, now available in Labs (#2079)
This commit is contained in:
@@ -253,6 +253,9 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requireInlineMediaPlaybackForMomentsFeature = true
|
||||||
|
const requireMediaUserInteractionForMomentsFeature = false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebView
|
<WebView
|
||||||
ref={webViewRef}
|
ref={webViewRef}
|
||||||
@@ -274,6 +277,8 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
|
|||||||
injectedJavaScriptBeforeContentLoaded={injectedJS}
|
injectedJavaScriptBeforeContentLoaded={injectedJS}
|
||||||
bounces={false}
|
bounces={false}
|
||||||
keyboardDisplayRequiresUserAction={false}
|
keyboardDisplayRequiresUserAction={false}
|
||||||
|
allowsInlineMediaPlayback={requireInlineMediaPlaybackForMomentsFeature}
|
||||||
|
mediaPlaybackRequiresUserAction={requireMediaUserInteractionForMomentsFeature}
|
||||||
scalesPageToFit={true}
|
scalesPageToFit={true}
|
||||||
/**
|
/**
|
||||||
* This disables the global window scroll but keeps scroll within div elements like lists and textareas.
|
* This disables the global window scroll but keeps scroll within div elements like lists and textareas.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export enum PrefKey {
|
|||||||
UpdateSavingStatusIndicator = 'updateSavingStatusIndicator',
|
UpdateSavingStatusIndicator = 'updateSavingStatusIndicator',
|
||||||
DarkMode = 'darkMode',
|
DarkMode = 'darkMode',
|
||||||
DefaultEditorIdentifier = 'defaultEditorIdentifier',
|
DefaultEditorIdentifier = 'defaultEditorIdentifier',
|
||||||
|
MomentsDefaultTagUuid = 'momentsDefaultTagUuid',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NewNoteTitleFormat {
|
export enum NewNoteTitleFormat {
|
||||||
@@ -103,4 +104,5 @@ export type PrefValue = {
|
|||||||
[PrefKey.UpdateSavingStatusIndicator]: boolean
|
[PrefKey.UpdateSavingStatusIndicator]: boolean
|
||||||
[PrefKey.DarkMode]: boolean
|
[PrefKey.DarkMode]: boolean
|
||||||
[PrefKey.DefaultEditorIdentifier]: EditorIdentifier
|
[PrefKey.DefaultEditorIdentifier]: EditorIdentifier
|
||||||
|
[PrefKey.MomentsDefaultTagUuid]: string | undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export enum StorageKey {
|
|||||||
CodeVerifier = 'code_verifier',
|
CodeVerifier = 'code_verifier',
|
||||||
LaunchPriorityUuids = 'launch_priority_uuids',
|
LaunchPriorityUuids = 'launch_priority_uuids',
|
||||||
LastReadChangelogVersion = 'last_read_changelog_version',
|
LastReadChangelogVersion = 'last_read_changelog_version',
|
||||||
|
MomentsEnabled = 'moments_enabled',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NonwrappedStorageKey {
|
export enum NonwrappedStorageKey {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { makeObservable, observable } from 'mobx'
|
import { makeObservable, observable } from 'mobx'
|
||||||
import { PanelResizedData } from '@/Types/PanelResizedData'
|
import { PanelResizedData } from '@/Types/PanelResizedData'
|
||||||
import { isDesktopApplication } from '@/Utils'
|
import { isAndroid, isDesktopApplication, isIOS } from '@/Utils'
|
||||||
import { DesktopManager } from './Device/DesktopManager'
|
import { DesktopManager } from './Device/DesktopManager'
|
||||||
import {
|
import {
|
||||||
ArchiveManager,
|
ArchiveManager,
|
||||||
@@ -45,6 +45,7 @@ import { WebServices } from './WebServices'
|
|||||||
import { FeatureName } from '@/Controllers/FeatureName'
|
import { FeatureName } from '@/Controllers/FeatureName'
|
||||||
import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController'
|
import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController'
|
||||||
import { VisibilityObserver } from './VisibilityObserver'
|
import { VisibilityObserver } from './VisibilityObserver'
|
||||||
|
import { MomentsService } from '@/Controllers/Moments/MomentsService'
|
||||||
|
|
||||||
export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
|
export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
|
||||||
|
|
||||||
@@ -98,6 +99,11 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
|||||||
: undefined
|
: undefined
|
||||||
this.webServices.viewControllerManager = new ViewControllerManager(this, deviceInterface)
|
this.webServices.viewControllerManager = new ViewControllerManager(this, deviceInterface)
|
||||||
this.webServices.changelogService = new ChangelogService(this.environment, this.storage)
|
this.webServices.changelogService = new ChangelogService(this.environment, this.storage)
|
||||||
|
this.webServices.momentsService = new MomentsService(
|
||||||
|
this,
|
||||||
|
this.webServices.viewControllerManager.filesController,
|
||||||
|
internalEventBus,
|
||||||
|
)
|
||||||
|
|
||||||
if (this.isNativeMobileWeb()) {
|
if (this.isNativeMobileWeb()) {
|
||||||
this.mobileWebReceiver = new MobileWebReceiver(this)
|
this.mobileWebReceiver = new MobileWebReceiver(this)
|
||||||
@@ -196,10 +202,22 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
|||||||
return this.webServices.viewControllerManager.paneController
|
return this.webServices.viewControllerManager.paneController
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get linkingController() {
|
||||||
|
return this.webServices.viewControllerManager.linkingController
|
||||||
|
}
|
||||||
|
|
||||||
public get changelogService() {
|
public get changelogService() {
|
||||||
return this.webServices.changelogService
|
return this.webServices.changelogService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get momentsService() {
|
||||||
|
return this.webServices.momentsService
|
||||||
|
}
|
||||||
|
|
||||||
|
public get featuresController() {
|
||||||
|
return this.getViewControllerManager().featuresController
|
||||||
|
}
|
||||||
|
|
||||||
public get desktopDevice(): DesktopDeviceInterface | undefined {
|
public get desktopDevice(): DesktopDeviceInterface | undefined {
|
||||||
if (isDesktopDevice(this.deviceInterface)) {
|
if (isDesktopDevice(this.deviceInterface)) {
|
||||||
return this.deviceInterface
|
return this.deviceInterface
|
||||||
@@ -212,6 +230,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
|||||||
return this.isNativeMobileWeb() && this.platform === Platform.Ios
|
return this.isNativeMobileWeb() && this.platform === Platform.Ios
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isMobileDevice() {
|
||||||
|
return this.isNativeMobileWeb() || isIOS() || isAndroid()
|
||||||
|
}
|
||||||
|
|
||||||
get hideOutboundSubscriptionLinks() {
|
get hideOutboundSubscriptionLinks() {
|
||||||
return this.isNativeIOS()
|
return this.isNativeIOS()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
KeyboardService,
|
KeyboardService,
|
||||||
ThemeManager,
|
ThemeManager,
|
||||||
} from '@standardnotes/ui-services'
|
} from '@standardnotes/ui-services'
|
||||||
|
import { MomentsService } from '@/Controllers/Moments/MomentsService'
|
||||||
|
|
||||||
export type WebServices = {
|
export type WebServices = {
|
||||||
viewControllerManager: ViewControllerManager
|
viewControllerManager: ViewControllerManager
|
||||||
@@ -16,4 +17,5 @@ export type WebServices = {
|
|||||||
themeService: ThemeManager
|
themeService: ThemeManager
|
||||||
keyboardService: KeyboardService
|
keyboardService: KeyboardService
|
||||||
changelogService: ChangelogServiceInterface
|
changelogService: ChangelogServiceInterface
|
||||||
|
momentsService: MomentsService
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Ref, forwardRef, ReactNode, ComponentPropsWithoutRef } from 'react'
|
import { Ref, forwardRef, ReactNode, ComponentPropsWithoutRef } from 'react'
|
||||||
|
|
||||||
type ButtonStyle = 'default' | 'contrast' | 'neutral' | 'info' | 'warning' | 'danger' | 'success'
|
export type ButtonStyle = 'default' | 'contrast' | 'neutral' | 'info' | 'warning' | 'danger' | 'success'
|
||||||
|
|
||||||
const getColorsForNormalVariant = (style: ButtonStyle) => {
|
const getColorsForNormalVariant = (style: ButtonStyle) => {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
@@ -21,7 +21,7 @@ const getColorsForNormalVariant = (style: ButtonStyle) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getColorsForPrimaryVariant = (style: ButtonStyle) => {
|
export const getColorsForPrimaryVariant = (style: ButtonStyle) => {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case 'default':
|
case 'default':
|
||||||
return 'bg-default text-foreground'
|
return 'bg-default text-foreground'
|
||||||
|
|||||||
@@ -365,8 +365,8 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
|
|||||||
<div className="flex flex-col pr-5">
|
<div className="flex flex-col pr-5">
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<div className="text-base font-semibold uppercase text-text lg:text-xs">Daily Notebook</div>
|
<div className="text-base font-semibold uppercase text-text lg:text-xs">Daily Notebook</div>
|
||||||
<div className="ml-2 rounded bg-success px-1.5 py-[1px] text-[10px] font-bold text-success-contrast">
|
<div className="ml-2 rounded bg-warning px-1.5 py-[1px] text-[10px] font-bold text-warning-contrast">
|
||||||
Experimental
|
Labs
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1">Capture new notes daily with a calendar-based layout</div>
|
<div className="mt-1">Capture new notes daily with a calendar-based layout</div>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
|
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||||
|
import { doesItemMatchSearchQuery } from '@/Utils/Items/Search/doesItemMatchSearchQuery'
|
||||||
|
import { Disclosure, DisclosurePanel } from '@reach/disclosure'
|
||||||
|
import { classNames, ContentType, DecryptedItem, naturalSort } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { ChangeEventHandler, FocusEventHandler, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useApplication } from '../ApplicationProvider'
|
||||||
|
import LinkedItemMeta from '../LinkedItems/LinkedItemMeta'
|
||||||
|
import Menu from '../Menu/Menu'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
contentTypes: ContentType[]
|
||||||
|
placeholder: string
|
||||||
|
onSelection: (item: DecryptedItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemSelectionDropdown = ({ contentTypes, placeholder, onSelection }: Props) => {
|
||||||
|
const application = useApplication()
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [dropdownVisible, setDropdownVisible] = useState(false)
|
||||||
|
|
||||||
|
const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto')
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const searchResultsMenuRef = useRef<HTMLMenuElement>(null)
|
||||||
|
const [items, setItems] = useState<DecryptedItem[]>([])
|
||||||
|
|
||||||
|
const showDropdown = () => {
|
||||||
|
const { clientHeight } = document.documentElement
|
||||||
|
const inputRect = inputRef.current?.getBoundingClientRect()
|
||||||
|
if (inputRect) {
|
||||||
|
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2)
|
||||||
|
setDropdownVisible(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [closeOnBlur] = useCloseOnBlur(containerRef, (visible: boolean) => {
|
||||||
|
setDropdownVisible(visible)
|
||||||
|
setSearchQuery('')
|
||||||
|
})
|
||||||
|
|
||||||
|
const onBlur: FocusEventHandler = (event) => {
|
||||||
|
closeOnBlur(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSearchQueryChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
setSearchQuery(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
showDropdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const searchableItems = naturalSort(application.items.getItems(contentTypes), 'title')
|
||||||
|
const filteredItems = searchableItems.filter((item) => {
|
||||||
|
return doesItemMatchSearchQuery(item, searchQuery, application)
|
||||||
|
})
|
||||||
|
setItems(filteredItems)
|
||||||
|
}, [searchQuery, application, contentTypes])
|
||||||
|
|
||||||
|
const onSelectItem = useCallback(
|
||||||
|
(item: DecryptedItem) => {
|
||||||
|
onSelection(item)
|
||||||
|
setSearchQuery('')
|
||||||
|
setDropdownVisible(false)
|
||||||
|
},
|
||||||
|
[onSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={containerRef}>
|
||||||
|
<Disclosure open={dropdownVisible} onChange={showDropdown}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className={classNames(
|
||||||
|
'mr-10 w-70',
|
||||||
|
'bg-transparent text-sm text-text focus:border-b-2 focus:border-solid focus:border-info lg:text-xs',
|
||||||
|
'no-border h-7 focus:shadow-none focus:outline-none',
|
||||||
|
)}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={onSearchQueryChange}
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{dropdownVisible && (
|
||||||
|
<DisclosurePanel
|
||||||
|
className={classNames(
|
||||||
|
'mr-10 w-70',
|
||||||
|
'absolute z-dropdown-menu flex flex-col overflow-y-auto rounded bg-default py-2 shadow-main',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
maxHeight: dropdownMaxHeight,
|
||||||
|
}}
|
||||||
|
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
isOpen={dropdownVisible}
|
||||||
|
a11yLabel="Tag search results"
|
||||||
|
ref={searchResultsMenuRef}
|
||||||
|
shouldAutoFocus={false}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.uuid}
|
||||||
|
className={classNames(
|
||||||
|
'flex w-full items-center justify-between gap-4 overflow-hidden py-2 px-3 hover:bg-contrast',
|
||||||
|
'hover:text-foreground focus:bg-info-backdrop',
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectItem(item)}
|
||||||
|
>
|
||||||
|
<LinkedItemMeta item={item} searchQuery={searchQuery} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Menu>
|
||||||
|
</DisclosurePanel>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(ItemSelectionDropdown)
|
||||||
@@ -13,7 +13,7 @@ import { getTitleForLinkedTag } from '@/Utils/Items/Display/getTitleForLinkedTag
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: ItemLink
|
link: ItemLink
|
||||||
activateItem: (item: LinkableItem) => Promise<void>
|
activateItem?: (item: LinkableItem) => Promise<void>
|
||||||
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
|
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
|
||||||
focusPreviousItem?: () => void
|
focusPreviousItem?: () => void
|
||||||
focusNextItem?: () => void
|
focusNextItem?: () => void
|
||||||
@@ -59,7 +59,7 @@ const LinkedItemBubble = ({
|
|||||||
const onClick: MouseEventHandler = (event) => {
|
const onClick: MouseEventHandler = (event) => {
|
||||||
if (wasClicked && event.target !== unlinkButtonRef.current) {
|
if (wasClicked && event.target !== unlinkButtonRef.current) {
|
||||||
setWasClicked(false)
|
setWasClicked(false)
|
||||||
void activateItem(link.item)
|
void activateItem?.(link.item)
|
||||||
} else {
|
} else {
|
||||||
setWasClicked(true)
|
setWasClicked(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSe
|
|||||||
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
|
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
|
||||||
import Persistence from './Persistence'
|
import Persistence from './Persistence'
|
||||||
import SmartViews from './SmartViews/SmartViews'
|
import SmartViews from './SmartViews/SmartViews'
|
||||||
|
import Moments from './Moments'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
viewControllerManager: ViewControllerManager
|
viewControllerManager: ViewControllerManager
|
||||||
@@ -24,6 +25,7 @@ const General: FunctionComponent<Props> = ({ viewControllerManager, application,
|
|||||||
<Tools application={application} />
|
<Tools application={application} />
|
||||||
<SmartViews application={application} featuresController={viewControllerManager.featuresController} />
|
<SmartViews application={application} featuresController={viewControllerManager.featuresController} />
|
||||||
<LabsPane application={application} />
|
<LabsPane application={application} />
|
||||||
|
<Moments application={application} />
|
||||||
<Advanced
|
<Advanced
|
||||||
application={application}
|
application={application}
|
||||||
viewControllerManager={viewControllerManager}
|
viewControllerManager={viewControllerManager}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Pill, Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
|
||||||
|
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
|
||||||
|
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
|
||||||
|
import Button from '@/Components/Button/Button'
|
||||||
|
import Switch from '@/Components/Switch/Switch'
|
||||||
|
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||||
|
import ItemSelectionDropdown from '@/Components/ItemSelectionDropdown/ItemSelectionDropdown'
|
||||||
|
import { ContentType, DecryptedItem, PrefKey, SNTag } from '@standardnotes/snjs'
|
||||||
|
import usePreference from '@/Hooks/usePreference'
|
||||||
|
import LinkedItemBubble from '@/Components/LinkedItems/LinkedItemBubble'
|
||||||
|
import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
const Moments: FunctionComponent<Props> = ({ application }: Props) => {
|
||||||
|
const momentsEnabled = application.momentsService.isEnabled
|
||||||
|
const premiumModal = usePremiumModal()
|
||||||
|
|
||||||
|
const defaultTagId = usePreference<string>(PrefKey.MomentsDefaultTagUuid)
|
||||||
|
const [defaultTag, setDefaultTag] = useState<SNTag | undefined>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defaultTagId) {
|
||||||
|
setDefaultTag(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = application.items.findItem(defaultTagId) as SNTag | undefined
|
||||||
|
setDefaultTag(tag)
|
||||||
|
}, [defaultTagId, application])
|
||||||
|
|
||||||
|
const enable = useCallback(() => {
|
||||||
|
if (!application.featuresController.entitledToFiles) {
|
||||||
|
premiumModal.activate('Moments')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void application.momentsService.enableMoments()
|
||||||
|
}, [application, premiumModal])
|
||||||
|
|
||||||
|
const disable = useCallback(() => {
|
||||||
|
void application.momentsService.disableMoments()
|
||||||
|
}, [application])
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (momentsEnabled) {
|
||||||
|
disable()
|
||||||
|
} else {
|
||||||
|
enable()
|
||||||
|
}
|
||||||
|
}, [momentsEnabled, enable, disable])
|
||||||
|
|
||||||
|
const takePhoto = useCallback(() => {
|
||||||
|
if (!application.featuresController.entitledToFiles) {
|
||||||
|
premiumModal.activate('Moments')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void application.momentsService.takePhoto()
|
||||||
|
}, [application, premiumModal])
|
||||||
|
|
||||||
|
const selectTag = useCallback(
|
||||||
|
(tag: DecryptedItem) => {
|
||||||
|
void application.setPreference(PrefKey.MomentsDefaultTagUuid, tag.uuid)
|
||||||
|
},
|
||||||
|
[application],
|
||||||
|
)
|
||||||
|
|
||||||
|
const unselectTag = useCallback(async () => {
|
||||||
|
void application.setPreference(PrefKey.MomentsDefaultTagUuid, undefined)
|
||||||
|
}, [application])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreferencesGroup>
|
||||||
|
<PreferencesSegment>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<Title>Moments</Title>
|
||||||
|
<Pill style={'warning'}>Labs</Pill>
|
||||||
|
<Pill style={'info'}>Professional</Pill>
|
||||||
|
</div>
|
||||||
|
<Switch onChange={toggle} checked={momentsEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Subtitle>Your personal photo journal</Subtitle>
|
||||||
|
|
||||||
|
{momentsEnabled && (
|
||||||
|
<div className="mb-2 flex items-center">
|
||||||
|
{defaultTag && (
|
||||||
|
<div>
|
||||||
|
<LinkedItemBubble
|
||||||
|
className="m-1 mr-2"
|
||||||
|
link={createLinkFromItem(defaultTag, 'linked')}
|
||||||
|
unlinkItem={unselectTag}
|
||||||
|
isBidirectional={false}
|
||||||
|
inlineFlex={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ItemSelectionDropdown
|
||||||
|
onSelection={selectTag}
|
||||||
|
placeholder="Select tag to save Moments to..."
|
||||||
|
contentTypes={[ContentType.Tag]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col"></div>
|
||||||
|
<PreferencesSegment>
|
||||||
|
<Text>
|
||||||
|
Introducing Moments, a new feature in Standard Notes that lets you capture candid photos of yourself
|
||||||
|
throughout the day, right in the app. With Moments, you can create a visual record of your life, one photo
|
||||||
|
at a time.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mt-3">
|
||||||
|
Moments uses your webcam or mobile selfie-cam to take a photo of you every half hour, ensuring that you
|
||||||
|
have a complete record of your day. And because all photos are end-to-end encrypted and stored in your
|
||||||
|
private account, you can trust that your memories are safe and secure.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mt-3">
|
||||||
|
Whether you're working at your computer or capturing notes on the go from your mobile device, Moments is a
|
||||||
|
fun and easy way to document your life. Plus, with customizable photo intervals coming soon, you'll be
|
||||||
|
able to tailor Moments to your unique needs. Enable Moments on a per-device basis to get started.
|
||||||
|
</Text>
|
||||||
|
<div className="mt-5 flex flex-row flex-wrap gap-3">
|
||||||
|
<Button colorStyle="info" onClick={takePhoto}>
|
||||||
|
Capture Present Moment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PreferencesSegment>
|
||||||
|
</div>
|
||||||
|
</PreferencesSegment>
|
||||||
|
</PreferencesGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(Moments)
|
||||||
@@ -7,7 +7,6 @@ import { FunctionComponent, useState } from 'react'
|
|||||||
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
|
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
|
||||||
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
|
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
|
||||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||||
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
@@ -28,7 +27,6 @@ const Tools: FunctionComponent<Props> = ({ application }: Props) => {
|
|||||||
<PreferencesSegment>
|
<PreferencesSegment>
|
||||||
<Title>Tools</Title>
|
<Title>Tools</Title>
|
||||||
<div>
|
<div>
|
||||||
<HorizontalSeparator classes="my-4" />
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Subtitle>Show note saving status while editing</Subtitle>
|
<Subtitle>Show note saving status while editing</Subtitle>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ButtonStyle, getColorsForPrimaryVariant } from '@/Components/Button/Button'
|
||||||
import { classNames } from '@standardnotes/utils'
|
import { classNames } from '@standardnotes/utils'
|
||||||
import { FunctionComponent, MouseEventHandler, ReactNode } from 'react'
|
import { FunctionComponent, MouseEventHandler, ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -37,3 +38,14 @@ export const LinkButton: FunctionComponent<{
|
|||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type PillProps = Props & {
|
||||||
|
style: ButtonStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Pill: FunctionComponent<PillProps> = ({ children, className, style }) => {
|
||||||
|
const colorClass = getColorsForPrimaryVariant(style)
|
||||||
|
return (
|
||||||
|
<div className={classNames('ml-2 rounded px-2 py-1 text-[10px] font-bold', className, colorClass)}>{children}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export class LinkingController extends AbstractViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
linkItems = async (item: LinkableItem, itemToLink: LinkableItem) => {
|
linkItems = async (item: SNNote | FileItem, itemToLink: LinkableItem) => {
|
||||||
if (item instanceof SNNote) {
|
if (item instanceof SNNote) {
|
||||||
if (itemToLink instanceof FileItem) {
|
if (itemToLink instanceof FileItem) {
|
||||||
await this.application.items.associateFileWithNote(itemToLink, item)
|
await this.application.items.associateFileWithNote(itemToLink, item)
|
||||||
@@ -248,6 +248,8 @@ export class LinkingController extends AbstractViewController {
|
|||||||
await this.application.items.linkNoteToNote(item, itemToLink)
|
await this.application.items.linkNoteToNote(item, itemToLink)
|
||||||
} else if (itemToLink instanceof SNTag) {
|
} else if (itemToLink instanceof SNTag) {
|
||||||
await this.addTagToItem(itemToLink, item)
|
await this.addTagToItem(itemToLink, item)
|
||||||
|
} else {
|
||||||
|
throw Error('Invalid item type')
|
||||||
}
|
}
|
||||||
} else if (item instanceof FileItem) {
|
} else if (item instanceof FileItem) {
|
||||||
if (itemToLink instanceof SNNote) {
|
if (itemToLink instanceof SNNote) {
|
||||||
@@ -256,7 +258,11 @@ export class LinkingController extends AbstractViewController {
|
|||||||
await this.application.items.linkFileToFile(item, itemToLink)
|
await this.application.items.linkFileToFile(item, itemToLink)
|
||||||
} else if (itemToLink instanceof SNTag) {
|
} else if (itemToLink instanceof SNTag) {
|
||||||
await this.addTagToItem(itemToLink, item)
|
await this.addTagToItem(itemToLink, item)
|
||||||
|
} else {
|
||||||
|
throw Error('Invalid item to link')
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('First item must be a note or file')
|
||||||
}
|
}
|
||||||
|
|
||||||
void this.application.sync.sync()
|
void this.application.sync.sync()
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export async function awaitVideoReady(video: HTMLVideoElement) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
video.addEventListener('canplaythrough', () => {
|
||||||
|
resolve(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopCameraStream(canvas: HTMLCanvasElement, video: HTMLVideoElement, stream: MediaStream) {
|
||||||
|
video.pause()
|
||||||
|
video.parentElement?.removeChild(video)
|
||||||
|
canvas.parentElement?.removeChild(canvas)
|
||||||
|
video.remove()
|
||||||
|
canvas.remove()
|
||||||
|
|
||||||
|
stream.getTracks().forEach((track) => {
|
||||||
|
track.stop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function takePhoto(
|
||||||
|
filename: string,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): Promise<File | undefined> {
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
context?.drawImage(video, 0, 0, width, height)
|
||||||
|
const dataUrl = canvas.toDataURL('image/png')
|
||||||
|
|
||||||
|
const isFailedImage = dataUrl.length < 100000
|
||||||
|
|
||||||
|
if (isFailedImage) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const res: Response = await fetch(dataUrl)
|
||||||
|
const blob: Blob = await res.blob()
|
||||||
|
const file = new File([blob], filename, { type: 'image/png' })
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preparePhotoOperation() {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
|
||||||
|
const video = document.createElement('video')
|
||||||
|
video.playsInline = true
|
||||||
|
video.style.position = 'absolute'
|
||||||
|
video.style.display = 'none'
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
document.body.append(video)
|
||||||
|
video.srcObject = stream
|
||||||
|
await video.play()
|
||||||
|
await awaitVideoReady(video)
|
||||||
|
|
||||||
|
const videoTrack = stream.getVideoTracks()[0]
|
||||||
|
const settings = videoTrack.getSettings()
|
||||||
|
const width = settings.width ?? 1280
|
||||||
|
const height = settings.height ?? 720
|
||||||
|
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
|
||||||
|
return { canvas, video, stream, width, height }
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
|
||||||
|
import { ApplicationEvent, InternalEventBus, StorageKey } from '@standardnotes/services'
|
||||||
|
import { isDev } from '@/Utils'
|
||||||
|
import { FileItem, PrefKey, sleep, SNTag } from '@standardnotes/snjs'
|
||||||
|
import { FilesController } from '../FilesController'
|
||||||
|
import { preparePhotoOperation, takePhoto, stopCameraStream } from './CameraUtils'
|
||||||
|
import { action, makeObservable, observable } from 'mobx'
|
||||||
|
import { AbstractViewController } from '@/Controllers/Abstract/AbstractViewController'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { dateToStringStyle1 } from '@/Utils/DateUtils'
|
||||||
|
|
||||||
|
const EVERY_HALF_HOUR = 1000 * 60 * 30
|
||||||
|
const EVERY_TEN_SECONDS = 1000 * 10
|
||||||
|
const DEBUG_MODE = isDev && false
|
||||||
|
|
||||||
|
const DELAY_AFTER_STARTING_CAMERA_TO_ALLOW_MOBILE_AUTOFOCUS = 2000
|
||||||
|
|
||||||
|
export class MomentsService extends AbstractViewController {
|
||||||
|
isEnabled = false
|
||||||
|
private intervalReference: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
constructor(application: WebApplication, private filesController: FilesController, eventBus: InternalEventBus) {
|
||||||
|
super(application, eventBus)
|
||||||
|
|
||||||
|
this.disposers.push(
|
||||||
|
application.addEventObserver(async () => {
|
||||||
|
this.isEnabled = (this.application.getValue(StorageKey.MomentsEnabled) as boolean) ?? false
|
||||||
|
if (this.isEnabled) {
|
||||||
|
void this.beginTakingPhotos()
|
||||||
|
}
|
||||||
|
}, ApplicationEvent.Launched),
|
||||||
|
)
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
isEnabled: observable,
|
||||||
|
|
||||||
|
enableMoments: action,
|
||||||
|
disableMoments: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override deinit() {
|
||||||
|
super.deinit()
|
||||||
|
;(this.application as unknown) = undefined
|
||||||
|
;(this.filesController as unknown) = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
public enableMoments = (): void => {
|
||||||
|
this.application.setValue(StorageKey.MomentsEnabled, true)
|
||||||
|
|
||||||
|
this.isEnabled = true
|
||||||
|
|
||||||
|
void this.beginTakingPhotos()
|
||||||
|
}
|
||||||
|
|
||||||
|
public disableMoments = (): void => {
|
||||||
|
this.application.setValue(StorageKey.MomentsEnabled, false)
|
||||||
|
|
||||||
|
this.isEnabled = false
|
||||||
|
|
||||||
|
clearInterval(this.intervalReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginTakingPhotos() {
|
||||||
|
void this.takePhoto()
|
||||||
|
|
||||||
|
this.intervalReference = setInterval(
|
||||||
|
() => {
|
||||||
|
void this.takePhoto()
|
||||||
|
},
|
||||||
|
DEBUG_MODE ? EVERY_TEN_SECONDS : EVERY_HALF_HOUR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultTag(): SNTag | undefined {
|
||||||
|
const defaultTagId = this.application.getPreference(PrefKey.MomentsDefaultTagUuid)
|
||||||
|
|
||||||
|
if (defaultTagId) {
|
||||||
|
return this.application.items.findItem(defaultTagId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async takePhoto(): Promise<FileItem[] | undefined> {
|
||||||
|
const toastId = addToast({
|
||||||
|
type: ToastType.Loading,
|
||||||
|
message: 'Capturing Moment...',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { canvas, video, stream, width, height } = await preparePhotoOperation()
|
||||||
|
|
||||||
|
const filename = `Moment ${dateToStringStyle1(new Date())}.png`
|
||||||
|
|
||||||
|
if (this.application.isMobileDevice) {
|
||||||
|
await sleep(DELAY_AFTER_STARTING_CAMERA_TO_ALLOW_MOBILE_AUTOFOCUS)
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = await takePhoto(filename, canvas, video, width, height)
|
||||||
|
if (!file) {
|
||||||
|
await sleep(1000)
|
||||||
|
file = await takePhoto(filename, canvas, video, width, height)
|
||||||
|
if (!file) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissToast(toastId)
|
||||||
|
|
||||||
|
const uploadedFile = await this.filesController.uploadNewFile(file)
|
||||||
|
|
||||||
|
const defaultTag = this.getDefaultTag()
|
||||||
|
if (defaultTag && uploadedFile) {
|
||||||
|
void this.application.linkingController.linkItems(uploadedFile[0], defaultTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCameraStream(canvas, video, stream)
|
||||||
|
|
||||||
|
return uploadedFile
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/web/src/javascripts/Hooks/usePreference.tsx
Normal file
19
packages/web/src/javascripts/Hooks/usePreference.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useApplication } from '@/Components/ApplicationProvider'
|
||||||
|
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export default function usePreference<T>(preference: PrefKey) {
|
||||||
|
const application = useApplication()
|
||||||
|
|
||||||
|
const [value, setValue] = useState<T>(application.getPreference(preference) as T)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return application.addEventObserver(async () => {
|
||||||
|
const latestValue = application.getPreference(preference)
|
||||||
|
|
||||||
|
setValue(latestValue as T)
|
||||||
|
}, ApplicationEvent.PreferencesChanged)
|
||||||
|
}, [application, preference])
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -177,6 +177,8 @@ export const isIOS = () =>
|
|||||||
(navigator.userAgent.includes('Mac') && 'ontouchend' in document && navigator.maxTouchPoints > 1) ||
|
(navigator.userAgent.includes('Mac') && 'ontouchend' in document && navigator.maxTouchPoints > 1) ||
|
||||||
window.platform === Platform.Ios
|
window.platform === Platform.Ios
|
||||||
|
|
||||||
|
export const isAndroid = () => navigator.userAgent.toLowerCase().includes('android')
|
||||||
|
|
||||||
// https://stackoverflow.com/a/57527009/2504429
|
// https://stackoverflow.com/a/57527009/2504429
|
||||||
export const disableIosTextFieldZoom = () => {
|
export const disableIosTextFieldZoom = () => {
|
||||||
const addMaximumScaleToMetaViewport = () => {
|
const addMaximumScaleToMetaViewport = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user