diff --git a/packages/frontend/core/src/mobile/components/index.ts b/packages/frontend/core/src/mobile/components/index.ts
index 81bb7db8a3..f0334eda3f 100644
--- a/packages/frontend/core/src/mobile/components/index.ts
+++ b/packages/frontend/core/src/mobile/components/index.ts
@@ -5,5 +5,6 @@ export * from './page-header';
export * from './rename';
export * from './search-input';
export * from './search-result';
+export * from './swipe-menu';
export * from './user-plan-tag';
export * from './workspace-selector';
diff --git a/packages/frontend/core/src/mobile/components/swipe-menu/index.tsx b/packages/frontend/core/src/mobile/components/swipe-menu/index.tsx
new file mode 100644
index 0000000000..6e1b0339d9
--- /dev/null
+++ b/packages/frontend/core/src/mobile/components/swipe-menu/index.tsx
@@ -0,0 +1,175 @@
+import { LiveData, useLiveData, useService } from '@toeverything/infra';
+import anime from 'animejs';
+import clsx from 'clsx';
+import {
+ type HTMLAttributes,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useId,
+ useRef,
+} from 'react';
+
+import { HapticsService } from '../../modules/haptics';
+import { SwipeHelper } from '../../utils';
+import * as styles from './styles.css';
+
+export interface SwipeMenuProps extends HTMLAttributes
{
+ menu: ReactNode;
+ /**
+ * if the swipe distance is greater than the threshold, will execute the callback
+ * @default 200
+ */
+ executeThreshold?: number;
+ onExecute?: () => void;
+ normalWidth?: number;
+}
+
+type TickFunc = any[]> = (
+ content: HTMLDivElement,
+ menu: HTMLDivElement,
+ options: {
+ deltaX: number;
+ normalWidth: number;
+ },
+ ...args: T
+) => void;
+
+const tick: TickFunc = (content, menu, options) => {
+ const limitedDeltaX = Math.min(
+ 0,
+ Math.max(options.deltaX, -options.normalWidth * 3)
+ );
+ content.style.transform = `translateX(${limitedDeltaX}px)`;
+ menu.style.transform = `translateX(${limitedDeltaX}px)`;
+ menu.style.width = `${Math.max(options.normalWidth, Math.abs(limitedDeltaX))}px`;
+};
+
+const animate: TickFunc<[number]> = (content, menu, options, to) => {
+ const styleX = Number(content.style.transform.match(/-?\d+/)?.[0]);
+ const deltaX = isNaN(styleX) ? options.deltaX : styleX;
+
+ const proxy = new Proxy(
+ { deltaX },
+ {
+ set(target, key, value) {
+ if (key !== 'deltaX') return false;
+ target.deltaX = value;
+ tick(content, menu, { ...options, deltaX: value });
+ return true;
+ },
+ }
+ );
+ if (deltaX === to) return;
+ anime({
+ targets: proxy,
+ deltaX: to,
+ duration: 230,
+ easing: 'easeInOutSine',
+ });
+};
+
+const activeId$ = new LiveData(null);
+
+/**
+ * Only support swipe left yet
+ * Only support single menu item yet
+ */
+export const SwipeMenu = ({
+ children,
+ className,
+ menu,
+ normalWidth = 90,
+ executeThreshold = 200,
+ onExecute,
+ ...props
+}: SwipeMenuProps) => {
+ const id = useId();
+ const haptics = useService(HapticsService);
+ const activeId = useLiveData(activeId$);
+
+ const isOpenRef = useRef(false);
+ const containerRef = useRef(null);
+ const menuRef = useRef(null);
+ const contentRef = useRef(null);
+
+ const handleClose = useCallback(() => {
+ isOpenRef.current = false;
+ const content = contentRef.current;
+ const menu = menuRef.current;
+ if (!content || !menu) return;
+ animate(content, menu, { deltaX: -normalWidth, normalWidth }, 0);
+ }, [normalWidth]);
+
+ useEffect(() => {
+ if (activeId && activeId !== id && isOpenRef.current) {
+ handleClose();
+ }
+ }, [activeId, handleClose, id]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ const menu = menuRef.current;
+ const content = contentRef.current;
+ if (!container || !menu || !content) return;
+ const swipeHelper = new SwipeHelper();
+
+ let shouldExecute = false;
+
+ return swipeHelper.init(container, {
+ preventScroll: true,
+ direction: 'horizontal',
+ onSwipeStart() {
+ activeId$.next(id);
+ },
+ onSwipe({ deltaX: dragX, initialDirection }) {
+ if (initialDirection !== 'horizontal') return;
+
+ const deltaX = isOpenRef.current ? dragX - normalWidth : dragX;
+ const prevShouldExecute = shouldExecute;
+ shouldExecute = Math.abs(deltaX) > executeThreshold;
+ if (shouldExecute && !prevShouldExecute) {
+ haptics.impact({ style: 'LIGHT' });
+ }
+ tick(content, menu, { deltaX, normalWidth });
+ },
+ onSwipeEnd({ deltaX: dragX, initialDirection }) {
+ activeId$.next(null);
+ if (initialDirection !== 'horizontal') return;
+
+ const deltaX = isOpenRef.current ? dragX - normalWidth : dragX;
+ if (shouldExecute) {
+ animate(content, menu, { deltaX, normalWidth }, 0);
+ onExecute?.();
+ isOpenRef.current = false;
+ return;
+ }
+ // open
+ if (Math.abs(deltaX) > normalWidth / 2) {
+ animate(content, menu, { deltaX, normalWidth }, -normalWidth);
+ isOpenRef.current = true;
+ }
+ // close
+ else {
+ animate(content, menu, { deltaX, normalWidth }, 0);
+ isOpenRef.current = false;
+ }
+ },
+ });
+ }, [executeThreshold, haptics, id, normalWidth, onExecute]);
+
+ return (
+
+
+ {children}
+
+
+ {menu}
+
+
+ );
+};
diff --git a/packages/frontend/core/src/mobile/components/swipe-menu/styles.css.ts b/packages/frontend/core/src/mobile/components/swipe-menu/styles.css.ts
new file mode 100644
index 0000000000..2b7cf84954
--- /dev/null
+++ b/packages/frontend/core/src/mobile/components/swipe-menu/styles.css.ts
@@ -0,0 +1,22 @@
+import { cssVarV2 } from '@toeverything/theme/v2';
+import { style } from '@vanilla-extract/css';
+
+export const container = style({
+ position: 'relative',
+ overflow: 'hidden',
+});
+export const content = style({
+ position: 'relative',
+ zIndex: 1,
+});
+export const menu = style({
+ position: 'absolute',
+ left: '100%',
+ top: 0,
+ height: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: cssVarV2('icon/primary'),
+ background: cssVarV2('layer/background/mobile/tertiary'),
+});
diff --git a/packages/frontend/core/src/mobile/modules/haptics/index.ts b/packages/frontend/core/src/mobile/modules/haptics/index.ts
new file mode 100644
index 0000000000..18849ccfa1
--- /dev/null
+++ b/packages/frontend/core/src/mobile/modules/haptics/index.ts
@@ -0,0 +1,13 @@
+import type { Framework } from '@toeverything/infra';
+
+import { HapticProvider } from './providers/haptic';
+import { HapticsService } from './services/haptics';
+
+export function configureMobileHapticsModule(framework: Framework) {
+ framework.service(
+ HapticsService,
+ f => new HapticsService(f.getOptional(HapticProvider))
+ );
+}
+
+export { HapticProvider, HapticsService };
diff --git a/packages/frontend/core/src/mobile/modules/haptics/providers/haptic.ts b/packages/frontend/core/src/mobile/modules/haptics/providers/haptic.ts
new file mode 100644
index 0000000000..9c91ab216e
--- /dev/null
+++ b/packages/frontend/core/src/mobile/modules/haptics/providers/haptic.ts
@@ -0,0 +1,15 @@
+import { createIdentifier } from '@toeverything/infra';
+
+export interface HapticProvider {
+ impact: (options?: { style?: 'HEAVY' | 'LIGHT' | 'MEDIUM' }) => Promise;
+ notification: (options?: {
+ type?: 'SUCCESS' | 'ERROR' | 'WARNING';
+ }) => Promise;
+ vibrate: (options?: { duration?: number }) => Promise;
+ selectionStart: () => Promise;
+ selectionChanged: () => Promise;
+ selectionEnd: () => Promise;
+}
+
+export const HapticProvider =
+ createIdentifier('HapticProvider');
diff --git a/packages/frontend/core/src/mobile/modules/haptics/services/haptics.ts b/packages/frontend/core/src/mobile/modules/haptics/services/haptics.ts
new file mode 100644
index 0000000000..49e073319d
--- /dev/null
+++ b/packages/frontend/core/src/mobile/modules/haptics/services/haptics.ts
@@ -0,0 +1,37 @@
+import { Service } from '@toeverything/infra';
+
+import type { HapticProvider } from '../providers/haptic';
+
+type ExtractArg = Parameters<
+ HapticProvider[T]
+>[0];
+
+export class HapticsService extends Service {
+ constructor(private readonly provider?: HapticProvider) {
+ super();
+ }
+
+ impact(options?: ExtractArg<'impact'>) {
+ this.provider?.impact?.(options)?.catch(console.error);
+ }
+
+ notification(options?: ExtractArg<'notification'>) {
+ this.provider?.notification?.(options)?.catch(console.error);
+ }
+
+ vibrate(options?: ExtractArg<'vibrate'>) {
+ this.provider?.vibrate?.(options)?.catch(console.error);
+ }
+
+ selectionStart() {
+ this.provider?.selectionStart?.().catch(console.error);
+ }
+
+ selectionChanged() {
+ this.provider?.selectionChanged?.().catch(console.error);
+ }
+
+ selectionEnd() {
+ this.provider?.selectionEnd?.().catch(console.error);
+ }
+}
diff --git a/packages/frontend/core/src/mobile/modules/index.ts b/packages/frontend/core/src/mobile/modules/index.ts
index c316ca05fb..3c57e3c9eb 100644
--- a/packages/frontend/core/src/mobile/modules/index.ts
+++ b/packages/frontend/core/src/mobile/modules/index.ts
@@ -1,5 +1,6 @@
import type { Framework } from '@toeverything/infra';
+import { configureMobileHapticsModule } from './haptics';
import { configureMobileNavigationGestureModule } from './navigation-gesture';
import { configureMobileSearchModule } from './search';
import { configureMobileVirtualKeyboardModule } from './virtual-keyboard';
@@ -8,4 +9,5 @@ export function configureMobileModules(framework: Framework) {
configureMobileSearchModule(framework);
configureMobileVirtualKeyboardModule(framework);
configureMobileNavigationGestureModule(framework);
+ configureMobileHapticsModule(framework);
}
diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx
index 3a4a861618..80df2caa39 100644
--- a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx
+++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx
@@ -1,3 +1,4 @@
+import { SwipeHelper } from '@affine/core/mobile/utils';
import anime from 'animejs';
import clsx from 'clsx';
import dayjs from 'dayjs';
@@ -20,7 +21,6 @@ import {
} from './constants';
import { JournalDatePickerContext } from './context';
import * as styles from './month.css';
-import { SwipeHelper } from './swipe-helper';
import { getFirstDayOfMonth } from './utils';
import { WeekRow } from './week';
export interface MonthViewProps {
@@ -106,9 +106,9 @@ export const MonthView = ({ viewportHeight }: MonthViewProps) => {
setCursor(selected);
}
},
- onSwipeEnd: ({ deltaX }) => {
+ onSwipeEnd: ({ deltaX, speedX }) => {
setSwiping(false);
- if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD) {
+ if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD || speedX > 100) {
animateTo(deltaX > 0 ? -1 : 1);
} else {
animateTo(0);
diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx
index 061292e198..1d21537a79 100644
--- a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx
+++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx
@@ -1,4 +1,5 @@
import { useGlobalEvent } from '@affine/core/mobile/hooks/use-global-events';
+import { SwipeHelper } from '@affine/core/mobile/utils';
import anime from 'animejs';
import clsx from 'clsx';
import dayjs from 'dayjs';
@@ -20,7 +21,6 @@ import {
} from './constants';
import { JournalDatePickerContext } from './context';
import { MonthView } from './month';
-import { SwipeHelper } from './swipe-helper';
import * as styles from './viewport.css';
import { WeekHeader, WeekRowSwipe } from './week';
@@ -97,8 +97,8 @@ export const ResizeViewport = ({
setDragOffset(deltaY);
setIsDragging(true);
},
- onSwipeEnd: ({ deltaY }) => {
- if (Math.abs(deltaY) > 2 * CELL_HEIGHT) {
+ onSwipeEnd: ({ deltaY, speedY }) => {
+ if (Math.abs(deltaY) > 2 * CELL_HEIGHT || speedY > 100) {
handleToggleModeWithAnimation(
mode === 'week' ? 'month' : 'week',
deltaY
diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx
index 63f277fd0e..7447dcc66e 100644
--- a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx
+++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx
@@ -1,3 +1,4 @@
+import { SwipeHelper } from '@affine/core/mobile/utils';
import { useI18n } from '@affine/i18n';
import anime from 'animejs';
import clsx from 'clsx';
@@ -16,7 +17,6 @@ import {
import { DATE_FORMAT, HORIZONTAL_SWIPE_THRESHOLD } from './constants';
import { JournalDatePickerContext } from './context';
import { DayCell } from './day-cell';
-import { SwipeHelper } from './swipe-helper';
import { getFirstDayOfWeek } from './utils';
import * as styles from './week.css';
@@ -115,10 +115,10 @@ export const WeekRowSwipe = ({ start }: WeekRowProps) => {
setCursor(start);
}
},
- onSwipeEnd({ deltaX }) {
+ onSwipeEnd({ deltaX, speedX }) {
setSwiping(false);
- if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD) {
+ if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD || speedX > 100) {
animateTo(deltaX > 0 ? -1 : 1);
} else {
animateTo(0);
diff --git a/packages/frontend/core/src/mobile/utils/index.ts b/packages/frontend/core/src/mobile/utils/index.ts
new file mode 100644
index 0000000000..72fef474f5
--- /dev/null
+++ b/packages/frontend/core/src/mobile/utils/index.ts
@@ -0,0 +1 @@
+export * from './swipe-helper';
diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/swipe-helper.ts b/packages/frontend/core/src/mobile/utils/swipe-helper.ts
similarity index 57%
rename from packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/swipe-helper.ts
rename to packages/frontend/core/src/mobile/utils/swipe-helper.ts
index 2946ee138e..0802a5deed 100644
--- a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/swipe-helper.ts
+++ b/packages/frontend/core/src/mobile/utils/swipe-helper.ts
@@ -1,3 +1,5 @@
+type Direction = 'horizontal' | 'vertical';
+
export interface SwipeInfo {
e: TouchEvent;
startX: number;
@@ -7,25 +9,60 @@ export interface SwipeInfo {
deltaX: number;
deltaY: number;
isFirst: boolean;
+ /**
+ * Instant horizontal speed in `px/s`
+ */
+ speedX: number;
+ /**
+ * Average horizontal speed in `px/s`
+ */
+ averageSpeedX: number;
+ /**
+ * Instant vertical speed in `px/s`
+ */
+ speedY: number;
+ /**
+ * Average vertical speed in `px/s`
+ */
+ averageSpeedY: number;
+ /**
+ * The first detected direction
+ */
+ initialDirection: Direction | null;
}
export interface SwipeHelperOptions {
scope?: 'global' | 'inside';
+ /**
+ * When swipe started, the direction will be detected and not change until swipe ended.
+ * If the direction is specified, and not match the detected one, the scroll won't be prevented.
+ */
+ direction?: Direction;
+ /**
+ * @description The distance in px that determine which direction the swipe gesture is
+ * @default 10
+ */
+ directionThreshold?: number;
preventScroll?: boolean;
onTap?: () => void;
onSwipeStart?: () => void;
onSwipe?: (info: SwipeInfo) => void;
onSwipeEnd?: (info: SwipeInfo) => void;
}
+const defaultOptions = Object.freeze({
+ scope: 'global',
+ directionThreshold: 10,
+} as const);
export class SwipeHelper {
private _trigger: HTMLElement | null = null;
- private _options: SwipeHelperOptions = {
- scope: 'global',
- };
+ private _options: SwipeHelperOptions = defaultOptions;
+ private _direction: Direction | null = null;
private _startPos: { x: number; y: number } = { x: 0, y: 0 };
private _isFirst: boolean = true;
private _lastInfo: SwipeInfo | null = null;
+ private _startTime: number = 0;
+ private _lastTime: number = 0;
get scopeElement() {
return this._options.scope === 'inside'
@@ -69,6 +106,8 @@ export class SwipeHelper {
x: touch.clientX,
y: touch.clientY,
};
+ this._lastTime = Date.now();
+ this._startTime = this._lastTime;
this._options.onSwipeStart?.();
const moveHandler = this._handleTouchMove.bind(this);
this.scopeElement.addEventListener('touchmove', moveHandler, {
@@ -87,10 +126,18 @@ export class SwipeHelper {
}
private _handleTouchMove(e: TouchEvent) {
- if (this._options.preventScroll) {
+ const info = this._getInfo(e);
+ if (
+ this._options.preventScroll &&
+ // direction is not detected
+ (!this._direction ||
+ // direction is not specified
+ !this._options.direction ||
+ // direction is same as specified
+ this._direction === this._options.direction)
+ ) {
e.preventDefault();
}
- const info = this._getInfo(e);
this._lastInfo = info;
this._isFirst = false;
this._options.onSwipe?.(info);
@@ -110,9 +157,30 @@ export class SwipeHelper {
}
private _getInfo(e: TouchEvent): SwipeInfo {
+ const lastTime = this._lastTime;
+ this._lastTime = Date.now();
+ const deltaTime = this._lastTime - lastTime;
const touch = e.touches[0];
const deltaX = touch.clientX - this._startPos.x;
const deltaY = touch.clientY - this._startPos.y;
+ const speedX =
+ (Math.abs(deltaX - (this._lastInfo?.deltaX ?? 0)) / deltaTime) * 1000;
+ const averageSpeedX =
+ (Math.abs(deltaX) / (this._lastTime - this._startTime)) * 1000;
+ const speedY =
+ (Math.abs(deltaY - (this._lastInfo?.deltaY ?? 0)) / deltaTime) * 1000;
+ const averageSpeedY =
+ (Math.abs(deltaY) / (this._lastTime - this._startTime)) * 1000;
+
+ // detect direction
+ const threshold =
+ this._options.directionThreshold ?? defaultOptions.directionThreshold;
+ const maxDelta = Math.max(Math.abs(deltaX), Math.abs(deltaY));
+ if (!this._direction && maxDelta > threshold) {
+ this._direction =
+ Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical';
+ }
+
return {
e,
startX: this._startPos.x,
@@ -122,11 +190,19 @@ export class SwipeHelper {
deltaX,
deltaY,
isFirst: this._isFirst,
+ speedX,
+ averageSpeedX,
+ speedY,
+ averageSpeedY,
+ initialDirection: this._direction,
};
}
private _clearDrag() {
this._lastInfo = null;
+ this._lastTime = 0;
+ this._startTime = 0;
+ this._direction = null;
this._dragMoveCleanup();
this._dragEndCleanup();
}
diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json
index 28bb1fb5c1..8d36f8bea8 100644
--- a/packages/frontend/i18n/src/i18n-completenesses.json
+++ b/packages/frontend/i18n/src/i18n-completenesses.json
@@ -1,5 +1,5 @@
{
- "ar": 75,
+ "ar": 74,
"ca": 5,
"da": 6,
"de": 28,
@@ -13,12 +13,12 @@
"it-IT": 1,
"it": 1,
"ja": 99,
- "ko": 79,
+ "ko": 78,
"pl": 0,
"pt-BR": 85,
"ru": 73,
"sv-SE": 4,
"ur": 3,
- "zh-Hans": 100,
+ "zh-Hans": 99,
"zh-Hant": 99
}
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index c7b8b7fc9a..6a425c271b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -343,7 +343,7 @@ __metadata:
"@storybook/react": "npm:^8.2.9"
"@storybook/react-vite": "npm:^8.2.9"
"@testing-library/react": "npm:^16.0.0"
- "@toeverything/theme": "npm:^1.0.18"
+ "@toeverything/theme": "npm:^1.0.21"
"@types/react": "npm:^18.2.75"
"@types/react-dom": "npm:^18.2.24"
"@vanilla-extract/css": "npm:^1.14.2"
@@ -422,7 +422,7 @@ __metadata:
"@sentry/react": "npm:^8.0.0"
"@testing-library/react": "npm:^16.0.0"
"@toeverything/pdf-viewer": "npm:^0.1.1"
- "@toeverything/theme": "npm:^1.0.18"
+ "@toeverything/theme": "npm:^1.0.21"
"@types/animejs": "npm:^3.1.12"
"@types/bytes": "npm:^3.1.4"
"@types/image-blob-reduce": "npm:^4.1.4"
@@ -618,6 +618,7 @@ __metadata:
"@capacitor/browser": "npm:^6.0.3"
"@capacitor/cli": "npm:^6.1.2"
"@capacitor/core": "npm:^6.1.2"
+ "@capacitor/haptics": "npm:^6.0.2"
"@capacitor/ios": "npm:^6.1.2"
"@capacitor/keyboard": "npm:^6.0.2"
"@sentry/react": "npm:^8.0.0"
@@ -3006,6 +3007,15 @@ __metadata:
languageName: node
linkType: hard
+"@capacitor/haptics@npm:^6.0.2":
+ version: 6.0.2
+ resolution: "@capacitor/haptics@npm:6.0.2"
+ peerDependencies:
+ "@capacitor/core": ^6.0.0
+ checksum: 10/986003d8327d914762f5c0a4f4b6e494654fa0c27344c2a1764f06d08895bce035d8105c0f2a571b8b8bd3bd2f72b08bdc2ad4e1167aa186632a7a16bb6118a2
+ languageName: node
+ linkType: hard
+
"@capacitor/ios@npm:^6.1.2":
version: 6.2.0
resolution: "@capacitor/ios@npm:6.2.0"
@@ -12998,7 +13008,7 @@ __metadata:
languageName: node
linkType: hard
-"@toeverything/theme@npm:^1.0.18, @toeverything/theme@npm:^1.0.19":
+"@toeverything/theme@npm:^1.0.19, @toeverything/theme@npm:^1.0.21":
version: 1.0.21
resolution: "@toeverything/theme@npm:1.0.21"
checksum: 10/31b741d940b77716825abf7b1a4e64372add4ee4b61439fa59920403c5c615ef72f566857900663d2e5250e09a2e7eae031b0465b98d198b83fc754bac4a8c41