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 // Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html 59 // but increasing limit per user feedback 60 export const MAX_ALT_TEXT = 2000 61 62 export const MAX_REPORT_REASON_GRAPHEME_LENGTH = 2000 63
··· 58 // Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html 59 // but increasing limit per user feedback 60 export const MAX_ALT_TEXT = 2000 61 + export const DEFAULT_ALT_TEXT_AI_MODEL = 'google/gemini-2.5-flash-lite' 62 63 export const MAX_REPORT_REASON_GRAPHEME_LENGTH = 2000 64
+202
src/screens/Settings/RunesSettings.tsx
··· 5 import {useLingui} from '@lingui/react' 6 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 7 8 import {usePalette} from '#/lib/hooks/usePalette' 9 import {type CommonNavigatorParams} from '#/lib/routes/types' 10 import {dynamicActivate} from '#/locale/i18n' ··· 106 useNoDiscoverFallback, 107 useSetNoDiscoverFallback, 108 } from '#/state/preferences/no-discover-fallback' 109 import { 110 usePostReplacement, 111 useSetPostReplacement, ··· 488 ) 489 } 490 491 export function RunesSettingsScreen({}: Props) { 492 const {_} = useLingui() 493 ··· 577 const setLibreTranslateInstanceControl = Dialog.useDialogControl() 578 579 const setPostReplacementDialogControl = Dialog.useDialogControl() 580 581 return ( 582 <Layout.Screen> ··· 982 983 <SettingsList.Divider /> 984 985 <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 986 <SettingsList.ItemIcon icon={VisibilityIcon} /> 987 <SettingsList.ItemText> ··· 1148 control={setLibreTranslateInstanceControl} 1149 /> 1150 <PostReplacementDialog control={setPostReplacementDialogControl} /> 1151 </Layout.Screen> 1152 ) 1153 }
··· 5 import {useLingui} from '@lingui/react' 6 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 7 8 + import { DEFAULT_ALT_TEXT_AI_MODEL } from '#/lib/constants' 9 import {usePalette} from '#/lib/hooks/usePalette' 10 import {type CommonNavigatorParams} from '#/lib/routes/types' 11 import {dynamicActivate} from '#/locale/i18n' ··· 107 useNoDiscoverFallback, 108 useSetNoDiscoverFallback, 109 } from '#/state/preferences/no-discover-fallback' 110 + import { 111 + useOpenRouterApiKey, 112 + useOpenRouterConfigured, 113 + useOpenRouterModel, 114 + useSetOpenRouterApiKey, 115 + useSetOpenRouterModel, 116 + } from '#/state/preferences/openrouter' 117 import { 118 usePostReplacement, 119 useSetPostReplacement, ··· 496 ) 497 } 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 + 630 export function RunesSettingsScreen({}: Props) { 631 const {_} = useLingui() 632 ··· 716 const setLibreTranslateInstanceControl = Dialog.useDialogControl() 717 718 const setPostReplacementDialogControl = Dialog.useDialogControl() 719 + 720 + const setOpenRouterApiKeyControl = Dialog.useDialogControl() 721 + const openRouterModel = useOpenRouterModel() 722 + const setOpenRouterModelControl = Dialog.useDialogControl() 723 + const openRouterConfigured = useOpenRouterConfigured() 724 725 return ( 726 <Layout.Screen> ··· 1126 1127 <SettingsList.Divider /> 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 + 1185 <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 1186 <SettingsList.ItemIcon icon={VisibilityIcon} /> 1187 <SettingsList.ItemText> ··· 1348 control={setLibreTranslateInstanceControl} 1349 /> 1350 <PostReplacementDialog control={setPostReplacementDialogControl} /> 1351 + <OpenRouterApiKeyDialog control={setOpenRouterApiKeyControl} /> 1352 + <OpenRouterModelDialog control={setOpenRouterModelControl} /> 1353 </Layout.Screen> 1354 ) 1355 }
+7
src/state/persisted/schema.ts
··· 1 import {z} from 'zod' 2 3 import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' 4 import {findSupportedAppLanguage} from '#/locale/helpers' 5 import {logger} from '#/logger' ··· 187 ]), 188 libreTranslateInstance: z.string().optional(), 189 190 /** @deprecated */ 191 mutedThreads: z.array(z.string()), 192 trendingDisabled: z.boolean().optional(), ··· 299 showExternalShareButtons: false, 300 translationServicePreference: 'google', 301 libreTranslateInstance: 'https://libretranslate.com/', 302 303 postReplacement: { 304 enabled: false,
··· 1 import {z} from 'zod' 2 3 + import { DEFAULT_ALT_TEXT_AI_MODEL } from '#/lib/constants' 4 import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' 5 import {findSupportedAppLanguage} from '#/locale/helpers' 6 import {logger} from '#/logger' ··· 188 ]), 189 libreTranslateInstance: z.string().optional(), 190 191 + openRouterApiKey: z.string().optional(), 192 + openRouterModel: z.string().optional(), 193 + 194 /** @deprecated */ 195 mutedThreads: z.array(z.string()), 196 trendingDisabled: z.boolean().optional(), ··· 303 showExternalShareButtons: false, 304 translationServicePreference: 'google', 305 libreTranslateInstance: 'https://libretranslate.com/', 306 + 307 + openRouterApiKey: undefined, 308 + openRouterModel: DEFAULT_ALT_TEXT_AI_MODEL, 309 310 postReplacement: { 311 enabled: false,
+17 -7
src/state/preferences/index.tsx
··· 36 import {Provider as LargeAltBadgeProvider} from './large-alt-badge' 37 import {Provider as NoAppLabelersProvider} from './no-app-labelers' 38 import {Provider as NoDiscoverProvider} from './no-discover-fallback' 39 import {Provider as PostNameReplacementProvider} from './post-name-replacement.tsx' 40 import {Provider as RepostCarouselProvider} from './repost-carousel-enabled' 41 import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle' ··· 70 } from './hide-feeds-promo-tab' 71 export {useLabelDefinitions} from './label-defs' 72 export {useLanguagePrefs, useLanguagePrefsApi} from './languages' 73 export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles' 74 export { 75 useSetTranslationServicePreference, ··· 119 <PostNameReplacementProvider> 120 <DisableVerifyEmailReminderProvider> 121 <TranslationServicePreferenceProvider> 122 - <DisableComposerPromptProvider> 123 - <DiscoverContextEnabledProvider> 124 - { 125 - children 126 - } 127 - </DiscoverContextEnabledProvider> 128 - </DisableComposerPromptProvider> 129 </TranslationServicePreferenceProvider> 130 </DisableVerifyEmailReminderProvider> 131 </PostNameReplacementProvider>
··· 36 import {Provider as LargeAltBadgeProvider} from './large-alt-badge' 37 import {Provider as NoAppLabelersProvider} from './no-app-labelers' 38 import {Provider as NoDiscoverProvider} from './no-discover-fallback' 39 + import {Provider as OpenRouterProvider} from './openrouter' 40 import {Provider as PostNameReplacementProvider} from './post-name-replacement.tsx' 41 import {Provider as RepostCarouselProvider} from './repost-carousel-enabled' 42 import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle' ··· 71 } from './hide-feeds-promo-tab' 72 export {useLabelDefinitions} from './label-defs' 73 export {useLanguagePrefs, useLanguagePrefsApi} from './languages' 74 + export { 75 + useOpenRouterApiKey, 76 + useOpenRouterConfigured, 77 + useOpenRouterModel, 78 + useSetOpenRouterApiKey, 79 + useSetOpenRouterModel, 80 + } from './openrouter' 81 export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles' 82 export { 83 useSetTranslationServicePreference, ··· 127 <PostNameReplacementProvider> 128 <DisableVerifyEmailReminderProvider> 129 <TranslationServicePreferenceProvider> 130 + <OpenRouterProvider> 131 + <DisableComposerPromptProvider> 132 + <DiscoverContextEnabledProvider> 133 + { 134 + children 135 + } 136 + </DiscoverContextEnabledProvider> 137 + </DisableComposerPromptProvider> 138 + </OpenRouterProvider> 139 </TranslationServicePreferenceProvider> 140 </DisableVerifyEmailReminderProvider> 141 </PostNameReplacementProvider>
+132 -3
src/view/com/composer/Composer.tsx
··· 13 Keyboard, 14 KeyboardAvoidingView, 15 type LayoutChangeEvent, 16 ScrollView, 17 type StyleProp, 18 StyleSheet, ··· 43 } from 'react-native-reanimated' 44 import {useSafeAreaInsets} from 'react-native-safe-area-context' 45 import * as FileSystem from 'expo-file-system' 46 import {type ImagePickerAsset} from 'expo-image-picker' 47 import { 48 AppBskyDraftCreateDraft, ··· 57 import {useNavigation} from '@react-navigation/native' 58 import {useQueryClient} from '@tanstack/react-query' 59 60 import * as apilib from '#/lib/api/index' 61 import {EmbeddingDisabledError} from '#/lib/api/resolve' 62 import {useAppState} from '#/lib/appState' 63 import {retry} from '#/lib/async/retry' 64 import {until} from '#/lib/async/until' 65 import { 66 MAX_DRAFT_GRAPHEME_LENGTH, 67 MAX_GRAPHEME_LENGTH, 68 SUPPORTED_MIME_TYPES, ··· 93 useLanguagePrefs, 94 useLanguagePrefsApi, 95 } from '#/state/preferences/languages' 96 import {usePreferencesQuery} from '#/state/queries/preferences' 97 import {useProfileQuery} from '#/state/queries/profile' 98 import {type Gif} from '#/state/queries/tenor' ··· 1158 isEditingDraft={!!composerState.draftId} 1159 canSaveDraft={allPostsWithinLimit} 1160 textLength={thread.posts[0].richtext.text.length}> 1161 - {missingAltError && <AltTextReminder error={missingAltError} />} 1162 <ErrorBanner 1163 error={error} 1164 videoState={erroredVideo} ··· 1626 ) 1627 } 1628 1629 - function AltTextReminder({error}: {error: string}) { 1630 return ( 1631 <Admonition type="error" style={[a.mt_2xs, a.mb_sm, a.mx_lg]}> 1632 - {error} 1633 </Admonition> 1634 ) 1635 }
··· 13 Keyboard, 14 KeyboardAvoidingView, 15 type LayoutChangeEvent, 16 + Pressable, 17 ScrollView, 18 type StyleProp, 19 StyleSheet, ··· 44 } from 'react-native-reanimated' 45 import {useSafeAreaInsets} from 'react-native-safe-area-context' 46 import * as FileSystem from 'expo-file-system' 47 + import {EncodingType, readAsStringAsync} from 'expo-file-system/legacy' 48 import {type ImagePickerAsset} from 'expo-image-picker' 49 import { 50 AppBskyDraftCreateDraft, ··· 59 import {useNavigation} from '@react-navigation/native' 60 import {useQueryClient} from '@tanstack/react-query' 61 62 + import {generateAltText} from '#/lib/ai/generateAltText' 63 import * as apilib from '#/lib/api/index' 64 import {EmbeddingDisabledError} from '#/lib/api/resolve' 65 import {useAppState} from '#/lib/appState' 66 import {retry} from '#/lib/async/retry' 67 import {until} from '#/lib/async/until' 68 import { 69 + DEFAULT_ALT_TEXT_AI_MODEL, 70 + MAX_ALT_TEXT, 71 MAX_DRAFT_GRAPHEME_LENGTH, 72 MAX_GRAPHEME_LENGTH, 73 SUPPORTED_MIME_TYPES, ··· 98 useLanguagePrefs, 99 useLanguagePrefsApi, 100 } from '#/state/preferences/languages' 101 + import { 102 + useOpenRouterApiKey, 103 + useOpenRouterConfigured, 104 + useOpenRouterModel, 105 + } from '#/state/preferences/openrouter' 106 import {usePreferencesQuery} from '#/state/queries/preferences' 107 import {useProfileQuery} from '#/state/queries/profile' 108 import {type Gif} from '#/state/queries/tenor' ··· 1168 isEditingDraft={!!composerState.draftId} 1169 canSaveDraft={allPostsWithinLimit} 1170 textLength={thread.posts[0].richtext.text.length}> 1171 + {missingAltError && ( 1172 + <AltTextReminder 1173 + error={missingAltError} 1174 + thread={thread} 1175 + dispatch={composerDispatch} 1176 + /> 1177 + )} 1178 <ErrorBanner 1179 error={error} 1180 videoState={erroredVideo} ··· 1642 ) 1643 } 1644 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 + 1741 return ( 1742 <Admonition type="error" style={[a.mt_2xs, a.mb_sm, a.mx_lg]}> 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> 1762 </Admonition> 1763 ) 1764 }
+105 -3
src/view/com/composer/photos/ImageAltTextDialog.tsx
··· 1 import React from 'react' 2 - import {type ImageStyle, useWindowDimensions, View} from 'react-native' 3 import {Image} from 'expo-image' 4 import {msg, Plural, Trans} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 7 - import {MAX_ALT_TEXT} from '#/lib/constants' 8 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 9 import {enforceLen} from '#/lib/strings/helpers' 10 import {type ComposerImage} from '#/state/gallery' 11 import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' 12 import {atoms as a, useTheme} from '#/alf' 13 import {Button, ButtonText} from '#/components/Button' ··· 15 import {type DialogControlProps} from '#/components/Dialog' 16 import * as TextField from '#/components/forms/TextField' 17 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 18 import {Text} from '#/components/Typography' 19 import {IS_ANDROID, IS_WEB} from '#/env' 20 ··· 31 }: Props): React.ReactNode => { 32 const {height: minHeight} = useWindowDimensions() 33 const [altText, setAltText] = React.useState(image.alt) 34 35 return ( 36 <Dialog.Outer ··· 69 const windim = useWindowDimensions() 70 71 const [isKeyboardVisible] = useIsKeyboardVisible() 72 73 const imageStyle = React.useMemo<ImageStyle>(() => { 74 const maxWidth = IS_WEB ? 450 : windim.width ··· 89 } 90 }, [image, windim]) 91 92 return ( 93 <Dialog.ScrollableInner label={_(msg`Add alt text`)}> 94 <Dialog.Close /> ··· 123 onChangeText={text => { 124 setAltText(text) 125 }} 126 - defaultValue={altText} 127 multiline 128 numberOfLines={3} 129 autoFocus ··· 150 </Text> 151 </View> 152 )} 153 </View> 154 155 <AltTextCounterWrapper altText={altText}> 156 <Button
··· 1 import React from 'react' 2 + import { 3 + ActivityIndicator, 4 + type ImageStyle, 5 + useWindowDimensions, 6 + View, 7 + } from 'react-native' 8 + import {EncodingType, readAsStringAsync} from 'expo-file-system/legacy' 9 import {Image} from 'expo-image' 10 import {msg, Plural, Trans} from '@lingui/macro' 11 import {useLingui} from '@lingui/react' 12 13 + import {generateAltText} from '#/lib/ai/generateAltText' 14 + import {DEFAULT_ALT_TEXT_AI_MODEL, MAX_ALT_TEXT} from '#/lib/constants' 15 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 16 import {enforceLen} from '#/lib/strings/helpers' 17 import {type ComposerImage} from '#/state/gallery' 18 + import { 19 + useOpenRouterApiKey, 20 + useOpenRouterConfigured, 21 + useOpenRouterModel, 22 + } from '#/state/preferences/openrouter' 23 import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' 24 import {atoms as a, useTheme} from '#/alf' 25 import {Button, ButtonText} from '#/components/Button' ··· 27 import {type DialogControlProps} from '#/components/Dialog' 28 import * as TextField from '#/components/forms/TextField' 29 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 30 + import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' 31 import {Text} from '#/components/Typography' 32 import {IS_ANDROID, IS_WEB} from '#/env' 33 ··· 44 }: Props): React.ReactNode => { 45 const {height: minHeight} = useWindowDimensions() 46 const [altText, setAltText] = React.useState(image.alt) 47 + 48 + React.useEffect(() => { 49 + setAltText(image.alt) 50 + }, [image.alt]) 51 52 return ( 53 <Dialog.Outer ··· 86 const windim = useWindowDimensions() 87 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() 95 96 const imageStyle = React.useMemo<ImageStyle>(() => { 97 const maxWidth = IS_WEB ? 450 : windim.width ··· 112 } 113 }, [image, windim]) 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 + 165 return ( 166 <Dialog.ScrollableInner label={_(msg`Add alt text`)}> 167 <Dialog.Close /> ··· 196 onChangeText={text => { 197 setAltText(text) 198 }} 199 + value={altText} 200 multiline 201 numberOfLines={3} 202 autoFocus ··· 223 </Text> 224 </View> 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 + )} 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 + )} 256 257 <AltTextCounterWrapper altText={altText}> 258 <Button