feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
357
apps/mobile-rn/src/components/ContentReportSheet.tsx
Normal file
357
apps/mobile-rn/src/components/ContentReportSheet.tsx
Normal 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 },
|
||||
})
|
||||
}
|
||||
98
apps/mobile-rn/src/components/FreeTierBanner.tsx
Normal file
98
apps/mobile-rn/src/components/FreeTierBanner.tsx
Normal 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 },
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue