232 lines
6.5 KiB
TypeScript
232 lines
6.5 KiB
TypeScript
import {useState} from 'react'
|
|
import {TouchableOpacity, View} from 'react-native'
|
|
import {msg} from '@lingui/core/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import {Plural, Trans} from '@lingui/react/macro'
|
|
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
|
|
|
import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
|
|
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
|
|
import {
|
|
type EmbedPlayerParams,
|
|
parseEmbedPlayerFromUrl,
|
|
} from '#/lib/strings/embed-player'
|
|
import {useResolveGifQuery} from '#/state/queries/resolve-link'
|
|
import {atoms as a, useTheme} from '#/alf'
|
|
import {Admonition} from '#/components/Admonition'
|
|
import {Button, ButtonText} from '#/components/Button'
|
|
import * as Dialog from '#/components/Dialog'
|
|
import {type DialogControlProps} from '#/components/Dialog'
|
|
import * as TextField from '#/components/forms/TextField'
|
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
|
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
|
import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif'
|
|
import {Text} from '#/components/Typography'
|
|
import {type Gif} from '#/features/gifPicker/types'
|
|
|
|
export function GifAltTextDialog({
|
|
gif,
|
|
altText,
|
|
onSubmit,
|
|
}: {
|
|
gif: Gif
|
|
altText: string
|
|
onSubmit: (alt: string) => void
|
|
}) {
|
|
const {data} = useResolveGifQuery(gif)
|
|
const vendorAltText = parseAltFromGIFDescription(data?.description ?? '').alt
|
|
const params = data ? parseEmbedPlayerFromUrl(data.uri) : undefined
|
|
if (!data || !params) {
|
|
return null
|
|
}
|
|
return (
|
|
<GifAltTextDialogLoaded
|
|
altText={altText}
|
|
vendorAltText={vendorAltText}
|
|
thumb={data.thumb?.source.path}
|
|
params={params}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function GifAltTextDialogLoaded({
|
|
vendorAltText,
|
|
altText,
|
|
onSubmit,
|
|
params,
|
|
thumb,
|
|
}: {
|
|
vendorAltText: string
|
|
altText: string
|
|
onSubmit: (alt: string) => void
|
|
params: EmbedPlayerParams
|
|
thumb: string | undefined
|
|
}) {
|
|
const control = Dialog.useDialogControl()
|
|
const {_} = useLingui()
|
|
const t = useTheme()
|
|
const [altTextDraft, setAltTextDraft] = useState(altText || vendorAltText)
|
|
return (
|
|
<>
|
|
<TouchableOpacity
|
|
testID="altTextButton"
|
|
accessibilityRole="button"
|
|
accessibilityLabel={_(msg`Add alt text`)}
|
|
accessibilityHint=""
|
|
hitSlop={HITSLOP_10}
|
|
onPress={control.open}
|
|
style={[
|
|
a.absolute,
|
|
{top: 8, left: 8},
|
|
{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)'},
|
|
]}>
|
|
{altText ? (
|
|
<Check size="xs" fill={t.palette.white} style={a.ml_xs} />
|
|
) : (
|
|
<Plus size="sm" fill={t.palette.white} />
|
|
)}
|
|
<Text
|
|
style={[a.font_semi_bold, {color: t.palette.white}]}
|
|
accessible={false}>
|
|
<Trans>ALT</Trans>
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
<Admonition type="info" style={[a.mt_sm]}>
|
|
<Trans>
|
|
Alt text describes images for blind and low-vision users, and helps
|
|
give context to everyone.
|
|
</Trans>
|
|
</Admonition>
|
|
|
|
<Dialog.Outer
|
|
control={control}
|
|
onClose={() => {
|
|
onSubmit(altTextDraft)
|
|
}}
|
|
nativeOptions={{fullHeight: true}}>
|
|
<Dialog.Handle />
|
|
<AltTextInner
|
|
vendorAltText={vendorAltText}
|
|
altText={altTextDraft}
|
|
onChange={setAltTextDraft}
|
|
thumb={thumb}
|
|
control={control}
|
|
params={params}
|
|
/>
|
|
</Dialog.Outer>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function AltTextInner({
|
|
vendorAltText,
|
|
altText,
|
|
onChange,
|
|
control,
|
|
params,
|
|
thumb,
|
|
}: {
|
|
vendorAltText: string
|
|
altText: string
|
|
onChange: (text: string) => void
|
|
control: DialogControlProps
|
|
params: EmbedPlayerParams
|
|
thumb: string | undefined
|
|
}) {
|
|
const t = useTheme()
|
|
const {_, i18n} = useLingui()
|
|
|
|
const altTextLength = countGraphemes(altText)
|
|
|
|
return (
|
|
<Dialog.ScrollableInner label={_(msg`Add alt text`)}>
|
|
<View style={a.flex_col_reverse}>
|
|
<View style={[a.mt_md, a.gap_md]}>
|
|
<View style={[a.gap_sm]}>
|
|
<View style={[a.relative]}>
|
|
<TextField.LabelText>
|
|
<Trans>
|
|
Descriptive alt text ({altTextLength}/{MAX_ALT_TEXT})
|
|
</Trans>
|
|
</TextField.LabelText>
|
|
<TextField.Root>
|
|
<Dialog.Input
|
|
label={_(msg`Alt text`)}
|
|
placeholder={vendorAltText}
|
|
onChangeText={onChange}
|
|
defaultValue={altText}
|
|
multiline
|
|
numberOfLines={3}
|
|
autoFocus
|
|
onKeyPress={({nativeEvent}) => {
|
|
if (nativeEvent.key === 'Escape') {
|
|
control.close()
|
|
}
|
|
}}
|
|
/>
|
|
</TextField.Root>
|
|
</View>
|
|
|
|
{altTextLength > MAX_ALT_TEXT && (
|
|
<View style={[a.pb_sm, a.flex_row, a.gap_xs]}>
|
|
<CircleInfo fill={t.palette.negative_500} size="sm" />
|
|
<Text
|
|
style={[
|
|
a.italic,
|
|
a.leading_snug,
|
|
t.atoms.text_contrast_medium,
|
|
]}>
|
|
<Trans>
|
|
Alt text will be truncated.{' '}
|
|
<Plural
|
|
value={MAX_ALT_TEXT}
|
|
other={`Limit: ${i18n.number(MAX_ALT_TEXT)} characters.`}
|
|
/>
|
|
</Trans>
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<Button
|
|
label={_(msg`Save`)}
|
|
size="large"
|
|
color="primary"
|
|
onPress={() => {
|
|
control.close()
|
|
}}>
|
|
<ButtonText>
|
|
<Trans>Save</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
</View>
|
|
{/* below the text input to force tab order */}
|
|
<View>
|
|
<Text
|
|
style={[a.text_2xl, a.font_semi_bold, a.leading_tight, a.pb_sm]}>
|
|
<Trans>Add alt text</Trans>
|
|
</Text>
|
|
<GifEmbed
|
|
thumb={thumb}
|
|
altText={altText}
|
|
isPreferredAltText={true}
|
|
params={params}
|
|
hideAlt
|
|
/>
|
|
</View>
|
|
</View>
|
|
<Dialog.Close />
|
|
</Dialog.ScrollableInner>
|
|
)
|
|
}
|