Skip to content

Commit

Permalink
Improve Android haptic, offer toggle for haptics in the app (#3482)
Browse files Browse the repository at this point in the history
* improve android haptics, offer toggle for haptics

* update haptics.ts

* default to false

* simplify to `playHaptic`

* just leave them as `feedInfo`

* use a hook for `playHaptic`

* missed one of them
  • Loading branch information
haileyok authored Apr 11, 2024
1 parent 9007810 commit 740cd02
Show file tree
Hide file tree
Showing 14 changed files with 231 additions and 200 deletions.
11 changes: 11 additions & 0 deletions patches/expo-haptics+12.8.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Expo Haptics Patch

Whenever we migrated to Expo Haptics, there was a difference between how the previous and new libraries handled the
Android implementation of an iOS "light" haptic. The previous library used the `Vibration` API solely, which does not
have any configuration for intensity of vibration. The `Vibration` API has also been deprecated since SDK 26. See:
https://github.com/mkuczera/react-native-haptic-feedback/blob/master/android/src/main/java/com/mkuczera/vibrateFactory/VibrateWithDuration.java

Expo Haptics is using `VibrationManager` API on SDK >= 31. See: https://github.com/expo/expo/blob/main/packages/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt#L19
The timing and intensity of their haptic configurations though differs greatly from the original implementation. This
patch uses the new `VibrationManager` API to create the same vibration that would have been seen in the deprecated
`Vibration` API.
13 changes: 13 additions & 0 deletions patches/expo-haptics+12.8.1.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
index 26c52af..b949a4c 100644
--- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
+++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
@@ -42,7 +42,7 @@ class HapticsModule : Module() {

private fun vibrate(type: HapticsVibrationType) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1))
+ vibrator.vibrate(VibrationEffect.createWaveform(type.oldSDKPattern, intArrayOf(0, 100), -1))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(type.oldSDKPattern, -1)
45 changes: 9 additions & 36 deletions src/lib/haptics.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,20 @@
import {
impactAsync,
ImpactFeedbackStyle,
notificationAsync,
NotificationFeedbackType,
selectionAsync,
} from 'expo-haptics'
import React from 'react'
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'

import {isIOS, isWeb} from 'platform/detection'
import {useHapticsDisabled} from 'state/preferences/disable-haptics'

const hapticImpact: ImpactFeedbackStyle = isIOS
? ImpactFeedbackStyle.Medium
: ImpactFeedbackStyle.Light // Users said the medium impact was too strong on Android; see APP-537s

export class Haptics {
static default() {
if (isWeb) {
export function useHaptics() {
const isHapticsDisabled = useHapticsDisabled()

return React.useCallback(() => {
if (isHapticsDisabled || isWeb) {
return
}
impactAsync(hapticImpact)
}
static impact(type: ImpactFeedbackStyle = hapticImpact) {
if (isWeb) {
return
}
impactAsync(type)
}
static selection() {
if (isWeb) {
return
}
selectionAsync()
}
static notification = (type: 'success' | 'warning' | 'error') => {
if (isWeb) {
return
}
switch (type) {
case 'success':
return notificationAsync(NotificationFeedbackType.Success)
case 'warning':
return notificationAsync(NotificationFeedbackType.Warning)
case 'error':
return notificationAsync(NotificationFeedbackType.Error)
}
}
}, [isHapticsDisabled])
}
7 changes: 4 additions & 3 deletions src/screens/Profile/Header/ProfileHeaderLabeler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'

import {Haptics} from '#/lib/haptics'
import {isAppLabeler} from '#/lib/moderation'
import {pluralize} from '#/lib/strings/helpers'
import {logger} from '#/logger'
Expand All @@ -21,6 +20,7 @@ import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {useHaptics} from 'lib/haptics'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
Expand Down Expand Up @@ -64,6 +64,7 @@ let ProfileHeaderLabeler = ({
const {currentAccount, hasSession} = useSession()
const {openModal} = useModalControls()
const {track} = useAnalytics()
const playHaptic = useHaptics()
const cantSubscribePrompt = Prompt.usePromptControl()
const isSelf = currentAccount?.did === profile.did

Expand Down Expand Up @@ -93,7 +94,7 @@ let ProfileHeaderLabeler = ({
return
}
try {
Haptics.default()
playHaptic()

if (likeUri) {
await unlikeMod({uri: likeUri})
Expand All @@ -114,7 +115,7 @@ let ProfileHeaderLabeler = ({
)
logger.error(`Failed to toggle labeler like`, {message: e.message})
}
}, [labeler, likeUri, likeMod, unlikeMod, track, _])
}, [labeler, playHaptic, likeUri, unlikeMod, track, likeMod, _])

const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
Expand Down
3 changes: 2 additions & 1 deletion src/state/persisted/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'

import {logger} from '#/logger'
import {defaults, Schema, schema} from '#/state/persisted/schema'
import {write, read} from '#/state/persisted/store'
import {read, write} from '#/state/persisted/store'

/**
* The shape of the serialized data from our legacy Mobx store.
Expand Down Expand Up @@ -113,6 +113,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
externalEmbeds: defaults.externalEmbeds,
lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
pdsAddressHistory: defaults.pdsAddressHistory,
disableHaptics: defaults.disableHaptics,
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/state/persisted/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {z} from 'zod'

import {deviceLocales} from '#/platform/detection'

const externalEmbedOptions = ['show', 'hide'] as const
Expand Down Expand Up @@ -58,6 +59,7 @@ export const schema = z.object({
useInAppBrowser: z.boolean().optional(),
lastSelectedHomeFeed: z.string().optional(),
pdsAddressHistory: z.array(z.string()).optional(),
disableHaptics: z.boolean().optional(),
})
export type Schema = z.infer<typeof schema>

Expand Down Expand Up @@ -93,4 +95,5 @@ export const defaults: Schema = {
useInAppBrowser: undefined,
lastSelectedHomeFeed: undefined,
pdsAddressHistory: [],
disableHaptics: false,
}
42 changes: 42 additions & 0 deletions src/state/preferences/disable-haptics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react'

import * as persisted from '#/state/persisted'

type StateContext = boolean
type SetContext = (v: boolean) => void

const stateContext = React.createContext<StateContext>(
Boolean(persisted.defaults.disableHaptics),
)
const setContext = React.createContext<SetContext>((_: boolean) => {})

export function Provider({children}: {children: React.ReactNode}) {
const [state, setState] = React.useState(
Boolean(persisted.get('disableHaptics')),
)

const setStateWrapped = React.useCallback(
(hapticsEnabled: persisted.Schema['disableHaptics']) => {
setState(Boolean(hapticsEnabled))
persisted.write('disableHaptics', hapticsEnabled)
},
[setState],
)

React.useEffect(() => {
return persisted.onUpdate(() => {
setState(Boolean(persisted.get('disableHaptics')))
})
}, [setStateWrapped])

return (
<stateContext.Provider value={state}>
<setContext.Provider value={setStateWrapped}>
{children}
</setContext.Provider>
</stateContext.Provider>
)
}

export const useHapticsDisabled = () => React.useContext(stateContext)
export const useSetHapticsDisabled = () => React.useContext(setContext)
10 changes: 7 additions & 3 deletions src/state/preferences/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React from 'react'
import {Provider as LanguagesProvider} from './languages'

import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
import {Provider as DisableHapticsProvider} from './disable-haptics'
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
import {Provider as InAppBrowserProvider} from './in-app-browser'
import {Provider as LanguagesProvider} from './languages'

export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
export {
useRequireAltTextEnabled,
useSetRequireAltTextEnabled,
Expand All @@ -16,14 +17,17 @@ export {
} from './external-embeds-prefs'
export * from './hidden-posts'
export {useLabelDefinitions} from './label-defs'
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'

export function Provider({children}: React.PropsWithChildren<{}>) {
return (
<LanguagesProvider>
<AltTextRequiredProvider>
<ExternalEmbedsProvider>
<HiddenPostsProvider>
<InAppBrowserProvider>{children}</InAppBrowserProvider>
<InAppBrowserProvider>
<DisableHapticsProvider>{children}</DisableHapticsProvider>
</InAppBrowserProvider>
</HiddenPostsProvider>
</ExternalEmbedsProvider>
</AltTextRequiredProvider>
Expand Down
18 changes: 10 additions & 8 deletions src/view/com/util/post-ctrls/PostCtrls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'

import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
import {Haptics} from '#/lib/haptics'
import {CommentBottomArrow, HeartIcon, HeartIconSolid} from '#/lib/icons'
import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
Expand All @@ -32,6 +31,7 @@ import {
} from '#/state/queries/post'
import {useRequireAuth} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {useHaptics} from 'lib/haptics'
import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
import * as Prompt from '#/components/Prompt'
Expand Down Expand Up @@ -67,6 +67,7 @@ let PostCtrls = ({
)
const requireAuth = useRequireAuth()
const loggedOutWarningPromptControl = useDialogControl()
const playHaptic = useHaptics()

const shouldShowLoggedOutWarning = React.useMemo(() => {
return !!post.author.labels?.find(
Expand All @@ -84,7 +85,7 @@ let PostCtrls = ({
const onPressToggleLike = React.useCallback(async () => {
try {
if (!post.viewer?.like) {
Haptics.default()
playHaptic()
await queueLike()
} else {
await queueUnlike()
Expand All @@ -94,13 +95,13 @@ let PostCtrls = ({
throw e
}
}
}, [post.viewer?.like, queueLike, queueUnlike])
}, [playHaptic, post.viewer?.like, queueLike, queueUnlike])

const onRepost = useCallback(async () => {
closeModal()
try {
if (!post.viewer?.repost) {
Haptics.default()
playHaptic()
await queueRepost()
} else {
await queueUnrepost()
Expand All @@ -110,7 +111,7 @@ let PostCtrls = ({
throw e
}
}
}, [post.viewer?.repost, queueRepost, queueUnrepost, closeModal])
}, [closeModal, post.viewer?.repost, playHaptic, queueRepost, queueUnrepost])

const onQuote = useCallback(() => {
closeModal()
Expand All @@ -123,15 +124,16 @@ let PostCtrls = ({
indexedAt: post.indexedAt,
},
})
Haptics.default()
playHaptic()
}, [
closeModal,
openComposer,
post.uri,
post.cid,
post.author,
post.indexedAt,
record.text,
openComposer,
closeModal,
playHaptic,
])

const onShare = useCallback(() => {
Expand Down
Loading

0 comments on commit 740cd02

Please sign in to comment.