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.
+1
src/lib/constants.ts
+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
+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
+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
+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
+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
+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`)}