diff --git a/.gitignore b/.gitignore index 9ea2689..a3c6b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,6 @@ android/generated # React Native Nitro Modules nitrogen/ + +# argent screen recordings +.argent/ diff --git a/example/assets/components/voice-delete.png b/example/assets/components/voice-delete.png new file mode 100644 index 0000000..01bb617 Binary files /dev/null and b/example/assets/components/voice-delete.png differ diff --git a/example/assets/instagram/back.png b/example/assets/instagram/back.png new file mode 100644 index 0000000..f5d99ef Binary files /dev/null and b/example/assets/instagram/back.png differ diff --git a/example/assets/instagram/bookmark.png b/example/assets/instagram/bookmark.png new file mode 100644 index 0000000..5969fa1 Binary files /dev/null and b/example/assets/instagram/bookmark.png differ diff --git a/example/assets/instagram/camera.png b/example/assets/instagram/camera.png new file mode 100644 index 0000000..b013874 Binary files /dev/null and b/example/assets/instagram/camera.png differ diff --git a/example/assets/instagram/image.png b/example/assets/instagram/image.png new file mode 100644 index 0000000..83987df Binary files /dev/null and b/example/assets/instagram/image.png differ diff --git a/example/assets/instagram/info.png b/example/assets/instagram/info.png new file mode 100644 index 0000000..5a10819 Binary files /dev/null and b/example/assets/instagram/info.png differ diff --git a/example/assets/instagram/mic.png b/example/assets/instagram/mic.png new file mode 100644 index 0000000..9b89ac6 Binary files /dev/null and b/example/assets/instagram/mic.png differ diff --git a/example/assets/instagram/phone.png b/example/assets/instagram/phone.png new file mode 100644 index 0000000..7d9ca11 Binary files /dev/null and b/example/assets/instagram/phone.png differ diff --git a/example/assets/instagram/play.png b/example/assets/instagram/play.png new file mode 100644 index 0000000..1af7987 Binary files /dev/null and b/example/assets/instagram/play.png differ diff --git a/example/assets/instagram/plus.png b/example/assets/instagram/plus.png new file mode 100644 index 0000000..01601b5 Binary files /dev/null and b/example/assets/instagram/plus.png differ diff --git a/example/assets/instagram/send.png b/example/assets/instagram/send.png new file mode 100644 index 0000000..e8ed077 Binary files /dev/null and b/example/assets/instagram/send.png differ diff --git a/example/assets/instagram/share.png b/example/assets/instagram/share.png new file mode 100644 index 0000000..a4d32e8 Binary files /dev/null and b/example/assets/instagram/share.png differ diff --git a/example/assets/instagram/sticker.png b/example/assets/instagram/sticker.png new file mode 100644 index 0000000..a2be709 Binary files /dev/null and b/example/assets/instagram/sticker.png differ diff --git a/example/assets/instagram/trash.png b/example/assets/instagram/trash.png new file mode 100644 index 0000000..fcdf2ee Binary files /dev/null and b/example/assets/instagram/trash.png differ diff --git a/example/assets/instagram/video.png b/example/assets/instagram/video.png new file mode 100644 index 0000000..afea376 Binary files /dev/null and b/example/assets/instagram/video.png differ diff --git a/example/src/App.tsx b/example/src/App.tsx index 3b8fbc8..bf8d018 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -37,6 +37,7 @@ import TelegramDissolveScreen from './screens/TelegramDissolveScreen'; import BirthdayCandleScreen from './screens/BirthdayCandleScreen'; import ParticleDispersionScreen from './screens/ParticleDispersionScreen'; import FoamLabScreen from './screens/FoamLabScreen'; +import VoiceDeleteScreen from './screens/VoiceDeleteScreen'; import type { RootStackParamList } from './types'; const Stack = createNativeStackNavigator(); @@ -129,6 +130,10 @@ export default function App() { component={ParticleDispersionScreen} /> + diff --git a/example/src/components/instagram/SmokePoof.tsx b/example/src/components/instagram/SmokePoof.tsx new file mode 100644 index 0000000..62b143c --- /dev/null +++ b/example/src/components/instagram/SmokePoof.tsx @@ -0,0 +1,136 @@ +import { + ShaderView, + type ParamsSynchronizable, + type ShaderViewProps, +} from 'react-native-effects'; + +type SmokePoofProps = Omit< + ShaderViewProps, + 'fragmentShader' | 'paramsSynchronizable' | 'transparent' +> & { + /** `u.live = (progress 0→1, seed, 0, 0)`. Nothing is drawn at 0 or 1. */ + paramsSynchronizable: ParamsSynchronizable; +}; + +/** + * A single grey smoke puff that bursts out of the centre, billows, drifts + * up-left and thins into wisps — the "poof" Instagram plays when a voice + * message is discarded. Progress is pushed through the live channel so a + * Reanimated timing curve drives it frame-accurately. + */ +export default function SmokePoof({ + paramsSynchronizable, + ...rest +}: SmokePoofProps) { + return ( + + ); +} + +const SHADER = /* wgsl */ ` +struct Uniforms { + resolution: vec4, + time: vec4, + color0: vec4, + color1: vec4, + params0: vec4, + params1: vec4, + live: vec4, +}; +@group(0) @binding(0) var u: Uniforms; + +fn hash21(p: vec2) -> f32 { + var p3 = fract(vec3(p.xyx) * 0.1031); + p3 = p3 + dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +fn vnoise(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let w = f * f * (3.0 - 2.0 * f); + let a = hash21(i); + let b = hash21(i + vec2(1.0, 0.0)); + let c = hash21(i + vec2(0.0, 1.0)); + let d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, w.x), mix(c, d, w.x), w.y); +} + +fn fbm(p0: vec2) -> f32 { + var p = p0; + var v = 0.0; + var a = 0.5; + let m = mat2x2(1.6, 1.2, -1.2, 1.6); + for (var i = 0; i < 4; i = i + 1) { + v = v + a * vnoise(p); + p = m * p; + a = a * 0.5; + } + return v; +} + +// Soft round puff: 1 at the centre, 0 at radius r. +fn puff(q: vec2, centre: vec2, r: f32) -> f32 { + let d = length(q - centre) / r; + return 1.0 - smoothstep(0.0, 1.0, d); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let p = u.live.x; + if (p <= 0.001 || p >= 0.999) { + return vec4(0.0); + } + let seed = u.live.y * 17.0; + + // Centred, aspect-corrected, y-up. The button sits at the origin. + let c = (ndc * 0.5) * vec2(u.resolution.z, 1.0); + + // Burst: fast expansion that decelerates, then a gentle drift up and left. + let grow = 1.0 - pow(1.0 - p, 2.4); + let radius = 0.12 + 0.25 * grow; + let drift = vec2(-0.05, 0.07) * p * p + vec2(-0.01, 0.02) * p; + var q = c - drift; + + // Domain warp so the cloud boils rather than scales like a sticker. + let warpN = fbm(q * 4.0 + seed + vec2(0.0, -p * 1.2)); + let warpM = fbm(q * 4.0 - seed + vec2(p * 0.9, 0.4)); + q = q + (vec2(warpN, warpM) - 0.5) * (0.03 + 0.07 * grow); + + // Overlapping puffs around the centre with a slight upward bias. + var d = puff(q, vec2(0.0, 0.0), radius * 1.1); + d = d + puff(q, vec2(-0.6, 0.35) * radius, radius * 0.8); + d = d + puff(q, vec2(0.6, 0.3) * radius, radius * 0.75); + d = d + puff(q, vec2(-0.35, -0.55) * radius, radius * 0.7); + d = d + puff(q, vec2(0.45, -0.5) * radius, radius * 0.7); + d = d + puff(q, vec2(-0.15, 0.8) * radius, radius * 0.7); + d = d + puff(q, vec2(0.2, 0.75) * radius, radius * 0.6); + d = min(d, 1.5); + + // Erode with soft, low-frequency noise; the threshold rises over time so + // the cloud thins into a few wisps instead of fading uniformly. + let n = fbm(q * 4.5 + seed * 0.7 + vec2(p * 0.5, p * 1.4)); + let field = d * (0.6 + 0.8 * n); + let thr = 0.32 + 0.7 * p; + var alpha = smoothstep(thr, thr + 0.85, field); + + // Envelope: pops in over the first ~5%, holds, thins out at the end. + let env = smoothstep(0.0, 0.05, p) * (1.0 - smoothstep(0.5, 1.0, p)); + alpha = alpha * env * 0.82; + + // Colour: mid ash grey, a touch lighter where dense and lit from top-left, + // with the button's red bleeding into the young smoke. + let lit = fbm(q * 4.5 + seed * 0.7 + vec2(p * 0.5 - 0.1, p * 1.4 + 0.1)); + var col = vec3(0.44, 0.45, 0.48); + col = col + vec3(0.16) * clamp((lit - n) * 3.0 + 0.3, 0.0, 1.0) * min(d, 1.0); + col = mix(col, col * vec3(1.3, 0.7, 0.75), + (1.0 - smoothstep(0.0, 0.3, p)) * 0.5 * min(d, 1.0)); + + return vec4(col * alpha, alpha); +} +`; diff --git a/example/src/components/instagram/VoiceComposer.tsx b/example/src/components/instagram/VoiceComposer.tsx new file mode 100644 index 0000000..8b4db32 --- /dev/null +++ b/example/src/components/instagram/VoiceComposer.tsx @@ -0,0 +1,409 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + View, + Text, + StyleSheet, + Image, + Pressable, + type LayoutChangeEvent, +} from 'react-native'; +import Animated, { + Easing, + FadeIn, + interpolate, + interpolateColor, + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from 'react-native-reanimated'; +import { useParamsSynchronizable } from 'react-native-effects'; +import SmokePoof from './SmokePoof'; +import { IG_BAR, IG_BLUE, IG_ICONS, IG_MUTED, IG_RED } from './icons'; + +// Timings measured off the Instagram recording (60 fps frames). +const EXPAND_MS = 320; // mic tap → full blue bar +const COLLAPSE_MS = 130; // trash tap → bar sucked into the trash button +const HOLD_MS = 300; // red trash button sits still before it pops +const POOF_MS = 700; // smoke burst → wisps gone +const BAR_EVERY_MS = 90; // one new waveform bar per tick +const BAR_STEP = 5; // 2pt bar + 3pt gap + +const BAR_H = 44; +const BTN = 36; +const PAD = 4; +const SMOKE = 130; // smoke canvas size, centred on the trash button + +function Icon({ + source, + size, + color = '#fff', +}: { + source: number; + size: number; + color?: string; +}) { + return ( + + ); +} + +type Phase = 'idle' | 'recording' | 'deleting'; + +/** + * Instagram DM composer with the voice-message flow: tap the mic and a blue bar + * swells out of the camera button and starts drawing a waveform; tap the trash + * and the bar is sucked back into the button, which turns red, holds, and + * bursts into a puff of smoke that clears to reveal the camera again. + */ +export default function VoiceComposer() { + const [phase, setPhase] = useState('idle'); + const [bars, setBars] = useState([]); + const [elapsed, setElapsed] = useState(0); + const [width, setWidth] = useState(0); + const lastTall = useRef(false); + + // 0 → 1 as the blue bar expands out of the camera button. + const rec = useSharedValue(0); + // 0 → 1 as the bar collapses back into the (now red) trash button. + const del = useSharedValue(0); + // 0 → 1 smoke burst; also fades the red button out underneath it. + const poof = useSharedValue(0); + const seed = useSharedValue(0); + + // Bridge the poof curve into the shader every frame on the UI thread. + const { paramsSynchronizable } = useParamsSynchronizable([0, 0, 0, 0]); + useAnimatedReaction( + () => [poof.value, seed.value] as const, + ([p, s]) => { + 'worklet'; + paramsSynchronizable.setBlocking(() => Float64Array.of(p, s, 0, 0)); + } + ); + + // Waveform + clock tick while recording. + useEffect(() => { + if (phase !== 'recording') { + return; + } + const waveArea = width - (PAD + BTN + 10) - (10 + 44 + BTN + PAD); + const maxBars = Math.max(1, Math.floor(waveArea / BAR_STEP)); + const wave = setInterval(() => { + // Bursty amplitude: tall bars tend to come in runs, like speech. + const tall = Math.random() < (lastTall.current ? 0.55 : 0.22); + lastTall.current = tall; + const h = tall ? 10 + Math.random() * 14 : 4 + Math.random() * 3; + setBars((prev) => [...prev.slice(-(maxBars - 1)), h]); + }, BAR_EVERY_MS); + const clock = setInterval(() => setElapsed((t) => t + 1), 1000); + return () => { + clearInterval(wave); + clearInterval(clock); + }; + }, [phase, width]); + + const startRecording = useCallback(() => { + if (phase !== 'idle') { + return; + } + setBars([]); + setElapsed(0); + setPhase('recording'); + rec.value = withTiming(1, { + duration: EXPAND_MS, + easing: Easing.out(Easing.cubic), + }); + }, [phase, rec]); + + const finish = useCallback(() => { + setPhase('idle'); + setBars([]); + setElapsed(0); + rec.value = 0; + del.value = 0; + poof.value = 0; + }, [rec, del, poof]); + + const send = useCallback(() => { + if (phase !== 'recording') { + return; + } + setPhase('deleting'); + rec.value = withTiming( + 0, + { duration: 260, easing: Easing.inOut(Easing.cubic) }, + (done) => { + if (done) { + runOnJS(finish)(); + } + } + ); + }, [phase, rec, finish]); + + const discard = useCallback(() => { + if (phase !== 'recording') { + return; + } + setPhase('deleting'); + seed.value = Math.random(); + del.value = withTiming(1, { + duration: COLLAPSE_MS, + easing: Easing.out(Easing.cubic), + }); + poof.value = withDelay( + COLLAPSE_MS + HOLD_MS, + withTiming(1, { duration: POOF_MS, easing: Easing.linear }, (done) => { + if (done) { + runOnJS(finish)(); + } + }) + ); + }, [phase, del, poof, seed, finish]); + + const onLayout = useCallback((e: LayoutChangeEvent) => { + setWidth(e.nativeEvent.layout.width); + }, []); + + // --- animated styles ----------------------------------------------------- + + // The blue pill grows out of the camera circle, then shrinks back into it. + const pillStyle = useAnimatedStyle(() => { + const open = rec.value * (1 - del.value); + const w = interpolate(open, [0, 1], [BTN + PAD * 2, Math.max(width, 1)]); + return { + width: w, + opacity: rec.value * (1 - interpolate(del.value, [0.85, 1], [0, 1])), + }; + }); + + // Trash button: white while recording, red + 1.2× while deleting, then gone. + const trashStyle = useAnimatedStyle(() => { + const scale = interpolate(del.value, [0, 1], [1, 1.2]); + const gone = interpolate(poof.value, [0.12, 0.42], [0, 1], 'clamp'); + return { + backgroundColor: interpolateColor(del.value, [0, 1], ['#F8F8F8', IG_RED]), + transform: [{ scale: scale * (1 - 0.15 * gone) }], + opacity: interpolate(rec.value, [0, 0.4], [0, 1], 'clamp') * (1 - gone), + }; + }); + const trashGreyIcon = useAnimatedStyle(() => ({ + opacity: 1 - del.value, + })); + const trashWhiteIcon = useAnimatedStyle(() => ({ + opacity: del.value, + })); + + // Waveform, clock and send button live inside the pill: they fade in once + // the bar is mostly open and vanish the instant a delete starts. + const contentStyle = useAnimatedStyle(() => ({ + opacity: + interpolate(rec.value, [0.6, 1], [0, 1], 'clamp') * (1 - del.value), + })); + const timerStyle = useAnimatedStyle(() => ({ + opacity: interpolate(rec.value, [0.6, 1], [0, 1], 'clamp'), + })); + const sendStyle = useAnimatedStyle(() => ({ + opacity: + interpolate(rec.value, [0.85, 1], [0, 1], 'clamp') * + (1 - interpolate(del.value, [0, 0.15], [0, 1], 'clamp')), + transform: [ + { scale: interpolate(rec.value, [0.85, 1], [0.6, 1], 'clamp') }, + ], + })); + + const clock = `${Math.floor(elapsed / 60)}:${elapsed % 60 < 10 ? '0' : ''}${ + elapsed % 60 + }`; + + return ( + + {/* Idle composer — always mounted; the pill slides over it. */} + + + + + Message... + + + + + + + + + + + + + + + {/* Recording pill */} + + + {bars.map((h, i) => ( + + ))} + + + {clock} + + + + + + + + + {/* Trash button — outside the pill so it can pop past its bounds. */} + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + height: BAR_H, + }, + composer: { + height: BAR_H, + borderRadius: BAR_H / 2, + backgroundColor: IG_BAR, + flexDirection: 'row', + alignItems: 'center', + paddingLeft: PAD, + paddingRight: 8, + }, + circle: { + width: BTN, + height: BTN, + borderRadius: BTN / 2, + alignItems: 'center', + justifyContent: 'center', + }, + white: { + backgroundColor: '#F8F8F8', + }, + placeholder: { + flex: 1, + marginLeft: 11, + color: IG_MUTED, + fontSize: 16, + }, + composerIcon: { + width: 39, + height: BAR_H, + alignItems: 'center', + justifyContent: 'center', + }, + pill: { + position: 'absolute', + left: 0, + top: 0, + height: BAR_H, + borderRadius: BAR_H / 2, + backgroundColor: IG_BLUE, + overflow: 'hidden', + flexDirection: 'row', + alignItems: 'center', + paddingLeft: PAD + BTN + 10, + paddingRight: PAD, + }, + waveRow: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 3, + overflow: 'hidden', + }, + waveBar: { + width: 2, + borderRadius: 1, + backgroundColor: '#fff', + }, + timer: { + flexShrink: 0, + color: '#fff', + fontSize: 15, + fontWeight: '600', + marginLeft: 10, + marginRight: 10, + fontVariant: ['tabular-nums'], + }, + sendWrap: { + flexShrink: 0, + width: BTN, + height: BTN, + }, + trash: { + position: 'absolute', + left: PAD, + top: PAD, + width: BTN, + height: BTN, + borderRadius: BTN / 2, + }, + fill: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + abs: { + ...StyleSheet.absoluteFill, + alignItems: 'center', + justifyContent: 'center', + }, + smoke: { + position: 'absolute', + width: SMOKE, + height: SMOKE, + left: PAD + BTN / 2 - SMOKE / 2, + top: PAD + BTN / 2 - SMOKE / 2, + }, +}); diff --git a/example/src/components/instagram/icons.ts b/example/src/components/instagram/icons.ts new file mode 100644 index 0000000..f11bbd4 --- /dev/null +++ b/example/src/components/instagram/icons.ts @@ -0,0 +1,25 @@ +// Instagram glyphs cropped from a screen recording and converted to white +// alpha masks — tint them with `tintColor`. +export const IG_ICONS = { + back: require('../../../assets/instagram/back.png'), + phone: require('../../../assets/instagram/phone.png'), + video: require('../../../assets/instagram/video.png'), + share: require('../../../assets/instagram/share.png'), + bookmark: require('../../../assets/instagram/bookmark.png'), + info: require('../../../assets/instagram/info.png'), + play: require('../../../assets/instagram/play.png'), + camera: require('../../../assets/instagram/camera.png'), + mic: require('../../../assets/instagram/mic.png'), + image: require('../../../assets/instagram/image.png'), + sticker: require('../../../assets/instagram/sticker.png'), + plus: require('../../../assets/instagram/plus.png'), + trash: require('../../../assets/instagram/trash.png'), + send: require('../../../assets/instagram/send.png'), +}; + +export const IG_BLUE = '#4C49FA'; +export const IG_RED = '#FE5971'; +export const IG_BG = '#0E1115'; +export const IG_SURFACE = '#21242A'; +export const IG_BAR = '#16191F'; +export const IG_MUTED = '#8B8F96'; diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index 2a94e2e..80faaa4 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -11,6 +11,14 @@ import { useNavigation } from '@react-navigation/native'; import type { ExampleCategory, HomeScreenNavigationProp } from '../types'; const EXAMPLE_CATEGORIES: ExampleCategory[] = [ + { + id: 'voice-delete', + title: 'Voice Delete', + description: 'Instagram DM — delete a voice message', + screen: 'VoiceDeleteExample', + color: '#4C49FA', + image: require('../../assets/components/voice-delete.png'), + }, { id: 'particle-dispersion', title: 'Particle Dispersion', diff --git a/example/src/screens/VoiceDeleteScreen.tsx b/example/src/screens/VoiceDeleteScreen.tsx new file mode 100644 index 0000000..f13860b --- /dev/null +++ b/example/src/screens/VoiceDeleteScreen.tsx @@ -0,0 +1,455 @@ +import { + View, + Text, + StyleSheet, + StatusBar, + Image, + Pressable, + ScrollView, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import VoiceComposer from '../components/instagram/VoiceComposer'; +import { + IG_BG as BG, + IG_ICONS as ICONS, + IG_MUTED as MUTED, + IG_SURFACE as SURFACE, +} from '../components/instagram/icons'; + +// Instagram DM thread clone. The chrome (icons, colours, spacing) mirrors the +// real app; the people, posts and copy are placeholders. + +const AVATAR = { uri: 'https://picsum.photos/seed/rne-dm-avatar/200/200' }; +const POST_IMAGE = { uri: 'https://picsum.photos/seed/rne-dm-post/720/612' }; +const REEL_IMAGE = { uri: 'https://picsum.photos/seed/rne-dm-reel/480/870' }; +const REEL_AVATAR = { + uri: 'https://picsum.photos/seed/rne-dm-reel-av/100/100', +}; + +function Icon({ + source, + size, + color = '#fff', +}: { + source: number; + size: number; + color?: string; +}) { + return ( + + ); +} + +function CircleButton({ + size, + color, + children, + style, + onPress, + label, +}: { + size: number; + color: string; + children: React.ReactNode; + style?: object; + onPress?: () => void; + label?: string; +}) { + return ( + + {children} + + ); +} + +function MessageActions({ top }: { top: number }) { + return ( + + + + + + + + + ); +} + +export default function VoiceDeleteScreen() { + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const headerHeight = insets.top + 56; + + return ( + + + + + {/* Shared post */} + + + + + + + AI info + + + + reanimated_daily + Tomasz Zawadzki is the goat 🐐 + + + + ❤️ + + + + + 16 AUG AT 10:10 + + {/* Shared reel */} + + + + + + + + + yarn_ios_enjoyer and the… + + + + What it's like living with someone who runs pod install a lot? + + + + + + + + + + + Seen + + + {/* Header */} + + + navigation.goBack()} + label="Back" + > + + + + + + Metro Bundler › + + Bundling 99% for 22m + + + + + + + + + {/* Fade the thread out under the header. */} + + {[0.85, 0.7, 0.55, 0.4, 0.25, 0.12, 0.04].map((o, i) => ( + + ))} + + + + {/* Composer */} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: BG, + }, + thread: { + paddingHorizontal: 8, + }, + row: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'flex-start', + }, + actions: { + marginRight: 9, + alignItems: 'center', + }, + actionCircle: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: SURFACE, + alignItems: 'center', + justifyContent: 'center', + }, + + // Post card + postCard: { + width: 240, + borderRadius: 16, + backgroundColor: SURFACE, + overflow: 'visible', + }, + postImage: { + width: 240, + height: 204, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + backgroundColor: '#3a4a5a', + }, + aiPill: { + position: 'absolute', + left: 8, + top: 204 - 8 - 30, + height: 30, + paddingLeft: 7, + paddingRight: 10, + borderRadius: 15, + backgroundColor: 'rgba(18,16,14,0.82)', + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + aiPillText: { + color: '#fff', + fontSize: 13, + fontWeight: '600', + }, + postCaption: { + paddingHorizontal: 12, + paddingVertical: 8, + minHeight: 50, + justifyContent: 'center', + borderBottomLeftRadius: 16, + borderBottomRightRadius: 16, + }, + captionText: { + color: '#fff', + fontSize: 14, + lineHeight: 17, + }, + captionUser: { + fontWeight: '700', + }, + reaction: { + position: 'absolute', + left: 5, + bottom: -23, + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: SURFACE, + alignItems: 'center', + justifyContent: 'center', + }, + reactionText: { + fontSize: 13, + }, + + date: { + marginTop: 42, + marginBottom: 23, + textAlign: 'center', + color: MUTED, + fontSize: 12, + letterSpacing: 0.2, + }, + + // Reel card + reelCard: { + width: 160, + height: 289, + borderRadius: 16, + overflow: 'hidden', + backgroundColor: SURFACE, + }, + reelImage: { + ...StyleSheet.absoluteFill, + width: 160, + height: 289, + }, + reelShade: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: 70, + backgroundColor: 'rgba(0,0,0,0.3)', + }, + reelHeader: { + position: 'absolute', + left: 19, + top: 13, + right: 10, + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + }, + reelAvatar: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: '#555', + }, + reelUser: { + flex: 1, + color: '#fff', + fontSize: 14, + lineHeight: 17, + fontWeight: '600', + textShadowColor: 'rgba(0,0,0,0.6)', + textShadowRadius: 3, + }, + reelCaption: { + position: 'absolute', + left: 10, + right: 10, + top: 55, + color: '#fff', + fontSize: 12, + lineHeight: 15, + fontWeight: '800', + fontStyle: 'italic', + textAlign: 'center', + textShadowColor: '#000', + textShadowRadius: 2, + textShadowOffset: { width: 0, height: 1 }, + }, + playButton: { + position: 'absolute', + left: 60, + top: 125, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.4)', + alignItems: 'center', + justifyContent: 'center', + paddingLeft: 3, + }, + reelBadge: { + position: 'absolute', + left: 14, + bottom: 14, + width: 18, + height: 18, + borderRadius: 5, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + paddingLeft: 1, + }, + + seen: { + marginTop: 8, + marginRight: 14, + textAlign: 'right', + color: MUTED, + fontSize: 12, + }, + + // Header + header: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: BG, + justifyContent: 'flex-end', + }, + headerRow: { + height: 56, + flexDirection: 'row', + alignItems: 'center', + paddingLeft: 16, + paddingRight: 12, + }, + avatar: { + width: 36, + height: 36, + borderRadius: 18, + marginLeft: 11, + backgroundColor: '#444', + }, + headerText: { + flex: 1, + marginLeft: 10, + }, + name: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + chevron: { + color: '#fff', + fontSize: 18, + fontWeight: '400', + }, + active: { + color: MUTED, + fontSize: 13, + marginTop: 1, + }, + headerFade: { + position: 'absolute', + left: 0, + right: 0, + top: '100%', + height: 28, + }, + + // Composer + composerWrap: { + position: 'absolute', + left: 8, + right: 8, + }, +}); diff --git a/example/src/types.ts b/example/src/types.ts index d75940a..da6b37a 100644 --- a/example/src/types.ts +++ b/example/src/types.ts @@ -37,6 +37,7 @@ export type RootStackParamList = { BirthdayCandleExample: undefined; ParticleDispersionExample: undefined; FoamLabExample: undefined; + VoiceDeleteExample: undefined; }; export type HomeScreenNavigationProp =