diff --git a/example/assets/components/imessage.png b/example/assets/components/imessage.png new file mode 100644 index 0000000..d0ce4d0 Binary files /dev/null and b/example/assets/components/imessage.png differ diff --git a/example/assets/imessage/video.png b/example/assets/imessage/video.png new file mode 100644 index 0000000..cdcf269 Binary files /dev/null and b/example/assets/imessage/video.png differ diff --git a/example/src/App.tsx b/example/src/App.tsx index dcf3138..d48d492 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -43,6 +43,7 @@ import CigaretteScreen from './screens/CigaretteScreen'; import DandelionScreen from './screens/DandelionScreen'; import HourglassScreen from './screens/HourglassScreen'; import TelegramSpoilerScreen from './screens/TelegramSpoilerScreen'; +import IMessageScreen from './screens/IMessageScreen'; import type { RootStackParamList } from './types'; const Stack = createNativeStackNavigator(); @@ -147,6 +148,7 @@ export default function App() { name="TelegramSpoilerExample" component={TelegramSpoilerScreen} /> + diff --git a/example/src/components/imessage/Bubble.tsx b/example/src/components/imessage/Bubble.tsx new file mode 100644 index 0000000..79edc31 --- /dev/null +++ b/example/src/components/imessage/Bubble.tsx @@ -0,0 +1,201 @@ +import { useCallback, useRef, type ComponentRef } from 'react'; +import { StyleSheet, Text, View, type LayoutChangeEvent } from 'react-native'; +import { captureRef } from 'react-native-view-shot'; +import Animated, { Easing, withTiming } from 'react-native-reanimated'; +import type { BubbleTarget, Message, Rect } from './types'; + +// Colours sampled from a real Messages thread in the dark appearance. +export const IM_BLUE = '#4C95F7'; +export const IM_GREY = '#262629'; +export const IM_BG = '#000'; +export const IM_MUTED = '#8E8E93'; +export const IM_CHROME = '#181818'; +const RADIUS = 20; + +type ViewHandle = ComponentRef; + +// Measure and snapshot only once the entrance animation has finished and the +// thread has scrolled to the end; earlier, the snapshot would catch the +// bubble mid-fade and the rect would be where it was, not where it is. +const ENTER_MS = 260; +const MEASURE_DELAY_MS = ENTER_MS + 60; + +// Bubbles ease in from their tail corner, the way Messages lands them: a +// short fade with a slight rise and grow, no overshoot. +const EASE_OUT = Easing.out(Easing.cubic); +const popIn = () => { + 'worklet'; + return { + initialValues: { + opacity: 0, + transform: [{ scale: 0.94 }, { translateY: 8 }], + }, + animations: { + opacity: withTiming(1, { duration: 200, easing: EASE_OUT }), + transform: [ + { scale: withTiming(1, { duration: ENTER_MS, easing: EASE_OUT }) }, + { translateY: withTiming(0, { duration: ENTER_MS, easing: EASE_OUT }) }, + ], + }, + }; +}; + +type Props = { + msg: Message; + /** Only the last bubble of a run gets iMessage's tail. */ + tail: boolean; + /** Report the window rect (and, when asked, a snapshot) once laid out. */ + onTarget?: (target: BubbleTarget) => void; + wantSnapshot?: boolean; +}; + +/** + * One iMessage bubble: a real `Text` in a rounded view with the tail drawn + * under it. A bubble that a screen effect will play for measures itself in + * the window once laid out, and snapshots itself when the effect (echo) + * needs its pixels. + */ +export default function Bubble({ + msg, + tail, + onTarget, + wantSnapshot = false, +}: Props) { + const mine = msg.from === 'me'; + const bubbleRef = useRef(null); + const reported = useRef(false); + + const onLayout = useCallback( + (_e: LayoutChangeEvent) => { + if (reported.current || !onTarget) { + return; + } + reported.current = true; + setTimeout(async () => { + const node = bubbleRef.current; + if (!node) { + return; + } + try { + const rect = await new Promise((resolve) => + node.measureInWindow((x, y, w, h) => resolve({ x, y, w, h })) + ); + let snapshotUri: string | null = null; + if (wantSnapshot) { + const raw = await captureRef(bubbleRef, { + format: 'png', + quality: 1, + result: 'tmpfile', + }); + snapshotUri = raw.startsWith('file://') ? raw : `file://${raw}`; + } + onTarget({ rect, snapshotUri }); + } catch (err) { + if (__DEV__) { + console.warn('bubble capture failed', err); + } + } + }, MEASURE_DELAY_MS); + }, + [onTarget, wantSnapshot] + ); + + const bg = mine ? IM_BLUE : IM_GREY; + return ( + + + {tail && ( + <> + + + + )} + + {msg.text} + + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + marginTop: 5, + paddingHorizontal: 16, + }, + rowMine: { + justifyContent: 'flex-end', + }, + rowThem: { + justifyContent: 'flex-start', + }, + wrap: { + // Messages caps a bubble at about two thirds of the screen. + maxWidth: '68%', + }, + wrapMine: { + transformOrigin: 'right bottom', + }, + wrapThem: { + transformOrigin: 'left bottom', + }, + bubble: { + borderRadius: RADIUS, + paddingHorizontal: 15, + paddingVertical: 8, + overflow: 'hidden', + }, + text: { + color: '#fff', + fontSize: 17, + lineHeight: 21, + }, + // The tail is the CSS two-shape trick: a filled corner under the bubble, + // cut by a ground-coloured shape beside it. + tailA: { + position: 'absolute', + bottom: 0, + width: 20, + height: 24, + }, + tailAMine: { + right: -8, + borderBottomLeftRadius: 16, + }, + tailAThem: { + left: -8, + borderBottomRightRadius: 16, + }, + tailB: { + position: 'absolute', + bottom: 0, + width: 26, + height: 24, + backgroundColor: IM_BG, + }, + tailBMine: { + right: -26, + borderBottomLeftRadius: 10, + }, + tailBThem: { + left: -26, + borderBottomRightRadius: 10, + }, +}); diff --git a/example/src/components/imessage/ConfettiEffect.tsx b/example/src/components/imessage/ConfettiEffect.tsx new file mode 100644 index 0000000..adfbde4 --- /dev/null +++ b/example/src/components/imessage/ConfettiEffect.tsx @@ -0,0 +1,143 @@ +import { StyleSheet } from 'react-native'; +import { ShaderView } from 'react-native-effects'; +import { useEffectProgress } from './useEffectProgress'; + +export const CONFETTI_MS = 4200; + +type Props = { + onDone: () => void; +}; + +/** + * iMessage's Confetti: a shower of paper pieces dropped from above the + * screen that fall through the chat in front of the bubbles, tumbling and + * swaying, and are gone once they clear the bottom. Every piece is a cell + * in a column grid with its own drop time, so the first second is a dense + * front and the rest a thinning trail; a fragment gathers the columns that + * can reach it and tests each piece's rotated, flipping rectangle. + */ +export default function ConfettiEffect({ onDone }: Props) { + const paramsSynchronizable = useEffectProgress(CONFETTI_MS, onDone); + 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; + +const COL: f32 = 26.0; // column width, logical px +const PER_COL: i32 = 9; // pieces per column per layer +const SPAWN: f32 = 1.7; // seconds over which a column's pieces drop +const SWAY: f32 = 15.0; // sideways drift amplitude, px +const PIECE: vec2 = vec2(5.6, 3.3); // half size of a front piece, px + +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 paper(i: f32) -> vec3 { + // Bright party paper: red, orange, yellow, green, blue, violet, pink, white. + var palette = array, 8>( + vec3(1.00, 0.27, 0.27), + vec3(1.00, 0.58, 0.15), + vec3(1.00, 0.86, 0.22), + vec3(0.30, 0.85, 0.40), + vec3(0.25, 0.60, 1.00), + vec3(0.62, 0.42, 1.00), + vec3(1.00, 0.45, 0.75), + vec3(0.95, 0.95, 0.97) + ); + return palette[i32(i * 7.999)]; +} + +// Colour and coverage of the pieces of one layer at q, at effect time t. +fn layer(q: vec2, t: f32, res: vec2, scale: f32, seed: f32) + -> vec4 { + var rgb = vec3(0.0); + var a = 0.0; + let c0 = floor(q.x / COL); + for (var dc = -1; dc <= 1; dc = dc + 1) { + let col = c0 + f32(dc); + for (var k = 0; k < PER_COL; k = k + 1) { + let id = vec2(col * 3.1 + seed, f32(k) * 7.7 + seed); + let h1 = hash21(id); + let h2 = hash21(id + 1.7); + let h3 = hash21(id + 4.3); + let h4 = hash21(id + 9.1); + // Most pieces drop in the first half second (a dense front), the rest + // trail out over the spawn window. + let tt = t - pow(h1, 2.2) * SPAWN; + if (tt <= 0.0) { + continue; + } + // Drop with a touch of acceleration and a slow terminal drift; big + // pieces fall faster than small ones. + let fall = res.y * (0.30 + 0.06 * h2) * scale; + let y = -12.0 + fall * tt + 0.09 * res.y * tt * tt * scale; + let x = (col + 0.2 + 0.6 * h3) * COL + + SWAY * sin(tt * (1.6 + h4 * 1.4) + h2 * 6.2831853); + let d = q - vec2(x, y); + // Spin in the plane and tumble about the long axis. + let ang = tt * (2.5 + 3.5 * h4) + h3 * 6.2831853; + let ca = cos(ang); + let sa = sin(ang); + let r = vec2(ca * d.x + sa * d.y, -sa * d.x + ca * d.y); + let tumble = sin(tt * (3.0 + 3.0 * h2) + h1 * 6.2831853); + let ext = PIECE * scale * vec2(1.0, 0.25 + 0.75 * abs(tumble)); + let edge = 0.7; + let cov = (1.0 - smoothstep(ext.x - edge, ext.x + edge, abs(r.x))) + * (1.0 - smoothstep(ext.y - edge, ext.y + edge, abs(r.y))); + if (cov <= 0.0) { + continue; + } + let shade = 0.62 + 0.38 * abs(tumble); + let col3 = paper(h2) * shade; + let w = cov * (1.0 - a); + rgb = rgb + col3 * w; + a = a + w; + } + } + return vec4(rgb, a); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let p = u.live.x; + if (p <= 0.0 || p >= 1.0) { + return vec4(0.0); + } + let t = p * u.params0.x; + let uv = ndc * 0.5 + 0.5; + let res = u.resolution.xy / u.resolution.w; + let q = vec2(uv.x, 1.0 - uv.y) * res; + + // Small, dimmer pieces behind; full-size ones in front. + let back = layer(q, t, res, 0.7, 11.0); + let front = layer(q, t, res, 1.0, 37.0); + var rgb = back.rgb * 0.75 * (1.0 - front.a) + front.rgb; + var a = back.a * (1.0 - front.a) + front.a; + + // Whatever is still on screen at the end fades rather than vanishing. + let out = 1.0 - smoothstep(0.84, 1.0, p); + return vec4(rgb * out, a * out); +} +`; diff --git a/example/src/components/imessage/EchoEffect.tsx b/example/src/components/imessage/EchoEffect.tsx new file mode 100644 index 0000000..d446c89 --- /dev/null +++ b/example/src/components/imessage/EchoEffect.tsx @@ -0,0 +1,124 @@ +import { StyleSheet } from 'react-native'; +import { ShaderView } from 'react-native-effects'; +import type { Rect } from './types'; +import { useEffectProgress } from './useEffectProgress'; + +export const ECHO_MS = 2400; + +type Props = { + /** The sent bubble, window logical px. */ + rect: Rect; + /** View-shot of that bubble; every echo is a copy of it. */ + snapshotUri: string; + onDone: () => void; +}; + +/** + * iMessage's Echo: the bubble is copied a couple of dozen times and the + * copies swirl out of it across the whole screen, shrinking a little and + * fading at the end of their flight, while the real bubble stays put. The copies are the bubble's own + * pixels: for every fragment each copy's transform is undone and the + * snapshot sampled where it lands inside the rect. + */ +export default function EchoEffect({ rect, snapshotUri, onDone }: Props) { + const paramsSynchronizable = useEffectProgress(ECHO_MS, onDone); + 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; +@group(0) @binding(1) var samp: sampler; +@group(0) @binding(2) var tex: texture_2d; + +const COPIES: i32 = 22; +const STAGGER: f32 = 0.38; // share of the play spent launching copies +const GOLDEN: f32 = 2.39996; // sunflower angle between neighbouring slots +const SWIRL: f32 = 1.2; // radians a copy swings about the bubble in flight + +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); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let p = u.live.x; + if (p <= 0.0 || p >= 1.0) { + return vec4(0.0); + } + // Window logical px, y-down: the space the bubble rect was measured in. + let uv = ndc * 0.5 + 0.5; + let res = u.resolution.xy / u.resolution.w; + let q = vec2(uv.x, 1.0 - uv.y) * res; + + let origin = u.params0.xy; + let size = u.params0.zw; + let centre = origin + size * 0.5; + + var rgb = vec3(0.0); + var a = 0.0; + for (var i = 0; i < COPIES; i = i + 1) { + let fi = f32(i); + let h2 = hash21(vec2(fi, 7.9)); + // Copies leave one after another, then all fly on the same clock. + let ti = clamp((p - STAGGER * fi / f32(COPIES)) / (1.0 - STAGGER), 0.0, 1.0); + if (ti <= 0.0) { + continue; + } + let ease = 1.0 - pow(1.0 - ti, 2.4); + // Each copy owns a slot in a sunflower laid over the whole screen, so + // the swarm fills it evenly the way iMessage does, and swings into its + // slot about the bubble rather than flying straight, so the flight + // reads as a spiral. + let slot = vec2(cos(fi * GOLDEN), sin(fi * GOLDEN)) + * sqrt((fi + 0.5) / f32(COPIES)); + let dest = res * 0.5 + slot * res * vec2(0.44, 0.46); + let arm = dest - centre; + let turn = SWIRL * (1.0 - ease); + let ct = cos(turn); + let st = sin(turn); + let c = centre + + vec2(ct * arm.x - st * arm.y, st * arm.x + ct * arm.y) * ease; + let scale = mix(1.0, 0.7, ease); + // The swarm holds together and each copy thins only at the end of its + // flight, so the screen is full before anything is gone. + let alpha = 1.0 - smoothstep(0.65, 1.0, ti); + let rot = (h2 - 0.5) * 0.9 * ease; + + // Undo this copy's transform: where in the bubble does q come from? + let d = q - c; + let cr = cos(rot); + let sr = sin(rot); + let local = vec2(cr * d.x + sr * d.y, -sr * d.x + cr * d.y) / scale; + let t = (local + size * 0.5) / size; + if (t.x < 0.0 || t.x > 1.0 || t.y < 0.0 || t.y > 1.0) { + continue; + } + let s = textureSampleLevel(tex, samp, t, 0.0); + let w = s.a * alpha * (1.0 - a); + rgb = rgb + s.rgb * w; + a = a + w; + } + return vec4(rgb, a); +} +`; diff --git a/example/src/components/imessage/EffectsSheet.tsx b/example/src/components/imessage/EffectsSheet.tsx new file mode 100644 index 0000000..f792cfe --- /dev/null +++ b/example/src/components/imessage/EffectsSheet.tsx @@ -0,0 +1,247 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Pressable, + ScrollView, + StyleSheet, + Text, + View, + useWindowDimensions, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Bubble, { IM_BLUE } from './Bubble'; +import ScreenEffectPlayer from './ScreenEffectPlayer'; +import { + EFFECT_LABEL, + SCREEN_EFFECTS, + type BubbleTarget, + type EffectId, + type Message, + type ScreenEffectId, +} from './types'; + +type Props = { + text: string; + onClose: () => void; + onSend: (effect: EffectId) => void; +}; + +/** + * The "Send with effect" sheet: the pending message shown as a real bubble + * and the screen effects in a pager, each previewing on the bubble the way + * iMessage previews before you commit. The arrow sends with the effect. + */ +export default function EffectsSheet({ text, onClose, onSend }: Props) { + const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); + const [page, setPage] = useState(0); + const [target, setTarget] = useState(null); + const [playKey, setPlayKey] = useState(0); + const replay = useRef | null>(null); + + const effect: ScreenEffectId = SCREEN_EFFECTS[page] ?? 'echo'; + + // The preview bubble is remounted per effect so its snapshot is fresh for + // the effects that need one. + const preview: Message = { + id: `preview-${effect}`, + from: 'me', + text, + effect, + time: '', + }; + + const onDone = useCallback(() => { + replay.current = setTimeout(() => setPlayKey((k) => k + 1), 600); + }, []); + useEffect( + () => () => { + if (replay.current) { + clearTimeout(replay.current); + } + }, + [] + ); + useEffect(() => { + // A new effect starts its preview from the top. + setTarget(null); + setPlayKey((k) => k + 1); + }, [effect]); + + const onPage = useCallback( + (e: NativeSyntheticEvent) => { + const i = Math.round(e.nativeEvent.contentOffset.x / width); + setPage(Math.max(0, Math.min(SCREEN_EFFECTS.length - 1, i))); + }, + [width] + ); + + const player = target ? ( + + ) : null; + + return ( + + + Send with effect + + ✕ + + + + + + + + + + {SCREEN_EFFECTS.map((id) => ( + + {EFFECT_LABEL[id]} + + ))} + + + {SCREEN_EFFECTS.map((id, i) => ( + + ))} + + + Swipe to preview · tap ↑ to send + onSend(effect)} + style={styles.send} + accessibilityRole="button" + accessibilityLabel={`Send with ${EFFECT_LABEL[effect]}`} + > + ↑ + + + + + {player} + + ); +} + +const styles = StyleSheet.create({ + root: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.78)', + }, + top: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + paddingBottom: 10, + }, + title: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, + close: { + position: 'absolute', + right: 16, + bottom: 4, + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: 'rgba(118,118,128,0.3)', + alignItems: 'center', + justifyContent: 'center', + }, + closeText: { + color: '#fff', + fontSize: 15, + fontWeight: '700', + }, + stage: { + flex: 1, + justifyContent: 'flex-end', + paddingBottom: 28, + }, + bottom: { + paddingTop: 8, + }, + pager: { + height: 56, + }, + page: { + alignItems: 'center', + justifyContent: 'center', + }, + pageText: { + color: '#fff', + fontSize: 22, + fontWeight: '600', + }, + dots: { + flexDirection: 'row', + justifyContent: 'center', + gap: 7, + marginTop: 2, + }, + dot: { + width: 7, + height: 7, + borderRadius: 4, + backgroundColor: 'rgba(255,255,255,0.3)', + }, + dotOn: { + backgroundColor: '#fff', + }, + sendRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + marginTop: 18, + }, + hint: { + color: '#8e8e93', + fontSize: 13, + }, + send: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: IM_BLUE, + alignItems: 'center', + justifyContent: 'center', + }, + sendArrow: { + color: '#fff', + fontSize: 22, + fontWeight: '700', + marginTop: -2, + }, +}); diff --git a/example/src/components/imessage/FireworksEffect.tsx b/example/src/components/imessage/FireworksEffect.tsx new file mode 100644 index 0000000..25bfc74 --- /dev/null +++ b/example/src/components/imessage/FireworksEffect.tsx @@ -0,0 +1,158 @@ +import { StyleSheet } from 'react-native'; +import { ShaderView } from 'react-native-effects'; +import { useEffectProgress } from './useEffectProgress'; + +export const FIREWORKS_MS = 4000; +// The birthday banner these bursts were tuned for is this tall; measuring +// the full screen in banner heights keeps every burst the size it is there. +const BANNER_HEIGHT = 122; +const LAUNCHERS = 20; + +type Props = { + onDone: () => void; +}; + +/** + * iMessage's Fireworks: glitter-dust shells popping over the chat. The + * bursts are the birthday banner's (components/BirthdayFireworks.tsx) — + * hollow polar shells of twinkling stars with an ignition flash — drawn as + * a transparent overlay in front of the bubbles instead of on the banner's + * black glass, with launchers spread across the whole screen. + */ +export default function FireworksEffect({ onDone }: Props) { + const paramsSynchronizable = useEffectProgress(FIREWORKS_MS, onDone); + 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); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let prog = u.live.x; + if (prog <= 0.0 || prog >= 1.0) { + return vec4(0.0); + } + let t = u.time.x; + let uv = ndc * 0.5 + 0.5; + // Square units of one banner height, so bursts stay round and banner-sized. + let unitPx = u.params0.x * u.resolution.w; + let extent = u.resolution.xy / unitPx; + let p = uv * extent; + let launchers = i32(max(u.params0.y, 1.0)); + + // Vivid party palette: lime, violet, hot pink, gold, cyan, silver. + var palette = array, 6>( + vec3(0.55, 0.95, 0.35), + vec3(0.72, 0.42, 1.00), + vec3(1.00, 0.40, 0.65), + vec3(1.00, 0.80, 0.30), + vec3(0.40, 0.85, 1.00), + vec3(0.88, 0.88, 0.95) + ); + + var col = vec3(0.0); + for (var i = 0; i < launchers; i = i + 1) { + let fi = f32(i); + let period = 2.9 + 1.7 * fract(fi * 0.6180339); + let clock = t / period + fract(fi * 0.7548776); + let phase = fract(clock); + let gen = floor(clock); + + let seed = vec2(gen * 1.93 + fi * 17.0, fi * 7.31 - gen * 0.71); + if (hash21(seed + 9.9) > 0.78) { + continue; + } + + let center = vec2( + hash21(seed + 1.1) * extent.x, + mix(0.10, extent.y - 0.10, hash21(seed + 2.2)) + ); + + let ease = 1.0 - pow(1.0 - phase, 4.5); + let maxR = 0.36 + 0.34 * hash21(seed + 3.3); + let radius = maxR * (0.10 + 0.90 * ease); + let fade = smoothstep(1.0, 0.5, phase); + + let rel = p - center; + let d = length(rel); + + let flash = exp(-d * d / (maxR * maxR * 0.012)) * smoothstep(0.14, 0.0, phase); + col = col + vec3(1.0, 0.97, 0.9) * flash * 0.6; + + if (d > radius * 1.05) { + continue; + } + + let SPOKES = 56.0; + let LAYERS = 11.0; + let a01 = fract(atan2(rel.y, rel.x) * 0.15915494 + 1.0); + let q = d / max(radius, 1e-4); + + let cellA = floor(a01 * SPOKES); + let cellQ = floor(q * LAYERS); + let cellId = vec2(cellA + fi * 61.0, cellQ + gen * 13.0); + let rnd = hash21(cellId); + + let qc = (cellQ + 0.5) / LAYERS; + let exists = step(rnd, 0.12 + 0.88 * smoothstep(0.35, 0.95, qc)); + + if (exists > 0.5) { + let ja = (hash21(cellId + 5.5) - 0.5) * 0.8; + let jq = (hash21(cellId + 8.8) - 0.5) * 0.8; + let starAng = ((cellA + 0.5 + ja) / SPOKES) * 6.2831853; + let starQ = (cellQ + 0.5 + jq) / LAYERS; + var starPos = center + vec2(cos(starAng), sin(starAng)) * starQ * radius; + starPos.y = starPos.y - phase * phase * 0.07 * (0.5 + rnd); + + let toStar = p - starPos; + let size = 16000.0 + 26000.0 * hash21(cellId + 2.7); + let spark = exp(-dot(toStar, toStar) * size); + + let rnd2 = hash21(cellId + 47.0); + let twinkle = 0.2 + 0.8 * + pow(0.5 + 0.5 * sin(t * (3.0 + 6.0 * rnd2) + rnd2 * 40.0), 3.0); + + let pickA = i32(hash21(seed + 4.4) * 5.999); + let pickB = i32(hash21(seed + 6.6) * 5.999); + var sparkCol = palette[select(pickA, pickB, rnd2 > 0.72)]; + sparkCol = mix(sparkCol, vec3(1.0), step(0.95, rnd2) * 0.7); + + let rimBoost = 0.45 + 0.85 * smoothstep(0.4, 1.0, starQ); + col = col + sparkCol * spark * twinkle * fade * rimBoost * 2.4; + } + } + + col = col / (1.0 + col * 0.45); + // In and out with the play; the glitter is additive over whatever is + // under it, so its alpha follows its brightness. + let env = smoothstep(0.0, 0.1, prog) * (1.0 - smoothstep(0.8, 1.0, prog)); + let rgb = clamp(col, vec3(0.0), vec3(1.0)) * env; + let a = clamp(max(max(rgb.r, rgb.g), rgb.b), 0.0, 1.0); + return vec4(rgb, a); +} +`; diff --git a/example/src/components/imessage/LasersEffect.tsx b/example/src/components/imessage/LasersEffect.tsx new file mode 100644 index 0000000..5f95f74 --- /dev/null +++ b/example/src/components/imessage/LasersEffect.tsx @@ -0,0 +1,116 @@ +import { StyleSheet } from 'react-native'; +import { ShaderView } from 'react-native-effects'; +import { useEffectProgress } from './useEffectProgress'; + +export const LASERS_MS = 3200; + +type Props = { + onDone: () => void; +}; + +/** + * iMessage's Lasers: coloured beams fan up from below the screen and sweep + * across the chat through a thin haze. Each beam is a soft line with a wide + * glow; the fan breathes rather than strobes, and the whole thing fades in + * and out so it reads as a light show, not a flicker. + */ +export default function LasersEffect({ onDone }: Props) { + const paramsSynchronizable = useEffectProgress(LASERS_MS, onDone); + 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; + +const BEAMS: i32 = 8; +const CORE: f32 = 1.6; // beam half-width at full brightness, px +const GLOW: f32 = 34.0; // e-fold width of the haze around a beam, px +const SPREAD: f32 = 0.62; // half fan angle, radians +const SWEEP: f32 = 0.16; // how far each beam swings, radians +const PI: f32 = 3.14159265; + +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); +} + +// Saturated stage-light colours around the hue wheel. +fn beamColour(h: f32) -> vec3 { + return 0.55 + 0.45 * cos(6.2831853 * (h + vec3(0.0, 0.33, 0.67))); +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let p = u.live.x; + if (p <= 0.0 || p >= 1.0) { + return vec4(0.0); + } + let t = u.time.x; + let uv = ndc * 0.5 + 0.5; + let res = u.resolution.xy / u.resolution.w; + let q = vec2(uv.x, 1.0 - uv.y) * res; + + let env = smoothstep(0.0, 0.14, p) * (1.0 - smoothstep(0.78, 1.0, p)); + // The whole fan breathes slowly; no beam ever cuts out. + let breathe = 0.82 + 0.18 * sin(t * 4.2); + + // Beams come from a point just under the bottom edge, mid-screen. + let origin = vec2(res.x * 0.5, res.y + 30.0); + let v = q - origin; + + var rgb = vec3(0.0); + var lum = 0.0; + for (var i = 0; i < BEAMS; i = i + 1) { + let fi = f32(i); + let h = hash21(vec2(fi, 3.7)); + let g = hash21(vec2(fi, 9.1)); + // Fan spread evenly, each beam swinging on its own slow clock; the + // whole fan also leans with the play so it reads as a sweep, not a hold. + let base = -PI * 0.5 + SPREAD * ((fi + 0.5) / f32(BEAMS) * 2.0 - 1.0); + let ang = base + + SWEEP * sin(t * (0.7 + 0.5 * h) + h * 6.2831853) + + 0.22 * sin(p * PI * 2.0 + g * 3.0); + let dir = vec2(cos(ang), sin(ang)); + let along = dot(v, dir); + if (along <= 0.0) { + continue; + } + let perp = abs(v.x * dir.y - v.y * dir.x); + // Beams thin out as they travel, like light through haze. + let travel = exp(-along / (res.y * 1.6)); + let core = exp(-perp * perp / (2.0 * CORE * CORE)); + let glow = exp(-perp / GLOW) * 0.16; + let inten = (core + glow) * travel * (0.75 + 0.25 * g); + let col = beamColour(fi / f32(BEAMS) + t * 0.03); + rgb = rgb + col * inten; + lum = lum + inten; + } + + // A faint warm haze where the beams are densest, near their source. + let fog = exp(-length(v) / (res.y * 0.45)) * 0.10; + rgb = rgb + vec3(0.9, 0.7, 1.0) * fog; + lum = lum + fog; + + let gain = env * breathe; + let a = clamp(lum * 0.9 * gain, 0.0, 1.0); + return vec4(min(rgb * gain, vec3(1.0)), a); +} +`; diff --git a/example/src/components/imessage/ScreenEffectPlayer.tsx b/example/src/components/imessage/ScreenEffectPlayer.tsx new file mode 100644 index 0000000..d877708 --- /dev/null +++ b/example/src/components/imessage/ScreenEffectPlayer.tsx @@ -0,0 +1,41 @@ +import ConfettiEffect from './ConfettiEffect'; +import EchoEffect from './EchoEffect'; +import FireworksEffect from './FireworksEffect'; +import LasersEffect from './LasersEffect'; +import SpotlightEffect from './SpotlightEffect'; +import type { BubbleTarget, ScreenEffectId } from './types'; + +type Props = { + effect: ScreenEffectId; + target: BubbleTarget; + onDone: () => void; +}; + +/** + * One play of a screen effect for a bubble, as a transparent overlay in + * front of the chat, the way iMessage draws them; unmount it when `onDone` + * fires. + */ +export default function ScreenEffectPlayer({ effect, target, onDone }: Props) { + switch (effect) { + case 'confetti': + return ; + case 'fireworks': + return ; + case 'spotlight': + return ; + case 'lasers': + return ; + case 'echo': + if (!target.snapshotUri) { + return null; + } + return ( + + ); + } +} diff --git a/example/src/components/imessage/SpotlightEffect.tsx b/example/src/components/imessage/SpotlightEffect.tsx new file mode 100644 index 0000000..6d5b056 --- /dev/null +++ b/example/src/components/imessage/SpotlightEffect.tsx @@ -0,0 +1,90 @@ +import { StyleSheet } from 'react-native'; +import { ShaderView } from 'react-native-effects'; +import type { Rect } from './types'; +import { useEffectProgress } from './useEffectProgress'; + +export const SPOTLIGHT_MS = 2600; + +type Props = { + /** The bubble to light, window logical px. */ + rect: Rect; + onDone: () => void; +}; + +/** + * iMessage's Spotlight: the room goes dark and a soft beam sweeps down from + * the top onto the new bubble, holds, and lifts. The overlay only ever + * darkens, so the bubble itself is the real view seen through a hole in the + * dim: nothing here redraws it. + */ +export default function SpotlightEffect({ rect, onDone }: Props) { + const paramsSynchronizable = useEffectProgress(SPOTLIGHT_MS, onDone); + 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; + +const DIM: f32 = 0.9; // how dark the room gets +const BEAM: f32 = 0.5; // how much of the dim the beam lifts +const HOLE_PAD: f32 = 10.0; // px around the bubble left fully lit +const HOLE_SOFT: f32 = 26.0; // px over which the hole's edge fades + +fn sdRoundRect(p: vec2, ext: vec2, r: f32) -> f32 { + let d = abs(p) - ext + vec2(r); + return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r; +} + +@fragment +fn main(@location(0) ndc: vec2) -> @location(0) vec4 { + let p = u.live.x; + if (p <= 0.0 || p >= 1.0) { + return vec4(0.0); + } + let uv = ndc * 0.5 + 0.5; + let res = u.resolution.xy / u.resolution.w; + let q = vec2(uv.x, 1.0 - uv.y) * res; + + let origin = u.params0.xy; + let size = u.params0.zw; + let centre = origin + size * 0.5; + + // Dark in, hold, lift. + let env = smoothstep(0.0, 0.1, p) * (1.0 - smoothstep(0.82, 1.0, p)); + // The beam arrives from above the screen and settles on the bubble. + let arrive = smoothstep(0.08, 0.42, p); + let sweep = 1.0 - pow(1.0 - arrive, 3.0); + let beamC = mix(vec2(centre.x, -0.15 * res.y), centre, sweep); + let radius = max(size.x, size.y) * 0.75 + 70.0; + // A little taller than wide, as a lamp overhead throws it. + let rel = (q - beamC) * vec2(1.0, 0.8); + let beam = 1.0 - smoothstep(radius * 0.35, radius, length(rel)); + + // The bubble sits in a soft-edged hole once the beam has reached it. + let sd = sdRoundRect(q - centre, size * 0.5 + vec2(HOLE_PAD), 22.0); + let hole = (1.0 - smoothstep(0.0, HOLE_SOFT, sd)) * smoothstep(0.3, 0.5, p); + + var dim = DIM * env; + dim = dim * (1.0 - BEAM * beam); + dim = dim * (1.0 - hole); + return vec4(0.0, 0.0, 0.0, dim); +} +`; diff --git a/example/src/components/imessage/glyphs.tsx b/example/src/components/imessage/glyphs.tsx new file mode 100644 index 0000000..03b4ed8 --- /dev/null +++ b/example/src/components/imessage/glyphs.tsx @@ -0,0 +1,138 @@ +import { Image, StyleSheet, Text, View } from 'react-native'; + +// Messages' chrome glyphs. Most are drawn from plain views so they stay crisp +// at any scale without an icon font; the video camera is the SF Symbol +// "video" rendered to a white mask (assets/imessage/video.png) and tinted, +// because its flared lens does not draw well from rectangles. + +const WHITE = '#fff'; + +export function Chevron({ + color = WHITE, + size = 13, + direction = 'left', +}: { + color?: string; + size?: number; + direction?: 'left' | 'right'; +}) { + return ( + + ); +} + +const VIDEO_GLYPH = require('../../../assets/imessage/video.png'); + +export function VideoCamera({ color = WHITE }: { color?: string }) { + return ( + + ); +} + +export function Plus({ color = WHITE }: { color?: string }) { + return +; +} + +export function Microphone({ color = WHITE }: { color?: string }) { + return ( + + + + + + + ); +} + +export function Lock({ color = WHITE }: { color?: string }) { + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + chevron: { + borderLeftWidth: 2.5, + borderBottomWidth: 2.5, + }, + camera: { + width: 26, + height: 18, + }, + plus: { + fontSize: 28, + fontWeight: '300', + lineHeight: 30, + marginTop: -1, + }, + mic: { + width: 14, + height: 22, + alignItems: 'center', + }, + micCapsule: { + width: 8, + height: 13, + borderRadius: 4, + borderWidth: 2, + }, + micArc: { + position: 'absolute', + top: 6, + width: 14, + height: 11, + borderWidth: 2, + borderTopWidth: 0, + borderBottomLeftRadius: 7, + borderBottomRightRadius: 7, + }, + micStem: { + position: 'absolute', + top: 17, + width: 2, + height: 4, + }, + micFoot: { + position: 'absolute', + top: 20, + width: 8, + height: 2, + borderRadius: 1, + }, + lock: { + width: 10, + height: 12, + alignItems: 'center', + }, + lockShackle: { + width: 7, + height: 7, + borderWidth: 1.5, + borderBottomWidth: 0, + borderTopLeftRadius: 4, + borderTopRightRadius: 4, + }, + lockBody: { + width: 10, + height: 7, + borderRadius: 2, + marginTop: -1, + }, +}); diff --git a/example/src/components/imessage/types.ts b/example/src/components/imessage/types.ts new file mode 100644 index 0000000..446c655 --- /dev/null +++ b/example/src/components/imessage/types.ts @@ -0,0 +1,47 @@ +export type EffectId = + | 'none' + | 'echo' + | 'spotlight' + | 'confetti' + | 'fireworks' + | 'lasers'; + +export const SCREEN_EFFECTS = [ + 'echo', + 'spotlight', + 'confetti', + 'fireworks', + 'lasers', +] as const satisfies readonly EffectId[]; +export type ScreenEffectId = (typeof SCREEN_EFFECTS)[number]; + +export const EFFECT_LABEL: Record = { + none: 'None', + echo: 'Echo', + spotlight: 'Spotlight', + confetti: 'Confetti', + fireworks: 'Fireworks', + lasers: 'Lasers', +}; + +export function isScreenEffect(e: EffectId): e is ScreenEffectId { + return (SCREEN_EFFECTS as readonly string[]).includes(e); +} + +export type Message = { + id: string; + from: 'me' | 'them'; + text: string; + effect: EffectId; + time: string; +}; + +/** A rect in window (screen) logical px, y-down. */ +export type Rect = { x: number; y: number; w: number; h: number }; + +/** What a screen effect needs to know about the bubble it plays for. */ +export type BubbleTarget = { + rect: Rect; + /** A view-shot of the bubble, present once captured; echo needs it. */ + snapshotUri: string | null; +}; diff --git a/example/src/components/imessage/useEffectProgress.ts b/example/src/components/imessage/useEffectProgress.ts new file mode 100644 index 0000000..7951ba8 --- /dev/null +++ b/example/src/components/imessage/useEffectProgress.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { + Easing, + runOnJS, + useAnimatedReaction, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { + useParamsSynchronizable, + type ParamsSynchronizable, +} from 'react-native-effects'; + +/** + * A one-shot 0..1 progress for a screen effect, bridged into a ShaderView + * live channel as `u.live.x`. Runs linearly over `durationMs` from mount and + * calls `onDone` once, so the effect's owner can unmount it; the shader + * shapes its own envelope from the linear progress. + */ +export function useEffectProgress( + durationMs: number, + onDone: () => void +): ParamsSynchronizable { + const progress = useSharedValue(0); + const { paramsSynchronizable } = useParamsSynchronizable([0, 0, 0, 0]); + + useAnimatedReaction( + () => progress.value, + (p) => { + 'worklet'; + paramsSynchronizable.setBlocking(() => Float64Array.of(p, 0, 0, 0)); + } + ); + + useEffect(() => { + progress.value = withTiming( + 1, + { duration: durationMs, easing: Easing.linear }, + (finished) => { + if (finished) { + runOnJS(onDone)(); + } + } + ); + // Play once from mount; a re-render must not restart it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return paramsSynchronizable; +} diff --git a/example/src/hooks/useIMessageEffects.ts b/example/src/hooks/useIMessageEffects.ts new file mode 100644 index 0000000..5582054 --- /dev/null +++ b/example/src/hooks/useIMessageEffects.ts @@ -0,0 +1,294 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + EFFECT_LABEL, + isScreenEffect, + type BubbleTarget, + type EffectId, + type Message, + type ScreenEffectId, +} from '../components/imessage/types'; + +// iMessage starts the screen effect a beat after the bubble lands, so the eye +// has something to anchor the effect to. +export const EFFECT_START_DELAY_MS = 150; + +// A scripted demo. It opens with one text from Kacper; each effect has its +// own line from "me" written to match the effect's energy, and Kacper answers +// a beat later, except after the spotlight, which needs the last word. +// Fond, not bitter: the jokes are about the reasoning, not the people. +const OPENER = 'shopify is going back to native 💀'; + +type Step = { effect: ScreenEffectId; me: string; reply: string | null }; +const STEPS: Step[] = [ + { + effect: 'echo', + me: 'back to native? from what, react NATIVE?', + reply: 'their whole point is agents can port anything now', + }, + { + effect: 'lasers', + me: 'great. tell the agents to port it back', + reply: 'they rebuilt shop in 12 weeks though', + }, + { + effect: 'fireworks', + me: '12 weeks and zero pod installs. jealous honestly', + reply: 'still shipping flashlist fixes though', + }, + { + effect: 'confetti', + me: "flashlist does 1M a week. you don't just leave that", + reply: 'so what do we do', + }, + { + effect: 'spotlight', + me: 'keep shipping. react native for the win', + reply: null, + }, +]; + +/** The effects in the order the demo means them to be used. */ +export const DEMO_ORDER: ScreenEffectId[] = STEPS.map((s) => s.effect); + +/** The beat between the effect ending and Kacper's reply landing. */ +export const REPLY_DELAY_MS = 450; +/** If an effect never reports finishing, reply anyway after this long. */ +const REPLY_FALLBACK_MS = 8000; + +/** What a button or the debug hook sends once the script has run out. */ +export const DEFAULT_TEXT: Record = { + none: 'hello from the new architecture', + echo: 'back to native', + spotlight: 'read the january post again', + confetti: 'skia hit 2M downloads a week 🎉', + fireworks: 'new arch is the default now', + lasers: 'hermes go brrr', +}; + +export type Playing = { + /** Increments per play so the same effect can play twice in a row. */ + key: number; + effect: EffectId; + messageId: string; +}; + +// Every bubble that a screen effect plays for reports its window rect (and +// snapshot) here once laid out, so the effect can start where the bubble is. +const targets = new Map(); +const targetWaiters = new Map void)[]>(); + +export function reportTarget(id: string, target: BubbleTarget) { + targets.set(id, target); + const waiters = targetWaiters.get(id); + if (waiters) { + targetWaiters.delete(id); + waiters.forEach((w) => w(target)); + } +} + +export function forgetTarget(id: string) { + targets.delete(id); +} + +function waitForTarget( + id: string, + needSnapshot: boolean +): Promise { + const have = targets.get(id); + if (have && (!needSnapshot || have.snapshotUri)) { + return Promise.resolve(have); + } + return new Promise((resolve) => { + const list = targetWaiters.get(id) ?? []; + list.push((t) => { + if (!needSnapshot || t.snapshotUri) { + resolve(t); + } else { + waitForTarget(id, true).then(resolve); + } + }); + targetWaiters.set(id, list); + }); +} + +// Message ids carry the mount time so that a Fast Refresh, which re-evaluates +// this module, cannot hand out an id that a bubble already holds. +let nextId = 1; +const MOUNT = Date.now().toString(36); +function newId() { + return `m${MOUNT}-${nextId++}`; +} +function stamp() { + const d = new Date(); + const h = d.getHours() % 12 || 12; + return `${h}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +/** + * The conversation and the one screen effect that can play at a time. `send` + * appends a real bubble; when it carries a screen effect, the effect starts + * once the bubble has laid out and reported where it is (and, for echo, has + * been snapshotted), never sooner than the iMessage beat. + */ +export function useIMessageEffects() { + const [messages, setMessages] = useState(() => [ + { id: 's0', from: 'them', text: OPENER, effect: 'none', time: stamp() }, + ]); + const [playing, setPlaying] = useState(null); + const playKey = useRef(0); + // Steps already spoken; a second press of the same effect falls back to + // the default line and gets no reply. + const spoken = useRef(new Set()); + // Kacper's next line, held until the effect has finished playing. While it + // is held, presses are ignored so a quick run of taps cannot post three + // lines before his first answer lands. + const pendingReply = useRef<{ + text: string; + timer: ReturnType; + } | null>(null); + const [busy, setBusy] = useState(false); + + const deliverReply = useCallback(() => { + const pending = pendingReply.current; + if (!pending) { + return; + } + clearTimeout(pending.timer); + pendingReply.current = null; + setBusy(false); + setMessages((prev) => [ + ...prev, + { + id: newId(), + from: 'them', + text: pending.text, + effect: 'none', + time: stamp(), + }, + ]); + }, []); + + const send = useCallback( + (effect: EffectId, text?: string) => { + if (pendingReply.current !== null) { + return null; + } + let body = text?.trim(); + let reply: string | null = null; + if (!body) { + const step = STEPS.find((st) => st.effect === effect); + if (step && !spoken.current.has(step.effect)) { + spoken.current.add(step.effect); + body = step.me; + reply = step.reply; + } else { + body = DEFAULT_TEXT[effect]; + } + } + if (!body) { + return null; + } + const id = newId(); + const msg: Message = { + id, + from: 'me', + text: body, + effect, + time: stamp(), + }; + setMessages((prev) => [...prev, msg]); + if (isScreenEffect(effect)) { + const startedAt = Date.now(); + waitForTarget(id, effect === 'echo').then(() => { + const wait = Math.max( + 0, + EFFECT_START_DELAY_MS - (Date.now() - startedAt) + ); + setTimeout(() => { + playKey.current += 1; + setPlaying({ key: playKey.current, effect, messageId: id }); + }, wait); + }); + } + // Kacper waits for the effect to end (finishPlaying schedules the + // delivery), with a fallback in case it never reports back. + if (reply !== null) { + setBusy(true); + pendingReply.current = { + text: reply, + timer: setTimeout(deliverReply, REPLY_FALLBACK_MS), + }; + if (!isScreenEffect(effect)) { + setTimeout(deliverReply, REPLY_DELAY_MS); + } + } + return id; + }, + [deliverReply] + ); + + const finishPlaying = useCallback( + (key: number) => { + setPlaying((p) => (p && p.key === key ? null : p)); + if (pendingReply.current !== null) { + setTimeout(deliverReply, REPLY_DELAY_MS); + } + }, + [deliverReply] + ); + + const getTarget = useCallback((id: string) => targets.get(id) ?? null, []); + + useEffect( + () => () => { + targets.clear(); + targetWaiters.clear(); + if (pendingReply.current !== null) { + clearTimeout(pendingReply.current.timer); + } + }, + [] + ); + + return { messages, playing, busy, send, finishPlaying, getTarget }; +} + +/** + * Dev-only globals so the simulator can drive the screen without the + * keyboard or a long-press: + * - `__imsgSend(effect, text?)` — append an outgoing bubble and play its + * effect; `effect` is one of none, echo, spotlight, confetti, + * fireworks, lasers. Returns the new message id. + * - `__imsgState()` — the messages and what is playing. + */ +export function useIMessageDebugHooks( + send: (effect: EffectId, text?: string) => string | null, + getMessages: () => Message[], + getPlaying: () => Playing | null +) { + useEffect(() => { + if (!__DEV__) { + return; + } + const g = globalThis as Record; + g.__imsgSend = (effect: EffectId = 'none', text?: string) => { + if (!(effect in EFFECT_LABEL)) { + throw new Error(`unknown effect ${String(effect)}`); + } + return send(effect, text); + }; + g.__imsgState = () => ({ + messages: getMessages().map((m) => ({ + id: m.id, + from: m.from, + effect: m.effect, + text: m.text, + })), + playing: getPlaying(), + }); + return () => { + delete g.__imsgSend; + delete g.__imsgState; + }; + }, [getMessages, getPlaying, send]); +} diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index 7468336..02672e3 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -11,6 +11,15 @@ import { useNavigation } from '@react-navigation/native'; import type { ExampleCategory, HomeScreenNavigationProp } from '../types'; const EXAMPLE_CATEGORIES: ExampleCategory[] = [ + { + id: 'imessage', + title: 'iMessage Effects', + description: + 'Hold send to pick an effect — lasers, confetti, fireworks and more', + screen: 'IMessageExample', + color: '#0A84FF', + image: require('../../assets/components/imessage.png'), + }, { id: 'telegram-spoiler', title: 'Telegram Spoiler', diff --git a/example/src/screens/IMessageScreen.tsx b/example/src/screens/IMessageScreen.tsx new file mode 100644 index 0000000..8693130 --- /dev/null +++ b/example/src/screens/IMessageScreen.tsx @@ -0,0 +1,510 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type ComponentRef, +} from 'react'; +import { + Keyboard, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StatusBar, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Bubble, { + IM_BG, + IM_BLUE, + IM_CHROME, + IM_MUTED, +} from '../components/imessage/Bubble'; +import { + Chevron, + Lock, + Microphone, + Plus, + VideoCamera, +} from '../components/imessage/glyphs'; +import EffectsSheet from '../components/imessage/EffectsSheet'; +import ScreenEffectPlayer from '../components/imessage/ScreenEffectPlayer'; +import { + EFFECT_LABEL, + isScreenEffect, + type BubbleTarget, + type EffectId, +} from '../components/imessage/types'; +import { + DEFAULT_TEXT, + DEMO_ORDER, + forgetTarget, + reportTarget, + useIMessageDebugHooks, + useIMessageEffects, +} from '../hooks/useIMessageEffects'; + +// iMessage clone, dark appearance, laid out from a real Messages thread: +// circular back and video buttons flank a large avatar over the name pill, +// the conversation opens with the iMessage / Encrypted / date block, and the +// composer is a translucent pill whose mic becomes the send arrow once there +// is text. Sending can carry a screen effect (echo, spotlight, confetti, +// fireworks, lasers) that plays once over the chat. +// The demo is scripted: it opens with one text from Kacper, each effect +// button posts the next line with that effect and Kacper answers a beat +// later. Long-press the send arrow for the full effects sheet. + +const CONTACT = 'Kacper'; +const STARTED = '12 Sep 2026 at 09:41'; +// Header: avatar + gap + name pill, measured from the reference. +const HEADER_H = 60 + 6 + 30; + +export default function IMessageScreen() { + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const { messages, playing, busy, send, finishPlaying, getTarget } = + useIMessageEffects(); + const [draft, setDraft] = useState(''); + const [sheet, setSheet] = useState(null); + const scroll = useRef>(null); + const messagesRef = useRef(messages); + messagesRef.current = messages; + const playingRef = useRef(playing); + playingRef.current = playing; + + useIMessageDebugHooks( + send, + () => messagesRef.current, + () => playingRef.current + ); + + useEffect(() => { + // Pin the newest bubble before it is measured, so rects are final. + scroll.current?.scrollToEnd({ animated: false }); + }, [messages.length]); + + useEffect( + () => () => messages.forEach((m) => forgetTarget(m.id)), + [messages] + ); + + const sendPlain = useCallback(() => { + if (!draft.trim()) { + return; + } + send('none', draft); + setDraft(''); + }, [draft, send]); + + const openSheet = useCallback(() => { + // The sheet needs the whole screen; the keyboard would cover its preview. + Keyboard.dismiss(); + setSheet(draft.trim() || DEFAULT_TEXT.none); + }, [draft]); + + const sendWith = useCallback( + (effect: EffectId) => { + if (sheet !== null) { + send(effect, sheet); + } + setSheet(null); + setDraft(''); + }, + [send, sheet] + ); + + const onTargetFor = useCallback( + (id: string) => (t: BubbleTarget) => reportTarget(id, t), + [] + ); + + const target = playing ? getTarget(playing.messageId) : null; + const player = + playing && target && isScreenEffect(playing.effect) ? ( + finishPlaying(playing.key)} + /> + ) : null; + + const lastMine = [...messages].reverse().find((m) => m.from === 'me'); + const usedEffects = new Set( + messages.filter((m) => m.from === 'me').map((m) => m.effect) + ); + + return ( + + + + + + iMessage + + + Encrypted + + {STARTED} + + {messages.map((m, i) => { + const next = messages[i + 1]; + const tail = !next || next.from !== m.from; + return ( + 0 && next?.from !== m.from && styles.runGap} + > + + {lastMine?.id === m.id && ( + Delivered + )} + + ); + })} + + + + navigation.goBack()} + style={styles.circle} + accessibilityRole="button" + accessibilityLabel="Back" + > + + + + + {CONTACT[0]} + + + {CONTACT} + + + + + + + + + + + {DEMO_ORDER.map((e) => { + const used = usedEffects.has(e); + return ( + send(e)} + disabled={busy} + style={({ pressed }) => [ + styles.effectChip, + used && styles.effectChipUsed, + pressed && styles.effectChipPressed, + busy && styles.effectChipBusy, + ]} + accessibilityRole="button" + accessibilityState={{ selected: used, disabled: busy }} + accessibilityLabel={`Send with ${EFFECT_LABEL[e]}`} + > + {({ pressed }) => ( + + {EFFECT_LABEL[e]} + + )} + + ); + })} + + + + + + + + {draft.trim() ? ( + + ↑ + + ) : ( + + + + )} + + + + + {player} + + {sheet !== null && ( + setSheet(null)} + onSend={sendWith} + /> + )} + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: IM_BG, + }, + thread: { + flex: 1, + }, + threadContent: { + flexGrow: 1, + justifyContent: 'flex-end', + paddingBottom: 8, + }, + intro: { + alignItems: 'center', + gap: 2, + marginBottom: 14, + }, + introRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + introText: { + color: IM_MUTED, + fontSize: 14, + }, + introDate: { + color: IM_MUTED, + fontSize: 12, + fontWeight: '600', + marginTop: 18, + }, + runGap: { + marginBottom: 4, + }, + delivered: { + alignSelf: 'flex-end', + color: IM_MUTED, + fontSize: 11, + fontWeight: '600', + marginRight: 18, + marginTop: 3, + }, + + header: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + paddingHorizontal: 15, + backgroundColor: IM_BG, + }, + circle: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: IM_CHROME, + alignItems: 'center', + justifyContent: 'center', + marginTop: 8, + }, + contact: { + alignItems: 'center', + gap: 6, + paddingBottom: 6, + }, + avatar: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#4A465D', + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { + color: '#fff', + fontSize: 27, + fontWeight: '700', + }, + namePill: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + height: 30, + paddingLeft: 14, + paddingRight: 12, + borderRadius: 15, + backgroundColor: IM_CHROME, + }, + contactName: { + color: '#fff', + fontSize: 17, + fontWeight: '600', + }, + + bottom: { + backgroundColor: IM_BG, + }, + // One row, in the order the demo means them to be pressed. + effectRow: { + flexDirection: 'row', + justifyContent: 'center', + gap: 5, + paddingHorizontal: 12, + paddingBottom: 8, + }, + effectChip: { + flexShrink: 1, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 14, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.18)', + backgroundColor: 'rgba(255,255,255,0.06)', + }, + effectChipUsed: { + borderColor: 'rgba(76,149,247,0.45)', + backgroundColor: 'rgba(76,149,247,0.12)', + }, + effectChipPressed: { + borderColor: IM_BLUE, + backgroundColor: IM_BLUE, + transform: [{ scale: 0.94 }], + }, + // Kacper is typing: hold the row until his reply lands. + effectChipBusy: { + opacity: 0.4, + }, + effectChipText: { + color: '#D1D1D6', + fontSize: 12, + fontWeight: '600', + }, + effectChipTextUsed: { + color: IM_BLUE, + }, + effectChipTextPressed: { + color: '#fff', + }, + + composer: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: 11, + paddingLeft: 16, + paddingRight: 16, + paddingTop: 6, + }, + plus: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: IM_CHROME, + alignItems: 'center', + justifyContent: 'center', + }, + inputWrap: { + flex: 1, + minHeight: 40, + borderRadius: 20, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.14)', + backgroundColor: 'rgba(255,255,255,0.05)', + flexDirection: 'row', + alignItems: 'flex-end', + paddingLeft: 18, + paddingRight: 6, + paddingVertical: 3, + }, + input: { + flex: 1, + color: '#fff', + fontSize: 17, + maxHeight: 110, + paddingTop: 7, + paddingBottom: 7, + }, + micWrap: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 1, + }, + send: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: IM_BLUE, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 1, + }, + sendArrow: { + color: '#fff', + fontSize: 19, + fontWeight: '700', + marginTop: -2, + }, +}); diff --git a/example/src/types.ts b/example/src/types.ts index db5de05..982b45b 100644 --- a/example/src/types.ts +++ b/example/src/types.ts @@ -43,6 +43,7 @@ export type RootStackParamList = { DandelionExample: undefined; HourglassExample: undefined; TelegramSpoilerExample: undefined; + IMessageExample: undefined; }; export type HomeScreenNavigationProp =