Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

feat: ai alt text generation #63

merged opened by shi.gg targeting main

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.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:irs2tcoeuvuwj3m4yampbuco/sh.tangled.repo.pull/3metld5d7uv22
+464 -13
Diff #0
+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 ··· 578 717 579 718 const setPostReplacementDialogControl = Dialog.useDialogControl() 580 719 720 + const setOpenRouterApiKeyControl = Dialog.useDialogControl() 721 + const openRouterModel = useOpenRouterModel() 722 + const setOpenRouterModelControl = Dialog.useDialogControl() 723 + const openRouterConfigured = useOpenRouterConfigured() 724 + 581 725 return ( 582 726 <Layout.Screen> 583 727 <Layout.Header.Outer> ··· 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(), ··· 300 304 translationServicePreference: 'google', 301 305 libreTranslateInstance: 'https://libretranslate.com/', 302 306 307 + openRouterApiKey: undefined, 308 + openRouterModel: DEFAULT_ALT_TEXT_AI_MODEL, 309 + 303 310 postReplacement: { 304 311 enabled: false, 305 312 postName: 'skeet',
+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 ··· 32 45 const {height: minHeight} = useWindowDimensions() 33 46 const [altText, setAltText] = React.useState(image.alt) 34 47 48 + React.useEffect(() => { 49 + setAltText(image.alt) 50 + }, [image.alt]) 51 + 35 52 return ( 36 53 <Dialog.Outer 37 54 control={control} ··· 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> 154 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 + )} 256 + 155 257 <AltTextCounterWrapper altText={altText}> 156 258 <Button 157 259 label={_(msg`Save`)}

History

1 round 0 comments
sign up or login to add to the discussion
shi.gg submitted #0
expand 0 comments
pull request successfully merged