From 29368c51b801331dd73d8cbee9746abadf2e1120 Mon Sep 17 00:00:00 2001 From: Mo Date: Fri, 2 Dec 2022 08:41:21 -0600 Subject: [PATCH] feat: Moments: your personal photo journal, now available in Labs (#2079) --- packages/mobile/src/MobileWebAppContainer.tsx | 5 + .../src/Domain/Syncable/UserPrefs/PrefKey.ts | 2 + .../src/Domain/Storage/StorageKeys.ts | 1 + .../javascripts/Application/Application.ts | 24 ++- .../javascripts/Application/WebServices.ts | 2 + .../javascripts/Components/Button/Button.tsx | 4 +- .../Header/DisplayOptionsMenu.tsx | 4 +- .../ItemSelectionDropdown.tsx | 132 ++++++++++++++++ .../LinkedItems/LinkedItemBubble.tsx | 4 +- .../Preferences/Panes/General/General.tsx | 2 + .../Preferences/Panes/General/Moments.tsx | 144 ++++++++++++++++++ .../Preferences/Panes/General/Tools.tsx | 2 - .../PreferencesComponents/Content.tsx | 12 ++ .../Controllers/LinkingController.tsx | 8 +- .../Controllers/Moments/CameraUtils.ts | 65 ++++++++ .../Controllers/Moments/MomentsService.ts | 119 +++++++++++++++ .../src/javascripts/Hooks/usePreference.tsx | 19 +++ packages/web/src/javascripts/Utils/Utils.ts | 2 + 18 files changed, 541 insertions(+), 10 deletions(-) create mode 100644 packages/web/src/javascripts/Components/ItemSelectionDropdown/ItemSelectionDropdown.tsx create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/General/Moments.tsx create mode 100644 packages/web/src/javascripts/Controllers/Moments/CameraUtils.ts create mode 100644 packages/web/src/javascripts/Controllers/Moments/MomentsService.ts create mode 100644 packages/web/src/javascripts/Hooks/usePreference.tsx diff --git a/packages/mobile/src/MobileWebAppContainer.tsx b/packages/mobile/src/MobileWebAppContainer.tsx index 070f620c2..0dc2cb828 100644 --- a/packages/mobile/src/MobileWebAppContainer.tsx +++ b/packages/mobile/src/MobileWebAppContainer.tsx @@ -253,6 +253,9 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo return true } + const requireInlineMediaPlaybackForMomentsFeature = true + const requireMediaUserInteractionForMomentsFeature = false + return ( vo injectedJavaScriptBeforeContentLoaded={injectedJS} bounces={false} keyboardDisplayRequiresUserAction={false} + allowsInlineMediaPlayback={requireInlineMediaPlaybackForMomentsFeature} + mediaPlaybackRequiresUserAction={requireMediaUserInteractionForMomentsFeature} scalesPageToFit={true} /** * This disables the global window scroll but keeps scroll within div elements like lists and textareas. diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts index 0346f876b..ef39c08f0 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -39,6 +39,7 @@ export enum PrefKey { UpdateSavingStatusIndicator = 'updateSavingStatusIndicator', DarkMode = 'darkMode', DefaultEditorIdentifier = 'defaultEditorIdentifier', + MomentsDefaultTagUuid = 'momentsDefaultTagUuid', } export enum NewNoteTitleFormat { @@ -103,4 +104,5 @@ export type PrefValue = { [PrefKey.UpdateSavingStatusIndicator]: boolean [PrefKey.DarkMode]: boolean [PrefKey.DefaultEditorIdentifier]: EditorIdentifier + [PrefKey.MomentsDefaultTagUuid]: string | undefined } diff --git a/packages/services/src/Domain/Storage/StorageKeys.ts b/packages/services/src/Domain/Storage/StorageKeys.ts index 878fc3f4e..d22fc232e 100644 --- a/packages/services/src/Domain/Storage/StorageKeys.ts +++ b/packages/services/src/Domain/Storage/StorageKeys.ts @@ -40,6 +40,7 @@ export enum StorageKey { CodeVerifier = 'code_verifier', LaunchPriorityUuids = 'launch_priority_uuids', LastReadChangelogVersion = 'last_read_changelog_version', + MomentsEnabled = 'moments_enabled', } export enum NonwrappedStorageKey { diff --git a/packages/web/src/javascripts/Application/Application.ts b/packages/web/src/javascripts/Application/Application.ts index 94d0ce78f..86f875355 100644 --- a/packages/web/src/javascripts/Application/Application.ts +++ b/packages/web/src/javascripts/Application/Application.ts @@ -24,7 +24,7 @@ import { } from '@standardnotes/snjs' import { makeObservable, observable } from 'mobx' import { PanelResizedData } from '@/Types/PanelResizedData' -import { isDesktopApplication } from '@/Utils' +import { isAndroid, isDesktopApplication, isIOS } from '@/Utils' import { DesktopManager } from './Device/DesktopManager' import { ArchiveManager, @@ -45,6 +45,7 @@ import { WebServices } from './WebServices' import { FeatureName } from '@/Controllers/FeatureName' import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController' import { VisibilityObserver } from './VisibilityObserver' +import { MomentsService } from '@/Controllers/Moments/MomentsService' export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void @@ -98,6 +99,11 @@ export class WebApplication extends SNApplication implements WebApplicationInter : undefined this.webServices.viewControllerManager = new ViewControllerManager(this, deviceInterface) this.webServices.changelogService = new ChangelogService(this.environment, this.storage) + this.webServices.momentsService = new MomentsService( + this, + this.webServices.viewControllerManager.filesController, + internalEventBus, + ) if (this.isNativeMobileWeb()) { this.mobileWebReceiver = new MobileWebReceiver(this) @@ -196,10 +202,22 @@ export class WebApplication extends SNApplication implements WebApplicationInter return this.webServices.viewControllerManager.paneController } + public get linkingController() { + return this.webServices.viewControllerManager.linkingController + } + public get changelogService() { return this.webServices.changelogService } + public get momentsService() { + return this.webServices.momentsService + } + + public get featuresController() { + return this.getViewControllerManager().featuresController + } + public get desktopDevice(): DesktopDeviceInterface | undefined { if (isDesktopDevice(this.deviceInterface)) { return this.deviceInterface @@ -212,6 +230,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter return this.isNativeMobileWeb() && this.platform === Platform.Ios } + get isMobileDevice() { + return this.isNativeMobileWeb() || isIOS() || isAndroid() + } + get hideOutboundSubscriptionLinks() { return this.isNativeIOS() } diff --git a/packages/web/src/javascripts/Application/WebServices.ts b/packages/web/src/javascripts/Application/WebServices.ts index 7acca8b02..fc7ab6a6c 100644 --- a/packages/web/src/javascripts/Application/WebServices.ts +++ b/packages/web/src/javascripts/Application/WebServices.ts @@ -7,6 +7,7 @@ import { KeyboardService, ThemeManager, } from '@standardnotes/ui-services' +import { MomentsService } from '@/Controllers/Moments/MomentsService' export type WebServices = { viewControllerManager: ViewControllerManager @@ -16,4 +17,5 @@ export type WebServices = { themeService: ThemeManager keyboardService: KeyboardService changelogService: ChangelogServiceInterface + momentsService: MomentsService } diff --git a/packages/web/src/javascripts/Components/Button/Button.tsx b/packages/web/src/javascripts/Components/Button/Button.tsx index e310dd041..7a991b360 100644 --- a/packages/web/src/javascripts/Components/Button/Button.tsx +++ b/packages/web/src/javascripts/Components/Button/Button.tsx @@ -1,6 +1,6 @@ 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) => { switch (style) { @@ -21,7 +21,7 @@ const getColorsForNormalVariant = (style: ButtonStyle) => { } } -const getColorsForPrimaryVariant = (style: ButtonStyle) => { +export const getColorsForPrimaryVariant = (style: ButtonStyle) => { switch (style) { case 'default': return 'bg-default text-foreground' diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx index fa26c725d..8c9842a3c 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx @@ -365,8 +365,8 @@ const DisplayOptionsMenu: FunctionComponent = ({
Daily Notebook
-
- Experimental +
+ Labs
Capture new notes daily with a calendar-based layout
diff --git a/packages/web/src/javascripts/Components/ItemSelectionDropdown/ItemSelectionDropdown.tsx b/packages/web/src/javascripts/Components/ItemSelectionDropdown/ItemSelectionDropdown.tsx new file mode 100644 index 000000000..cbe550390 --- /dev/null +++ b/packages/web/src/javascripts/Components/ItemSelectionDropdown/ItemSelectionDropdown.tsx @@ -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('auto') + const containerRef = useRef(null) + const inputRef = useRef(null) + const searchResultsMenuRef = useRef(null) + const [items, setItems] = useState([]) + + 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 = (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 ( +
+ + + + {dropdownVisible && ( + + + {items.map((item) => { + return ( + + ) + })} + + + )} + +
+ ) +} + +export default observer(ItemSelectionDropdown) diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx index 070320567..432ab05b3 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx @@ -13,7 +13,7 @@ import { getTitleForLinkedTag } from '@/Utils/Items/Display/getTitleForLinkedTag type Props = { link: ItemLink - activateItem: (item: LinkableItem) => Promise + activateItem?: (item: LinkableItem) => Promise unlinkItem: LinkingController['unlinkItemFromSelectedItem'] focusPreviousItem?: () => void focusNextItem?: () => void @@ -59,7 +59,7 @@ const LinkedItemBubble = ({ const onClick: MouseEventHandler = (event) => { if (wasClicked && event.target !== unlinkButtonRef.current) { setWasClicked(false) - void activateItem(link.item) + void activateItem?.(link.item) } else { setWasClicked(true) } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx index b873127f9..e7356b6ee 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx @@ -10,6 +10,7 @@ import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSe import PreferencesPane from '../../PreferencesComponents/PreferencesPane' import Persistence from './Persistence' import SmartViews from './SmartViews/SmartViews' +import Moments from './Moments' type Props = { viewControllerManager: ViewControllerManager @@ -24,6 +25,7 @@ const General: FunctionComponent = ({ viewControllerManager, application, + = ({ application }: Props) => { + const momentsEnabled = application.momentsService.isEnabled + const premiumModal = usePremiumModal() + + const defaultTagId = usePreference(PrefKey.MomentsDefaultTagUuid) + const [defaultTag, setDefaultTag] = useState() + + 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 ( + + +
+
+ Moments + Labs + Professional +
+ +
+ + Your personal photo journal + + {momentsEnabled && ( +
+ {defaultTag && ( +
+ +
+ )} + +
+ )} + +
+
+ + + 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. + + + + 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. + + + + 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. + +
+ +
+
+
+
+
+ ) +} + +export default observer(Moments) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Tools.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Tools.tsx index b60613dc9..a86ce3ba0 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Tools.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Tools.tsx @@ -7,7 +7,6 @@ import { FunctionComponent, useState } from 'react' import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup' import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment' import { PrefDefaults } from '@/Constants/PrefDefaults' -import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' type Props = { application: WebApplication @@ -28,7 +27,6 @@ const Tools: FunctionComponent = ({ application }: Props) => { Tools
-
Show note saving status while editing diff --git a/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx b/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx index 7b9513978..d5d5147b3 100644 --- a/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx +++ b/packages/web/src/javascripts/Components/Preferences/PreferencesComponents/Content.tsx @@ -1,3 +1,4 @@ +import { ButtonStyle, getColorsForPrimaryVariant } from '@/Components/Button/Button' import { classNames } from '@standardnotes/utils' import { FunctionComponent, MouseEventHandler, ReactNode } from 'react' @@ -37,3 +38,14 @@ export const LinkButton: FunctionComponent<{ {label} ) + +type PillProps = Props & { + style: ButtonStyle +} + +export const Pill: FunctionComponent = ({ children, className, style }) => { + const colorClass = getColorsForPrimaryVariant(style) + return ( +
{children}
+ ) +} diff --git a/packages/web/src/javascripts/Controllers/LinkingController.tsx b/packages/web/src/javascripts/Controllers/LinkingController.tsx index 994a44604..24552cb46 100644 --- a/packages/web/src/javascripts/Controllers/LinkingController.tsx +++ b/packages/web/src/javascripts/Controllers/LinkingController.tsx @@ -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 (itemToLink instanceof FileItem) { await this.application.items.associateFileWithNote(itemToLink, item) @@ -248,6 +248,8 @@ export class LinkingController extends AbstractViewController { await this.application.items.linkNoteToNote(item, itemToLink) } else if (itemToLink instanceof SNTag) { await this.addTagToItem(itemToLink, item) + } else { + throw Error('Invalid item type') } } else if (item instanceof FileItem) { if (itemToLink instanceof SNNote) { @@ -256,7 +258,11 @@ export class LinkingController extends AbstractViewController { await this.application.items.linkFileToFile(item, itemToLink) } else if (itemToLink instanceof SNTag) { 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() diff --git a/packages/web/src/javascripts/Controllers/Moments/CameraUtils.ts b/packages/web/src/javascripts/Controllers/Moments/CameraUtils.ts new file mode 100644 index 000000000..45eeacecb --- /dev/null +++ b/packages/web/src/javascripts/Controllers/Moments/CameraUtils.ts @@ -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 { + 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 } +} diff --git a/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts b/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts new file mode 100644 index 000000000..3db7b0bac --- /dev/null +++ b/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts @@ -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 | 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 { + 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 + } +} diff --git a/packages/web/src/javascripts/Hooks/usePreference.tsx b/packages/web/src/javascripts/Hooks/usePreference.tsx new file mode 100644 index 000000000..ce3977a97 --- /dev/null +++ b/packages/web/src/javascripts/Hooks/usePreference.tsx @@ -0,0 +1,19 @@ +import { useApplication } from '@/Components/ApplicationProvider' +import { ApplicationEvent, PrefKey } from '@standardnotes/snjs' +import { useEffect, useState } from 'react' + +export default function usePreference(preference: PrefKey) { + const application = useApplication() + + const [value, setValue] = useState(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 +} diff --git a/packages/web/src/javascripts/Utils/Utils.ts b/packages/web/src/javascripts/Utils/Utils.ts index 51920c030..91d736acb 100644 --- a/packages/web/src/javascripts/Utils/Utils.ts +++ b/packages/web/src/javascripts/Utils/Utils.ts @@ -177,6 +177,8 @@ export const isIOS = () => (navigator.userAgent.includes('Mac') && 'ontouchend' in document && navigator.maxTouchPoints > 1) || window.platform === Platform.Ios +export const isAndroid = () => navigator.userAgent.toLowerCase().includes('android') + // https://stackoverflow.com/a/57527009/2504429 export const disableIosTextFieldZoom = () => { const addMaximumScaleToMetaViewport = () => {