Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add alt text limit to image dialog (#5611)

* Add alt text limit to image dialog

* GIF alt text too

* Fix

* tweaks, save alt on dialog dismiss

* simplify close behavior

* use state in gif alt

* state

---------

Co-authored-by: Hailey <me@haileyok.com>

authored by

Eric Bailey
Hailey
and committed by
GitHub
76d63f99 6dfd57e6

+231 -118
+1 -1
src/lib/constants.ts
··· 50 50 51 51 // Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html 52 52 // but increasing limit per user feedback 53 - export const MAX_ALT_TEXT = 1000 53 + export const MAX_ALT_TEXT = 2000 54 54 55 55 export function IS_TEST_USER(handle?: string) { 56 56 return handle && handle?.endsWith('.test')
+33
src/view/com/composer/AltTextCounterWrapper.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + 4 + import {MAX_ALT_TEXT} from '#/lib/constants' 5 + import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 6 + import {atoms as a, useTheme} from '#/alf' 7 + 8 + export function AltTextCounterWrapper({ 9 + altText, 10 + children, 11 + }: { 12 + altText?: string 13 + children: React.ReactNode 14 + }) { 15 + const t = useTheme() 16 + return ( 17 + <View style={[a.flex_row]}> 18 + <CharProgress 19 + style={[ 20 + a.flex_col_reverse, 21 + a.align_center, 22 + a.mr_xs, 23 + {minWidth: 50, gap: 1}, 24 + ]} 25 + textStyle={[{marginRight: 0}, a.text_sm, t.atoms.text_contrast_medium]} 26 + size={26} 27 + count={altText?.length || 0} 28 + max={MAX_ALT_TEXT} 29 + /> 30 + {children} 31 + </View> 32 + ) 33 + }
+80 -57
src/view/com/composer/GifAltText.tsx
··· 1 - import React, {useCallback, useState} from 'react' 1 + import React, {useState} from 'react' 2 2 import {TouchableOpacity, View} from 'react-native' 3 3 import {AppBskyEmbedExternal} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' ··· 11 11 EmbedPlayerParams, 12 12 parseEmbedPlayerFromUrl, 13 13 } from '#/lib/strings/embed-player' 14 - import {enforceLen} from '#/lib/strings/helpers' 15 14 import {isAndroid} from '#/platform/detection' 16 15 import {Gif} from '#/state/queries/tenor' 16 + import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' 17 17 import {atoms as a, native, useTheme} from '#/alf' 18 18 import {Button, ButtonText} from '#/components/Button' 19 19 import * as Dialog from '#/components/Dialog' 20 + import {DialogControlProps} from '#/components/Dialog' 20 21 import * as TextField from '#/components/forms/TextField' 21 22 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 23 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 22 24 import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 23 25 import {PortalComponent} from '#/components/Portal' 24 26 import {Text} from '#/components/Typography' ··· 52 54 } 53 55 }, [linkProp]) 54 56 55 - const onPressSubmit = useCallback( 56 - (alt: string) => { 57 - control.close(() => { 58 - onSubmit(alt) 59 - }) 60 - }, 61 - [onSubmit, control], 62 - ) 57 + const parsedAlt = parseAltFromGIFDescription(link.description) 58 + const [altText, setAltText] = useState(parsedAlt.alt) 63 59 64 60 if (!gif || !params) return null 65 61 66 - const parsedAlt = parseAltFromGIFDescription(link.description) 67 62 return ( 68 63 <> 69 64 <TouchableOpacity ··· 99 94 100 95 <AltTextReminder /> 101 96 102 - <Dialog.Outer control={control} Portal={Portal}> 97 + <Dialog.Outer 98 + control={control} 99 + onClose={() => { 100 + onSubmit(altText) 101 + }} 102 + Portal={Portal}> 103 103 <Dialog.Handle /> 104 104 <AltTextInner 105 - onSubmit={onPressSubmit} 105 + altText={altText} 106 + setAltText={setAltText} 107 + control={control} 106 108 link={link} 107 109 params={params} 108 - initialValue={parsedAlt.isPreferred ? parsedAlt.alt : ''} 109 110 key={link.uri} 110 111 /> 111 112 </Dialog.Outer> ··· 114 115 } 115 116 116 117 function AltTextInner({ 117 - onSubmit, 118 + altText, 119 + setAltText, 120 + control, 118 121 link, 119 122 params, 120 - initialValue: initalValue, 121 123 }: { 122 - onSubmit: (text: string) => void 124 + altText: string 125 + setAltText: (text: string) => void 126 + control: DialogControlProps 123 127 link: AppBskyEmbedExternal.ViewExternal 124 128 params: EmbedPlayerParams 125 - initialValue: string 126 129 }) { 127 - const {_} = useLingui() 128 - const [altText, setAltText] = useState(initalValue) 129 - const control = Dialog.useDialogContext() 130 - 131 - const onPressSubmit = useCallback(() => { 132 - onSubmit(altText) 133 - }, [onSubmit, altText]) 130 + const t = useTheme() 131 + const {_, i18n} = useLingui() 134 132 135 133 return ( 136 134 <Dialog.ScrollableInner label={_(msg`Add alt text`)}> 137 135 <View style={a.flex_col_reverse}> 138 136 <View style={[a.mt_md, a.gap_md]}> 139 - <View> 140 - <TextField.LabelText> 141 - <Trans>Descriptive alt text</Trans> 142 - </TextField.LabelText> 143 - <TextField.Root> 144 - <Dialog.Input 145 - label={_(msg`Alt text`)} 146 - placeholder={link.title} 147 - onChangeText={text => 148 - setAltText(enforceLen(text, MAX_ALT_TEXT)) 149 - } 150 - value={altText} 151 - multiline 152 - numberOfLines={3} 153 - autoFocus 154 - onKeyPress={({nativeEvent}) => { 155 - if (nativeEvent.key === 'Escape') { 156 - control.close() 157 - } 158 - }} 159 - /> 160 - </TextField.Root> 137 + <View style={[a.gap_sm]}> 138 + <View style={[a.relative]}> 139 + <TextField.LabelText> 140 + <Trans>Descriptive alt text</Trans> 141 + </TextField.LabelText> 142 + <TextField.Root> 143 + <Dialog.Input 144 + label={_(msg`Alt text`)} 145 + placeholder={link.title} 146 + onChangeText={text => { 147 + setAltText(text) 148 + }} 149 + defaultValue={altText} 150 + multiline 151 + numberOfLines={3} 152 + autoFocus 153 + onKeyPress={({nativeEvent}) => { 154 + if (nativeEvent.key === 'Escape') { 155 + control.close() 156 + } 157 + }} 158 + /> 159 + </TextField.Root> 160 + </View> 161 + 162 + {altText.length > MAX_ALT_TEXT && ( 163 + <View style={[a.pb_sm, a.flex_row, a.gap_xs]}> 164 + <CircleInfo fill={t.palette.negative_500} /> 165 + <Text 166 + style={[ 167 + a.italic, 168 + a.leading_snug, 169 + t.atoms.text_contrast_medium, 170 + ]}> 171 + <Trans> 172 + Alt text will be truncated. Limit:{' '} 173 + {i18n.number(MAX_ALT_TEXT)} characters. 174 + </Trans> 175 + </Text> 176 + </View> 177 + )} 161 178 </View> 162 - <Button 163 - label={_(msg`Save`)} 164 - size="large" 165 - color="primary" 166 - variant="solid" 167 - onPress={onPressSubmit}> 168 - <ButtonText> 169 - <Trans>Save</Trans> 170 - </ButtonText> 171 - </Button> 179 + 180 + <AltTextCounterWrapper altText={altText}> 181 + <Button 182 + label={_(msg`Save`)} 183 + size="large" 184 + color="primary" 185 + variant="solid" 186 + onPress={() => { 187 + control.close() 188 + }} 189 + style={[a.flex_grow]}> 190 + <ButtonText> 191 + <Trans>Save</Trans> 192 + </ButtonText> 193 + </Button> 194 + </AltTextCounterWrapper> 172 195 </View> 173 196 {/* below the text input to force tab order */} 174 197 <View>
+29 -21
src/view/com/composer/char-progress/CharProgress.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 2 + import {StyleProp, TextStyle, View, ViewStyle} from 'react-native' 3 3 // @ts-ignore no type definition -prf 4 4 import ProgressCircle from 'react-native-progress/Circle' 5 5 // @ts-ignore no type definition -prf 6 6 import ProgressPie from 'react-native-progress/Pie' 7 7 8 - import {MAX_GRAPHEME_LENGTH} from 'lib/constants' 9 - import {usePalette} from 'lib/hooks/usePalette' 10 - import {s} from 'lib/styles' 8 + import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' 9 + import {usePalette} from '#/lib/hooks/usePalette' 10 + import {s} from '#/lib/styles' 11 11 import {Text} from '../../util/text/Text' 12 12 13 - const DANGER_LENGTH = MAX_GRAPHEME_LENGTH 14 - 15 - export function CharProgress({count}: {count: number}) { 13 + export function CharProgress({ 14 + count, 15 + max, 16 + style, 17 + textStyle, 18 + size, 19 + }: { 20 + count: number 21 + max?: number 22 + style?: StyleProp<ViewStyle> 23 + textStyle?: StyleProp<TextStyle> 24 + size?: number 25 + }) { 26 + const maxLength = max || MAX_GRAPHEME_LENGTH 16 27 const pal = usePalette('default') 17 - const textColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.text 18 - const circleColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.link 28 + const textColor = count > maxLength ? '#e60000' : pal.colors.text 29 + const circleColor = count > maxLength ? '#e60000' : pal.colors.link 19 30 return ( 20 - <> 21 - <Text style={[s.mr10, s.tabularNum, {color: textColor}]}> 22 - {MAX_GRAPHEME_LENGTH - count} 31 + <View style={style}> 32 + <Text style={[s.mr10, s.tabularNum, {color: textColor}, textStyle]}> 33 + {maxLength - count} 23 34 </Text> 24 35 <View> 25 - {count > DANGER_LENGTH ? ( 36 + {count > maxLength ? ( 26 37 <ProgressPie 27 - size={30} 38 + size={size ?? 30} 28 39 borderWidth={4} 29 40 borderColor={circleColor} 30 41 color={circleColor} 31 - progress={Math.min( 32 - (count - MAX_GRAPHEME_LENGTH) / MAX_GRAPHEME_LENGTH, 33 - 1, 34 - )} 42 + progress={Math.min((count - maxLength) / maxLength, 1)} 35 43 /> 36 44 ) : ( 37 45 <ProgressCircle 38 - size={30} 46 + size={size ?? 30} 39 47 borderWidth={1} 40 48 borderColor={pal.colors.border} 41 49 color={circleColor} 42 - progress={count / MAX_GRAPHEME_LENGTH} 50 + progress={count / maxLength} 43 51 /> 44 52 )} 45 53 </View> 46 - </> 54 + </View> 47 55 ) 48 56 }
+88 -39
src/view/com/composer/photos/ImageAltTextDialog.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {MAX_ALT_TEXT} from '#/lib/constants' 8 + import {enforceLen} from '#/lib/strings/helpers' 8 9 import {isAndroid, isWeb} from '#/platform/detection' 9 10 import {ComposerImage} from '#/state/gallery' 11 + import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' 10 12 import {atoms as a, useTheme} from '#/alf' 11 13 import {Button, ButtonText} from '#/components/Button' 12 14 import * as Dialog from '#/components/Dialog' 15 + import {DialogControlProps} from '#/components/Dialog' 13 16 import * as TextField from '#/components/forms/TextField' 17 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 14 18 import {PortalComponent} from '#/components/Portal' 15 19 import {Text} from '#/components/Typography' 16 20 ··· 21 25 Portal: PortalComponent 22 26 } 23 27 24 - export const ImageAltTextDialog = (props: Props): React.ReactNode => { 28 + export const ImageAltTextDialog = ({ 29 + control, 30 + image, 31 + onChange, 32 + Portal, 33 + }: Props): React.ReactNode => { 34 + const [altText, setAltText] = React.useState(image.alt) 35 + 25 36 return ( 26 - <Dialog.Outer control={props.control} Portal={props.Portal}> 37 + <Dialog.Outer 38 + control={control} 39 + onClose={() => { 40 + onChange({ 41 + ...image, 42 + alt: enforceLen(altText, MAX_ALT_TEXT, true), 43 + }) 44 + }} 45 + Portal={Portal}> 27 46 <Dialog.Handle /> 28 - <ImageAltTextInner {...props} /> 47 + <ImageAltTextInner 48 + control={control} 49 + image={image} 50 + altText={altText} 51 + setAltText={setAltText} 52 + /> 29 53 </Dialog.Outer> 30 54 ) 31 55 } 32 56 33 57 const ImageAltTextInner = ({ 58 + altText, 59 + setAltText, 34 60 control, 35 61 image, 36 - onChange, 37 - }: Props): React.ReactNode => { 38 - const {_} = useLingui() 62 + }: { 63 + altText: string 64 + setAltText: (text: string) => void 65 + control: DialogControlProps 66 + image: Props['image'] 67 + }): React.ReactNode => { 68 + const {_, i18n} = useLingui() 39 69 const t = useTheme() 40 - 41 70 const windim = useWindowDimensions() 42 - 43 - const [altText, setAltText] = React.useState(image.alt) 44 - 45 - const onPressSubmit = React.useCallback(() => { 46 - control.close() 47 - onChange({...image, alt: altText.trim()}) 48 - }, [control, image, altText, onChange]) 49 71 50 72 const imageStyle = React.useMemo<ImageStyle>(() => { 51 73 const maxWidth = isWeb ? 450 : windim.width ··· 90 112 </View> 91 113 92 114 <View style={[a.mt_md, a.gap_md]}> 93 - <View> 94 - <TextField.LabelText> 95 - <Trans>Descriptive alt text</Trans> 96 - </TextField.LabelText> 97 - <TextField.Root> 98 - <Dialog.Input 99 - label={_(msg`Alt text`)} 100 - onChangeText={text => setAltText(text)} 101 - value={altText} 102 - multiline 103 - numberOfLines={3} 104 - autoFocus 105 - /> 106 - </TextField.Root> 115 + <View style={[a.gap_sm]}> 116 + <View style={[a.relative, {width: '100%'}]}> 117 + <TextField.LabelText> 118 + <Trans>Descriptive alt text</Trans> 119 + </TextField.LabelText> 120 + <TextField.Root> 121 + <Dialog.Input 122 + label={_(msg`Alt text`)} 123 + onChangeText={text => { 124 + setAltText(text) 125 + }} 126 + defaultValue={altText} 127 + multiline 128 + numberOfLines={3} 129 + autoFocus 130 + /> 131 + </TextField.Root> 132 + </View> 133 + 134 + {altText.length > MAX_ALT_TEXT && ( 135 + <View style={[a.pb_sm, a.flex_row, a.gap_xs]}> 136 + <CircleInfo fill={t.palette.negative_500} /> 137 + <Text 138 + style={[ 139 + a.italic, 140 + a.leading_snug, 141 + t.atoms.text_contrast_medium, 142 + ]}> 143 + <Trans> 144 + Alt text will be truncated. Limit: {i18n.number(MAX_ALT_TEXT)}{' '} 145 + characters. 146 + </Trans> 147 + </Text> 148 + </View> 149 + )} 107 150 </View> 108 - <Button 109 - label={_(msg`Save`)} 110 - disabled={altText.length > MAX_ALT_TEXT || altText === image.alt} 111 - size="large" 112 - color="primary" 113 - variant="solid" 114 - onPress={onPressSubmit}> 115 - <ButtonText> 116 - <Trans>Save</Trans> 117 - </ButtonText> 118 - </Button> 151 + 152 + <AltTextCounterWrapper altText={altText}> 153 + <Button 154 + label={_(msg`Save`)} 155 + disabled={altText === image.alt} 156 + size="large" 157 + color="primary" 158 + variant="solid" 159 + onPress={() => { 160 + control.close() 161 + }} 162 + style={[a.flex_grow]}> 163 + <ButtonText> 164 + <Trans>Save</Trans> 165 + </ButtonText> 166 + </Button> 167 + </AltTextCounterWrapper> 119 168 </View> 120 169 {/* Maybe fix this later -h */} 121 170 {isAndroid ? <View style={{height: 300}} /> : null}