Compare commits

...
Author SHA1 Message Date
StandardNotes CI b9b4bdf257 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected].3
2022-10-28 19:15:38 +00:00
Aman Harwara 6071ebffeb fix: color scheme handling on mobile (#1902) 2022-10-29 00:12:03 +05:30
StandardNotes CI f8181d9a4f chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
2022-10-28 17:51:02 +00:00
Mo 026fbc1524 style: copy 2022-10-28 12:17:30 -05:00
Mo 5058aaee33 fix: boolean 2022-10-28 12:14:23 -05:00
StandardNotes CI f67b486a0d chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-10-28 16:16:17 +00:00
Mo a9efce21f0 refactor: daily notes (#1901) 2022-10-28 10:45:03 -05:00
Aman Harwara 5bb923cff3 fix: privacy overlay on iOS 2022-10-28 20:19:57 +05:30
Aman Harwara 5a6f4fb35c fix: screenshot privacy setting not working on app relaunch (#1900) 2022-10-28 19:31:15 +05:30
Aman Harwara 9ac5bd0c27 refactor: extract biometrics conditional to constant 2022-10-28 17:11:37 +05:30
Aman Harwara 02ad46147d fix: only auto-toggle biometrics prompt if user hasn't interacted 2022-10-28 16:07:26 +05:30
39 changed files with 667 additions and 395 deletions
+12
View File
@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.23.257](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.256](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.255](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.254](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-27)
**Note:** Version bump only for package @standardnotes/desktop
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@standardnotes/desktop",
"main": "./app/dist/index.js",
"version": "3.23.254",
"version": "3.23.257",
"license": "AGPL-3.0-or-later",
"author": "Standard Notes.",
"private": true,
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.45.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
### Bug Fixes
* color scheme handling on mobile ([#1902](https://github.com/standardnotes/app/issues/1902)) ([6071ebf](https://github.com/standardnotes/app/commit/6071ebffeb55c78eac79f39aecca07f6ebdbed93))
## [3.45.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
**Note:** Version bump only for package @standardnotes/mobile
## [3.45.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
### Bug Fixes
* screenshot privacy setting not working on app relaunch ([#1900](https://github.com/standardnotes/app/issues/1900)) ([5a6f4fb](https://github.com/standardnotes/app/commit/5a6f4fb35c2e70a463e04ce5f71bde55bba4bb30))
## [3.45.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-27)
**Note:** Version bump only for package @standardnotes/mobile
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/mobile",
"version": "3.45.1",
"version": "3.45.4",
"author": "Standard Notes.",
"private": true,
"license": "AGPL-3.0-or-later",
@@ -0,0 +1,19 @@
import { AbstractService, InternalEventBus, ReactNativeToWebEvent } from '@standardnotes/snjs'
import { Appearance, NativeEventSubscription } from 'react-native'
export class ColorSchemeObserverService extends AbstractService<ReactNativeToWebEvent> {
private removeListener: NativeEventSubscription
constructor() {
const internalEventBus = new InternalEventBus()
super(internalEventBus)
this.removeListener = Appearance.addChangeListener(() => {
void this.notifyEvent(ReactNativeToWebEvent.ColorSchemeChanged)
})
}
deinit() {
this.removeListener.remove()
}
}
+21 -1
View File
@@ -13,7 +13,18 @@ import {
TransferPayload,
UuidString,
} from '@standardnotes/snjs'
import { Alert, AppState, AppStateStatus, Linking, PermissionsAndroid, Platform, StatusBar } from 'react-native'
import { ColorSchemeObserverService } from 'ColorSchemeObserverService'
import {
Alert,
Appearance,
AppState,
AppStateStatus,
ColorSchemeName,
Linking,
PermissionsAndroid,
Platform,
StatusBar,
} from 'react-native'
import FileViewer from 'react-native-file-viewer'
import FingerprintScanner from 'react-native-fingerprint-scanner'
import FlagSecure from 'react-native-flag-secure-android'
@@ -85,6 +96,7 @@ export class MobileDevice implements MobileDeviceInterface {
constructor(
private stateObserverService?: AppStateObserverService,
private androidBackHandlerService?: AndroidBackHandlerService,
private colorSchemeService?: ColorSchemeObserverService,
) {}
deinit() {
@@ -92,6 +104,8 @@ export class MobileDevice implements MobileDeviceInterface {
;(this.stateObserverService as unknown) = undefined
this.androidBackHandlerService?.deinit()
;(this.androidBackHandlerService as unknown) = undefined
this.colorSchemeService?.deinit()
;(this.colorSchemeService as unknown) = undefined
}
consoleLog(...args: any[]): void {
@@ -220,10 +234,12 @@ export class MobileDevice implements MobileDeviceInterface {
hideMobileInterfaceFromScreenshots(): void {
hide()
this.setAndroidScreenshotPrivacy(true)
}
stopHidingMobileInterfaceFromScreenshots(): void {
show()
this.setAndroidScreenshotPrivacy(false)
}
async getAllRawStorageKeyValues() {
@@ -614,4 +630,8 @@ export class MobileDevice implements MobileDeviceInterface {
async getAppState(): Promise<AppStateStatus> {
return AppState.currentState
}
async getColorScheme(): Promise<ColorSchemeName> {
return Appearance.getColorScheme()
}
}
+10 -3
View File
@@ -1,4 +1,5 @@
import { ReactNativeToWebEvent } from '@standardnotes/snjs'
import { ColorSchemeObserverService } from './ColorSchemeObserverService'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Keyboard, Platform } from 'react-native'
import VersionInfo from 'react-native-version-info'
@@ -28,9 +29,10 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html'
const stateService = useMemo(() => new AppStateObserverService(), [])
const androidBackHandlerService = useMemo(() => new AndroidBackHandlerService(), [])
const colorSchemeService = useMemo(() => new ColorSchemeObserverService(), [])
const device = useMemo(
() => new MobileDevice(stateService, androidBackHandlerService),
[androidBackHandlerService, stateService],
() => new MobileDevice(stateService, androidBackHandlerService, colorSchemeService),
[androidBackHandlerService, colorSchemeService, stateService],
)
useEffect(() => {
@@ -44,6 +46,10 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
},
)
const removeColorSchemeServiceListener = colorSchemeService.addEventObserver((event: ReactNativeToWebEvent) => {
webViewRef.current?.postMessage(JSON.stringify({ reactNativeEvent: event, messageType: 'event' }))
})
const keyboardShowListener = Keyboard.addListener('keyboardWillShow', () => {
device.reloadStatusBarStyle(false)
})
@@ -55,10 +61,11 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
return () => {
removeStateServiceListener()
removeBackHandlerServiceListener()
removeColorSchemeServiceListener()
keyboardShowListener.remove()
keyboardHideListener.remove()
}
}, [webViewRef, stateService, device, androidBackHandlerService])
}, [webViewRef, stateService, device, androidBackHandlerService, colorSchemeService])
useEffect(() => {
const observer = device.addMobileWebEventReceiver((event) => {
+12
View File
@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.3.186](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.185](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.184](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.183](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-27)
**Note:** Version bump only for package @standardnotes/releases
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/releases",
"version": "1.3.183",
"version": "1.3.186",
"license": "AGPL-3.0-or-later",
"main": "dist/releases.json",
"types": "dist/index.d.ts",
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.39.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
### Bug Fixes
* color scheme handling on mobile ([#1902](https://github.com/standardnotes/app/issues/1902)) ([6071ebf](https://github.com/standardnotes/app/commit/6071ebffeb55c78eac79f39aecca07f6ebdbed93))
# [1.39.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-27)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/services",
"version": "1.39.0",
"version": "1.39.1",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -10,6 +10,7 @@ export interface WebApplicationInterface extends ApplicationInterface {
handleMobileGainingFocusEvent(): Promise<void>
handleMobileLosingFocusEvent(): Promise<void>
handleMobileResumingFromBackgroundEvent(): Promise<void>
handleMobileColorSchemeChangeEvent(): void
isNativeMobileWeb(): boolean
mobileDevice(): MobileDeviceInterface
handleAndroidBackButtonPressed(): void
@@ -21,4 +21,5 @@ export interface MobileDeviceInterface extends DeviceInterface {
removeComponentUrl(componentUuid: string): void
isUrlComponentUrl(url: string): boolean
getAppState(): Promise<'active' | 'background' | 'inactive' | 'unknown' | 'extension'>
getColorScheme(): Promise<'light' | 'dark' | null | undefined>
}
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.145.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
### Bug Fixes
* color scheme handling on mobile ([#1902](https://github.com/standardnotes/app/issues/1902)) ([6071ebf](https://github.com/standardnotes/app/commit/6071ebffeb55c78eac79f39aecca07f6ebdbed93))
# [2.145.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-27)
### Features
@@ -4,4 +4,5 @@ export enum ReactNativeToWebEvent {
GainingFocus = 'GainingFocus',
LosingFocus = 'LosingFocus',
AndroidBackButtonPressed = 'AndroidBackButtonPressed',
ColorSchemeChanged = 'ColorSchemeChanged',
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/snjs",
"version": "2.145.0",
"version": "2.145.1",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.10.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
### Bug Fixes
* color scheme handling on mobile ([#1902](https://github.com/standardnotes/app/issues/1902)) ([6071ebf](https://github.com/standardnotes/app/commit/6071ebffeb55c78eac79f39aecca07f6ebdbed93))
## [1.10.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-27)
**Note:** Version bump only for package @standardnotes/ui-services
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/ui-services",
"version": "1.10.5",
"version": "1.10.6",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+20 -5
View File
@@ -59,11 +59,13 @@ export class ThemeManager extends AbstractService {
break
}
case ApplicationEvent.Launched: {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.colorSchemeEventHandler)
if (!this.application.isNativeMobileWeb()) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.colorSchemeEventHandler)
}
break
}
case ApplicationEvent.PreferencesChanged: {
this.handlePreferencesChangeEvent()
void this.handlePreferencesChangeEvent()
break
}
}
@@ -88,7 +90,16 @@ export class ThemeManager extends AbstractService {
})
}
private handlePreferencesChangeEvent(): void {
async handleMobileColorSchemeChangeEvent() {
const useDeviceThemeSettings = this.application.getPreference(PrefKey.UseSystemColorScheme, false)
if (useDeviceThemeSettings) {
const prefersDarkColorScheme = (await this.application.mobileDevice().getColorScheme()) === 'dark'
this.setThemeAsPerColorScheme(prefersDarkColorScheme)
}
}
private async handlePreferencesChangeEvent() {
const useDeviceThemeSettings = this.application.getPreference(PrefKey.UseSystemColorScheme, false)
const hasPreferenceChanged = useDeviceThemeSettings !== this.lastUseDeviceThemeSettings
@@ -98,9 +109,13 @@ export class ThemeManager extends AbstractService {
}
if (hasPreferenceChanged && useDeviceThemeSettings) {
const prefersDarkColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
let prefersDarkColorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches
this.setThemeAsPerColorScheme(prefersDarkColorScheme.matches)
if (this.application.isNativeMobileWeb()) {
prefersDarkColorScheme = (await this.application.mobileDevice().getColorScheme()) === 'dark'
}
this.setThemeAsPerColorScheme(prefersDarkColorScheme)
}
}
+20
View File
@@ -3,6 +3,26 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.90.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
### Bug Fixes
* color scheme handling on mobile ([#1902](https://github.com/standardnotes/app/issues/1902)) ([6071ebf](https://github.com/standardnotes/app/commit/6071ebffeb55c78eac79f39aecca07f6ebdbed93))
## [3.90.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
### Bug Fixes
* boolean ([5058aae](https://github.com/standardnotes/app/commit/5058aaee3395ee2760ae4de72378db1de286a2bb))
## [3.90.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-28)
### Bug Fixes
* only auto-toggle biometrics prompt if user hasn't interacted ([02ad461](https://github.com/standardnotes/app/commit/02ad46147dbb05543edb7bf9a4b95d7fa61ae713))
* privacy overlay on iOS ([5bb923c](https://github.com/standardnotes/app/commit/5bb923cff3b595a0c78d16098e66006073ff2a59))
* screenshot privacy setting not working on app relaunch ([#1900](https://github.com/standardnotes/app/issues/1900)) ([5a6f4fb](https://github.com/standardnotes/app/commit/5a6f4fb35c2e70a463e04ce5f71bde55bba4bb30))
# [3.90.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-27)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/web",
"version": "3.90.0",
"version": "3.90.3",
"license": "AGPL-3.0-or-later",
"main": "dist/app.js",
"author": "Standard Notes.",
@@ -261,6 +261,18 @@ export class WebApplication extends SNApplication implements WebApplicationInter
setViewportHeightWithFallback()
}
handleInitialMobileScreenshotPrivacy(): void {
if (this.platform !== Platform.Android) {
return
}
if (this.protections.getMobileScreenshotPrivacyEnabled()) {
this.mobileDevice().setAndroidScreenshotPrivacy(true)
} else {
this.mobileDevice().setAndroidScreenshotPrivacy(false)
}
}
async handleMobileLosingFocusEvent(): Promise<void> {
if (this.protections.getMobileScreenshotPrivacyEnabled()) {
this.mobileDevice().stopHidingMobileInterfaceFromScreenshots()
@@ -277,6 +289,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
setViewportHeightWithFallback()
}
handleMobileColorSchemeChangeEvent() {
void this.getThemeService().handleMobileColorSchemeChangeEvent()
}
private async lockApplicationAfterMobileEventIfApplicable(): Promise<void> {
const isLocked = await this.isLocked()
if (isLocked) {
@@ -42,11 +42,17 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({
}
}
const hasUserAlreadyInteracted = typeof values[prompt.id].value === 'boolean'
if (hasUserAlreadyInteracted) {
return
}
biometricsButtonRef.current?.click()
} else {
inputRef.current?.focus()
}
}, [application, prompt.validation])
}, [application, prompt.id, prompt.validation, values])
useEffect(() => {
if (!application.isNativeMobileWeb()) {
@@ -3,7 +3,7 @@ import { CalendarActivity } from './CalendarActivity'
import CalendarDay from './CalendarDay'
import { CalendarDays, CalendarDaysLeap, CalendarDaysOfTheWeek } from './Constants'
import { createActivityRecord, dateToDateOnlyString, isLeapYear, getStartDayOfMonth } from './CalendarUtilts'
import { isDateInSameDay } from '@/Utils/DateUtils'
import { areDatesInSameDay } from '@/Utils/DateUtils'
type Props = {
activities: CalendarActivity[]
@@ -29,10 +29,11 @@ const Calendar: FunctionComponent<Props> = ({ activities, startDate, onDateSelec
}, [startDate])
const today = new Date()
const days = isLeapYear(year) ? CalendarDaysLeap : CalendarDays
const dayBundle = isLeapYear(year) ? CalendarDaysLeap : CalendarDays
const days = Array(dayBundle[month] + (startDay - 1)).fill(null)
return (
<div className={`w-300 ${className} border-left border-right border border-neutral`}>
<div className={`w-300 ${className} min-h-[210px]`}>
<div className="mr-auto ml-auto w-70">
<div className="flex w-full flex-wrap">
{CalendarDaysOfTheWeek.map((d) => (
@@ -42,23 +43,24 @@ const Calendar: FunctionComponent<Props> = ({ activities, startDate, onDateSelec
))}
</div>
<div className="flex w-full flex-wrap">
{Array(days[month] + (startDay - 1))
.fill(null)
.map((_, index) => {
const d = index - (startDay - 2)
const date = new Date(year, month, d)
const activities = activityMap[dateToDateOnlyString(date)] || []
return (
<CalendarDay
key={index}
day={d}
isToday={isDateInSameDay(date, today)}
activities={activities}
onClick={() => onDateSelect(date)}
hasPendingEntry={selectedDay && isDateInSameDay(selectedDay, date)}
/>
)
})}
{days.map((_, index) => {
const dayIndex = index - (startDay - 2)
const date = new Date(year, month, dayIndex)
const day = date.getDate()
const activities = activityMap[dateToDateOnlyString(date)] || []
const isTemplate = selectedDay && areDatesInSameDay(selectedDay, date)
const type = activities.length > 0 ? 'item' : isTemplate ? 'template' : 'empty'
return (
<CalendarDay
isLastMonth={dayIndex <= 0}
key={index}
day={day}
isToday={areDatesInSameDay(date, today)}
onClick={() => onDateSelect(date)}
type={type}
/>
)
})}
</div>
</div>
</div>
@@ -1,33 +1,37 @@
import { FunctionComponent } from 'react'
import { CalendarActivity } from './CalendarActivity'
type Props = {
day: number
activities: CalendarActivity[]
isToday: boolean
onClick: () => void
hasPendingEntry?: boolean
type: 'empty' | 'item' | 'template'
isLastMonth: boolean
}
const CalendarDay: FunctionComponent<Props> = ({ day, activities = [], hasPendingEntry, isToday, onClick }) => {
const hasActivity = day > 0 && activities.length > 0
const todayClassNames = 'bg-danger text-danger-contrast font-bold'
const hasActivityClassNames = 'bg-danger-light text-danger font-bold'
const defaultClassNames = 'bg-transparent hover:bg-contrast'
const hasPendingEntryNames = 'bg-contrast'
const CalendarDay: FunctionComponent<Props> = ({ day, type, isToday, onClick, isLastMonth }) => {
let classNames = ''
if (isToday) {
classNames += 'bg-danger text-danger-contrast font-bold'
} else if (isLastMonth) {
classNames += 'text-passive-3'
} else {
if (type === 'empty') {
classNames += 'bg-transparent hover:bg-contrast'
} else if (type === 'item') {
classNames += 'bg-danger-light text-danger font-bold'
} else {
classNames += 'bg-contrast'
}
}
return (
<div className="h-7 w-[14.2%] p-0.5">
<div
className={`${
!hasActivity && !isToday ? defaultClassNames : ''
} flex h-full w-full cursor-pointer items-center justify-center rounded ${
isToday ? todayClassNames : hasActivity ? hasActivityClassNames : ''
} ${hasPendingEntry ? hasPendingEntryNames : ''}`}
className={`${classNames} flex h-full w-full cursor-pointer items-center justify-center rounded`}
key={day}
onClick={onClick}
>
{day > 0 ? day : ''}
{day}
</div>
</div>
)
@@ -1,43 +1,39 @@
import { areDatesInSameMonth } from '@/Utils/DateUtils'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { areDatesInSameDay, areDatesInSameMonth } from '@/Utils/DateUtils'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import Calendar from './Calendar'
import { CalendarActivity, CalendarActivityType } from './CalendarActivity'
import { CalendarMonth } from './CalendarMonth'
import { CalendarMonths } from './Constants'
import { insertMonths, insertMonthsWithTarget } from './CalendarUtilts'
import { InfiniteScrollerInterface, InfinteScroller } from '../InfiniteScroller/InfiniteScroller'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { LoggingDomain, log } from '@/Logging'
import { usePrevious } from './usePrevious'
import { isMobileScreen } from '@/Utils'
type Props = {
activityType: CalendarActivityType
activities: CalendarActivity[]
onDateSelect: (date: Date) => void
selectedTemplateDay?: Date
selectedItemDay?: Date
selectedDay?: Date
selectedDayType?: 'item' | 'template'
className?: string
}
export type InfiniteCalendarInterface = {
changeMonth: (month: Date) => void
goToMonth: (month: Date) => void
}
const PageSize = 10
const PageSize = 2
const InfiniteCalendar = forwardRef<InfiniteCalendarInterface, Props>(
({ activities, onDateSelect, selectedTemplateDay, selectedItemDay }: Props, ref) => {
const [date, setDate] = useState(new Date())
const [month, setMonth] = useState(date.getMonth())
const [year, setYear] = useState(date.getFullYear())
({ activities, onDateSelect, selectedDay, className }: Props, ref) => {
const [expanded, setExpanded] = useState(true)
const [scrollWidth, setScrollWidth] = useState(0)
const [restoreScrollAfterExpand, setRestoreScrollAfterExpand] = useState(false)
const scrollerRef = useRef<InfiniteScrollerInterface | null>(null)
const previousSelectedDay = usePrevious(selectedDay)
const [activeDate, setActiveDate] = useState(new Date())
const today = new Date()
const [months, setMonths] = useState<CalendarMonth[]>(() => {
const base = [{ date: today }]
@@ -46,18 +42,6 @@ const InfiniteCalendar = forwardRef<InfiniteCalendarInterface, Props>(
return base
})
useImperativeHandle(ref, () => ({
changeMonth(date: Date) {
setDate(date)
},
}))
const [firstElement, setFirstElement] = useState<HTMLDivElement | null>(null)
const [lastElement, setLastElement] = useState<HTMLDivElement | null>(null)
const [didPaginateLeft, setDidPaginateLeft] = useState(false)
const [restoreScrollAfterExpand, setRestoreScrollAfterExpand] = useState(false)
const scrollArea = useRef<HTMLDivElement>(null)
const hasMonthInList = useCallback(
(date: Date): boolean => {
for (const month of months) {
@@ -74,7 +58,38 @@ const InfiniteCalendar = forwardRef<InfiniteCalendarInterface, Props>(
(date: Date): void => {
setMonths(insertMonthsWithTarget(months, date))
},
[months, setMonths],
[months],
)
const scrollToMonth = useCallback(
(date: Date) => {
const elementId = elementIdForMonth(date)
scrollerRef.current?.scrollToElementId(elementId)
},
[scrollerRef],
)
const goToMonth = useCallback(
(month: Date) => {
if (!hasMonthInList(month)) {
insertMonthInList(month)
}
log(LoggingDomain.DailyNotes, '[Calendar] Scrolling to month', month, 'from goToMonth')
setActiveDate(month)
scrollToMonth(month)
},
[hasMonthInList, insertMonthInList, scrollToMonth],
)
useImperativeHandle(
ref,
() => ({
goToMonth(date: Date) {
goToMonth(date)
},
}),
[goToMonth],
)
const resetNumberOfCalendarsToBase = useCallback(
@@ -88,153 +103,63 @@ const InfiniteCalendar = forwardRef<InfiniteCalendarInterface, Props>(
)
useEffect(() => {
if (selectedTemplateDay) {
setDate(selectedTemplateDay)
if (selectedDay) {
if (previousSelectedDay && areDatesInSameDay(previousSelectedDay, selectedDay)) {
log(LoggingDomain.DailyNotes, '[Calendar] selectedDay has changed, but is same as previous', selectedDay)
return
}
log(LoggingDomain.DailyNotes, '[Calendar] selectedDay has changed, going to month:', selectedDay)
goToMonth(selectedDay)
}
}, [selectedTemplateDay])
useEffect(() => {
if (selectedItemDay) {
setDate(selectedItemDay)
}
}, [selectedItemDay])
const scrollToMonth = useCallback((date: Date) => {
const elementId = elementIdForMonth(date)
const element = document.getElementById(elementId)
if (!element) {
return
}
scrollArea.current!.scrollLeft = element.offsetLeft + -60
}, [])
useLayoutEffect(() => {
setMonth(date.getMonth())
setYear(date.getFullYear())
if (!hasMonthInList(date)) {
insertMonthInList(date)
}
scrollToMonth(date)
}, [date, hasMonthInList, insertMonthInList, scrollToMonth])
}, [selectedDay, goToMonth, previousSelectedDay])
useEffect(() => {
if (!restoreScrollAfterExpand) {
return
}
if (scrollArea.current && expanded) {
scrollToMonth(date)
if (expanded) {
log(
LoggingDomain.DailyNotes,
'[Calendar] Scrolling to month',
activeDate,
'from restoreScrollAfterExpand useEffect',
)
scrollToMonth(activeDate)
setRestoreScrollAfterExpand(false)
}
}, [expanded, scrollToMonth, date, restoreScrollAfterExpand, setRestoreScrollAfterExpand])
useLayoutEffect(() => {
if (!scrollArea.current) {
return
}
if (didPaginateLeft) {
scrollArea.current.scrollLeft += scrollArea.current.scrollWidth - scrollWidth
setDidPaginateLeft(false)
}
}, [months, didPaginateLeft, scrollWidth])
}, [expanded, scrollToMonth, activeDate, restoreScrollAfterExpand, setRestoreScrollAfterExpand])
const paginateLeft = useCallback(() => {
if (scrollArea.current) {
setScrollWidth(scrollArea.current.scrollWidth)
}
const copy = months.slice()
insertMonths(copy, 'front', PageSize)
setDidPaginateLeft(true)
setMonths(copy)
}, [months, setMonths])
log(LoggingDomain.DailyNotes, '[Calendar] paginateLeft')
setMonths((prevMonths) => {
const copy = prevMonths.slice()
insertMonths(copy, 'front', PageSize)
return copy
})
}, [setMonths])
const paginateRight = useCallback(() => {
const copy = months.slice()
insertMonths(copy, 'end', PageSize)
setDidPaginateLeft(false)
setMonths(copy)
}, [months, setMonths])
log(LoggingDomain.DailyNotes, '[Calendar] paginateRight')
setMonths((prevMonths) => {
const copy = prevMonths.slice()
insertMonths(copy, 'end', PageSize)
return copy
})
}, [setMonths])
const updateCurrentMonth = useCallback(
(index: number) => {
const newMonth = months[index]
setMonth(newMonth.date.getMonth())
setYear(newMonth.date.getFullYear())
const onElementVisibility = useCallback(
(id: string) => {
const index = months.findIndex((candidate) => elementIdForMonth(candidate.date) === id)
if (index >= 0) {
const newMonth = months[index]
log(LoggingDomain.DailyNotes, '[Calendar] Month element did become visible, setting activeDate', newMonth)
setActiveDate(newMonth.date)
}
},
[months, setMonth, setYear],
[months],
)
const visibilityObserver = useMemo(
() =>
new IntersectionObserver(
(entries) => {
const visibleEntry = entries.find((entry) => entry.isIntersecting)
if (visibleEntry) {
const id = visibleEntry.target.id
const index = months.findIndex((candidate) => elementIdForMonth(candidate.date) === id)
updateCurrentMonth(index)
}
},
{ threshold: 0.9 },
),
[updateCurrentMonth, months],
)
const rightObserver = useMemo(
() =>
new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
paginateRight()
}
},
{ threshold: 0.5 },
),
[paginateRight],
)
const leftObserver = useMemo(
() =>
new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
paginateLeft()
}
},
{ threshold: 1.0 },
),
[paginateLeft],
)
useEffect(() => {
if (lastElement) {
rightObserver.observe(lastElement)
}
return () => {
if (lastElement) {
rightObserver.unobserve(lastElement)
}
}
}, [lastElement, rightObserver])
useEffect(() => {
if (firstElement) {
leftObserver.observe(firstElement)
}
return () => {
if (firstElement) {
leftObserver.unobserve(firstElement)
}
}
}, [firstElement, leftObserver])
const toggleVisibility = useCallback(() => {
setRestoreScrollAfterExpand(true)
@@ -254,48 +179,42 @@ const InfiniteCalendar = forwardRef<InfiniteCalendarInterface, Props>(
)
return (
<div className="w-full flex-shrink-0 border-b border-solid border-border">
<div className={'border-b border-solid border-border'}>
<div
className="text-md flex cursor-pointer items-center justify-center py-2 px-4 text-center font-bold hover:bg-contrast"
onClick={toggleVisibility}
className={classNames(
'text-md flex cursor-pointer items-center justify-center py-2 px-4',
'text-center font-bold hover:bg-contrast',
)}
>
{CalendarMonths[month]} {year}
{CalendarMonths[activeDate.getMonth()]} {activeDate.getFullYear()}
</div>
{expanded && (
<div
style={{ scrollBehavior: 'smooth' }}
ref={scrollArea}
id="calendar-scroller"
className="flex w-full overflow-x-scroll pb-2 md:max-w-full"
<InfinteScroller
paginateFront={paginateLeft}
paginateEnd={paginateRight}
direction={'horizontal'}
onElementVisibility={onElementVisibility}
ref={scrollerRef}
className={className}
isMobileScreen={isMobileScreen()}
>
{months.map((month, index) => {
const isFirst = index === 0
const isLast = index === months.length - 1
{months.map((month) => {
const id = elementIdForMonth(month.date)
return (
<div
id={id}
key={id}
ref={(ref) => {
isFirst ? setFirstElement(ref) : isLast ? setLastElement(ref) : null
if (ref) {
visibilityObserver.observe(ref)
}
}}
>
<div id={id} key={id}>
<Calendar
key={id}
className="mx-2"
activities={activities}
onDateSelect={handleDaySelection}
startDate={month.date}
selectedDay={selectedTemplateDay}
selectedDay={selectedDay}
/>
</div>
)
})}
</div>
</InfinteScroller>
)}
</div>
)
@@ -0,0 +1,9 @@
import { useEffect, useRef } from 'react'
export const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
})
return ref.current
}
@@ -247,7 +247,7 @@ const ContentListView: FunctionComponent<Props> = ({
<div
id="items-column"
className={classNames(
'sn-component section app-column flex h-screen flex-col pt-safe-top md:h-full',
'sn-component section app-column flex h-screen flex-col overflow-hidden pt-safe-top md:h-full',
'xl:w-87.5 xsm-only:!w-full sm-only:!w-full',
isTabletScreenSize && !isNotesListVisibleOnTablets
? 'pointer-coarse:md-only:!w-0 pointer-coarse:lg-only:!w-0'
@@ -256,7 +256,7 @@ const ContentListView: FunctionComponent<Props> = ({
aria-label={'Notes & Files'}
ref={itemsViewPanelRef}
>
<ResponsivePaneContent paneId={AppPaneId.Items}>
<ResponsivePaneContent className="overflow-hidden" paneId={AppPaneId.Items}>
<div id="items-title-bar" className="section-title-bar border-b border-solid border-border">
<div id="items-title-bar-container">
<input
@@ -0,0 +1,9 @@
import { createDailyItemsWithToday } from './CreateDailySections'
describe('create daily sections', () => {
it('createDailyItemsWithToday', () => {
const result = createDailyItemsWithToday(10)
expect(result).toHaveLength(10)
})
})
@@ -31,8 +31,9 @@ export const templateEntryForDate = (date: Date): DailyItemsDay => {
}
export function createDailyItemsWithToday(count: number): DailyItemsDay[] {
const today = templateEntryForDate(new Date())
return insertBlanks([today], 'end', count)
const items = [templateEntryForDate(new Date())]
insertBlanks(items, 'front', count / 2 - 1)
return insertBlanks(items, 'end', count / 2)
}
/**
@@ -1,10 +1,7 @@
import { FunctionComponent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { ListableContentItem } from '../Types/ListableContentItem'
import { ItemListController } from '@/Controllers/ItemList/ItemListController'
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
import { ElementIds } from '@/Constants/ElementIDs'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { useResponsiveAppPane } from '../../ResponsivePane/ResponsivePaneProvider'
import { AppPaneId } from '../../ResponsivePane/AppPaneMetadata'
import { createDailyItemsWithToday, createItemsByDateMapping, insertBlanks } from './CreateDailySections'
@@ -14,6 +11,9 @@ import { SNTag } from '@standardnotes/snjs'
import { CalendarActivity } from '../Calendar/CalendarActivity'
import { dateToDailyDayIdentifier } from './Utils'
import InfiniteCalendar, { InfiniteCalendarInterface } from '../Calendar/InfiniteCalendar'
import { InfiniteScrollerInterface, InfinteScroller } from '../InfiniteScroller/InfiniteScroller'
import { LoggingDomain, log } from '@/Logging'
import { isMobileScreen } from '@/Utils'
type Props = {
itemListController: ItemListController
@@ -36,12 +36,9 @@ const DailyContentList: FunctionComponent<Props> = ({
const [needsSelectionReload, setNeedsSelectionReload] = useState(false)
const [todayItem, setTodayItem] = useState<DailyItemsDay>()
const [selectedDay, setSelectedDay] = useState<Date>()
const [lastElement, setLastElement] = useState<HTMLDivElement | null>(null)
const [firstElement, setFirstElement] = useState<HTMLDivElement | null>(null)
const [lastScrollHeight, setLastScrollHeight] = useState(0)
const [didPaginateTop, setDidPaginateTop] = useState(false)
const scrollArea = useRef<HTMLDivElement>(null)
const calendarRef = useRef<InfiniteCalendarInterface | null>(null)
const [lastVisibleDay, setLastVisibleDay] = useState<DailyItemsDay>()
const scrollerRef = useRef<InfiniteScrollerInterface | null>(null)
const [dailyItems, setDailyItems] = useState<DailyItemsDay[]>(() => {
return createDailyItemsWithToday(PageSize)
@@ -57,6 +54,12 @@ const DailyContentList: FunctionComponent<Props> = ({
setTodayItem(dailyItems.find((item) => item.isToday) as DailyItemsDay)
}, [dailyItems])
useLayoutEffect(() => {
if (todayItem && scrollerRef.current) {
scrollerRef.current?.scrollToElementId(todayItem.id)
}
}, [todayItem, scrollerRef])
const calendarActivities: CalendarActivity[] = useMemo(() => {
return items.map((item) => {
return {
@@ -67,108 +70,37 @@ const DailyContentList: FunctionComponent<Props> = ({
}, [items])
const paginateBottom = useCallback(() => {
const copy = dailyItems.slice()
insertBlanks(copy, 'end', PageSize)
setDailyItems(copy)
}, [dailyItems, setDailyItems])
log(LoggingDomain.DailyNotes, '[ContentList] paginateBottom')
setDailyItems((prev) => {
const copy = prev.slice()
insertBlanks(copy, 'end', PageSize)
return copy
})
}, [setDailyItems])
const paginateTop = useCallback(() => {
if (scrollArea.current) {
setLastScrollHeight(scrollArea.current.scrollHeight)
}
const copy = dailyItems.slice()
insertBlanks(copy, 'front', PageSize)
setDidPaginateTop(true)
setDailyItems(copy)
}, [dailyItems, setDailyItems, setDidPaginateTop])
useLayoutEffect(() => {
if (!scrollArea.current) {
return
}
if (didPaginateTop) {
scrollArea.current.scrollTop += scrollArea.current.scrollHeight - lastScrollHeight
setDidPaginateTop(false)
}
}, [didPaginateTop, lastScrollHeight])
log(LoggingDomain.DailyNotes, '[ContentList] paginateTop')
setDailyItems((prev) => {
const copy = prev.slice()
insertBlanks(copy, 'front', PageSize)
return copy
})
}, [setDailyItems])
const onListItemDidBecomeVisible = useCallback(
(elementId: string) => {
const dailyItem = dailyItems.find((candidate) => candidate.id === elementId)
if (dailyItem) {
calendarRef?.current?.changeMonth(dailyItem.date)
if (dailyItem && dailyItem !== lastVisibleDay) {
setLastVisibleDay(dailyItem)
log(LoggingDomain.DailyNotes, '[ContentList] Item did become visible for date', dailyItem.date)
calendarRef?.current?.goToMonth(dailyItem.date)
} else {
log(LoggingDomain.DailyNotes, '[ContentList] Ignoring duplicate day visibility')
}
},
[dailyItems],
[dailyItems, lastVisibleDay],
)
const visibilityObserver = useMemo(
() =>
new IntersectionObserver(
(entries) => {
const visibleEntry = entries.find((entry) => entry.isIntersecting)
if (visibleEntry) {
onListItemDidBecomeVisible(visibleEntry.target.id)
}
},
{ threshold: 0.9 },
),
[onListItemDidBecomeVisible],
)
const bottomObserver = useMemo(
() =>
new IntersectionObserver(
(entries) => {
const first = entries[0]
if (first.isIntersecting) {
paginateBottom()
}
},
{ threshold: 0.5 },
),
[paginateBottom],
)
const topObserver = useMemo(
() =>
new IntersectionObserver(
(entries) => {
const first = entries[0]
if (first.isIntersecting) {
paginateTop()
}
},
{ threshold: 0.5 },
),
[paginateTop],
)
useEffect(() => {
if (lastElement) {
bottomObserver.observe(lastElement)
}
return () => {
if (lastElement) {
bottomObserver.unobserve(lastElement)
}
}
}, [lastElement, bottomObserver])
useEffect(() => {
if (firstElement) {
topObserver.observe(firstElement)
}
return () => {
if (firstElement) {
topObserver.unobserve(firstElement)
}
}
}, [firstElement, topObserver])
const onClickItem = useCallback(
async (day: DailyItemsDay, item: ListableContentItem, userTriggered: boolean) => {
await onSelect(item, userTriggered)
@@ -239,29 +171,29 @@ const DailyContentList: FunctionComponent<Props> = ({
[onClickItem, onClickTemplate, dailyItemForDate, itemsByDateMapping],
)
const hasItemsOnSelectedDay = selectedDay && itemsByDateMapping[dateToDailyDayIdentifier(selectedDay)]?.length > 0
return (
<>
<InfiniteCalendar
activities={calendarActivities}
activityType={'created'}
onDateSelect={onCalendarSelect}
selectedTemplateDay={selectedDay}
selectedItemDay={selectedDay}
selectedDay={selectedDay}
selectedDayType={!selectedDay ? undefined : hasItemsOnSelectedDay ? 'item' : 'template'}
ref={calendarRef}
className={'flex-column flex'}
/>
<div
className={classNames(
'infinite-scroll overflow-y-auto overflow-x-hidden focus:shadow-none focus:outline-none',
'md:max-h-full md:overflow-y-hidden md:hover:overflow-y-auto pointer-coarse:md:overflow-y-auto',
'md:hover:[overflow-y:_overlay]',
)}
ref={scrollArea}
id={ElementIds.ContentList}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
<InfinteScroller
paginateFront={paginateTop}
paginateEnd={paginateBottom}
direction="vertical"
onElementVisibility={onListItemDidBecomeVisible}
className={'flex-1'}
ref={scrollerRef}
isMobileScreen={isMobileScreen()}
>
{dailyItems.map((dailyItem, index) => {
const isFirst = index === 0
const isLast = index === dailyItems.length - 1
{dailyItems.map((dailyItem) => {
const items = itemsByDateMapping[dailyItem.id]
if (items) {
return items.map((item) => (
@@ -275,12 +207,6 @@ const DailyContentList: FunctionComponent<Props> = ({
hidePreview={hideNotePreview}
hideTags={hideTags}
onClick={() => onClickItem(dailyItem, item, true)}
ref={(ref) => {
isLast ? setLastElement(ref) : isFirst ? setFirstElement(ref) : null
if (ref) {
visibilityObserver.observe(ref)
}
}}
/>
))
} else {
@@ -291,17 +217,11 @@ const DailyContentList: FunctionComponent<Props> = ({
id={dailyItem.id}
key={dailyItem.dateKey}
onClick={() => onClickTemplate(dailyItem.date)}
ref={(ref) => {
isLast ? setLastElement(ref) : isFirst ? setFirstElement(ref) : null
if (ref) {
visibilityObserver.observe(ref)
}
}}
/>
)
}
})}
</div>
</InfinteScroller>
</>
)
}
@@ -166,12 +166,9 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
void changePreferences({ hideEditorIcon: !preferences.hideEditorIcon })
}, [preferences, changePreferences])
const setEntryMode = useCallback(
(mode: 'normal' | 'daily') => {
void changePreferences({ entryMode: mode })
},
[changePreferences],
)
const toggleEntryMode = useCallback(() => {
void changePreferences({ entryMode: isDailyEntry ? 'normal' : 'daily' })
}, [isDailyEntry, changePreferences])
const TabButton: FunctionComponent<{
label: string
@@ -211,7 +208,10 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
<h1 className="sk-h3 m-0 text-sm font-semibold">Upgrade for per-tag preferences</h1>
</div>
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">
Create powerful workflows and organizational layouts with per-tag display preferences.
{DailyEntryModeEnabled &&
'Create powerful workflows and organizational layouts with per-tag display preferences and the all-new Daily Notebook feature.'}
{!DailyEntryModeEnabled &&
'Create powerful workflows and organizational layouts with per-tag display preferences.'}
</p>
<Button
primary
@@ -376,26 +376,17 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
{currentMode === 'tag' && DailyEntryModeEnabled && (
<>
<MenuItemSeparator />
<div className="px-3 py-1 text-xs font-semibold uppercase text-text">Entry Mode</div>
<MenuItem
disabled={controlsDisabled}
className="py-2"
type={MenuItemType.RadioButton}
onClick={() => setEntryMode('normal')}
checked={!selectedTag.preferences?.entryMode || selectedTag.preferences?.entryMode === 'normal'}
>
<div className="ml-2 flex flex-grow items-center justify-between">Normal</div>
</MenuItem>
<MenuItem
disabled={controlsDisabled}
className="py-2"
type={MenuItemType.RadioButton}
onClick={() => setEntryMode('daily')}
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={isDailyEntry}
onChange={toggleEntryMode}
>
<div className="ml-2 flex flex-grow items-center justify-between">Daily</div>
<div className="flex flex-col pr-5">
<div className="text-xs font-semibold uppercase text-text">Daily Notebook</div>
<div className="mt-1">Capture new notes daily with a calendar-based layout</div>
</div>
</MenuItem>
</>
)}
@@ -0,0 +1,209 @@
import {
forwardRef,
ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { LoggingDomain, log } from '@/Logging'
type Props = {
children: ReactNode
paginateFront: () => void
paginateEnd: () => void
direction: 'horizontal' | 'vertical'
onElementVisibility?: (elementId: string) => void
className?: string
isMobileScreen?: boolean
}
export type InfiniteScrollerInterface = {
scrollToElementId: (id: string) => void
}
export const InfinteScroller = forwardRef<InfiniteScrollerInterface, Props>(
(
{
children,
paginateFront,
paginateEnd,
direction = 'vertical',
onElementVisibility,
className,
isMobileScreen,
}: Props,
ref,
) => {
const frontSentinel = useRef<HTMLDivElement | null>(null)
const endSentinel = useRef<HTMLDivElement | null>(null)
const [ignoreFirstFrontSentinelEvent, setIgnoreFirstFrontSentinelEvent] = useState(true)
const [needsMobilePaginationFix, setNeedsMobilePaginationFix] = useState(false)
const scrollArea = useRef<HTMLDivElement | null>(null)
const [scrollSize, setScrollSize] = useState(0)
const [didPaginateFront, setDidPaginateFront] = useState(false)
useImperativeHandle(ref, () => ({
scrollToElementId(id: string) {
scrollToElementId(id)
},
}))
const visibilityObserver = useMemo(
() =>
new IntersectionObserver(
(entries) => {
const visibleEntry = entries.find((entry) => entry.isIntersecting)
if (visibleEntry) {
onElementVisibility?.(visibleEntry.target.id)
}
},
{ threshold: 1.0 },
),
[onElementVisibility],
)
useEffect(() => {
const childElements = Array.from(scrollArea.current!.children)
for (const child of childElements) {
visibilityObserver.observe(child)
}
return () => {
for (const child of childElements) {
visibilityObserver.unobserve(child)
}
}
}, [visibilityObserver, children])
const scrollToElementId = useCallback((id: string) => {
const element = document.getElementById(id)
if (!element) {
log(LoggingDomain.DailyNotes, 'Element not found', id)
return
}
log(LoggingDomain.DailyNotes, 'Scrolling to element', id)
element.scrollIntoView({
behavior: 'auto',
block: 'center',
inline: 'center',
})
}, [])
useLayoutEffect(() => {
if (!scrollArea.current) {
return
}
if (didPaginateFront) {
if (direction === 'vertical') {
scrollArea.current.scrollTop += scrollArea.current.scrollHeight - scrollSize
if (isMobileScreen) {
setNeedsMobilePaginationFix(true)
}
} else {
scrollArea.current.scrollLeft += scrollArea.current.scrollWidth - scrollSize
}
setDidPaginateFront(false)
}
}, [didPaginateFront, scrollSize, direction, isMobileScreen])
useLayoutEffect(() => {
/**
* iOS Safari has an issue rendering paginated items from the top where the new
* scrolled to area is white until the user interacts with scroll again. The fix
* we apply is to re-set scrollTop to its same value to trigger a refresh.
* https://stackoverflow.com/questions/9807620
*/
if (needsMobilePaginationFix) {
setTimeout(() => {
if (!scrollArea.current) {
return
}
log(LoggingDomain.DailyNotes, '[InfiniteScroller] Applying mobile pagination fix')
scrollArea.current.scrollTop += scrollArea.current.scrollHeight - scrollSize
setNeedsMobilePaginationFix(false)
}, 50)
}
}, [needsMobilePaginationFix, scrollSize])
const _paginateFront = useCallback(() => {
if (direction === 'vertical') {
setScrollSize(scrollArea!.current!.scrollHeight)
} else {
setScrollSize(scrollArea!.current!.scrollWidth)
}
setDidPaginateFront(true)
paginateFront()
}, [paginateFront, direction])
const _paginateEnd = useCallback(() => {
paginateEnd()
}, [paginateEnd])
const frontObserver = useMemo(
() =>
new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
if (ignoreFirstFrontSentinelEvent) {
log(LoggingDomain.DailyNotes, '[InfiniteScroller] Ignoring first front sentinel event')
setIgnoreFirstFrontSentinelEvent(false)
return
}
_paginateFront()
}
},
{ threshold: 0.5 },
),
[_paginateFront, ignoreFirstFrontSentinelEvent],
)
useEffect(() => {
if (frontSentinel.current) {
frontObserver.observe(frontSentinel.current)
}
}, [frontObserver, frontSentinel])
const endObserver = useMemo(
() =>
new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
_paginateEnd()
}
},
{ threshold: 0.5 },
),
[_paginateEnd],
)
useEffect(() => {
if (endSentinel.current) {
endObserver.observe(endSentinel.current)
}
}, [endObserver, endSentinel])
return (
<div
ref={scrollArea}
className={className}
style={{
overflowY: 'scroll',
flexDirection: direction === 'vertical' ? 'column' : 'row',
}}
>
<div style={{ width: 1, height: 1, backgroundColor: 'transparent' }} ref={frontSentinel}></div>
{children}
<div style={{ width: 1, height: 1, backgroundColor: 'transparent' }} ref={endSentinel}></div>
</div>
)
},
)
@@ -150,6 +150,7 @@ export class ViewControllerManager implements InternalEventHandlerInterface {
this.toastService = new ToastService()
this.applicationEventObserver = new ApplicationEventObserver(
application,
application.routeService,
this.purchaseFlowController,
this.accountMenuController,
@@ -26,8 +26,10 @@ import { SyncStatusController } from '@/Controllers/SyncStatusController'
import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane'
import { ApplicationEventObserver } from './ApplicationEventObserver'
import { WebApplication } from '@/Application/Application'
describe('ApplicationEventObserver', () => {
let application: WebApplication
let routeService: RouteServiceInterface
let purchaseFlowController: PurchaseFlowController
let accountMenuController: AccountMenuController
@@ -40,6 +42,7 @@ describe('ApplicationEventObserver', () => {
const createObserver = () =>
new ApplicationEventObserver(
application,
routeService,
purchaseFlowController,
accountMenuController,
@@ -52,6 +55,8 @@ describe('ApplicationEventObserver', () => {
)
beforeEach(() => {
application = {} as jest.Mocked<WebApplication>
routeService = {} as jest.Mocked<RouteServiceInterface>
routeService.getRoute = jest.fn().mockReturnValue({
type: RouteType.None,
@@ -223,4 +228,15 @@ describe('ApplicationEventObserver', () => {
expect(syncStatusController.update).toHaveBeenCalled()
})
})
describe('Upon Database Loaded', () => {
it('should handle mobile screenshot privacy setting', async () => {
application.isNativeMobileWeb = jest.fn().mockReturnValue(true)
application.handleInitialMobileScreenshotPrivacy = jest.fn()
await createObserver().handle(ApplicationEvent.LocalDataLoaded)
expect(application.handleInitialMobileScreenshotPrivacy).toHaveBeenCalled()
})
})
})
@@ -20,9 +20,11 @@ import { SyncStatusController } from '@/Controllers/SyncStatusController'
import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane'
import { EventObserverInterface } from './EventObserverInterface'
import { WebApplication } from '@/Application/Application'
export class ApplicationEventObserver implements EventObserverInterface {
constructor(
private application: WebApplication,
private routeService: RouteServiceInterface,
private purchaseFlowController: PurchaseFlowController,
private accountMenuController: AccountMenuController,
@@ -89,6 +91,11 @@ export class ApplicationEventObserver implements EventObserverInterface {
case ApplicationEvent.SyncStatusChanged:
this.syncStatusController.update(this.syncClient.getSyncStatus())
break
case ApplicationEvent.LocalDataLoaded:
if (this.application.isNativeMobileWeb()) {
this.application.handleInitialMobileScreenshotPrivacy()
}
break
}
}
+17
View File
@@ -0,0 +1,17 @@
import { log as utilsLog } from '@standardnotes/utils'
export enum LoggingDomain {
DailyNotes,
}
const LoggingStatus: Record<LoggingDomain, boolean> = {
[LoggingDomain.DailyNotes]: false,
}
export function log(domain: LoggingDomain, ...args: any[]): void {
if (!LoggingStatus[domain]) {
return
}
utilsLog(LoggingDomain[domain], ...args)
}
@@ -68,6 +68,9 @@ export class MobileWebReceiver {
case ReactNativeToWebEvent.AndroidBackButtonPressed:
void this.application.handleAndroidBackButtonPressed()
break
case ReactNativeToWebEvent.ColorSchemeChanged:
void this.application.handleMobileColorSchemeChangeEvent()
break
default:
break
@@ -33,7 +33,7 @@ export function numHoursBetweenDates(date1: Date, date2: Date): number {
return Math.abs(date1.getTime() - date2.getTime()) / 3600000
}
export function isDateInSameDay(date1: Date, date2: Date): boolean {
export function areDatesInSameDay(date1: Date, date2: Date): boolean {
return date1.toLocaleDateString() === date2.toLocaleDateString()
}