Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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

authored by

Samuel Newman and committed by
GitHub
c33c3b7d ae7626ce

+344 -47
+3 -1
src/components/Prompt.tsx
··· 43 43 <Dialog.ScrollableInner 44 44 accessibilityLabelledBy={titleId} 45 45 accessibilityDescribedBy={descriptionId} 46 - style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}> 46 + style={[ 47 + gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, 48 + ]}> 47 49 {children} 48 50 </Dialog.ScrollableInner> 49 51 </Context.Provider>
+2 -2
src/components/dialogs/MutedWords.tsx
··· 37 37 return ( 38 38 <Dialog.Outer control={control}> 39 39 <Dialog.Handle /> 40 - <MutedWordsInner control={control} /> 40 + <MutedWordsInner /> 41 41 </Dialog.Outer> 42 42 ) 43 43 } 44 44 45 - function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { 45 + function MutedWordsInner() { 46 46 const t = useTheme() 47 47 const {_} = useLingui() 48 48 const {gtMobile} = useBreakpoints()
+37 -9
src/view/com/composer/Composer.tsx
··· 59 59 import {UserAvatar} from '../util/UserAvatar' 60 60 import {CharProgress} from './char-progress/CharProgress' 61 61 import {ExternalEmbed} from './ExternalEmbed' 62 + import {GifAltText} from './GifAltText' 62 63 import {LabelsBtn} from './labels/LabelsBtn' 63 64 import {Gallery} from './photos/Gallery' 64 65 import {OpenCameraBtn} from './photos/OpenCameraBtn' ··· 327 328 image: gif.media_formats.preview.url, 328 329 likelyType: LikelyType.HTML, 329 330 title: gif.content_description, 330 - description: `ALT: ${gif.content_description}`, 331 + description: '', 331 332 }, 332 333 }) 333 334 setExtGif(gif) 335 + }, 336 + [setExtLink], 337 + ) 338 + 339 + const handleChangeGifAltText = useCallback( 340 + (altText: string) => { 341 + setExtLink(ext => 342 + ext && ext.meta 343 + ? { 344 + ...ext, 345 + meta: { 346 + ...ext?.meta, 347 + description: 348 + altText.trim().length === 0 349 + ? '' 350 + : `Alt text: ${altText.trim()}`, 351 + }, 352 + } 353 + : ext, 354 + ) 334 355 }, 335 356 [setExtLink], 336 357 ) ··· 474 495 475 496 <Gallery gallery={gallery} /> 476 497 {gallery.isEmpty && extLink && ( 477 - <ExternalEmbed 478 - link={extLink} 479 - gif={extGif} 480 - onRemove={() => { 481 - setExtLink(undefined) 482 - setExtGif(undefined) 483 - }} 484 - /> 498 + <View style={a.relative}> 499 + <ExternalEmbed 500 + link={extLink} 501 + gif={extGif} 502 + onRemove={() => { 503 + setExtLink(undefined) 504 + setExtGif(undefined) 505 + }} 506 + /> 507 + <GifAltText 508 + link={extLink} 509 + gif={extGif} 510 + onSubmit={handleChangeGifAltText} 511 + /> 512 + </View> 485 513 )} 486 514 {quote ? ( 487 515 <View style={[s.mt5, isWeb && s.mb10]}>
+7 -2
src/view/com/composer/ExternalEmbed.tsx
··· 46 46 : undefined 47 47 48 48 return ( 49 - <View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}> 49 + <View 50 + style={[ 51 + !gif && a.mb_xl, 52 + a.overflow_hidden, 53 + t.atoms.border_contrast_medium, 54 + ]}> 50 55 {link.isLoading ? ( 51 56 <Container style={loadingStyle}> 52 57 <Loader size="xl" /> ··· 62 67 </Container> 63 68 ) : linkInfo ? ( 64 69 <View style={{pointerEvents: !gif ? 'none' : 'auto'}}> 65 - <ExternalLinkEmbed link={linkInfo} /> 70 + <ExternalLinkEmbed link={linkInfo} hideAlt /> 66 71 </View> 67 72 ) : null} 68 73 <TouchableOpacity
+177
src/view/com/composer/GifAltText.tsx
··· 1 + import React, {useCallback, useState} from 'react' 2 + import {TouchableOpacity, View} from 'react-native' 3 + import {AppBskyEmbedExternal} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {ExternalEmbedDraft} from '#/lib/api' 8 + import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants' 9 + import { 10 + EmbedPlayerParams, 11 + parseEmbedPlayerFromUrl, 12 + } from '#/lib/strings/embed-player' 13 + import {enforceLen} from '#/lib/strings/helpers' 14 + import {isAndroid} from '#/platform/detection' 15 + import {Gif} from '#/state/queries/tenor' 16 + import {atoms as a, native, useTheme} from '#/alf' 17 + import {Button, ButtonText} from '#/components/Button' 18 + import * as Dialog from '#/components/Dialog' 19 + import * as TextField from '#/components/forms/TextField' 20 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 21 + import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 22 + import {Text} from '#/components/Typography' 23 + import {GifEmbed} from '../util/post-embeds/GifEmbed' 24 + import {AltTextReminder} from './photos/Gallery' 25 + 26 + export function GifAltText({ 27 + link: linkProp, 28 + gif, 29 + onSubmit, 30 + }: { 31 + link: ExternalEmbedDraft 32 + gif?: Gif 33 + onSubmit: (alt: string) => void 34 + }) { 35 + const control = Dialog.useDialogControl() 36 + const {_} = useLingui() 37 + const t = useTheme() 38 + 39 + const {link, params} = React.useMemo(() => { 40 + return { 41 + link: { 42 + title: linkProp.meta?.title ?? linkProp.uri, 43 + uri: linkProp.uri, 44 + description: linkProp.meta?.description ?? '', 45 + thumb: linkProp.localThumb?.path, 46 + }, 47 + params: parseEmbedPlayerFromUrl(linkProp.uri), 48 + } 49 + }, [linkProp]) 50 + 51 + const onPressSubmit = useCallback( 52 + (alt: string) => { 53 + control.close(() => { 54 + onSubmit(alt) 55 + }) 56 + }, 57 + [onSubmit, control], 58 + ) 59 + 60 + if (!gif || !params) return null 61 + 62 + return ( 63 + <> 64 + <TouchableOpacity 65 + testID="altTextButton" 66 + accessibilityRole="button" 67 + accessibilityLabel={_(msg`Add alt text`)} 68 + accessibilityHint="" 69 + hitSlop={HITSLOP_10} 70 + onPress={control.open} 71 + style={[ 72 + a.absolute, 73 + {top: 20, left: 12}, 74 + {borderRadius: 6}, 75 + a.pl_xs, 76 + a.pr_sm, 77 + a.py_2xs, 78 + a.flex_row, 79 + a.gap_xs, 80 + a.align_center, 81 + {backgroundColor: 'rgba(0, 0, 0, 0.75)'}, 82 + ]}> 83 + {link.description ? ( 84 + <Check size="xs" fill={t.palette.white} style={a.ml_xs} /> 85 + ) : ( 86 + <Plus size="sm" fill={t.palette.white} /> 87 + )} 88 + <Text 89 + style={[a.font_bold, {color: t.palette.white}]} 90 + accessible={false}> 91 + <Trans>ALT</Trans> 92 + </Text> 93 + </TouchableOpacity> 94 + 95 + <AltTextReminder /> 96 + 97 + <Dialog.Outer 98 + control={control} 99 + nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}> 100 + <Dialog.Handle /> 101 + <AltTextInner 102 + onSubmit={onPressSubmit} 103 + link={link} 104 + params={params} 105 + initalValue={link.description.replace('Alt text: ', '')} 106 + key={link.uri} 107 + /> 108 + </Dialog.Outer> 109 + </> 110 + ) 111 + } 112 + 113 + function AltTextInner({ 114 + onSubmit, 115 + link, 116 + params, 117 + initalValue, 118 + }: { 119 + onSubmit: (text: string) => void 120 + link: AppBskyEmbedExternal.ViewExternal 121 + params: EmbedPlayerParams 122 + initalValue: string 123 + }) { 124 + const {_} = useLingui() 125 + const [altText, setAltText] = useState(initalValue) 126 + 127 + const onPressSubmit = useCallback(() => { 128 + onSubmit(altText) 129 + }, [onSubmit, altText]) 130 + 131 + return ( 132 + <Dialog.ScrollableInner label={_(msg`Add alt text`)}> 133 + <View style={a.flex_col_reverse}> 134 + <View style={[a.mt_md, a.gap_md]}> 135 + <View> 136 + <TextField.LabelText> 137 + <Trans>Descriptive alt text</Trans> 138 + </TextField.LabelText> 139 + <TextField.Root> 140 + <Dialog.Input 141 + label={_(msg`Alt text`)} 142 + placeholder={link.title} 143 + onChangeText={text => 144 + setAltText(enforceLen(text, MAX_ALT_TEXT)) 145 + } 146 + value={altText} 147 + multiline 148 + numberOfLines={3} 149 + autoFocus 150 + /> 151 + </TextField.Root> 152 + </View> 153 + <Button 154 + label={_(msg`Save`)} 155 + size="medium" 156 + color="primary" 157 + variant="solid" 158 + onPress={onPressSubmit}> 159 + <ButtonText> 160 + <Trans>Save</Trans> 161 + </ButtonText> 162 + </Button> 163 + </View> 164 + {/* below the text input to force tab order */} 165 + <View> 166 + <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}> 167 + <Trans>Add ALT text</Trans> 168 + </Text> 169 + <View style={[a.w_full, a.align_center, native({maxHeight: 200})]}> 170 + <GifEmbed link={link} params={params} hideAlt /> 171 + </View> 172 + </View> 173 + </View> 174 + <Dialog.Close /> 175 + </Dialog.ScrollableInner> 176 + ) 177 + }
+41 -26
src/view/com/composer/photos/Gallery.tsx
··· 1 1 import React, {useState} from 'react' 2 2 import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native' 3 - import {GalleryModel} from 'state/models/media/gallery' 4 - import {observer} from 'mobx-react-lite' 5 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 6 - import {s, colors} from 'lib/styles' 7 3 import {StyleSheet, TouchableOpacity, View} from 'react-native' 8 4 import {Image} from 'expo-image' 9 - import {Text} from 'view/com/util/text/Text' 10 - import {Dimensions} from 'lib/media/types' 11 - import {usePalette} from 'lib/hooks/usePalette' 12 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 13 - import {Trans, msg} from '@lingui/macro' 5 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 6 + import {msg, Trans} from '@lingui/macro' 14 7 import {useLingui} from '@lingui/react' 8 + import {observer} from 'mobx-react-lite' 9 + 15 10 import {useModalControls} from '#/state/modals' 11 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 12 + import {Dimensions} from 'lib/media/types' 13 + import {colors, s} from 'lib/styles' 16 14 import {isNative} from 'platform/detection' 15 + import {GalleryModel} from 'state/models/media/gallery' 16 + import {Text} from 'view/com/util/text/Text' 17 + import {useTheme} from '#/alf' 17 18 18 19 const IMAGE_GAP = 8 19 20 ··· 49 50 gallery, 50 51 containerInfo, 51 52 }: GalleryInnerProps) { 52 - const pal = usePalette('default') 53 53 const {_} = useLingui() 54 54 const {isMobile} = useWebMediaQueries() 55 55 const {openModal} = useModalControls() 56 + const t = useTheme() 56 57 57 58 let side: number 58 59 ··· 126 127 }) 127 128 }} 128 129 style={[styles.altTextControl, altTextControlStyle]}> 129 - <Text style={styles.altTextControlLabel} accessible={false}> 130 - <Trans>ALT</Trans> 131 - </Text> 132 130 {image.altText.length > 0 ? ( 133 131 <FontAwesomeIcon 134 132 icon="check" 135 133 size={10} 136 - style={{color: colors.green3}} 134 + style={{color: t.palette.white}} 135 + /> 136 + ) : ( 137 + <FontAwesomeIcon 138 + icon="plus" 139 + size={10} 140 + style={{color: t.palette.white}} 137 141 /> 138 - ) : undefined} 142 + )} 143 + <Text style={styles.altTextControlLabel} accessible={false}> 144 + <Trans>ALT</Trans> 145 + </Text> 139 146 </TouchableOpacity> 140 147 <View style={imageControlsStyle}> 141 148 <TouchableOpacity ··· 201 208 </View> 202 209 ))} 203 210 </View> 204 - <View style={[styles.reminder]}> 205 - <View style={[styles.infoIcon, pal.viewLight]}> 206 - <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} /> 207 - </View> 208 - <Text type="sm" style={[pal.textLight, s.flex1]}> 209 - <Trans> 210 - Alt text describes images for blind and low-vision users, and helps 211 - give context to everyone. 212 - </Trans> 213 - </Text> 214 - </View> 211 + <AltTextReminder /> 215 212 </> 216 213 ) : null 217 214 }) 215 + 216 + export function AltTextReminder() { 217 + const t = useTheme() 218 + return ( 219 + <View style={[styles.reminder]}> 220 + <View style={[styles.infoIcon, t.atoms.bg_contrast_25]}> 221 + <FontAwesomeIcon icon="info" size={12} color={t.atoms.text.color} /> 222 + </View> 223 + <Text type="sm" style={[t.atoms.text_contrast_medium, s.flex1]}> 224 + <Trans> 225 + Alt text describes images for blind and low-vision users, and helps 226 + give context to everyone. 227 + </Trans> 228 + </Text> 229 + </View> 230 + ) 231 + } 218 232 219 233 const styles = StyleSheet.create({ 220 234 gallery: { ··· 244 258 paddingVertical: 3, 245 259 flexDirection: 'row', 246 260 alignItems: 'center', 261 + gap: 4, 247 262 }, 248 263 altTextControlLabel: { 249 264 color: 'white',
+3 -2
src/view/com/util/images/Gallery.tsx
··· 1 - import {AppBskyEmbedImages} from '@atproto/api' 2 1 import React, {ComponentProps, FC} from 'react' 3 - import {StyleSheet, Text, Pressable, View} from 'react-native' 2 + import {Pressable, StyleSheet, Text, View} from 'react-native' 4 3 import {Image} from 'expo-image' 4 + import {AppBskyEmbedImages} from '@atproto/api' 5 5 import {msg} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 + 7 8 import {isWeb} from 'platform/detection' 8 9 9 10 type EventFunction = (index: number) => void
+3 -1
src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
··· 20 20 export const ExternalLinkEmbed = ({ 21 21 link, 22 22 style, 23 + hideAlt, 23 24 }: { 24 25 link: AppBskyEmbedExternal.ViewExternal 25 26 style?: StyleProp<ViewStyle> 27 + hideAlt?: boolean 26 28 }) => { 27 29 const pal = usePalette('default') 28 30 const {isMobile} = useWebMediaQueries() ··· 37 39 }, [link.uri, externalEmbedPrefs]) 38 40 39 41 if (embedPlayerParams?.source === 'tenor') { 40 - return <GifEmbed params={embedPlayerParams} link={link} /> 42 + return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} /> 41 43 } 42 44 43 45 return (
+71 -4
src/view/com/util/post-embeds/GifEmbed.tsx
··· 1 1 import React from 'react' 2 - import {Pressable, View} from 'react-native' 2 + import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native' 3 3 import {AppBskyEmbedExternal} from '@atproto/api' 4 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 - import {msg} from '@lingui/macro' 5 + import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 + import {HITSLOP_10} from '#/lib/constants' 9 + import {isWeb} from '#/platform/detection' 8 10 import {EmbedPlayerParams} from 'lib/strings/embed-player' 9 11 import {useAutoplayDisabled} from 'state/preferences' 10 12 import {atoms as a, useTheme} from '#/alf' 11 13 import {Loader} from '#/components/Loader' 14 + import * as Prompt from '#/components/Prompt' 15 + import {Text} from '#/components/Typography' 12 16 import {GifView} from '../../../../../modules/expo-bluesky-gif-view' 13 17 import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' 14 18 ··· 82 86 export function GifEmbed({ 83 87 params, 84 88 link, 89 + hideAlt, 85 90 }: { 86 91 params: EmbedPlayerParams 87 92 link: AppBskyEmbedExternal.ViewExternal 93 + hideAlt?: boolean 88 94 }) { 89 95 const {_} = useLingui() 90 96 const autoplayDisabled = useAutoplayDisabled() ··· 111 117 }, []) 112 118 113 119 return ( 114 - <View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}> 120 + <View 121 + style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {maxWidth: '100%'}]}> 115 122 <View 116 123 style={[ 117 124 a.rounded_sm, ··· 133 140 onPlayerStateChange={onPlayerStateChange} 134 141 ref={playerRef} 135 142 accessibilityHint={_(msg`Animated GIF`)} 136 - accessibilityLabel={link.description.replace('ALT: ', '')} 143 + accessibilityLabel={link.description.replace('Alt text: ', '')} 137 144 /> 145 + 146 + {!hideAlt && link.description.startsWith('Alt text: ') && ( 147 + <AltText text={link.description.replace('Alt text: ', '')} /> 148 + )} 138 149 </View> 139 150 </View> 140 151 ) 141 152 } 153 + 154 + function AltText({text}: {text: string}) { 155 + const control = Prompt.usePromptControl() 156 + 157 + const {_} = useLingui() 158 + return ( 159 + <> 160 + <TouchableOpacity 161 + testID="altTextButton" 162 + accessibilityRole="button" 163 + accessibilityLabel={_(msg`Show alt text`)} 164 + accessibilityHint="" 165 + hitSlop={HITSLOP_10} 166 + onPress={control.open} 167 + style={styles.altContainer}> 168 + <Text style={styles.alt} accessible={false}> 169 + <Trans>ALT</Trans> 170 + </Text> 171 + </TouchableOpacity> 172 + 173 + <Prompt.Outer control={control}> 174 + <Prompt.TitleText> 175 + <Trans>Alt Text</Trans> 176 + </Prompt.TitleText> 177 + <Prompt.DescriptionText>{text}</Prompt.DescriptionText> 178 + <Prompt.Actions> 179 + <Prompt.Action 180 + onPress={control.close} 181 + cta={_(msg`Close`)} 182 + color="secondary" 183 + /> 184 + </Prompt.Actions> 185 + </Prompt.Outer> 186 + </> 187 + ) 188 + } 189 + 190 + const styles = StyleSheet.create({ 191 + altContainer: { 192 + backgroundColor: 'rgba(0, 0, 0, 0.75)', 193 + borderRadius: 6, 194 + paddingHorizontal: 6, 195 + paddingVertical: 3, 196 + position: 'absolute', 197 + // Related to margin/gap hack. This keeps the alt label in the same position 198 + // on all platforms 199 + left: isWeb ? 8 : 5, 200 + bottom: isWeb ? 8 : 5, 201 + zIndex: 2, 202 + }, 203 + alt: { 204 + color: 'white', 205 + fontSize: 10, 206 + fontWeight: 'bold', 207 + }, 208 + })