about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--patches/expo-haptics+12.8.1.md11
-rw-r--r--patches/expo-haptics+12.8.1.patch13
-rw-r--r--src/lib/haptics.ts45
-rw-r--r--src/screens/Profile/Header/ProfileHeaderLabeler.tsx7
-rw-r--r--src/state/persisted/legacy.ts3
-rw-r--r--src/state/persisted/schema.ts3
-rw-r--r--src/state/preferences/disable-haptics.tsx42
-rw-r--r--src/state/preferences/index.tsx10
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx18
-rw-r--r--src/view/screens/ProfileFeed.tsx30
-rw-r--r--src/view/screens/ProfileList.tsx106
-rw-r--r--src/view/screens/SavedFeeds.tsx52
-rw-r--r--src/view/screens/Settings/index.tsx84
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx7
14 files changed, 231 insertions, 200 deletions
diff --git a/patches/expo-haptics+12.8.1.md b/patches/expo-haptics+12.8.1.md
new file mode 100644
index 000000000..afa7395bc
--- /dev/null
+++ b/patches/expo-haptics+12.8.1.md
@@ -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.
diff --git a/patches/expo-haptics+12.8.1.patch b/patches/expo-haptics+12.8.1.patch
new file mode 100644
index 000000000..a95b56f3b
--- /dev/null
+++ b/patches/expo-haptics+12.8.1.patch
@@ -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)
diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts
index b22d69d70..02940f793 100644
--- a/src/lib/haptics.ts
+++ b/src/lib/haptics.ts
@@ -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])
 }
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
index 4d8dbad86..d0fd5e20b 100644
--- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -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'
@@ -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'
@@ -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
 
@@ -93,7 +94,7 @@ let ProfileHeaderLabeler = ({
       return
     }
     try {
-      Haptics.default()
+      playHaptic()
 
       if (likeUri) {
         await unlikeMod({uri: likeUri})
@@ -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')
diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts
index fd94a96a2..ca7967cd2 100644
--- a/src/state/persisted/legacy.ts
+++ b/src/state/persisted/legacy.ts
@@ -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.
@@ -113,6 +113,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
     externalEmbeds: defaults.externalEmbeds,
     lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
     pdsAddressHistory: defaults.pdsAddressHistory,
+    disableHaptics: defaults.disableHaptics,
   }
 }
 
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 0aefaa474..67e082a95 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -1,4 +1,5 @@
 import {z} from 'zod'
+
 import {deviceLocales} from '#/platform/detection'
 
 const externalEmbedOptions = ['show', 'hide'] as const
@@ -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>
 
@@ -93,4 +95,5 @@ export const defaults: Schema = {
   useInAppBrowser: undefined,
   lastSelectedHomeFeed: undefined,
   pdsAddressHistory: [],
+  disableHaptics: false,
 }
diff --git a/src/state/preferences/disable-haptics.tsx b/src/state/preferences/disable-haptics.tsx
new file mode 100644
index 000000000..af2c55a18
--- /dev/null
+++ b/src/state/preferences/disable-haptics.tsx
@@ -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)
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index cf1d90151..804d0fc31 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -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,
@@ -16,6 +17,7 @@ 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 (
@@ -23,7 +25,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       <AltTextRequiredProvider>
         <ExternalEmbedsProvider>
           <HiddenPostsProvider>
-            <InAppBrowserProvider>{children}</InAppBrowserProvider>
+            <InAppBrowserProvider>
+              <DisableHapticsProvider>{children}</DisableHapticsProvider>
+            </InAppBrowserProvider>
           </HiddenPostsProvider>
         </ExternalEmbedsProvider>
       </AltTextRequiredProvider>
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 58874cd55..cd4a36373 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -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'
@@ -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'
@@ -67,6 +67,7 @@ let PostCtrls = ({
   )
   const requireAuth = useRequireAuth()
   const loggedOutWarningPromptControl = useDialogControl()
+  const playHaptic = useHaptics()
 
   const shouldShowLoggedOutWarning = React.useMemo(() => {
     return !!post.author.labels?.find(
@@ -84,7 +85,7 @@ let PostCtrls = ({
   const onPressToggleLike = React.useCallback(async () => {
     try {
       if (!post.viewer?.like) {
-        Haptics.default()
+        playHaptic()
         await queueLike()
       } else {
         await queueUnlike()
@@ -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()
@@ -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()
@@ -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(() => {
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index 4560e14eb..814c1e855 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -27,7 +27,7 @@ import {truncateAndInvalidate} from '#/state/queries/util'
 import {useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {Haptics} from 'lib/haptics'
+import {useHaptics} from 'lib/haptics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {ComposeIcon2} from 'lib/icons'
@@ -159,6 +159,7 @@ export function ProfileFeedScreenInner({
   const reportDialogControl = useReportDialogControl()
   const {openComposer} = useComposerControls()
   const {track} = useAnalytics()
+  const playHaptic = useHaptics()
   const feedSectionRef = React.useRef<SectionRef>(null)
   const isScreenFocused = useIsFocused()
 
@@ -201,7 +202,7 @@ export function ProfileFeedScreenInner({
 
   const onToggleSaved = React.useCallback(async () => {
     try {
-      Haptics.default()
+      playHaptic()
 
       if (isSaved) {
         await removeFeed({uri: feedInfo.uri})
@@ -221,18 +222,19 @@ export function ProfileFeedScreenInner({
       logger.error('Failed up update feeds', {message: err})
     }
   }, [
-    feedInfo,
+    playHaptic,
     isSaved,
-    saveFeed,
     removeFeed,
-    resetSaveFeed,
+    feedInfo,
     resetRemoveFeed,
     _,
+    saveFeed,
+    resetSaveFeed,
   ])
 
   const onTogglePinned = React.useCallback(async () => {
     try {
-      Haptics.default()
+      playHaptic()
 
       if (isPinned) {
         await unpinFeed({uri: feedInfo.uri})
@@ -245,7 +247,16 @@ export function ProfileFeedScreenInner({
       Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to toggle pinned feed', {message: e})
     }
-  }, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed, _])
+  }, [
+    playHaptic,
+    isPinned,
+    unpinFeed,
+    feedInfo,
+    resetUnpinFeed,
+    pinFeed,
+    resetPinFeed,
+    _,
+  ])
 
   const onPressShare = React.useCallback(() => {
     const url = toShareUrl(feedInfo.route.href)
@@ -517,6 +528,7 @@ function AboutSection({
   const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri)
   const {hasSession} = useSession()
   const {track} = useAnalytics()
+  const playHaptic = useHaptics()
   const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation()
   const {mutateAsync: unlikeFeed, isPending: isUnlikePending} =
     useUnlikeMutation()
@@ -527,7 +539,7 @@ function AboutSection({
 
   const onToggleLiked = React.useCallback(async () => {
     try {
-      Haptics.default()
+      playHaptic()
 
       if (isLiked && likeUri) {
         await unlikeFeed({uri: likeUri})
@@ -546,7 +558,7 @@ function AboutSection({
       )
       logger.error('Failed up toggle like', {message: err})
     }
-  }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed, track, _])
+  }, [playHaptic, isLiked, likeUri, unlikeFeed, track, likeFeed, feedInfo, _])
 
   return (
     <View style={[styles.aboutSectionContainer]}>
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 58b89f239..1d93a9fd7 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -1,69 +1,70 @@
 import React, {useCallback, useMemo} from 'react'
 import {Pressable, StyleSheet, View} from 'react-native'
+import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useFocusEffect, useIsFocused} from '@react-navigation/native'
-import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {useNavigation} from '@react-navigation/native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api'
 import {useQueryClient} from '@tanstack/react-query'
-import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
-import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
-import {Feed} from 'view/com/posts/Feed'
-import {Text} from 'view/com/util/text/Text'
-import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
-import {CenteredView} from 'view/com/util/Views'
-import {EmptyState} from 'view/com/util/EmptyState'
-import {LoadingScreen} from 'view/com/util/LoadingScreen'
-import {RichText} from '#/components/RichText'
-import {Button} from 'view/com/util/forms/Button'
-import {TextLink} from 'view/com/util/Link'
-import {ListRef} from 'view/com/util/List'
-import * as Toast from 'view/com/util/Toast'
-import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
-import {FAB} from 'view/com/util/fab/FAB'
-import {Haptics} from 'lib/haptics'
-import {FeedDescriptor} from '#/state/queries/post-feed'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useSetTitle} from 'lib/hooks/useSetTitle'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
-import {NavigationProp} from 'lib/routes/types'
-import {toShareUrl} from 'lib/strings/url-helpers'
-import {shareUrl} from 'lib/sharing'
-import {s} from 'lib/styles'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {makeProfileLink, makeListLink} from 'lib/routes/links'
-import {ComposeIcon2} from 'lib/icons'
-import {ListMembers} from '#/view/com/lists/ListMembers'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useSetMinimalShellMode} from '#/state/shell'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {isNative, isWeb} from '#/platform/detection'
+import {listenSoftReset} from '#/state/events'
 import {useModalControls} from '#/state/modals'
-import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
-import {useResolveUriQuery} from '#/state/queries/resolve-uri'
 import {
-  useListQuery,
-  useListMuteMutation,
   useListBlockMutation,
   useListDeleteMutation,
+  useListMuteMutation,
+  useListQuery,
 } from '#/state/queries/list'
-import {cleanError} from '#/lib/strings/errors'
-import {useSession} from '#/state/session'
-import {useComposerControls} from '#/state/shell/composer'
-import {isNative, isWeb} from '#/platform/detection'
-import {truncateAndInvalidate} from '#/state/queries/util'
+import {FeedDescriptor} from '#/state/queries/post-feed'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {
-  usePreferencesQuery,
   usePinFeedMutation,
-  useUnpinFeedMutation,
+  usePreferencesQuery,
   useSetSaveFeedsMutation,
+  useUnpinFeedMutation,
 } from '#/state/queries/preferences'
-import {logger} from '#/logger'
-import {useAnalytics} from '#/lib/analytics/analytics'
-import {listenSoftReset} from '#/state/events'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {truncateAndInvalidate} from '#/state/queries/util'
+import {useSession} from '#/state/session'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {useComposerControls} from '#/state/shell/composer'
+import {useHaptics} from 'lib/haptics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {ComposeIcon2} from 'lib/icons'
+import {makeListLink, makeProfileLink} from 'lib/routes/links'
+import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {NavigationProp} from 'lib/routes/types'
+import {shareUrl} from 'lib/sharing'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {s} from 'lib/styles'
+import {ListMembers} from '#/view/com/lists/ListMembers'
+import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
+import {Feed} from 'view/com/posts/Feed'
+import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
+import {EmptyState} from 'view/com/util/EmptyState'
+import {FAB} from 'view/com/util/fab/FAB'
+import {Button} from 'view/com/util/forms/Button'
+import {DropdownItem, NativeDropdown} from 'view/com/util/forms/NativeDropdown'
+import {TextLink} from 'view/com/util/Link'
+import {ListRef} from 'view/com/util/List'
+import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
+import {LoadingScreen} from 'view/com/util/LoadingScreen'
+import {Text} from 'view/com/util/text/Text'
+import * as Toast from 'view/com/util/Toast'
+import {CenteredView} from 'view/com/util/Views'
 import {atoms as a, useTheme} from '#/alf'
-import * as Prompt from '#/components/Prompt'
 import {useDialogControl} from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
+import {RichText} from '#/components/RichText'
 
 const SECTION_TITLES_CURATE = ['Posts', 'About']
 const SECTION_TITLES_MOD = ['About']
@@ -254,6 +255,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const {data: preferences} = usePreferencesQuery()
   const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
   const {track} = useAnalytics()
+  const playHaptic = useHaptics()
 
   const deleteListPromptControl = useDialogControl()
   const subscribeMutePromptControl = useDialogControl()
@@ -263,7 +265,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const isSaved = preferences?.feeds?.saved?.includes(list.uri)
 
   const onTogglePinned = React.useCallback(async () => {
-    Haptics.default()
+    playHaptic()
 
     try {
       if (isPinned) {
@@ -275,7 +277,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
       Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to toggle pinned feed', {message: e})
     }
-  }, [list.uri, isPinned, pinFeed, unpinFeed, _])
+  }, [playHaptic, isPinned, unpinFeed, list.uri, pinFeed, _])
 
   const onSubscribeMute = useCallback(async () => {
     try {
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 251c70638..0003dbd5d 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -1,31 +1,32 @@
 import React from 'react'
-import {StyleSheet, View, ActivityIndicator, Pressable} from 'react-native'
+import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
+
 import {track} from '#/lib/analytics/analytics'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {usePalette} from 'lib/hooks/usePalette'
-import {CommonNavigatorParams} from 'lib/routes/types'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {ViewHeader} from 'view/com/util/ViewHeader'
-import {ScrollView, CenteredView} from 'view/com/util/Views'
-import {Text} from 'view/com/util/text/Text'
-import {s, colors} from 'lib/styles'
-import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import * as Toast from 'view/com/util/Toast'
-import {Haptics} from 'lib/haptics'
-import {TextLink} from 'view/com/util/Link'
 import {logger} from '#/logger'
-import {useSetMinimalShellMode} from '#/state/shell'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
 import {
-  usePreferencesQuery,
   usePinFeedMutation,
-  useUnpinFeedMutation,
+  usePreferencesQuery,
   useSetSaveFeedsMutation,
+  useUnpinFeedMutation,
 } from '#/state/queries/preferences'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useHaptics} from 'lib/haptics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {colors, s} from 'lib/styles'
+import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
+import {TextLink} from 'view/com/util/Link'
+import {Text} from 'view/com/util/text/Text'
+import * as Toast from 'view/com/util/Toast'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {CenteredView, ScrollView} from 'view/com/util/Views'
 
 const HITSLOP_TOP = {
   top: 20,
@@ -189,13 +190,14 @@ function ListItem({
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
+  const playHaptic = useHaptics()
   const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
   const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
     useUnpinFeedMutation()
   const isPending = isPinPending || isUnpinPending
 
   const onTogglePinned = React.useCallback(async () => {
-    Haptics.default()
+    playHaptic()
 
     try {
       resetSaveFeedsMutationState()
@@ -209,7 +211,15 @@ function ListItem({
       Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to toggle pinned feed', {message: e})
     }
-  }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState, _])
+  }, [
+    playHaptic,
+    resetSaveFeedsMutationState,
+    isPinned,
+    unpinFeed,
+    feedUri,
+    pinFeed,
+    _,
+  ])
 
   const onPressUp = React.useCallback(async () => {
     if (!isPinned) return
diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx
index 830a73ff2..8a7fa5e71 100644
--- a/src/view/screens/Settings/index.tsx
+++ b/src/view/screens/Settings/index.tsx
@@ -20,10 +20,9 @@ import {useLingui} from '@lingui/react'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
-import {isNative} from '#/platform/detection'
+import {isIOS, isNative} from '#/platform/detection'
 import {useModalControls} from '#/state/modals'
 import {clearLegacyStorage} from '#/state/persisted/legacy'
-// TODO import {useInviteCodesQuery} from '#/state/queries/invites'
 import {clear as clearStorage} from '#/state/persisted/store'
 import {
   useRequireAltTextEnabled,
@@ -57,6 +56,10 @@ import {makeProfileLink} from 'lib/routes/links'
 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
 import {NavigationProp} from 'lib/routes/types'
 import {colors, s} from 'lib/styles'
+import {
+  useHapticsDisabled,
+  useSetHapticsDisabled,
+} from 'state/preferences/disable-haptics'
 import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
 import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
 import {ToggleButton} from 'view/com/util/forms/ToggleButton'
@@ -155,6 +158,8 @@ export function SettingsScreen({}: Props) {
   const setRequireAltTextEnabled = useSetRequireAltTextEnabled()
   const inAppBrowserPref = useInAppBrowser()
   const setUseInAppBrowser = useSetInAppBrowser()
+  const isHapticsDisabled = useHapticsDisabled()
+  const setHapticsDisabled = useSetHapticsDisabled()
   const onboardingDispatch = useOnboardingDispatch()
   const navigation = useNavigation<NavigationProp>()
   const {isMobile} = useWebMediaQueries()
@@ -162,9 +167,6 @@ export function SettingsScreen({}: Props) {
   const {openModal} = useModalControls()
   const {isSwitchingAccounts, accounts, currentAccount} = useSession()
   const {mutate: clearPreferences} = useClearPreferencesMutation()
-  // TODO
-  // const {data: invites} = useInviteCodesQuery()
-  // const invitesAvailable = invites?.available?.length ?? 0
   const {setShowLoggedOut} = useLoggedOutViewControls()
   const closeAllActiveElements = useCloseAllActiveElements()
   const exportCarControl = useDialogControl()
@@ -220,13 +222,6 @@ export function SettingsScreen({}: Props) {
     exportCarControl.open()
   }, [exportCarControl])
 
-  /* TODO
-  const onPressInviteCodes = React.useCallback(() => {
-    track('Settings:InvitecodesButtonClicked')
-    openModal({name: 'invite-codes'})
-  }, [track, openModal])
- */
-
   const onPressLanguageSettings = React.useCallback(() => {
     navigation.navigate('LanguageSettings')
   }, [navigation])
@@ -414,58 +409,6 @@ export function SettingsScreen({}: Props) {
 
         <View style={styles.spacer20} />
 
-        {/* TODO (
-          <>
-            <Text type="xl-bold" style={[pal.text, styles.heading]}>
-              <Trans>Invite a Friend</Trans>
-            </Text>
-
-            <TouchableOpacity
-              testID="inviteFriendBtn"
-              style={[
-                styles.linkCard,
-                pal.view,
-                isSwitchingAccounts && styles.dimmed,
-              ]}
-              onPress={isSwitchingAccounts ? undefined : onPressInviteCodes}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Invite`)}
-              accessibilityHint={_(msg`Opens invite code list`)}
-              disabled={invites?.disabled}>
-              <View
-                style={[
-                  styles.iconContainer,
-                  invitesAvailable > 0 ? primaryBg : pal.btn,
-                ]}>
-                <FontAwesomeIcon
-                  icon="ticket"
-                  style={
-                    (invitesAvailable > 0
-                      ? primaryText
-                      : pal.text) as FontAwesomeIconStyle
-                  }
-                />
-              </View>
-              <Text
-                type="lg"
-                style={invitesAvailable > 0 ? pal.link : pal.text}>
-                {invites?.disabled ? (
-                  <Trans>
-                    Your invite codes are hidden when logged in using an App
-                    Password
-                  </Trans>
-                ) : invitesAvailable === 1 ? (
-                  <Trans>{invitesAvailable} invite code available</Trans>
-                ) : (
-                  <Trans>{invitesAvailable} invite codes available</Trans>
-                )}
-              </Text>
-            </TouchableOpacity>
-
-            <View style={styles.spacer20} />
-          </>
-        )*/}
-
         <Text type="xl-bold" style={[pal.text, styles.heading]}>
           <Trans>Accessibility</Trans>
         </Text>
@@ -738,6 +681,19 @@ export function SettingsScreen({}: Props) {
             />
           </View>
         )}
+        {isNative && (
+          <View style={[pal.view, styles.toggleCard]}>
+            <ToggleButton
+              type="default-light"
+              label={
+                isIOS ? _(msg`Disable haptics`) : _(msg`Disable vibrations`)
+              }
+              labelType="lg"
+              isSelected={isHapticsDisabled}
+              onPress={() => setHapticsDisabled(!isHapticsDisabled)}
+            />
+          </View>
+        )}
         <View style={styles.spacer20} />
         <Text type="xl-bold" style={[pal.text, styles.heading]}>
           <Trans>Account</Trans>
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index f41631a96..c35fa106d 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -8,7 +8,7 @@ import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
 import {StackActions} from '@react-navigation/native'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
-import {Haptics} from '#/lib/haptics'
+import {useHaptics} from '#/lib/haptics'
 import {useDedupe} from '#/lib/hooks/useDedupe'
 import {useMinimalShellMode} from '#/lib/hooks/useMinimalShellMode'
 import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
@@ -59,6 +59,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
   const closeAllActiveElements = useCloseAllActiveElements()
   const dedupe = useDedupe()
   const accountSwitchControl = useDialogControl()
+  const playHaptic = useHaptics()
 
   const showSignIn = React.useCallback(() => {
     closeAllActiveElements()
@@ -104,9 +105,9 @@ export function BottomBar({navigation}: BottomTabBarProps) {
   }, [onPressTab])
 
   const onLongPressProfile = React.useCallback(() => {
-    Haptics.default()
+    playHaptic()
     accountSwitchControl.open()
-  }, [accountSwitchControl])
+  }, [accountSwitchControl, playHaptic])
 
   return (
     <>