feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,357 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native'
import { useI18n, type TranslationKey } from '@d3ro/i18n'
import { ThemeButton, ThemeText } from '../theme/themed-components'
import { useMobilePreferences } from '../lib/preferences-context'
import { createUuidV4 } from '../lib/random-id'
import {
CONTENT_REPORT_REASONS,
ContentReportError,
submitAiContentReport,
type ContentReportErrorCode,
type ContentReportReason,
type ContentReportReceipt,
type ReportableGenerationSource,
} from '../features/reporting/content-report-service'
export interface ContentReportTarget {
sourceType: ReportableGenerationSource
generationId: string
snapshot: string
}
interface ContentReportSheetProps {
visible: boolean
accessToken: string | null
target: ContentReportTarget | null
onRequestClose: () => void
onSubmitted?: (receipt: ContentReportReceipt) => void
}
const REASON_KEYS: Record<ContentReportReason, TranslationKey> = {
harmful: 'mobile.report.reason.harmful',
sexual: 'mobile.report.reason.sexual',
hateful: 'mobile.report.reason.hateful',
violent: 'mobile.report.reason.violent',
self_harm: 'mobile.report.reason.selfHarm',
misinformation: 'mobile.report.reason.misinformation',
privacy: 'mobile.report.reason.privacy',
spam: 'mobile.report.reason.spam',
other: 'mobile.report.reason.other',
}
const ERROR_KEYS: Record<ContentReportErrorCode, TranslationKey> = {
AUTH_REQUIRED: 'mobile.report.error.auth',
INVALID_REQUEST: 'mobile.report.error.invalid',
SOURCE_NOT_FOUND: 'mobile.report.error.source',
ALREADY_REPORTED: 'mobile.report.error.alreadyReported',
RATE_LIMITED: 'mobile.report.error.rateLimited',
IDEMPOTENCY_CONFLICT: 'mobile.report.error.conflict',
TIMEOUT: 'mobile.report.error.network',
NETWORK: 'mobile.report.error.network',
INVALID_RESPONSE: 'mobile.report.error.server',
SERVER_ERROR: 'mobile.report.error.server',
}
export default function ContentReportSheet({
visible,
accessToken,
target,
onRequestClose,
onSubmitted,
}: ContentReportSheetProps): React.ReactElement {
const { t } = useI18n()
const { palette } = useMobilePreferences()
const styles = useMemo(() => createStyles(palette), [palette])
const [reason, setReason] = useState<ContentReportReason | null>(null)
const [comment, setComment] = useState('')
const [busy, setBusy] = useState(false)
const [errorCode, setErrorCode] = useState<ContentReportErrorCode | null>(null)
const [receipt, setReceipt] = useState<ContentReportReceipt | null>(null)
const [idempotencyKey, setIdempotencyKey] = useState('')
useEffect(() => {
if (!visible || target === null) return
setReason(null)
setComment('')
setBusy(false)
setErrorCode(null)
setReceipt(null)
setIdempotencyKey(createUuidV4())
}, [target, visible])
const close = (): void => {
if (!busy) onRequestClose()
}
const submit = async (): Promise<void> => {
if (busy || receipt !== null) return
if (target === null || accessToken === null) {
setErrorCode('AUTH_REQUIRED')
return
}
if (reason === null) {
setErrorCode('INVALID_REQUEST')
return
}
const stableKey = idempotencyKey || createUuidV4()
if (idempotencyKey.length === 0) setIdempotencyKey(stableKey)
setBusy(true)
setErrorCode(null)
try {
const submitted = await submitAiContentReport({
accessToken,
idempotencyKey: stableKey,
generationId: target.generationId,
sourceType: target.sourceType,
reason,
...(comment.trim().length === 0 ? {} : { comment }),
snapshot: target.snapshot,
})
setReceipt(submitted)
onSubmitted?.(submitted)
} catch (error) {
setErrorCode(error instanceof ContentReportError ? error.code : 'SERVER_ERROR')
} finally {
setBusy(false)
}
}
const excerpt = target?.snapshot.trim().slice(0, 240) ?? ''
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={close}
statusBarTranslucent
>
<View style={styles.overlay} testID="content-report-modal">
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.report.cancel')}
style={StyleSheet.absoluteFill}
onPress={close}
testID="content-report-backdrop"
/>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.keyboardArea}
pointerEvents="box-none"
>
<View
style={styles.sheet}
accessibilityViewIsModal
accessibilityLabel={t('mobile.report.title')}
>
{receipt === null ? (
<>
<View style={styles.header}>
<View style={styles.headerCopy}>
<ThemeText variant="heading" accessibilityRole="header">
{t('mobile.report.title')}
</ThemeText>
<ThemeText color="muted">{t('mobile.report.body')}</ThemeText>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.report.cancel')}
onPress={close}
disabled={busy}
style={styles.closeButton}
testID="content-report-close"
>
<ThemeText color="muted">×</ThemeText>
</Pressable>
</View>
<View style={styles.excerpt}>
<ThemeText variant="label" color="muted">
{t('mobile.report.selectedOutput')}
</ThemeText>
<ThemeText numberOfLines={4}>{excerpt}</ThemeText>
</View>
<ScrollView
style={styles.reasonScroll}
contentContainerStyle={styles.reasonList}
keyboardShouldPersistTaps="handled"
>
{CONTENT_REPORT_REASONS.map((candidate) => {
const selected = reason === candidate
return (
<Pressable
key={candidate}
accessibilityRole="radio"
accessibilityState={{ selected, disabled: busy }}
accessibilityLabel={t(REASON_KEYS[candidate])}
onPress={() => {
setReason(candidate)
setErrorCode(null)
}}
disabled={busy}
style={[styles.reasonButton, selected && styles.reasonButtonSelected]}
testID={`content-report-reason-${candidate}`}
>
<View style={[styles.radio, selected && styles.radioSelected]} />
<ThemeText color={selected ? 'accent' : 'primary'}>
{t(REASON_KEYS[candidate])}
</ThemeText>
</Pressable>
)
})}
</ScrollView>
<TextInput
accessibilityLabel={t('mobile.report.commentLabel')}
placeholder={t('mobile.report.commentPlaceholder')}
placeholderTextColor={palette.text.muted}
value={comment}
onChangeText={setComment}
editable={!busy}
maxLength={500}
multiline
style={styles.comment}
testID="content-report-comment"
/>
<ThemeText variant="label" color="muted" style={styles.privacyNote}>
{t('mobile.report.privacyNote')}
</ThemeText>
{errorCode !== null && (
<View accessibilityLiveRegion="assertive" style={styles.errorBox} testID="content-report-error">
<ThemeText color="danger">{t(ERROR_KEYS[errorCode])}</ThemeText>
</View>
)}
<View style={styles.actions}>
<ThemeButton
label={t('mobile.report.cancel')}
variant="secondary"
onPress={close}
disabled={busy}
style={styles.action}
testID="content-report-cancel"
/>
<ThemeButton
label={busy ? t('mobile.report.submitting') : t('mobile.report.submit')}
onPress={() => { void submit() }}
disabled={busy || reason === null}
style={styles.action}
testID="content-report-submit"
/>
</View>
</>
) : (
<View style={styles.success} accessibilityLiveRegion="polite" testID="content-report-success">
<ThemeText variant="heading" accessibilityRole="header">
{t('mobile.report.successTitle')}
</ThemeText>
<ThemeText color="muted">{t('mobile.report.successBody')}</ThemeText>
<ThemeText variant="label" color="muted" testID="content-report-receipt">
{t('mobile.report.receipt', { id: receipt.reportId.slice(0, 8).toUpperCase() })}
</ThemeText>
<ThemeButton
label={t('mobile.report.done')}
onPress={close}
testID="content-report-done"
/>
</View>
)}
</View>
</KeyboardAvoidingView>
</View>
</Modal>
)
}
type Palette = ReturnType<typeof useMobilePreferences>['palette']
function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
return StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'rgba(0, 0, 0, 0.62)',
},
keyboardArea: { width: '100%', justifyContent: 'flex-end' },
sheet: {
maxHeight: '92%',
gap: 16,
padding: 20,
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
backgroundColor: palette.bg.sidebar,
borderTopLeftRadius: 22,
borderTopRightRadius: 22,
borderWidth: 1,
borderBottomWidth: 0,
borderColor: palette.border.strong,
},
header: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 },
headerCopy: { flex: 1, gap: 6 },
closeButton: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' },
excerpt: {
gap: 7,
padding: 12,
borderRadius: 12,
backgroundColor: palette.bg.inset,
borderWidth: 1,
borderColor: palette.border.default,
},
reasonScroll: { flexGrow: 0, maxHeight: 250 },
reasonList: { gap: 8 },
reasonButton: {
minHeight: 48,
flexDirection: 'row',
alignItems: 'center',
gap: 11,
paddingHorizontal: 12,
borderRadius: 10,
borderWidth: 1,
borderColor: palette.border.default,
backgroundColor: palette.bg.card,
},
reasonButtonSelected: { borderColor: palette.accent.main, backgroundColor: palette.accent.dim },
radio: {
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 2,
borderColor: palette.border.strong,
},
radioSelected: { borderWidth: 5, borderColor: palette.accent.main },
comment: {
minHeight: 92,
maxHeight: 150,
padding: 12,
color: palette.text.primary,
textAlignVertical: 'top',
borderRadius: 10,
borderWidth: 1,
borderColor: palette.border.default,
backgroundColor: palette.bg.inset,
},
privacyNote: { lineHeight: 17 },
errorBox: {
padding: 11,
borderRadius: 9,
borderWidth: 1,
borderColor: palette.tag.red,
backgroundColor: palette.bg.card,
},
actions: { flexDirection: 'row', gap: 10 },
action: { flex: 1 },
success: { gap: 18, paddingVertical: 10 },
})
}

View file

@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { StyleSheet, View } from 'react-native'
import {
BannerAd,
BannerAdSize,
} from 'react-native-google-mobile-ads'
import { useMobileAds } from '../lib/mobile-ads-context'
import { useMobilePreferences } from '../lib/preferences-context'
import { ThemeText } from '../theme/themed-components'
const INITIAL_RETRY_DELAY_MS = 30_000
const MAX_RETRY_DELAY_MS = 5 * 60_000
export default function FreeTierBanner(): React.ReactElement | null {
const ads = useMobileAds()
const { palette } = useMobilePreferences()
const [loaded, setLoaded] = useState(false)
const [failed, setFailed] = useState(false)
const [retryAttempt, setRetryAttempt] = useState(0)
const [retryKey, setRetryKey] = useState(0)
useEffect(() => {
setLoaded(false)
setFailed(false)
setRetryAttempt(0)
setRetryKey(0)
}, [ads.bannerUnitId])
useEffect(() => {
if (!failed || !ads.bannerUnitId) return undefined
const delay = Math.min(
INITIAL_RETRY_DELAY_MS * (2 ** retryAttempt),
MAX_RETRY_DELAY_MS,
)
const timer = setTimeout(() => {
setLoaded(false)
setFailed(false)
setRetryAttempt((current) => current + 1)
setRetryKey((current) => current + 1)
}, delay)
return () => clearTimeout(timer)
}, [ads.bannerUnitId, failed, retryAttempt])
if (!ads.eligible) return null
return (
<View
style={[
styles.slot,
{
backgroundColor: palette.bg.inset,
borderTopColor: palette.border.subtle,
},
]}
testID="free-tier-ad-slot"
accessibilityLabel="Sponsored"
>
{ads.bannerUnitId && !failed ? (
<BannerAd
key={`${ads.bannerUnitId}:${retryKey}`}
unitId={ads.bannerUnitId}
size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER}
requestOptions={{ requestNonPersonalizedAdsOnly: true }}
onAdLoaded={() => {
setLoaded(true)
setFailed(false)
setRetryAttempt(0)
}}
onAdFailedToLoad={() => {
setLoaded(false)
setFailed(true)
}}
/>
) : (
<ThemeText variant="label" color="muted" style={styles.statusText}>
{failed ? 'AD UNAVAILABLE' : ads.status === 'ready' ? 'AD LOADING' : 'AD CONSENT'}
</ThemeText>
)}
{ads.bannerUnitId && !loaded && !failed && (
<ThemeText variant="label" color="muted" style={styles.loadingOverlay}>
AD LOADING
</ThemeText>
)}
</View>
)
}
const styles = StyleSheet.create({
slot: {
minHeight: 50,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
overflow: 'hidden',
},
statusText: { fontSize: 9, letterSpacing: 1.2 },
loadingOverlay: { position: 'absolute', fontSize: 9, letterSpacing: 1.2 },
})