about summary refs log tree commit diff
path: root/src/view/com/util
diff options
context:
space:
mode:
authorOllie H <renahlee@outlook.com>2023-05-01 18:38:47 -0700
committerGitHub <noreply@github.com>2023-05-01 20:38:47 -0500
commit83959c595d52ceb7aa4e3f68441c5ac41c389ebc (patch)
tree3385d9a16e90fc8d5290ebdef104f922c17642a9 /src/view/com/util
parentc75c888de2407d3314cad07989174201313facaa (diff)
downloadvoidsky-83959c595d52ceb7aa4e3f68441c5ac41c389ebc.tar.zst
React Native accessibility (#539)
* React Native accessibility

* First round of changes

* Latest update

* Checkpoint

* Wrap up

* Lint

* Remove unhelpful image hints

* Fix navigation

* Fix rebase and lint

* Mitigate an known issue with the password entry in login

* Fix composer dismiss

* Remove focus on input elements for web

* Remove i and npm

* pls work

* Remove stray declaration

* Regenerate yarn.lock

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Diffstat (limited to 'src/view/com/util')
-rw-r--r--src/view/com/util/BottomSheetCustomBackdrop.tsx14
-rw-r--r--src/view/com/util/Link.tsx34
-rw-r--r--src/view/com/util/Picker.tsx157
-rw-r--r--src/view/com/util/PostCtrls.tsx159
-rw-r--r--src/view/com/util/Selector.tsx6
-rw-r--r--src/view/com/util/UserAvatar.tsx7
-rw-r--r--src/view/com/util/UserBanner.tsx8
-rw-r--r--src/view/com/util/ViewHeader.tsx13
-rw-r--r--src/view/com/util/ViewSelector.tsx7
-rw-r--r--src/view/com/util/error/ErrorMessage.tsx5
-rw-r--r--src/view/com/util/error/ErrorScreen.tsx4
-rw-r--r--src/view/com/util/fab/FABInner.tsx18
-rw-r--r--src/view/com/util/forms/Button.tsx4
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx62
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx9
-rw-r--r--src/view/com/util/images/Gallery.tsx13
-rw-r--r--src/view/com/util/images/Image.tsx4
-rw-r--r--src/view/com/util/images/ImageHorzList.tsx22
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtn.web.tsx5
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtnMobile.tsx5
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx9
-rw-r--r--src/view/com/util/moderation/PostHider.tsx3
-rw-r--r--src/view/com/util/post-embeds/index.tsx5
23 files changed, 277 insertions, 296 deletions
diff --git a/src/view/com/util/BottomSheetCustomBackdrop.tsx b/src/view/com/util/BottomSheetCustomBackdrop.tsx
index e175b33a5..91379f1c9 100644
--- a/src/view/com/util/BottomSheetCustomBackdrop.tsx
+++ b/src/view/com/util/BottomSheetCustomBackdrop.tsx
@@ -1,5 +1,5 @@
 import React, {useMemo} from 'react'
-import {GestureResponderEvent, TouchableWithoutFeedback} from 'react-native'
+import {TouchableWithoutFeedback} from 'react-native'
 import {BottomSheetBackdropProps} from '@gorhom/bottom-sheet'
 import Animated, {
   Extrapolate,
@@ -8,7 +8,7 @@ import Animated, {
 } from 'react-native-reanimated'
 
 export function createCustomBackdrop(
-  onClose?: ((event: GestureResponderEvent) => void) | undefined,
+  onClose?: (() => void) | undefined,
 ): React.FC<BottomSheetBackdropProps> {
   const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => {
     // animated variables
@@ -27,7 +27,15 @@ export function createCustomBackdrop(
     )
 
     return (
-      <TouchableWithoutFeedback onPress={onClose}>
+      <TouchableWithoutFeedback
+        onPress={onClose}
+        accessibilityLabel="Close bottom drawer"
+        accessibilityHint=""
+        onAccessibilityEscape={() => {
+          if (onClose !== undefined) {
+            onClose()
+          }
+        }}>
         <Animated.View style={containerStyle} />
       </TouchableWithoutFeedback>
     )
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 5110acf48..503e22084 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {ComponentProps} from 'react'
 import {observer} from 'mobx-react-lite'
 import {
   Linking,
@@ -29,6 +29,16 @@ type Event =
   | React.MouseEvent<HTMLAnchorElement, MouseEvent>
   | GestureResponderEvent
 
+interface Props extends ComponentProps<typeof TouchableOpacity> {
+  testID?: string
+  style?: StyleProp<ViewStyle>
+  href?: string
+  title?: string
+  children?: React.ReactNode
+  noFeedback?: boolean
+  asAnchor?: boolean
+}
+
 export const Link = observer(function Link({
   testID,
   style,
@@ -37,15 +47,9 @@ export const Link = observer(function Link({
   children,
   noFeedback,
   asAnchor,
-}: {
-  testID?: string
-  style?: StyleProp<ViewStyle>
-  href?: string
-  title?: string
-  children?: React.ReactNode
-  noFeedback?: boolean
-  asAnchor?: boolean
-}) {
+  accessible,
+  ...props
+}: Props) {
   const store = useStores()
   const navigation = useNavigation<NavigationProp>()
 
@@ -64,7 +68,10 @@ export const Link = observer(function Link({
         testID={testID}
         onPress={onPress}
         // @ts-ignore web only -prf
-        href={asAnchor ? sanitizeUrl(href) : undefined}>
+        href={asAnchor ? sanitizeUrl(href) : undefined}
+        accessible={accessible}
+        accessibilityRole="link"
+        {...props}>
         <View style={style}>
           {children ? children : <Text>{title || 'link'}</Text>}
         </View>
@@ -76,8 +83,11 @@ export const Link = observer(function Link({
       testID={testID}
       style={style}
       onPress={onPress}
+      accessible={accessible}
+      accessibilityRole="link"
       // @ts-ignore web only -prf
-      href={asAnchor ? sanitizeUrl(href) : undefined}>
+      href={asAnchor ? sanitizeUrl(href) : undefined}
+      {...props}>
       {children ? children : <Text>{title || 'link'}</Text>}
     </TouchableOpacity>
   )
diff --git a/src/view/com/util/Picker.tsx b/src/view/com/util/Picker.tsx
deleted file mode 100644
index 9007cb1f0..000000000
--- a/src/view/com/util/Picker.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-// TODO: replaceme with something in the design system
-
-import React, {useRef} from 'react'
-import {
-  StyleProp,
-  StyleSheet,
-  TextStyle,
-  TouchableOpacity,
-  TouchableWithoutFeedback,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import RootSiblings from 'react-native-root-siblings'
-import {Text} from './text/Text'
-import {colors} from 'lib/styles'
-
-interface PickerItem {
-  value: string
-  label: string
-}
-
-interface PickerOpts {
-  style?: StyleProp<ViewStyle>
-  labelStyle?: StyleProp<TextStyle>
-  iconStyle?: FontAwesomeIconStyle
-  items: PickerItem[]
-  value: string
-  onChange: (value: string) => void
-  enabled?: boolean
-}
-
-const MENU_WIDTH = 200
-
-export function Picker({
-  style,
-  labelStyle,
-  iconStyle,
-  items,
-  value,
-  onChange,
-  enabled,
-}: PickerOpts) {
-  const ref = useRef<View>(null)
-  const valueLabel = items.find(item => item.value === value)?.label || value
-  const onPress = () => {
-    if (!enabled) {
-      return
-    }
-    ref.current?.measure(
-      (
-        _x: number,
-        _y: number,
-        width: number,
-        height: number,
-        pageX: number,
-        pageY: number,
-      ) => {
-        createDropdownMenu(pageX, pageY + height, MENU_WIDTH, items, onChange)
-      },
-    )
-  }
-  return (
-    <TouchableWithoutFeedback onPress={onPress}>
-      <View style={[styles.outer, style]} ref={ref}>
-        <View style={styles.label}>
-          <Text style={labelStyle}>{valueLabel}</Text>
-        </View>
-        <FontAwesomeIcon icon="angle-down" style={[styles.icon, iconStyle]} />
-      </View>
-    </TouchableWithoutFeedback>
-  )
-}
-
-function createDropdownMenu(
-  x: number,
-  y: number,
-  width: number,
-  items: PickerItem[],
-  onChange: (value: string) => void,
-): RootSiblings {
-  const onPressItem = (index: number) => {
-    sibling.destroy()
-    onChange(items[index].value)
-  }
-  const onOuterPress = () => sibling.destroy()
-  const sibling = new RootSiblings(
-    (
-      <>
-        <TouchableWithoutFeedback onPress={onOuterPress}>
-          <View style={styles.bg} />
-        </TouchableWithoutFeedback>
-        <View style={[styles.menu, {left: x, top: y, width}]}>
-          {items.map((item, index) => (
-            <TouchableOpacity
-              key={index}
-              style={[styles.menuItem, index !== 0 && styles.menuItemBorder]}
-              onPress={() => onPressItem(index)}>
-              <Text style={styles.menuItemLabel}>{item.label}</Text>
-            </TouchableOpacity>
-          ))}
-        </View>
-      </>
-    ),
-  )
-  return sibling
-}
-
-const styles = StyleSheet.create({
-  outer: {
-    flexDirection: 'row',
-    alignItems: 'center',
-  },
-  label: {
-    marginRight: 5,
-  },
-  icon: {},
-  bg: {
-    position: 'absolute',
-    top: 0,
-    right: 0,
-    bottom: 0,
-    left: 0,
-    backgroundColor: '#000',
-    opacity: 0.1,
-  },
-  menu: {
-    position: 'absolute',
-    backgroundColor: '#fff',
-    borderRadius: 14,
-    opacity: 1,
-    paddingVertical: 6,
-  },
-  menuItem: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    paddingVertical: 6,
-    paddingLeft: 15,
-    paddingRight: 30,
-  },
-  menuItemBorder: {
-    borderTopWidth: 1,
-    borderTopColor: colors.gray2,
-    marginTop: 4,
-    paddingTop: 12,
-  },
-  menuItemIcon: {
-    marginLeft: 6,
-    marginRight: 8,
-  },
-  menuItemLabel: {
-    fontSize: 15,
-  },
-})
diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx
index 07a67fd8a..725f3bbbe 100644
--- a/src/view/com/util/PostCtrls.tsx
+++ b/src/view/com/util/PostCtrls.tsx
@@ -170,83 +170,94 @@ export function PostCtrls(opts: PostCtrlsOpts) {
 
   return (
     <View style={[styles.ctrls, opts.style]}>
-      <View>
-        <TouchableOpacity
-          testID="replyBtn"
-          style={styles.ctrl}
-          hitSlop={HITSLOP}
-          onPress={opts.onPressReply}>
-          <CommentBottomArrow
-            style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
-            strokeWidth={3}
-            size={opts.big ? 20 : 15}
-          />
-          {typeof opts.replyCount !== 'undefined' ? (
-            <Text style={[defaultCtrlColor, s.ml5, s.f15]}>
-              {opts.replyCount}
-            </Text>
-          ) : undefined}
-        </TouchableOpacity>
-      </View>
-      <View>
-        <TouchableOpacity
-          testID="repostBtn"
-          hitSlop={HITSLOP}
-          onPress={onPressToggleRepostWrapper}
-          style={styles.ctrl}>
-          <RepostIcon
+      <TouchableOpacity
+        testID="replyBtn"
+        style={styles.ctrl}
+        hitSlop={HITSLOP}
+        onPress={opts.onPressReply}
+        accessibilityRole="button"
+        accessibilityLabel="Reply"
+        accessibilityHint="Opens reply composer">
+        <CommentBottomArrow
+          style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
+          strokeWidth={3}
+          size={opts.big ? 20 : 15}
+        />
+        {typeof opts.replyCount !== 'undefined' ? (
+          <Text style={[defaultCtrlColor, s.ml5, s.f15]}>
+            {opts.replyCount}
+          </Text>
+        ) : undefined}
+      </TouchableOpacity>
+      <TouchableOpacity
+        testID="repostBtn"
+        hitSlop={HITSLOP}
+        onPress={onPressToggleRepostWrapper}
+        style={styles.ctrl}
+        accessibilityRole="button"
+        accessibilityLabel={opts.isReposted ? 'Undo repost' : 'Repost'}
+        accessibilityHint={
+          opts.isReposted
+            ? `Remove your repost of ${opts.author}'s post`
+            : `Repost or quote post ${opts.author}'s post`
+        }>
+        <RepostIcon
+          style={
+            opts.isReposted
+              ? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
+              : defaultCtrlColor
+          }
+          strokeWidth={2.4}
+          size={opts.big ? 24 : 20}
+        />
+        {typeof opts.repostCount !== 'undefined' ? (
+          <Text
+            testID="repostCount"
             style={
               opts.isReposted
-                ? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
-                : defaultCtrlColor
-            }
-            strokeWidth={2.4}
-            size={opts.big ? 24 : 20}
+                ? [s.bold, s.green3, s.f15, s.ml5]
+                : [defaultCtrlColor, s.f15, s.ml5]
+            }>
+            {opts.repostCount}
+          </Text>
+        ) : undefined}
+      </TouchableOpacity>
+      <TouchableOpacity
+        testID="likeBtn"
+        style={styles.ctrl}
+        hitSlop={HITSLOP}
+        onPress={onPressToggleLikeWrapper}
+        accessibilityRole="button"
+        accessibilityLabel={opts.isLiked ? 'Unlike' : 'Like'}
+        accessibilityHint={
+          opts.isReposted
+            ? `Removes like from ${opts.author}'s post`
+            : `Like ${opts.author}'s post`
+        }>
+        {opts.isLiked ? (
+          <HeartIconSolid
+            style={styles.ctrlIconLiked as StyleProp<ViewStyle>}
+            size={opts.big ? 22 : 16}
           />
-          {typeof opts.repostCount !== 'undefined' ? (
-            <Text
-              testID="repostCount"
-              style={
-                opts.isReposted
-                  ? [s.bold, s.green3, s.f15, s.ml5]
-                  : [defaultCtrlColor, s.f15, s.ml5]
-              }>
-              {opts.repostCount}
-            </Text>
-          ) : undefined}
-        </TouchableOpacity>
-      </View>
-      <View>
-        <TouchableOpacity
-          testID="likeBtn"
-          style={styles.ctrl}
-          hitSlop={HITSLOP}
-          onPress={onPressToggleLikeWrapper}>
-          {opts.isLiked ? (
-            <HeartIconSolid
-              style={styles.ctrlIconLiked as StyleProp<ViewStyle>}
-              size={opts.big ? 22 : 16}
-            />
-          ) : (
-            <HeartIcon
-              style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
-              strokeWidth={3}
-              size={opts.big ? 20 : 16}
-            />
-          )}
-          {typeof opts.likeCount !== 'undefined' ? (
-            <Text
-              testID="likeCount"
-              style={
-                opts.isLiked
-                  ? [s.bold, s.red3, s.f15, s.ml5]
-                  : [defaultCtrlColor, s.f15, s.ml5]
-              }>
-              {opts.likeCount}
-            </Text>
-          ) : undefined}
-        </TouchableOpacity>
-      </View>
+        ) : (
+          <HeartIcon
+            style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
+            strokeWidth={3}
+            size={opts.big ? 20 : 16}
+          />
+        )}
+        {typeof opts.likeCount !== 'undefined' ? (
+          <Text
+            testID="likeCount"
+            style={
+              opts.isLiked
+                ? [s.bold, s.red3, s.f15, s.ml5]
+                : [defaultCtrlColor, s.f15, s.ml5]
+            }>
+            {opts.likeCount}
+          </Text>
+        ) : undefined}
+      </TouchableOpacity>
       <View>
         {opts.big ? undefined : (
           <PostDropdownBtn
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx
index 016ea77b8..223a069c8 100644
--- a/src/view/com/util/Selector.tsx
+++ b/src/view/com/util/Selector.tsx
@@ -85,6 +85,8 @@ export function Selector({
     onSelect?.(index)
   }
 
+  const numItems = items.length
+
   return (
     <View
       style={[pal.view, styles.outer]}
@@ -97,7 +99,9 @@ export function Selector({
           <Pressable
             testID={`selector-${i}`}
             key={item}
-            onPress={() => onPressItem(i)}>
+            onPress={() => onPressItem(i)}
+            accessibilityLabel={`Select ${item}`}
+            accessibilityHint={`Select option ${i} of ${numItems}`}>
             <View style={styles.item} ref={itemRefs[i]}>
               <Text
                 style={
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 7f55bf773..a2e607e47 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -150,6 +150,7 @@ export function UserAvatar({
             borderRadius: Math.floor(size / 2),
           }}
           source={{uri: avatar}}
+          accessibilityRole="image"
         />
       ) : (
         <DefaultAvatar size={size} />
@@ -167,7 +168,11 @@ export function UserAvatar({
     <View style={{width: size, height: size}}>
       <HighPriorityImage
         testID="userAvatarImage"
-        style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
+        style={{
+          width: size,
+          height: size,
+          borderRadius: Math.floor(size / 2),
+        }}
         contentFit="cover"
         source={{uri: avatar}}
         blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 14459bf77..51cfbccbb 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -5,7 +5,6 @@ import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {Image} from 'expo-image'
 import {colors} from 'lib/styles'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
-import {Image as TImage} from 'lib/media/types'
 import {useStores} from 'state/index'
 import {
   usePhotoLibraryPermission,
@@ -15,6 +14,7 @@ import {DropdownButton} from './forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
 import {AvatarModeration} from 'lib/labeling/types'
 import {isWeb, isAndroid} from 'platform/detection'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 
 export function UserBanner({
   banner,
@@ -23,7 +23,7 @@ export function UserBanner({
 }: {
   banner?: string | null
   moderation?: AvatarModeration
-  onSelectNewBanner?: (img: TImage | null) => void
+  onSelectNewBanner?: (img: RNImage | null) => void
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -94,6 +94,8 @@ export function UserBanner({
           testID="userBannerImage"
           style={styles.bannerImage}
           source={{uri: banner}}
+          accessible={true}
+          accessibilityIgnoresInvertColors
         />
       ) : (
         <View
@@ -118,6 +120,8 @@ export function UserBanner({
       resizeMode="cover"
       source={{uri: banner}}
       blurRadius={moderation?.blur ? 100 : 0}
+      accessible={true}
+      accessibilityIgnoresInvertColors
     />
   ) : (
     <View
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 816c835cc..9c85cfa24 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -60,7 +60,14 @@ export const ViewHeader = observer(function ({
           testID="viewHeaderDrawerBtn"
           onPress={canGoBack ? onPressBack : onPressMenu}
           hitSlop={BACK_HITSLOP}
-          style={canGoBack ? styles.backBtn : styles.backBtnWide}>
+          style={canGoBack ? styles.backBtn : styles.backBtnWide}
+          accessibilityRole="button"
+          accessibilityLabel={canGoBack ? 'Go back' : 'Go to menu'}
+          accessibilityHint={
+            canGoBack
+              ? 'Navigates to the previous screen'
+              : 'Navigates to the menu'
+          }>
           {canGoBack ? (
             <FontAwesomeIcon
               size={18}
@@ -171,9 +178,9 @@ const styles = StyleSheet.create({
     height: 30,
   },
   backBtnWide: {
-    width: 40,
+    width: 30,
     height: 30,
-    marginLeft: 6,
+    paddingHorizontal: 6,
   },
   backIcon: {
     marginTop: 6,
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index 02717053d..f9ef0945d 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -132,7 +132,12 @@ export function Selector({
           <Pressable
             testID={`selector-${i}`}
             key={item}
-            onPress={() => onPressItem(i)}>
+            onPress={() => onPressItem(i)}
+            accessibilityLabel={item}
+            accessibilityHint={`Selects ${item}`}
+            // TODO: Modify the component API such that lint fails
+            // at the invocation site as well
+          >
             <View
               style={[
                 styles.item,
diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx
index cc0df1b59..370f10ae3 100644
--- a/src/view/com/util/error/ErrorMessage.tsx
+++ b/src/view/com/util/error/ErrorMessage.tsx
@@ -47,7 +47,10 @@ export function ErrorMessage({
         <TouchableOpacity
           testID="errorMessageTryAgainButton"
           style={styles.btn}
-          onPress={onPressTryAgain}>
+          onPress={onPressTryAgain}
+          accessibilityRole="button"
+          accessibilityLabel="Retry"
+          accessibilityHint="Retries the last action, which errored out">
           <FontAwesomeIcon
             icon="arrows-rotate"
             style={{color: theme.palette.error.icon}}
diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx
index c849e37db..a5deeb18f 100644
--- a/src/view/com/util/error/ErrorScreen.tsx
+++ b/src/view/com/util/error/ErrorScreen.tsx
@@ -57,7 +57,9 @@ export function ErrorScreen({
             testID="errorScreenTryAgainButton"
             type="default"
             style={[styles.btn]}
-            onPress={onPressTryAgain}>
+            onPress={onPressTryAgain}
+            accessibilityLabel="Retry"
+            accessibilityHint="Retries the last action, which errored out">
             <FontAwesomeIcon
               icon="arrows-rotate"
               style={pal.link as FontAwesomeIconStyle}
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index 3d44c0dd4..5eb4a6588 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -1,25 +1,19 @@
-import React from 'react'
+import React, {ComponentProps} from 'react'
 import {observer} from 'mobx-react-lite'
-import {
-  Animated,
-  GestureResponderEvent,
-  StyleSheet,
-  TouchableWithoutFeedback,
-} from 'react-native'
+import {Animated, StyleSheet, TouchableWithoutFeedback} from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {gradients} from 'lib/styles'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useStores} from 'state/index'
 import {isMobileWeb} from 'platform/detection'
 
-type OnPress = ((event: GestureResponderEvent) => void) | undefined
-export interface FABProps {
+export interface FABProps
+  extends ComponentProps<typeof TouchableWithoutFeedback> {
   testID?: string
   icon: JSX.Element
-  onPress: OnPress
 }
 
-export const FABInner = observer(({testID, icon, onPress}: FABProps) => {
+export const FABInner = observer(({testID, icon, ...props}: FABProps) => {
   const store = useStores()
   const interp = useAnimatedValue(0)
   React.useEffect(() => {
@@ -34,7 +28,7 @@ export const FABInner = observer(({testID, icon, onPress}: FABProps) => {
     transform: [{translateY: Animated.multiply(interp, 60)}],
   }
   return (
-    <TouchableWithoutFeedback testID={testID} onPress={onPress}>
+    <TouchableWithoutFeedback testID={testID} {...props}>
       <Animated.View
         style={[styles.outer, isMobileWeb && styles.mobileWebOuter, transform]}>
         <LinearGradient
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index 8548860d0..3b5b00284 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -26,6 +26,7 @@ export type ButtonType =
   | 'secondary-light'
   | 'default-light'
 
+// TODO: Enforce that button always has a label
 export function Button({
   type = 'primary',
   label,
@@ -131,7 +132,8 @@ export function Button({
     <Pressable
       style={[typeOuterStyle, styles.outer, style]}
       onPress={onPressWrapped}
-      testID={testID}>
+      testID={testID}
+      accessibilityRole="button">
       {label ? (
         <Text type="button" style={[typeLabelStyle, labelStyle]}>
           {label}
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index 725d45c1b..04346d91f 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -1,4 +1,4 @@
-import React, {useRef} from 'react'
+import React, {PropsWithChildren, useMemo, useRef} from 'react'
 import {
   Dimensions,
   StyleProp,
@@ -39,6 +39,19 @@ type MaybeDropdownItem = DropdownItem | false | undefined
 
 export type DropdownButtonType = ButtonType | 'bare'
 
+interface DropdownButtonProps {
+  testID?: string
+  type?: DropdownButtonType
+  style?: StyleProp<ViewStyle>
+  items: MaybeDropdownItem[]
+  label?: string
+  menuWidth?: number
+  children?: React.ReactNode
+  openToRight?: boolean
+  rightOffset?: number
+  bottomOffset?: number
+}
+
 export function DropdownButton({
   testID,
   type = 'bare',
@@ -50,18 +63,7 @@ export function DropdownButton({
   openToRight = false,
   rightOffset = 0,
   bottomOffset = 0,
-}: {
-  testID?: string
-  type?: DropdownButtonType
-  style?: StyleProp<ViewStyle>
-  items: MaybeDropdownItem[]
-  label?: string
-  menuWidth?: number
-  children?: React.ReactNode
-  openToRight?: boolean
-  rightOffset?: number
-  bottomOffset?: number
-}) {
+}: PropsWithChildren<DropdownButtonProps>) {
   const ref1 = useRef<TouchableOpacity>(null)
   const ref2 = useRef<View>(null)
 
@@ -105,6 +107,18 @@ export function DropdownButton({
     )
   }
 
+  const numItems = useMemo(
+    () =>
+      items.filter(item => {
+        if (item === undefined || item === false) {
+          return false
+        }
+
+        return isBtn(item)
+      }).length,
+    [items],
+  )
+
   if (type === 'bare') {
     return (
       <TouchableOpacity
@@ -112,7 +126,10 @@ export function DropdownButton({
         style={style}
         onPress={onPress}
         hitSlop={HITSLOP}
-        ref={ref1}>
+        ref={ref1}
+        accessibilityRole="button"
+        accessibilityLabel={`Opens ${numItems} options`}
+        accessibilityHint={`Opens ${numItems} options`}>
         {children}
       </TouchableOpacity>
     )
@@ -283,9 +300,20 @@ const DropdownItems = ({
   const separatorColor =
     theme.colorScheme === 'dark' ? pal.borderDark : pal.border
 
+  const numItems = items.filter(isBtn).length
+
   return (
     <>
-      <TouchableWithoutFeedback onPress={onOuterPress}>
+      <TouchableWithoutFeedback
+        onPress={onOuterPress}
+        // TODO: Refactor dropdown components to:
+        // - (On web, if not handled by React Native) use semantic <select />
+        // and <option /> elements for keyboard navigation out of the box
+        // - (On mobile) be buttons by default, accept `label` and `nativeID`
+        // props, and always have an explicit label
+        accessibilityRole="button"
+        accessibilityLabel="Toggle dropdown"
+        accessibilityHint="">
         <View style={[styles.bg]} />
       </TouchableWithoutFeedback>
       <View
@@ -301,7 +329,9 @@ const DropdownItems = ({
                 testID={item.testID}
                 key={index}
                 style={[styles.menuItem]}
-                onPress={() => onPressItem(index)}>
+                onPress={() => onPressItem(index)}
+                accessibilityLabel={item.label}
+                accessibilityHint={`Option ${index + 1} of ${numItems}`}>
                 {item.icon && (
                   <FontAwesomeIcon
                     style={styles.icon}
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 8c31f5614..e6aba46f3 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -62,12 +62,17 @@ export function AutoSizedImage({
         onLongPress={onLongPress}
         onPressIn={onPressIn}
         delayPressIn={DELAY_PRESS_IN}
-        style={[styles.container, style]}>
+        style={[styles.container, style]}
+        accessible={true}
+        accessibilityLabel="Share image"
+        accessibilityHint="Opens ways of sharing image">
         <Image
           style={[styles.image, {aspectRatio}]}
           source={uri}
           accessible={true} // Must set for `accessibilityLabel` to work
+          accessibilityIgnoresInvertColors
           accessibilityLabel={alt}
+          accessibilityHint=""
         />
         {children}
       </TouchableOpacity>
@@ -80,7 +85,9 @@ export function AutoSizedImage({
         style={[styles.image, {aspectRatio}]}
         source={{uri}}
         accessible={true} // Must set for `accessibilityLabel` to work
+        accessibilityIgnoresInvertColors
         accessibilityLabel={alt}
+        accessibilityHint=""
       />
       {children}
     </View>
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 78ced0668..5b6c3384d 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -41,16 +41,25 @@ export const GalleryItem: FC<GalleryItemProps> = ({
         delayPressIn={DELAY_PRESS_IN}
         onPress={() => onPress?.(index)}
         onPressIn={() => onPressIn?.(index)}
-        onLongPress={() => onLongPress?.(index)}>
+        onLongPress={() => onLongPress?.(index)}
+        accessibilityRole="button"
+        accessibilityLabel="View image"
+        accessibilityHint="">
         <Image
           source={{uri: image.thumb}}
           style={imageStyle}
           accessible={true}
           accessibilityLabel={image.alt}
+          accessibilityHint=""
+          accessibilityIgnoresInvertColors
         />
       </TouchableOpacity>
       {image.alt === '' ? null : (
-        <Pressable onPress={onPressAltText}>
+        <Pressable
+          onPress={onPressAltText}
+          accessibilityRole="button"
+          accessibilityLabel="View alt text"
+          accessibilityHint="Opens modal with alt text">
           <Text style={styles.alt}>ALT</Text>
         </Pressable>
       )}
diff --git a/src/view/com/util/images/Image.tsx b/src/view/com/util/images/Image.tsx
index e3d0d7fcc..e779fa378 100644
--- a/src/view/com/util/images/Image.tsx
+++ b/src/view/com/util/images/Image.tsx
@@ -8,5 +8,7 @@ export function HighPriorityImage({source, ...props}: HighPriorityImageProps) {
   const updatedSource = {
     uri: typeof source === 'object' && source ? source.uri : '',
   } satisfies ImageSource
-  return <Image source={updatedSource} {...props} />
+  return (
+    <Image accessibilityIgnoresInvertColors source={updatedSource} {...props} />
+  )
 }
diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx
index 5c232e0b4..88494bba3 100644
--- a/src/view/com/util/images/ImageHorzList.tsx
+++ b/src/view/com/util/images/ImageHorzList.tsx
@@ -16,15 +16,33 @@ interface Props {
 }
 
 export function ImageHorzList({images, onPress, style}: Props) {
+  const numImages = images.length
   return (
     <View style={[styles.flexRow, style]}>
       {images.map(({thumb, alt}, i) => (
-        <TouchableWithoutFeedback key={i} onPress={() => onPress?.(i)}>
+        <TouchableWithoutFeedback
+          key={i}
+          onPress={() => onPress?.(i)}
+          accessible={true}
+          accessibilityLabel={`Open image ${i} of ${numImages}`}
+          accessibilityHint="Opens image in viewer"
+          accessibilityActions={[{name: 'press', label: 'Press'}]}
+          onAccessibilityAction={action => {
+            switch (action.nativeEvent.actionName) {
+              case 'press':
+                onPress?.(0)
+                break
+              default:
+                break
+            }
+          }}>
           <Image
             source={{uri: thumb}}
             style={styles.image}
             accessible={true}
-            accessibilityLabel={alt}
+            accessibilityIgnoresInvertColors
+            accessibilityHint={alt}
+            accessibilityLabel=""
           />
         </TouchableWithoutFeedback>
       ))}
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
index 1b6f18b62..839685029 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
@@ -23,7 +23,10 @@ export const LoadLatestBtn = ({
     <TouchableOpacity
       style={[pal.view, pal.borderDark, styles.loadLatest]}
       onPress={onPress}
-      hitSlop={HITSLOP}>
+      hitSlop={HITSLOP}
+      accessibilityRole="button"
+      accessibilityLabel={`Load new ${label}`}
+      accessibilityHint="">
       <Text type="md-bold" style={pal.text}>
         <UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} />
         Load new {label}
diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
index 75a812760..5279696a2 100644
--- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
@@ -23,7 +23,10 @@ export const LoadLatestBtn = observer(
           },
         ]}
         onPress={onPress}
-        hitSlop={HITSLOP}>
+        hitSlop={HITSLOP}
+        accessibilityRole="button"
+        accessibilityLabel={`Load new ${label}`}
+        accessibilityHint={`Loads new ${label}`}>
         <LinearGradient
           colors={[gradients.blueLight.start, gradients.blueLight.end]}
           start={{x: 0, y: 0}}
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index 74fb479ad..0f3e47d61 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -55,7 +55,14 @@ export function ContentHider({
         </Text>
         <TouchableOpacity
           style={styles.showBtn}
-          onPress={() => setOverride(v => !v)}>
+          onPress={() => setOverride(v => !v)}
+          accessibilityLabel={override ? 'Hide post' : 'Show post'}
+          // TODO: The text labelling should be split up so controls have unique roles
+          accessibilityHint={
+            override
+              ? 'Re-hide post'
+              : 'Shows post hidden based on your moderation settings'
+          }>
           <Text type="md" style={pal.link}>
             {override ? 'Hide' : 'Show'}
           </Text>
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index b3c4c9593..2cc7ea62b 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -46,7 +46,8 @@ export function PostHider({
           </Text>
           <TouchableOpacity
             style={styles.showBtn}
-            onPress={() => setOverride(v => !v)}>
+            onPress={() => setOverride(v => !v)}
+            accessibilityRole="button">
             <Text type="md" style={pal.link}>
               {override ? 'Hide' : 'Show'} post
             </Text>
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 6a7759840..929c85adc 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -136,7 +136,10 @@ export function PostEmbeds({
                 <Pressable
                   onPress={() => {
                     onPressAltText(alt)
-                  }}>
+                  }}
+                  accessibilityRole="button"
+                  accessibilityLabel="View alt text"
+                  accessibilityHint="Opens modal with alt text">
                   <Text style={styles.alt}>ALT</Text>
                 </Pressable>
               )}