Skip to content
Dashboard

How we built the v0 iOS app

Head of Mobile

Link to headingTable of contents

Link to headingHow we built the v0 chat experience

Link to headingBuilding a composable chat

export function ChatProvider({ children }) {
return (
<ComposerHeightProvider>
<MessageListProvider>
<NewMessageAnimationProvider>
<KeyboardStateProvider>{children}</KeyboardStateProvider>
</NewMessageAnimationProvider>
</MessageListProvider>
</ComposerHeightProvider>
)
}

export function ChatMessagesList({ chatId }) {
const messages = useMessages({ chatId }).data
return (
<ChatProvider key={chatId}>
<MessagesList messages={messages} />
</ChatProvider>
)
}

function MessagesList({ messages }) {
useKeyboardAwareMessageList()
useScrollMessageListFromComposerSizeUpdates()
useUpdateLastMessageIndex()
const { animatedProps, ref, onContentSizeChange, onScroll } = useMessageListProps()
return (
<AnimatedLegendList
animatedProps={animatedProps}
ref={ref}
onContentSizeChange={onContentSizeChange}
onScroll={onScroll}
enableAverages={false}
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => {
if (item.role === 'user') {
return <UserMessage message={item} index={index} />
}
if (item.role === 'assistant') {
return <AssistantMessage message={item} index={index} />
}
if (item.role === 'optimistic-placeholder') {
return <OptimisticAssistantMessage index={index} />
}
}}
/>
)
}

Link to headingSending your first message

const { isMessageSendAnimating } = useNewMessageAnimation()
const chatId = useChatId()
const onSubmit = () => {
const isNewChat = !chatId
if (isNewChat) {
isMessageSendAnimating.set(true)
}
send()
}

Setting the animation state on submit

export function UserMessage({ message, index }) {
const isFirstUserMessage = index === 0
const { style, ref, onLayout } = useFirstMessageAnimation({
disabled: !isFirstUserMessage,
})
return (
<Animated.View style={style} ref={ref} onLayout={onLayout}>
<UserMessageContent message={message} />
</Animated.View>
)
}

Wrapping the user message in an animated view

Link to headingHow useFirstMessageAnimation works

export function useFirstMessageAnimation({ disabled }) {
const { keyboardHeight } = useKeyboardContextState()
const { isMessageSendAnimating } = useNewMessageAnimation()
const windowHeight = useWindowDimensions().height
const translateY = useSharedValue(0)
const progress = useSharedValue(-1)
const { itemHeight, ref, onLayout } = useMessageRenderedHeight()
useAnimatedReaction(
() => {
const didAnimate = progress.get() !== -1
if (disabled || didAnimate || !isMessageSendAnimating.get()) {
return -1
}
return itemHeight.get()
},
(messageHeight) => {
if (messageHeight <= 0) return
const animatedValues = getAnimatedValues({
itemHeight: messageHeight,
windowHeight,
keyboardHeight: keyboardHeight.get(),
})
const { start, end, duration, easing, config } = animatedValues
translateY.set(
// initialize values at the "start" state with duration 0
withTiming(start.translateY, { duration: 0 }, () => {
// next, transition to the "end" state
translateY.set(withSpring(end.translateY, config))
})
)
progress.set(
withTiming(start.progress, { duration: 0 }, () => {
progress.set(withTiming(end.progress, { duration, easing }), () => {
isMessageSendAnimating.set(false)
})
})
)
}
)
const style = useAnimatedStyle(...)
const didUserMessageAnimate = useDerivedValue(() => progress.get() === 1)
return { style, ref, onLayout, didUserMessageAnimate }
}

The useFirstMessageAnimation hook

Link to headingFading in the first assistant message

function AssistantMessage({ message, index }) {
const isFirstAssistantMessage = index === 1
const { didUserMessageAnimate } = useFirstMessageAnimation({
disabled: !isFirstAssistantMessage,
})
const style = useAnimatedStyle(() => ({
opacity: didUserMessageAnimate.get() ? withTiming(1, { duration: 350 }) : 0,
}))
return (
<Animated.View style={style}>
<AssistantMessageContent message={message} />
</Animated.View>
)
}

Fading in after the user message animation completes

Link to headingSending messages in an existing chat

useEffect(function onNewMessage() {
const didNewMessageSend = // ...some logic
if (didNewMessageSend) {
listRef.current?.scrollToEnd()
}
}, ...)

The naive approach

Link to headingHow we solved it

Link to headingImplementing useMessageBlankSize

function AssistantMessage({ message, index }) {
// ...styling logic
const { onLayout, ref } = useMessageBlankSize({ index })
return (
<Animated.View ref={ref} onLayout={onLayout}>
<AssistantMessageContent message={message} />
</Animated.View>
)
}

export function MessagesList(props) {
const { blankSize, composerHeight, keyboardHeight } = useMessageListContext()
const animatedProps = useAnimatedProps(() => {
return {
contentInset: {
bottom: blankSize.get() + composerHeight.get() + keyboardHeight.get(),
},
}
})
return <AnimatedLegendList {...props} animatedProps={animatedProps} />
}

Passing blankSize to contentInset

Link to headingTaming the keyboard

Link to headingBuilding useKeyboardAwareMessageList

function MessagesList() {
useKeyboardAwareMessageList()
// ...rest of the message list
}

Consuming useKeyboardAwareMessageList

Link to headingScrolling to end initially

import { scheduleOnRN } from 'react-native-worklets'
export function useInitialScrollToEnd(blankSize, scrollToEnd, hasMessages) {
const hasStartedScrolledToEnd = useSharedValue(false)
const hasScrolledToEnd = useSharedValue(false)
const scrollToEndJS = useLatestCallback(() => {
scrollToEnd({ animated: false })
// Do another one just in case because the list may not have fully laid out yet
requestAnimationFrame(() => {
scrollToEnd({ animated: false })
// and another one again in case
setTimeout(() => {
scrollToEnd({ animated: false })
// and yet another!
requestAnimationFrame(() => {
hasScrolledToEnd.set(true)
})
}, 16)
})
})
useAnimatedReaction(
() => {
if (hasStartedScrolledToEnd.get() || !hasMessages) {
return false
}
return blankSize.get() > 0
},
(shouldScroll) => {
if (shouldScroll) {
hasStartedScrolledToEnd.set(true)
scheduleOnRN(scrollToEndJS)
}
}
)
return hasScrolledToEnd
}

Calling scrollToEnd multiple times to handle dynamic heights

Link to headingFloating composer

<LiquidGlassContainerView spacing={8}>
<LiquidGlassView interactive>...</LiquidGlassView>
<LiquidGlassView interactive>...</LiquidGlassView>
</LiquidGlassContainerView>

Adding Liquid Glass to the composer

Link to headingMake it float

function Composer() {
const { composerHeight } = useComposerHeightContext()
const { onLayout, ref } = useSyncLayoutHandler((layout) => {
composerHeight.set(layout.height)
})
const insets = useInsets()
return (
<KeyboardStickyView
style={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}
offset={{ closed: -insets.bottom, opened: -8 }}
>
<View
ref={ref}
onLayout={onLayout}
>
{/* ... */}
</View>
</KeyboardStickyView>
)
}

Positioning the composer with KeyboardStickyView

Link to headinguseScrollWhenComposerSizeUpdates

export function MessagesList() {
useScrollWhenComposerSizeUpdates()
// ...message list code
}

Consuming useScrollWhenComposerSizeUpdates

export function useScrollWhenComposerSizeUpdates() {
const { listRef, scrollToEnd } = useMessageListContext()
const { composerHeight } = useComposerHeightContext()
const autoscrollToEnd = () => {
const list = listRef.current
if (!list) {
return
}
const state = list.getState()
const distanceFromEnd =
state.contentLength - state.scroll - state.scrollLength
if (distanceFromEnd < 0) {
scrollToEnd({ animated: false })
// wait a frame for LegendList to update, and fire it again
setTimeout(() => {
scrollToEnd({ animated: false })
}, 16)
}
}
useAnimatedReaction(
() => composerHeight.get(),
(height, prevHeight) => {
if (height > 0 && height !== prevHeight) {
scheduleOnRN(autoscrollToEnd)
}
}
)
}

Scrolling to end when the composer grows

Link to headingMake it feel native

diff --git a/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
index 6e9c3841cee19632eaa59ae2dbd541a85ce7cabf..e3f920acbc2bb074582ed2b531ddd90e2017d59c 100644
--- a/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
+++ b/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
@@ -55,6 +55,16 @@ - (instancetype)initWithFrame:(CGRect)frame
self.textContainer.lineFragmentPadding = 0;
self.scrollsToTop = NO;
self.scrollEnabled = YES;
+
+ // Fix bouncing, scroll indicator, and keyboard mode gesture
+ self.showsVerticalScrollIndicator = NO;
+ self.showsHorizontalScrollIndicator = NO;
+ self.bounces = NO;
+ self.alwaysBounceVertical = NO;
+ self.alwaysBounceHorizontal = NO;
+ self.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive;
+ [self.panGestureRecognizer addTarget:self action:@selector(_handlePanToFocus:)];
+
_initialValueLeadingBarButtonGroups = nil;
_initialValueTrailingBarButtonGroups = nil;
}
@@ -62,6 +72,18 @@ - (instancetype)initWithFrame:(CGRect)frame
return self;
}
+- (void)_handlePanToFocus:(UIPanGestureRecognizer *)g
+{
+ if (self.isFirstResponder) { return; }
+ if (g.state != UIGestureRecognizerStateBegan) { return; }
+ CGPoint v = [g velocityInView:self];
+ CGPoint t = [g translationInView:self];
+ // Add pan gesture to focus the keyboard
+ if (v.y < -250.0 && !self.isFirstResponder) {
+ [self becomeFirstResponder];
+ }
+}
+
- (void)setDelegate:(id<UITextViewDelegate>)delegate
{
// Delegate is set inside `[RCTBackedTextViewDelegateAdapter initWithTextView]` and

Link to headingPasting images

<TextInputWrapper onPaste={pasted => ...}>
<TextInput />
</TextInputWrapper>

Wrapping TextInput to handle paste events

Link to headingFading in streaming content

const mdxComponents = {
a: function A(props) {
return (
<Elements.A {...props}>
<TextFadeInStaggeredIfStreaming>
{props.children}
</TextFadeInStaggeredIfStreaming>
</Elements.A>
)
},
// ...other components
}

Using TextFadeInStaggeredIfStreaming in MDX components

const useIsAnimatedInPool = createUsePool()
function FadeInStaggered({ children }) {
const { isActive, evict } = useIsAnimatedInPool()
return isActive ? <FadeIn onFadedIn={evict}>{children}</FadeIn> : children
}

Managing animation state with a pool

const useStaggeredAnimation = createUseStaggered(32)
function FadeIn({ children, onFadedIn, Component }) {
const opacity = useSharedValue(0)
const startAnimation = () => {
opacity.set(withTiming(1, { duration: 500 }))
setTimeout(onFadedIn, 500)
}
useStaggeredAnimation(startAnimation)
return <Component style={{ opacity }}>{children}</Component>
}

Staggered fade animation

const useShouldTextFadePool = createUsePool(4)
function TextFadeInStaggeredIfStreaming(props) {
const { isStreaming } = use(MessageContext)
const { isActive } = useShouldTextFadePool()
const [shouldFade] = useState(isActive && isStreaming)
let { children } = props
if (shouldFade && children) {
if (Array.isArray(children)) {
children = Children.map(children, (child, i) =>
typeof child === 'string' ? <AnimatedFadeInText key={i} text={child} /> : child,
)
} else if (typeof children === 'string') {
children = <AnimatedFadeInText text={children} />
}
}
return children
}
function AnimatedFadeInText({ text }) {
const chunks = text.split(' ')
return chunks.map((chunk, i) => <TextFadeInStaggered key={i} text={chunk + ' '} />)
}
function TextFadeInStaggered({ text }) {
const { isActive, evict } = useIsAnimatedInPool()
return isActive ? <FadeIn onFadedIn={evict}>{text}</FadeIn> : text
}

Chunking text and limiting concurrent animations

function TextFadeInStaggeredIfStreaming(props) {
const { isStreaming } = use(MessageContext)
const { isActive } = useShouldTextFadePool()
const isFadeDisabled = useDisableFadeContext()
const [shouldFade] = useState(!isFadeDisabled && isActive && isStreaming)
if (shouldFade) // here we render TextFadeIn...
return props.children
}

Disabling fade for already-seen content

Link to headingSharing code between web and native

Link to headingBuilding a shared API

import { termsFindOptions } from '@/api' // this folder is generated
import { useQuery } from '@tanstack/react-query'
export function useTermsQuery({ after }) {
return useQuery(termsFindOptions({ after }))
}

Generated API helpers with Tanstack Query

Link to headingStyling

Link to headingNative menus

Link to headingNative alerts

Link to headingNative bottom sheets

Link to headingModal dragging issues

Link to headingFixing Yoga flickering

Link to headingLooking forward