Bluesky app fork with some witchin' additions 💫

feat: ai alt text generation

This lets users configure a openrouter api key and model in the runes settings. If an api key is set, a "Generate Alt Text with AI" button will appear in the alt text compose window, as well as in the alt text reminder banner.

authored by shi.gg and committed by tangled.org 4033a993 5efb0d1a

+464 -13
+1
src/lib/constants.ts
··· 58 58 // Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html 59 59 // but increasing limit per user feedback 60 60 export const MAX_ALT_TEXT = 2000 61 + export const DEFAULT_ALT_TEXT_AI_MODEL = 'google/gemini-2.5-flash-lite' 61 62 62 63 export const MAX_REPORT_REASON_GRAPHEME_LENGTH = 2000 63 64
+202
src/screens/Settings/RunesSettings.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 7 7 8 + import { DEFAULT_ALT_TEXT_AI_MODEL } from '#/lib/constants' 8 9 import {usePalette} from '#/lib/hooks/usePalette' 9 10 import {type CommonNavigatorParams} from '#/lib/routes/types' 10 11 import {dynamicActivate} from '#/locale/i18n' ··· 106 107 useNoDiscoverFallback, 107 108 useSetNoDiscoverFallback, 108 109 } from '#/state/preferences/no-discover-fallback' 110 + import { 111 + useOpenRouterApiKey, 112 + useOpenRouterConfigured, 113 + useOpenRouterModel, 114 + useSetOpenRouterApiKey, 115 + useSetOpenRouterModel, 116 + } from '#/state/preferences/openrouter' 109 117 import { 110 118 usePostReplacement, 111 119 useSetPostReplacement, ··· 488 496 ) 489 497 } 490 498 499 + function OpenRouterApiKeyDialog({ 500 + control, 501 + }: { 502 + control: Dialog.DialogControlProps 503 + }) { 504 + const pal = usePalette('default') 505 + const {_} = useLingui() 506 + 507 + const apiKey = useOpenRouterApiKey() 508 + const [value, setValue] = useState(apiKey ?? '') 509 + const setApiKey = useSetOpenRouterApiKey() 510 + 511 + const submit = () => { 512 + setApiKey(value.trim() || undefined) 513 + control.close() 514 + } 515 + 516 + return ( 517 + <Dialog.Outer 518 + control={control} 519 + nativeOptions={{preventExpansion: true}} 520 + onClose={() => setValue(apiKey ?? '')}> 521 + <Dialog.Handle /> 522 + <Dialog.ScrollableInner label={_(msg`OpenRouter API Key`)}> 523 + <View style={[a.gap_sm, a.pb_lg]}> 524 + <Text style={[a.text_2xl, a.font_bold]}> 525 + <Trans>OpenRouter API Key</Trans> 526 + </Text> 527 + </View> 528 + 529 + <View style={a.gap_lg}> 530 + <Dialog.Input 531 + label="API Key" 532 + autoFocus 533 + style={[styles.textInput, pal.border, pal.text]} 534 + onChangeText={setValue} 535 + placeholder="sk-or-..." 536 + placeholderTextColor={pal.colors.textLight} 537 + onSubmitEditing={submit} 538 + accessibilityHint={_( 539 + msg`Enter your OpenRouter API key for AI alt text generation`, 540 + )} 541 + defaultValue={apiKey ?? ''} 542 + secureTextEntry 543 + /> 544 + 545 + <View style={IS_WEB && [a.flex_row, a.justify_end]}> 546 + <Button 547 + label={_(msg`Save`)} 548 + size="large" 549 + onPress={submit} 550 + variant="solid" 551 + color="primary"> 552 + <ButtonText> 553 + <Trans>Save</Trans> 554 + </ButtonText> 555 + </Button> 556 + </View> 557 + </View> 558 + 559 + <Dialog.Close /> 560 + </Dialog.ScrollableInner> 561 + </Dialog.Outer> 562 + ) 563 + } 564 + 565 + function OpenRouterModelDialog({ 566 + control, 567 + }: { 568 + control: Dialog.DialogControlProps 569 + }) { 570 + const pal = usePalette('default') 571 + const {_} = useLingui() 572 + 573 + const model = useOpenRouterModel() 574 + const [value, setValue] = useState(model ?? '') 575 + const setModel = useSetOpenRouterModel() 576 + 577 + const submit = () => { 578 + setModel(value.trim() || undefined) 579 + control.close() 580 + } 581 + 582 + return ( 583 + <Dialog.Outer 584 + control={control} 585 + nativeOptions={{preventExpansion: true}} 586 + onClose={() => setValue(model ?? '')}> 587 + <Dialog.Handle /> 588 + <Dialog.ScrollableInner label={_(msg`OpenRouter Model`)}> 589 + <View style={[a.gap_sm, a.pb_lg]}> 590 + <Text style={[a.text_2xl, a.font_bold]}> 591 + <Trans>OpenRouter Model</Trans> 592 + </Text> 593 + </View> 594 + 595 + <View style={a.gap_lg}> 596 + <Dialog.Input 597 + label="Model" 598 + autoFocus 599 + style={[styles.textInput, pal.border, pal.text]} 600 + onChangeText={setValue} 601 + placeholder={DEFAULT_ALT_TEXT_AI_MODEL} 602 + placeholderTextColor={pal.colors.textLight} 603 + onSubmitEditing={submit} 604 + accessibilityHint={_( 605 + msg`Enter the model ID to use for alt text generation`, 606 + )} 607 + defaultValue={model ?? ''} 608 + /> 609 + 610 + <View style={IS_WEB && [a.flex_row, a.justify_end]}> 611 + <Button 612 + label={_(msg`Save`)} 613 + size="large" 614 + onPress={submit} 615 + variant="solid" 616 + color="primary"> 617 + <ButtonText> 618 + <Trans>Save</Trans> 619 + </ButtonText> 620 + </Button> 621 + </View> 622 + </View> 623 + 624 + <Dialog.Close /> 625 + </Dialog.ScrollableInner> 626 + </Dialog.Outer> 627 + ) 628 + } 629 + 491 630 export function RunesSettingsScreen({}: Props) { 492 631 const {_} = useLingui() 493 632 ··· 577 716 const setLibreTranslateInstanceControl = Dialog.useDialogControl() 578 717 579 718 const setPostReplacementDialogControl = Dialog.useDialogControl() 719 + 720 + const setOpenRouterApiKeyControl = Dialog.useDialogControl() 721 + const openRouterModel = useOpenRouterModel() 722 + const setOpenRouterModelControl = Dialog.useDialogControl() 723 + const openRouterConfigured = useOpenRouterConfigured() 580 724 581 725 return ( 582 726 <Layout.Screen> ··· 982 1126 983 1127 <SettingsList.Divider /> 984 1128 1129 + <SettingsList.Item> 1130 + <SettingsList.ItemIcon icon={_BeakerIcon} /> 1131 + <SettingsList.ItemText> 1132 + <Trans>OpenRouter API Key</Trans> 1133 + </SettingsList.ItemText> 1134 + <SettingsList.BadgeButton 1135 + label={openRouterConfigured ? _(msg`Change`) : _(msg`Set`)} 1136 + onPress={() => setOpenRouterApiKeyControl.open()} 1137 + /> 1138 + </SettingsList.Item> 1139 + 1140 + <SettingsList.Item> 1141 + <Admonition type="info" style={[a.flex_1]}> 1142 + <Trans> 1143 + Set your OpenRouter API key to enable AI-powered alt text 1144 + generation for images in the composer. Get an API key at{' '} 1145 + <InlineLinkText 1146 + to="https://openrouter.ai" 1147 + label="openrouter.ai"> 1148 + openrouter.ai 1149 + </InlineLinkText> 1150 + </Trans> 1151 + </Admonition> 1152 + </SettingsList.Item> 1153 + 1154 + {openRouterConfigured && ( 1155 + <SettingsList.Item> 1156 + <SettingsList.ItemIcon icon={_BeakerIcon} /> 1157 + <SettingsList.ItemText> 1158 + <Trans>{`OpenRouter Model`}</Trans> 1159 + </SettingsList.ItemText> 1160 + <SettingsList.BadgeButton 1161 + label={_(msg`Change`)} 1162 + onPress={() => setOpenRouterModelControl.open()} 1163 + /> 1164 + </SettingsList.Item> 1165 + )} 1166 + 1167 + {openRouterConfigured && ( 1168 + <SettingsList.Item> 1169 + <Admonition type="info" style={[a.flex_1]}> 1170 + <Trans> 1171 + Current model:{' '} 1172 + {openRouterModel ?? DEFAULT_ALT_TEXT_AI_MODEL}.{' '} 1173 + <InlineLinkText 1174 + to="https://openrouter.ai/models?fmt=cards&input_modalities=image&order=most-popular" 1175 + label="openrouter.ai"> 1176 + Search models 1177 + </InlineLinkText> 1178 + </Trans> 1179 + </Admonition> 1180 + </SettingsList.Item> 1181 + )} 1182 + 1183 + <SettingsList.Divider /> 1184 + 985 1185 <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 986 1186 <SettingsList.ItemIcon icon={VisibilityIcon} /> 987 1187 <SettingsList.ItemText> ··· 1148 1348 control={setLibreTranslateInstanceControl} 1149 1349 /> 1150 1350 <PostReplacementDialog control={setPostReplacementDialogControl} /> 1351 + <OpenRouterApiKeyDialog control={setOpenRouterApiKeyControl} /> 1352 + <OpenRouterModelDialog control={setOpenRouterModelControl} /> 1151 1353 </Layout.Screen> 1152 1354 ) 1153 1355 }
+7
src/state/persisted/schema.ts
··· 1 1 import {z} from 'zod' 2 2 3 + import { DEFAULT_ALT_TEXT_AI_MODEL } from '#/lib/constants' 3 4 import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' 4 5 import {findSupportedAppLanguage} from '#/locale/helpers' 5 6 import {logger} from '#/logger' ··· 187 188 ]), 188 189 libreTranslateInstance: z.string().optional(), 189 190 191 + openRouterApiKey: z.string().optional(), 192 + openRouterModel: z.string().optional(), 193 + 190 194 /** @deprecated */ 191 195 mutedThreads: z.array(z.string()), 192 196 trendingDisabled: z.boolean().optional(), ··· 299 303 showExternalShareButtons: false, 300 304 translationServicePreference: 'google', 301 305 libreTranslateInstance: 'https://libretranslate.com/', 306 + 307 + openRouterApiKey: undefined, 308 + openRouterModel: DEFAULT_ALT_TEXT_AI_MODEL, 302 309 303 310 postReplacement: { 304 311 enabled: false,
+17 -7
src/state/preferences/index.tsx
··· 36 36 import {Provider as LargeAltBadgeProvider} from './large-alt-badge' 37 37 import {Provider as NoAppLabelersProvider} from './no-app-labelers' 38 38 import {Provider as NoDiscoverProvider} from './no-discover-fallback' 39 + import {Provider as OpenRouterProvider} from './openrouter' 39 40 import {Provider as PostNameReplacementProvider} from './post-name-replacement.tsx' 40 41 import {Provider as RepostCarouselProvider} from './repost-carousel-enabled' 41 42 import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle' ··· 70 71 } from './hide-feeds-promo-tab' 71 72 export {useLabelDefinitions} from './label-defs' 72 73 export {useLanguagePrefs, useLanguagePrefsApi} from './languages' 74 + export { 75 + useOpenRouterApiKey, 76 + useOpenRouterConfigured, 77 + useOpenRouterModel, 78 + useSetOpenRouterApiKey, 79 + useSetOpenRouterModel, 80 + } from './openrouter' 73 81 export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles' 74 82 export { 75 83 useSetTranslationServicePreference, ··· 119 127 <PostNameReplacementProvider> 120 128 <DisableVerifyEmailReminderProvider> 121 129 <TranslationServicePreferenceProvider> 122 - <DisableComposerPromptProvider> 123 - <DiscoverContextEnabledProvider> 124 - { 125 - children 126 - } 127 - </DiscoverContextEnabledProvider> 128 - </DisableComposerPromptProvider> 130 + <OpenRouterProvider> 131 + <DisableComposerPromptProvider> 132 + <DiscoverContextEnabledProvider> 133 + { 134 + children 135 + } 136 + </DiscoverContextEnabledProvider> 137 + </DisableComposerPromptProvider> 138 + </OpenRouterProvider> 129 139 </TranslationServicePreferenceProvider> 130 140 </DisableVerifyEmailReminderProvider> 131 141 </PostNameReplacementProvider>
+132 -3
src/view/com/composer/Composer.tsx
··· 13 13 Keyboard, 14 14 KeyboardAvoidingView, 15 15 type LayoutChangeEvent, 16 + Pressable, 16 17 ScrollView, 17 18 type StyleProp, 18 19 StyleSheet, ··· 43 44 } from 'react-native-reanimated' 44 45 import {useSafeAreaInsets} from 'react-native-safe-area-context' 45 46 import * as FileSystem from 'expo-file-system' 47 + import {EncodingType, readAsStringAsync} from 'expo-file-system/legacy' 46 48 import {type ImagePickerAsset} from 'expo-image-picker' 47 49 import { 48 50 AppBskyDraftCreateDraft, ··· 57 59 import {useNavigation} from '@react-navigation/native' 58 60 import {useQueryClient} from '@tanstack/react-query' 59 61 62 + import {generateAltText} from '#/lib/ai/generateAltText' 60 63 import * as apilib from '#/lib/api/index' 61 64 import {EmbeddingDisabledError} from '#/lib/api/resolve' 62 65 import {useAppState} from '#/lib/appState' 63 66 import {retry} from '#/lib/async/retry' 64 67 import {until} from '#/lib/async/until' 65 68 import { 69 + DEFAULT_ALT_TEXT_AI_MODEL, 70 + MAX_ALT_TEXT, 66 71 MAX_DRAFT_GRAPHEME_LENGTH, 67 72 MAX_GRAPHEME_LENGTH, 68 73 SUPPORTED_MIME_TYPES, ··· 93 98 useLanguagePrefs, 94 99 useLanguagePrefsApi, 95 100 } from '#/state/preferences/languages' 101 + import { 102 + useOpenRouterApiKey, 103 + useOpenRouterConfigured, 104 + useOpenRouterModel, 105 + } from '#/state/preferences/openrouter' 96 106 import {usePreferencesQuery} from '#/state/queries/preferences' 97 107 import {useProfileQuery} from '#/state/queries/profile' 98 108 import {type Gif} from '#/state/queries/tenor' ··· 1158 1168 isEditingDraft={!!composerState.draftId} 1159 1169 canSaveDraft={allPostsWithinLimit} 1160 1170 textLength={thread.posts[0].richtext.text.length}> 1161 - {missingAltError && <AltTextReminder error={missingAltError} />} 1171 + {missingAltError && ( 1172 + <AltTextReminder 1173 + error={missingAltError} 1174 + thread={thread} 1175 + dispatch={composerDispatch} 1176 + /> 1177 + )} 1162 1178 <ErrorBanner 1163 1179 error={error} 1164 1180 videoState={erroredVideo} ··· 1626 1642 ) 1627 1643 } 1628 1644 1629 - function AltTextReminder({error}: {error: string}) { 1645 + function AltTextReminder({ 1646 + error, 1647 + thread, 1648 + dispatch, 1649 + }: { 1650 + error: string 1651 + thread: ThreadDraft 1652 + dispatch: (action: ComposerAction) => void 1653 + }) { 1654 + const {_} = useLingui() 1655 + const t = useTheme(); 1656 + const openRouterConfigured = useOpenRouterConfigured() 1657 + const openRouterApiKey = useOpenRouterApiKey() 1658 + const openRouterModel = useOpenRouterModel() 1659 + const [isGenerating, setIsGenerating] = useState(false) 1660 + 1661 + const hasImagesWithoutAlt = useMemo(() => { 1662 + for (const post of thread.posts) { 1663 + const media = post.embed.media 1664 + if (media?.type === 'images' && media.images.some(img => !img.alt)) { 1665 + return true 1666 + } 1667 + } 1668 + return false 1669 + }, [thread]) 1670 + 1671 + const handleGenerateAltText = useCallback(async () => { 1672 + if (!openRouterApiKey) return 1673 + 1674 + setIsGenerating(true) 1675 + 1676 + try { 1677 + for (const post of thread.posts) { 1678 + const media = post.embed.media 1679 + if (media?.type === 'images') { 1680 + for (const image of media.images) { 1681 + if (!image.alt) { 1682 + try { 1683 + const imagePath = (image.transformed ?? image.source).path 1684 + 1685 + let base64: string 1686 + let mimeType: string 1687 + 1688 + if (IS_WEB) { 1689 + const response = await fetch(imagePath) 1690 + const blob = await response.blob() 1691 + mimeType = blob.type || 'image/jpeg' 1692 + const arrayBuffer = await blob.arrayBuffer() 1693 + const uint8Array = new Uint8Array(arrayBuffer) 1694 + let binary = '' 1695 + for (let i = 0; i < uint8Array.length; i++) { 1696 + binary += String.fromCharCode(uint8Array[i]) 1697 + } 1698 + base64 = btoa(binary) 1699 + } else { 1700 + const base64Result = await readAsStringAsync(imagePath, { 1701 + encoding: EncodingType.Base64, 1702 + }) 1703 + base64 = base64Result 1704 + const pathParts = imagePath.split('.') 1705 + const ext = pathParts[pathParts.length - 1]?.toLowerCase() 1706 + mimeType = ext === 'png' ? 'image/png' : 'image/jpeg' 1707 + } 1708 + 1709 + const generated = await generateAltText( 1710 + openRouterApiKey, 1711 + openRouterModel ?? DEFAULT_ALT_TEXT_AI_MODEL, 1712 + base64, 1713 + mimeType, 1714 + ) 1715 + 1716 + dispatch({ 1717 + type: 'update_post', 1718 + postId: post.id, 1719 + postAction: { 1720 + type: 'embed_update_image', 1721 + image: { 1722 + ...image, 1723 + alt: generated.slice(0, MAX_ALT_TEXT), 1724 + }, 1725 + }, 1726 + }) 1727 + } catch (err) { 1728 + logger.error('Failed to generate alt text for image', { 1729 + error: err, 1730 + }) 1731 + } 1732 + } 1733 + } 1734 + } 1735 + } 1736 + } finally { 1737 + setIsGenerating(false) 1738 + } 1739 + }, [openRouterApiKey, openRouterModel, thread, dispatch]) 1740 + 1630 1741 return ( 1631 1742 <Admonition type="error" style={[a.mt_2xs, a.mb_sm, a.mx_lg]}> 1632 - {error} 1743 + <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}> 1744 + <Text style={[a.flex_1]}>{error}</Text> 1745 + {openRouterConfigured && hasImagesWithoutAlt && ( 1746 + <Pressable 1747 + accessibilityRole="button" 1748 + accessibilityLabel={_(msg`Generate Alt Text with AI`)} 1749 + accessibilityHint='' 1750 + onPress={handleGenerateAltText} 1751 + disabled={isGenerating}> 1752 + {isGenerating ? ( 1753 + <ActivityIndicator size="small" color={t.palette.primary_500} /> 1754 + ) : ( 1755 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 1756 + <Trans>Generate with Ai</Trans> 1757 + </Text> 1758 + )} 1759 + </Pressable> 1760 + )} 1761 + </View> 1633 1762 </Admonition> 1634 1763 ) 1635 1764 }
+105 -3
src/view/com/composer/photos/ImageAltTextDialog.tsx
··· 1 1 import React from 'react' 2 - import {type ImageStyle, useWindowDimensions, View} from 'react-native' 2 + import { 3 + ActivityIndicator, 4 + type ImageStyle, 5 + useWindowDimensions, 6 + View, 7 + } from 'react-native' 8 + import {EncodingType, readAsStringAsync} from 'expo-file-system/legacy' 3 9 import {Image} from 'expo-image' 4 10 import {msg, Plural, Trans} from '@lingui/macro' 5 11 import {useLingui} from '@lingui/react' 6 12 7 - import {MAX_ALT_TEXT} from '#/lib/constants' 13 + import {generateAltText} from '#/lib/ai/generateAltText' 14 + import {DEFAULT_ALT_TEXT_AI_MODEL, MAX_ALT_TEXT} from '#/lib/constants' 8 15 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 9 16 import {enforceLen} from '#/lib/strings/helpers' 10 17 import {type ComposerImage} from '#/state/gallery' 18 + import { 19 + useOpenRouterApiKey, 20 + useOpenRouterConfigured, 21 + useOpenRouterModel, 22 + } from '#/state/preferences/openrouter' 11 23 import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' 12 24 import {atoms as a, useTheme} from '#/alf' 13 25 import {Button, ButtonText} from '#/components/Button' ··· 15 27 import {type DialogControlProps} from '#/components/Dialog' 16 28 import * as TextField from '#/components/forms/TextField' 17 29 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 30 + import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' 18 31 import {Text} from '#/components/Typography' 19 32 import {IS_ANDROID, IS_WEB} from '#/env' 20 33 ··· 31 44 }: Props): React.ReactNode => { 32 45 const {height: minHeight} = useWindowDimensions() 33 46 const [altText, setAltText] = React.useState(image.alt) 47 + 48 + React.useEffect(() => { 49 + setAltText(image.alt) 50 + }, [image.alt]) 34 51 35 52 return ( 36 53 <Dialog.Outer ··· 69 86 const windim = useWindowDimensions() 70 87 71 88 const [isKeyboardVisible] = useIsKeyboardVisible() 89 + const [isGenerating, setIsGenerating] = React.useState(false) 90 + const [generateError, setGenerateError] = React.useState<string | null>(null) 91 + 92 + const openRouterConfigured = useOpenRouterConfigured() 93 + const openRouterApiKey = useOpenRouterApiKey() 94 + const openRouterModel = useOpenRouterModel() 72 95 73 96 const imageStyle = React.useMemo<ImageStyle>(() => { 74 97 const maxWidth = IS_WEB ? 450 : windim.width ··· 89 112 } 90 113 }, [image, windim]) 91 114 115 + const handleGenerateAltText = React.useCallback(async () => { 116 + if (!openRouterApiKey) return 117 + 118 + setIsGenerating(true) 119 + setGenerateError(null) 120 + 121 + try { 122 + const imagePath = (image.transformed ?? image.source).path 123 + 124 + let base64: string 125 + let mimeType: string 126 + 127 + if (IS_WEB) { 128 + const response = await fetch(imagePath) 129 + const blob = await response.blob() 130 + mimeType = blob.type || 'image/jpeg' 131 + const arrayBuffer = await blob.arrayBuffer() 132 + const uint8Array = new Uint8Array(arrayBuffer) 133 + let binary = '' 134 + for (let i = 0; i < uint8Array.length; i++) { 135 + binary += String.fromCharCode(uint8Array[i]) 136 + } 137 + base64 = btoa(binary) 138 + } else { 139 + const base64Result = await readAsStringAsync(imagePath, { 140 + encoding: EncodingType.Base64, 141 + }) 142 + base64 = base64Result 143 + const pathParts = imagePath.split('.') 144 + const ext = pathParts[pathParts.length - 1]?.toLowerCase() 145 + mimeType = ext === 'png' ? 'image/png' : 'image/jpeg' 146 + } 147 + 148 + const generated = await generateAltText( 149 + openRouterApiKey, 150 + openRouterModel ?? DEFAULT_ALT_TEXT_AI_MODEL, 151 + base64, 152 + mimeType, 153 + ) 154 + 155 + setAltText(enforceLen(generated, MAX_ALT_TEXT, true)) 156 + } catch (err) { 157 + setGenerateError( 158 + err instanceof Error ? err.message : 'Failed to generate alt text', 159 + ) 160 + } finally { 161 + setIsGenerating(false) 162 + } 163 + }, [openRouterApiKey, openRouterModel, image, setAltText]) 164 + 92 165 return ( 93 166 <Dialog.ScrollableInner label={_(msg`Add alt text`)}> 94 167 <Dialog.Close /> ··· 123 196 onChangeText={text => { 124 197 setAltText(text) 125 198 }} 126 - defaultValue={altText} 199 + value={altText} 127 200 multiline 128 201 numberOfLines={3} 129 202 autoFocus ··· 150 223 </Text> 151 224 </View> 152 225 )} 226 + 227 + {generateError && ( 228 + <View style={[a.pb_sm, a.flex_row, a.gap_xs]}> 229 + <CircleInfo fill={t.palette.negative_500} /> 230 + <Text style={[t.atoms.text_contrast_medium]}> 231 + {generateError} 232 + </Text> 233 + </View> 234 + )} 153 235 </View> 236 + 237 + {openRouterConfigured && ( 238 + <Button 239 + label={_(msg`Generate alt text with AI`)} 240 + size="large" 241 + color="secondary" 242 + variant="solid" 243 + onPress={handleGenerateAltText} 244 + disabled={isGenerating} 245 + style={[a.flex_grow]}> 246 + {isGenerating ? ( 247 + <ActivityIndicator color={t.palette.primary_500} /> 248 + ) : ( 249 + <SparkleIcon size="sm" /> 250 + )} 251 + <ButtonText> 252 + <Trans>Generate Alt Text with AI</Trans> 253 + </ButtonText> 254 + </Button> 255 + )} 154 256 155 257 <AltTextCounterWrapper altText={altText}> 156 258 <Button