React Native Integration
Add content moderation to your React Native app.
Using Expo? See the Expo integration guide for Expo-specific setup.
Installation
bash
npm install @vettly/sdkQuick Start
tsx
import { ModerationClient } from '@vettly/sdk'
const vettly = new ModerationClient({
apiKey: 'vettly_your_api_key'
})
async function moderateMessage(content: string) {
const result = await vettly.check({
content,
contentType: 'text'
})
if (result.action === 'block') {
return { allowed: false, reason: 'Content not allowed' }
}
return { allowed: true }
}Text Moderation
Moderated Input Component
tsx
import React, { useState } from 'react'
import {
View,
TextInput,
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator
} from 'react-native'
import { ModerationClient } from '@vettly/sdk'
const vettly = new ModerationClient({
apiKey: 'vettly_your_api_key'
})
interface Props {
onSubmit: (text: string) => void
placeholder?: string
}
export function ModeratedInput({ onSubmit, placeholder }: Props) {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!text.trim()) return
setLoading(true)
setError(null)
try {
const result = await vettly.check({
content: text,
contentType: 'text'
})
if (result.action === 'block') {
setError('This content is not allowed')
return
}
onSubmit(text)
setText('')
} catch (err) {
console.error('Moderation error:', err)
// Decide: fail open or closed
onSubmit(text) // Fail open
setText('')
} finally {
setLoading(false)
}
}
return (
<View style={styles.container}>
<TextInput
style={[styles.input, error && styles.inputError]}
value={text}
onChangeText={(t) => {
setText(t)
setError(null)
}}
placeholder={placeholder || 'Type something...'}
multiline
editable={!loading}
/>
{error && <Text style={styles.errorText}>{error}</Text>}
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={loading || !text.trim()}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Send</Text>
)}
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
padding: 16
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 16,
minHeight: 80,
textAlignVertical: 'top'
},
inputError: {
borderColor: '#ef4444'
},
errorText: {
color: '#ef4444',
fontSize: 14,
marginTop: 4
},
button: {
backgroundColor: '#3b82f6',
borderRadius: 8,
padding: 14,
alignItems: 'center',
marginTop: 8
},
buttonDisabled: {
opacity: 0.6
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600'
}
})Image Moderation
With react-native-image-picker
bash
npm install react-native-image-picker react-native-fstsx
import { launchImageLibrary } from 'react-native-image-picker'
import RNFS from 'react-native-fs'
import { ModerationClient } from '@vettly/sdk'
const vettly = new ModerationClient({
apiKey: 'vettly_your_api_key'
})
async function pickAndModerateImage() {
const result = await launchImageLibrary({
mediaType: 'photo',
quality: 0.8
})
if (result.didCancel || !result.assets?.[0]) {
return null
}
const asset = result.assets[0]
// Read as base64
const base64 = await RNFS.readFile(asset.uri!, 'base64')
// Moderate
const modResult = await vettly.check({
content: base64,
contentType: 'image'
})
if (modResult.action === 'block') {
Alert.alert('Error', 'This image is not allowed')
return null
}
return asset.uri
}Image Upload Component
tsx
import React, { useState } from 'react'
import {
View,
Image,
TouchableOpacity,
Text,
Alert,
ActivityIndicator,
StyleSheet
} from 'react-native'
import { launchImageLibrary } from 'react-native-image-picker'
import RNFS from 'react-native-fs'
import { ModerationClient } from '@vettly/sdk'
const vettly = new ModerationClient({
apiKey: 'vettly_your_api_key'
})
interface Props {
onImageSelected: (uri: string, moderationId: string) => void
}
export function ModeratedImagePicker({ onImageSelected }: Props) {
const [imageUri, setImageUri] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const pickImage = async () => {
const result = await launchImageLibrary({
mediaType: 'photo',
quality: 0.8
})
if (result.didCancel || !result.assets?.[0]) return
const asset = result.assets[0]
setLoading(true)
try {
// Convert to base64
const base64 = await RNFS.readFile(asset.uri!, 'base64')
// Moderate
const modResult = await vettly.check({
content: base64,
contentType: 'image'
})
if (modResult.action === 'block') {
Alert.alert(
'Image Not Allowed',
'Please select a different image.'
)
return
}
setImageUri(asset.uri!)
onImageSelected(asset.uri!, modResult.decisionId)
} catch (error) {
Alert.alert('Error', 'Failed to process image')
} finally {
setLoading(false)
}
}
return (
<View style={styles.container}>
<TouchableOpacity
style={styles.picker}
onPress={pickImage}
disabled={loading}
>
{loading ? (
<ActivityIndicator size="large" color="#3b82f6" />
) : imageUri ? (
<Image source={{ uri: imageUri }} style={styles.image} />
) : (
<Text style={styles.placeholderText}>Tap to select image</Text>
)}
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
alignItems: 'center'
},
picker: {
width: 200,
height: 200,
borderRadius: 12,
borderWidth: 2,
borderColor: '#ddd',
borderStyle: 'dashed',
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden'
},
image: {
width: '100%',
height: '100%'
},
placeholderText: {
color: '#666',
fontSize: 16
}
})Custom Hook
tsx
// hooks/useModeration.ts
import { useState, useCallback } from 'react'
import { ModerationClient, ModerationResult } from '@vettly/sdk'
const vettly = new ModerationClient({
apiKey: 'vettly_your_api_key'
})
export function useModeration() {
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<ModerationResult | null>(null)
const [error, setError] = useState<Error | null>(null)
const checkText = useCallback(async (content: string) => {
setLoading(true)
setError(null)
try {
const modResult = await vettly.check({
content,
contentType: 'text'
})
setResult(modResult)
return modResult
} catch (err) {
setError(err as Error)
throw err
} finally {
setLoading(false)
}
}, [])
const checkImage = useCallback(async (base64: string) => {
setLoading(true)
setError(null)
try {
const modResult = await vettly.check({
content: base64,
contentType: 'image'
})
setResult(modResult)
return modResult
} catch (err) {
setError(err as Error)
throw err
} finally {
setLoading(false)
}
}, [])
return {
checkText,
checkImage,
loading,
result,
error,
isBlocked: result?.action === 'block'
}
}Server-Side Moderation (Recommended)
Keep your API key secure by moderating on your backend:
tsx
// React Native app
async function sendMessage(content: string) {
const response = await fetch('https://your-api.com/api/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ content })
})
if (response.status === 403) {
const data = await response.json()
throw new Error(data.error)
}
return response.json()
}Error Handling
tsx
import { ModerationClient, VettlyError } from '@vettly/sdk'
async function safeModerate(content: string) {
try {
const result = await vettly.check({
content,
contentType: 'text'
})
return result
} catch (error) {
if (error instanceof VettlyError) {
// API error - decide how to handle
console.warn('Moderation unavailable:', error.message)
return { action: 'allow' } // Fail open
}
throw error
}
}Offline Support
tsx
import NetInfo from '@react-native-community/netinfo'
import AsyncStorage from '@react-native-async-storage/async-storage'
async function moderateWithOfflineSupport(content: string) {
const netInfo = await NetInfo.fetch()
if (!netInfo.isConnected) {
// Queue for later
const pending = JSON.parse(
await AsyncStorage.getItem('pendingModeration') || '[]'
)
pending.push({ content, timestamp: Date.now() })
await AsyncStorage.setItem('pendingModeration', JSON.stringify(pending))
return { action: 'pending', offline: true }
}
return vettly.check({ content, contentType: 'text' })
}
// Process queue when back online
async function processPendingModeration() {
const pending = JSON.parse(
await AsyncStorage.getItem('pendingModeration') || '[]'
)
for (const item of pending) {
try {
await vettly.check({ content: item.content, contentType: 'text' })
} catch (err) {
console.error('Failed to process:', err)
}
}
await AsyncStorage.removeItem('pendingModeration')
}App Store Compliance
If your app is on the Apple App Store, Guideline 1.2 requires content filtering, reporting, blocking, and published contact info for apps with user-generated content.
Report Button
tsx
import React, { useState } from 'react'
import { TouchableOpacity, Text, Alert, TextInput, Modal, View } from 'react-native'
import { ModerationClient } from '@vettly/sdk'
const vettly = new ModerationClient({
apiKey: 'vettly_your_api_key'
})
export function ReportButton({ contentId, userId }) {
const [showModal, setShowModal] = useState(false)
const [reason, setReason] = useState('')
const submitReport = async () => {
try {
await vettly.createAppeal({
contentId,
reason,
reporterId: userId
})
setShowModal(false)
setReason('')
Alert.alert('Reported', 'Thank you for reporting this content.')
} catch (err) {
Alert.alert('Error', 'Failed to submit report.')
}
}
return (
<>
<TouchableOpacity onPress={() => setShowModal(true)}>
<Text style={{ color: '#ef4444' }}>Report</Text>
</TouchableOpacity>
<Modal visible={showModal} transparent>
<View style={{ flex: 1, justifyContent: 'center', padding: 24, backgroundColor: 'rgba(0,0,0,0.5)' }}>
<View style={{ backgroundColor: '#fff', borderRadius: 12, padding: 20 }}>
<Text style={{ fontSize: 18, fontWeight: '600', marginBottom: 12 }}>Report Content</Text>
<TextInput
placeholder="Why are you reporting this?"
value={reason}
onChangeText={setReason}
multiline
style={{ borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 12, minHeight: 80, marginBottom: 12 }}
/>
<TouchableOpacity onPress={submitReport} style={{ backgroundColor: '#ef4444', borderRadius: 8, padding: 14, alignItems: 'center' }}>
<Text style={{ color: '#fff', fontWeight: '600' }}>Submit Report</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setShowModal(false)} style={{ padding: 14, alignItems: 'center' }}>
<Text>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</>
)
}Block User
tsx
async function blockUser(blockedUserId: string) {
await fetch('https://api.vettly.dev/v1/blocklist', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
userId: currentUser.id,
blockedUserId
})
})
Alert.alert('User Blocked', 'You will no longer see content from this user.')
}See the full App Store Compliance Guide for all four requirements.
Next Steps
- Expo Integration - Expo-specific guide
- App Store Compliance - Full Guideline 1.2 guide
- Custom Policies - Configure moderation rules
- Best Practices - Mobile moderation patterns
