about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-05-06 17:28:38 +0100
committerGitHub <noreply@github.com>2024-05-06 17:28:38 +0100
commitc33c3b7d1e44037d3370da0ac60926293bf8a158 (patch)
tree08c6c852549dd7a9486ffa12a2d1492314afd452
parentae7626ce6ed08059c161345046b6037313fc2505 (diff)
downloadvoidsky-c33c3b7d1e44037d3370da0ac60926293bf8a158.tar.zst
Alt text for gifs (#3876)
* add alt text dialog

* multiline alt text input

* add pressable alt text badge

* rename `ALT: ` to `Alt text: ` to avoid including old bad ones

* reuse alt text reminder

* reuse alt text reminder in gallery

* add alt text reminder in the dialog itself

* autofocus text input

* reorder components to fix tab order

* fix close btn position
-rw-r--r--src/components/Prompt.tsx4
-rw-r--r--src/components/dialogs/MutedWords.tsx4
-rw-r--r--src/view/com/composer/Composer.tsx46
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx9
-rw-r--r--src/view/com/composer/GifAltText.tsx177
-rw-r--r--src/view/com/composer/photos/Gallery.tsx67
-rw-r--r--src/view/com/util/images/Gallery.tsx5
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx4
-rw-r--r--src/view/com/util/post-embeds/GifEmbed.tsx75
9 files changed, 344 insertions, 47 deletions
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 0a171674d..327bbbaa2 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -43,7 +43,9 @@ export function Outer({
         <Dialog.ScrollableInner
           accessibilityLabelledBy={titleId}
           accessibilityDescribedBy={descriptionId}
-          style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
+          style={[
+            gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
+          ]}>
           {children}
         </Dialog.ScrollableInner>
       </Context.Provider>
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index 0eced11e3..534263422 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -37,12 +37,12 @@ export function MutedWordsDialog() {
   return (
     <Dialog.Outer control={control}>
       <Dialog.Handle />
-      <MutedWordsInner control={control} />
+      <MutedWordsInner />
     </Dialog.Outer>
   )
 }
 
-function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
+function MutedWordsInner() {
   const t = useTheme()
   const {_} = useLingui()
   const {gtMobile} = useBreakpoints()
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 0ac4ac56e..f472bb2e2 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -59,6 +59,7 @@ import * as Toast from '../util/Toast'
 import {UserAvatar} from '../util/UserAvatar'
 import {CharProgress} from './char-progress/CharProgress'
 import {ExternalEmbed} from './ExternalEmbed'
+import {GifAltText} from './GifAltText'
 import {LabelsBtn} from './labels/LabelsBtn'
 import {Gallery} from './photos/Gallery'
 import {OpenCameraBtn} from './photos/OpenCameraBtn'
@@ -327,7 +328,7 @@ export const ComposePost = observer(function ComposePost({
           image: gif.media_formats.preview.url,
           likelyType: LikelyType.HTML,
           title: gif.content_description,
-          description: `ALT: ${gif.content_description}`,
+          description: '',
         },
       })
       setExtGif(gif)
@@ -335,6 +336,26 @@ export const ComposePost = observer(function ComposePost({
     [setExtLink],
   )
 
+  const handleChangeGifAltText = useCallback(
+    (altText: string) => {
+      setExtLink(ext =>
+        ext && ext.meta
+          ? {
+              ...ext,
+              meta: {
+                ...ext?.meta,
+                description:
+                  altText.trim().length === 0
+                    ? ''
+                    : `Alt text: ${altText.trim()}`,
+              },
+            }
+          : ext,
+      )
+    },
+    [setExtLink],
+  )
+
   return (
     <KeyboardAvoidingView
       testID="composePostView"
@@ -474,14 +495,21 @@ export const ComposePost = observer(function ComposePost({
 
           <Gallery gallery={gallery} />
           {gallery.isEmpty && extLink && (
-            <ExternalEmbed
-              link={extLink}
-              gif={extGif}
-              onRemove={() => {
-                setExtLink(undefined)
-                setExtGif(undefined)
-              }}
-            />
+            <View style={a.relative}>
+              <ExternalEmbed
+                link={extLink}
+                gif={extGif}
+                onRemove={() => {
+                  setExtLink(undefined)
+                  setExtGif(undefined)
+                }}
+              />
+              <GifAltText
+                link={extLink}
+                gif={extGif}
+                onSubmit={handleChangeGifAltText}
+              />
+            </View>
           )}
           {quote ? (
             <View style={[s.mt5, isWeb && s.mb10]}>
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 321e29b30..b81065e99 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -46,7 +46,12 @@ export const ExternalEmbed = ({
     : undefined
 
   return (
-    <View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
+    <View
+      style={[
+        !gif && a.mb_xl,
+        a.overflow_hidden,
+        t.atoms.border_contrast_medium,
+      ]}>
       {link.isLoading ? (
         <Container style={loadingStyle}>
           <Loader size="xl" />
@@ -62,7 +67,7 @@ export const ExternalEmbed = ({
         </Container>
       ) : linkInfo ? (
         <View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
-          <ExternalLinkEmbed link={linkInfo} />
+          <ExternalLinkEmbed link={linkInfo} hideAlt />
         </View>
       ) : null}
       <TouchableOpacity
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
new file mode 100644
index 000000000..9e41a328f
--- /dev/null
+++ b/src/view/com/composer/GifAltText.tsx
@@ -0,0 +1,177 @@
+import React, {useCallback, useState} from 'react'
+import {TouchableOpacity, View} from 'react-native'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {ExternalEmbedDraft} from '#/lib/api'
+import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
+import {
+  EmbedPlayerParams,
+  parseEmbedPlayerFromUrl,
+} from '#/lib/strings/embed-player'
+import {enforceLen} from '#/lib/strings/helpers'
+import {isAndroid} from '#/platform/detection'
+import {Gif} from '#/state/queries/tenor'
+import {atoms as a, native, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Text} from '#/components/Typography'
+import {GifEmbed} from '../util/post-embeds/GifEmbed'
+import {AltTextReminder} from './photos/Gallery'
+
+export function GifAltText({
+  link: linkProp,
+  gif,
+  onSubmit,
+}: {
+  link: ExternalEmbedDraft
+  gif?: Gif
+  onSubmit: (alt: string) => void
+}) {
+  const control = Dialog.useDialogControl()
+  const {_} = useLingui()
+  const t = useTheme()
+
+  const {link, params} = React.useMemo(() => {
+    return {
+      link: {
+        title: linkProp.meta?.title ?? linkProp.uri,
+        uri: linkProp.uri,
+        description: linkProp.meta?.description ?? '',
+        thumb: linkProp.localThumb?.path,
+      },
+      params: parseEmbedPlayerFromUrl(linkProp.uri),
+    }
+  }, [linkProp])
+
+  const onPressSubmit = useCallback(
+    (alt: string) => {
+      control.close(() => {
+        onSubmit(alt)
+      })
+    },
+    [onSubmit, control],
+  )
+
+  if (!gif || !params) return null
+
+  return (
+    <>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Add alt text`)}
+        accessibilityHint=""
+        hitSlop={HITSLOP_10}
+        onPress={control.open}
+        style={[
+          a.absolute,
+          {top: 20, left: 12},
+          {borderRadius: 6},
+          a.pl_xs,
+          a.pr_sm,
+          a.py_2xs,
+          a.flex_row,
+          a.gap_xs,
+          a.align_center,
+          {backgroundColor: 'rgba(0, 0, 0, 0.75)'},
+        ]}>
+        {link.description ? (
+          <Check size="xs" fill={t.palette.white} style={a.ml_xs} />
+        ) : (
+          <Plus size="sm" fill={t.palette.white} />
+        )}
+        <Text
+          style={[a.font_bold, {color: t.palette.white}]}
+          accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+
+      <AltTextReminder />
+
+      <Dialog.Outer
+        control={control}
+        nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
+        <Dialog.Handle />
+        <AltTextInner
+          onSubmit={onPressSubmit}
+          link={link}
+          params={params}
+          initalValue={link.description.replace('Alt text: ', '')}
+          key={link.uri}
+        />
+      </Dialog.Outer>
+    </>
+  )
+}
+
+function AltTextInner({
+  onSubmit,
+  link,
+  params,
+  initalValue,
+}: {
+  onSubmit: (text: string) => void
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+  initalValue: string
+}) {
+  const {_} = useLingui()
+  const [altText, setAltText] = useState(initalValue)
+
+  const onPressSubmit = useCallback(() => {
+    onSubmit(altText)
+  }, [onSubmit, altText])
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Add alt text`)}>
+      <View style={a.flex_col_reverse}>
+        <View style={[a.mt_md, a.gap_md]}>
+          <View>
+            <TextField.LabelText>
+              <Trans>Descriptive alt text</Trans>
+            </TextField.LabelText>
+            <TextField.Root>
+              <Dialog.Input
+                label={_(msg`Alt text`)}
+                placeholder={link.title}
+                onChangeText={text =>
+                  setAltText(enforceLen(text, MAX_ALT_TEXT))
+                }
+                value={altText}
+                multiline
+                numberOfLines={3}
+                autoFocus
+              />
+            </TextField.Root>
+          </View>
+          <Button
+            label={_(msg`Save`)}
+            size="medium"
+            color="primary"
+            variant="solid"
+            onPress={onPressSubmit}>
+            <ButtonText>
+              <Trans>Save</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+        {/* below the text input to force tab order */}
+        <View>
+          <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
+            <Trans>Add ALT text</Trans>
+          </Text>
+          <View style={[a.w_full, a.align_center, native({maxHeight: 200})]}>
+            <GifEmbed link={link} params={params} hideAlt />
+          </View>
+        </View>
+      </View>
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 69c8debb0..7ff1b7b9a 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -1,19 +1,20 @@
 import React, {useState} from 'react'
 import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
-import {GalleryModel} from 'state/models/media/gallery'
-import {observer} from 'mobx-react-lite'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {s, colors} from 'lib/styles'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {Image} from 'expo-image'
-import {Text} from 'view/com/util/text/Text'
-import {Dimensions} from 'lib/media/types'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {Trans, msg} from '@lingui/macro'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {observer} from 'mobx-react-lite'
+
 import {useModalControls} from '#/state/modals'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Dimensions} from 'lib/media/types'
+import {colors, s} from 'lib/styles'
 import {isNative} from 'platform/detection'
+import {GalleryModel} from 'state/models/media/gallery'
+import {Text} from 'view/com/util/text/Text'
+import {useTheme} from '#/alf'
 
 const IMAGE_GAP = 8
 
@@ -49,10 +50,10 @@ const GalleryInner = observer(function GalleryImpl({
   gallery,
   containerInfo,
 }: GalleryInnerProps) {
-  const pal = usePalette('default')
   const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
   const {openModal} = useModalControls()
+  const t = useTheme()
 
   let side: number
 
@@ -126,16 +127,22 @@ const GalleryInner = observer(function GalleryImpl({
                 })
               }}
               style={[styles.altTextControl, altTextControlStyle]}>
-              <Text style={styles.altTextControlLabel} accessible={false}>
-                <Trans>ALT</Trans>
-              </Text>
               {image.altText.length > 0 ? (
                 <FontAwesomeIcon
                   icon="check"
                   size={10}
-                  style={{color: colors.green3}}
+                  style={{color: t.palette.white}}
+                />
+              ) : (
+                <FontAwesomeIcon
+                  icon="plus"
+                  size={10}
+                  style={{color: t.palette.white}}
                 />
-              ) : undefined}
+              )}
+              <Text style={styles.altTextControlLabel} accessible={false}>
+                <Trans>ALT</Trans>
+              </Text>
             </TouchableOpacity>
             <View style={imageControlsStyle}>
               <TouchableOpacity
@@ -201,21 +208,28 @@ const GalleryInner = observer(function GalleryImpl({
           </View>
         ))}
       </View>
-      <View style={[styles.reminder]}>
-        <View style={[styles.infoIcon, pal.viewLight]}>
-          <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} />
-        </View>
-        <Text type="sm" style={[pal.textLight, s.flex1]}>
-          <Trans>
-            Alt text describes images for blind and low-vision users, and helps
-            give context to everyone.
-          </Trans>
-        </Text>
-      </View>
+      <AltTextReminder />
     </>
   ) : null
 })
 
+export function AltTextReminder() {
+  const t = useTheme()
+  return (
+    <View style={[styles.reminder]}>
+      <View style={[styles.infoIcon, t.atoms.bg_contrast_25]}>
+        <FontAwesomeIcon icon="info" size={12} color={t.atoms.text.color} />
+      </View>
+      <Text type="sm" style={[t.atoms.text_contrast_medium, s.flex1]}>
+        <Trans>
+          Alt text describes images for blind and low-vision users, and helps
+          give context to everyone.
+        </Trans>
+      </Text>
+    </View>
+  )
+}
+
 const styles = StyleSheet.create({
   gallery: {
     flex: 1,
@@ -244,6 +258,7 @@ const styles = StyleSheet.create({
     paddingVertical: 3,
     flexDirection: 'row',
     alignItems: 'center',
+    gap: 4,
   },
   altTextControlLabel: {
     color: 'white',
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 7de3b093a..f6d2c7a1b 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -1,9 +1,10 @@
-import {AppBskyEmbedImages} from '@atproto/api'
 import React, {ComponentProps, FC} from 'react'
-import {StyleSheet, Text, Pressable, View} from 'react-native'
+import {Pressable, StyleSheet, Text, View} from 'react-native'
 import {Image} from 'expo-image'
+import {AppBskyEmbedImages} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+
 import {isWeb} from 'platform/detection'
 
 type EventFunction = (index: number) => void
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index 1fe75c44e..b84c04b83 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -20,9 +20,11 @@ import {Text} from '../text/Text'
 export const ExternalLinkEmbed = ({
   link,
   style,
+  hideAlt,
 }: {
   link: AppBskyEmbedExternal.ViewExternal
   style?: StyleProp<ViewStyle>
+  hideAlt?: boolean
 }) => {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
@@ -37,7 +39,7 @@ export const ExternalLinkEmbed = ({
   }, [link.uri, externalEmbedPrefs])
 
   if (embedPlayerParams?.source === 'tenor') {
-    return <GifEmbed params={embedPlayerParams} link={link} />
+    return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} />
   }
 
   return (
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx
index 5d21ce064..dde6efe34 100644
--- a/src/view/com/util/post-embeds/GifEmbed.tsx
+++ b/src/view/com/util/post-embeds/GifEmbed.tsx
@@ -1,14 +1,18 @@
 import React from 'react'
-import {Pressable, View} from 'react-native'
+import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {AppBskyEmbedExternal} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {HITSLOP_10} from '#/lib/constants'
+import {isWeb} from '#/platform/detection'
 import {EmbedPlayerParams} from 'lib/strings/embed-player'
 import {useAutoplayDisabled} from 'state/preferences'
 import {atoms as a, useTheme} from '#/alf'
 import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
 import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
 import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
 
@@ -82,9 +86,11 @@ function PlaybackControls({
 export function GifEmbed({
   params,
   link,
+  hideAlt,
 }: {
   params: EmbedPlayerParams
   link: AppBskyEmbedExternal.ViewExternal
+  hideAlt?: boolean
 }) {
   const {_} = useLingui()
   const autoplayDisabled = useAutoplayDisabled()
@@ -111,7 +117,8 @@ export function GifEmbed({
   }, [])
 
   return (
-    <View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
+    <View
+      style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {maxWidth: '100%'}]}>
       <View
         style={[
           a.rounded_sm,
@@ -133,9 +140,69 @@ export function GifEmbed({
           onPlayerStateChange={onPlayerStateChange}
           ref={playerRef}
           accessibilityHint={_(msg`Animated GIF`)}
-          accessibilityLabel={link.description.replace('ALT: ', '')}
+          accessibilityLabel={link.description.replace('Alt text: ', '')}
         />
+
+        {!hideAlt && link.description.startsWith('Alt text: ') && (
+          <AltText text={link.description.replace('Alt text: ', '')} />
+        )}
       </View>
     </View>
   )
 }
+
+function AltText({text}: {text: string}) {
+  const control = Prompt.usePromptControl()
+
+  const {_} = useLingui()
+  return (
+    <>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Show alt text`)}
+        accessibilityHint=""
+        hitSlop={HITSLOP_10}
+        onPress={control.open}
+        style={styles.altContainer}>
+        <Text style={styles.alt} accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+
+      <Prompt.Outer control={control}>
+        <Prompt.TitleText>
+          <Trans>Alt Text</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText>{text}</Prompt.DescriptionText>
+        <Prompt.Actions>
+          <Prompt.Action
+            onPress={control.close}
+            cta={_(msg`Close`)}
+            color="secondary"
+          />
+        </Prompt.Actions>
+      </Prompt.Outer>
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  altContainer: {
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderRadius: 6,
+    paddingHorizontal: 6,
+    paddingVertical: 3,
+    position: 'absolute',
+    // Related to margin/gap hack. This keeps the alt label in the same position
+    // on all platforms
+    left: isWeb ? 8 : 5,
+    bottom: isWeb ? 8 : 5,
+    zIndex: 2,
+  },
+  alt: {
+    color: 'white',
+    fontSize: 10,
+    fontWeight: 'bold',
+  },
+})