wip convo ui

This commit is contained in:
2026-06-23 00:59:06 +01:00
parent 25713ef614
commit 57c3275cce
30 changed files with 1649 additions and 251 deletions

View File

@@ -0,0 +1,284 @@
/* eslint-disable react-hooks/immutability */
import MaskedView from "@react-native-masked-view/masked-view"
import { BlurView } from "expo-blur"
import { LinearGradient } from "expo-linear-gradient"
import { atom, useAtomValue, useSetAtom, useStore } from "jotai"
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"
import { useColorScheme, View, StyleSheet, Platform, Dimensions } from "react-native"
import { easeGradient } from "react-native-easing-gradient"
import { useKeyboardHandler } from "react-native-keyboard-controller"
import Animated, { FadeIn, FadeOut, useSharedValue, withSpring } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import tw from "twrnc"
import { Button } from "@/components/ui/button"
import { TextInput } from "@/components/ui/text-input"
import { ConversationList } from "./conversation-list"
interface BottomProgressiveBlurRef {
setBlurHeight: (height: number) => void
}
interface ConversationListContainerRef {
showFullChat: () => void
}
const ChatViewMode = {
Hidden: "hidden",
Peek: "peek",
FullChat: "full-chat",
} as const
type ChatViewMode = (typeof ChatViewMode)[keyof typeof ChatViewMode]
const chatInputHeightAtom = atom(0)
const isChatInputFocusedAtom = atom(false)
const chatViewModeAtom = atom<ChatViewMode>(ChatViewMode.Hidden)
export function ChatOverlay() {
const theme = useColorScheme()
const setChatInputHeight = useSetAtom(chatInputHeightAtom)
const setIsChatInputFocused = useSetAtom(isChatInputFocusedAtom)
const setChatViewMode = useSetAtom(chatViewModeAtom)
const store = useStore()
const conversationListContainerRef = useRef<ConversationListContainerRef>(null)
const onTextInputFocus = () => {
setChatViewMode(ChatViewMode.Peek)
}
const onConversationListScroll = () => {
if (store.get(chatViewModeAtom) !== ChatViewMode.FullChat) {
setChatViewMode(ChatViewMode.FullChat)
conversationListContainerRef?.current?.showFullChat()
}
}
return (
<ChatOverlayContainer>
<OverlayBackdrop />
<ConversationListContainer ref={conversationListContainerRef}>
<ConversationList
ListHeaderComponent={ConversationListHeader}
ListFooterComponent={ConversationListFooter}
onScrollBeginDrag={onConversationListScroll}
/>
</ConversationListContainer>
<ChatInputContainer>
<BlurView
onLayout={({ nativeEvent: { layout } }) => {
setChatInputHeight(layout.height)
}}
intensity={35}
tint={theme === "dark" ? "systemThickMaterialDark" : "systemThickMaterialLight"}
style={tw`flex flex-row w-full py-1 pl-4 pr-1 border border-stone-300 dark:border-stone-700 rounded-full overflow-hidden`}
>
<TextInput
onFocus={onTextInputFocus}
onBlur={() => {
setIsChatInputFocused(false)
}}
style={tw`flex-1`}
placeholder="Message Freya..."
/>
<Button style={tw`size-8 p-0`}>
<Button.Icon name="arrow-up" />
</Button>
</BlurView>
</ChatInputContainer>
</ChatOverlayContainer>
)
}
function ChatOverlayContainer({ children }: React.PropsWithChildren) {
const bottom = useSharedValue(0)
useKeyboardHandler({
onMove: (event) => {
"worklet"
bottom.value = event.height
},
})
return (
<Animated.View pointerEvents="box-none" style={[tw`absolute top-0 left-0 right-0`, { bottom }]}>
{children}
</Animated.View>
)
}
function ConversationListContainer({
ref,
children,
}: React.PropsWithChildren<{ ref?: React.Ref<ConversationListContainerRef> }>) {
const chatViewMode = useAtomValue(chatViewModeAtom)
const height = useSharedValue(Dimensions.get("window").height * 0.4)
const { colors, locations } = useMemo(
() =>
easeGradient({
colorStops: {
0: { color: "transparent" },
0.1: { color: "transparent" },
0.3: { color: tw.color("bg-stone-100 dark:bg-stone-950")! },
0.9: { color: tw.color("bg-stone-100 dark:bg-stone-950")! },
1: { color: tw.color("bg-stone-100 dark:bg-stone-950")! },
},
}),
[],
)
const showFullChat = useCallback(() => {
height.value = withSpring(Dimensions.get("window").height + 80)
}, [height])
useImperativeHandle(
ref,
() => ({
showFullChat,
}),
[showFullChat],
)
return (
<View pointerEvents="box-none" style={tw.style("absolute top-0 left-0 right-0 bottom-0")}>
<MaskedView
pointerEvents="box-none"
maskElement={
<Animated.View style={[tw`absolute bottom-0 right-0 left-0`, { height }]}>
<LinearGradient
locations={locations as any}
colors={colors as any}
style={tw`size-full`}
/>
</Animated.View>
}
style={tw`size-full`}
>
<View
style={tw.style("size-full", chatViewMode === ChatViewMode.Hidden ? "opacity-0" : "")}
>
{children}
</View>
</MaskedView>
</View>
)
}
function OverlayBackdrop() {
const chatViewMode = useAtomValue(chatViewModeAtom)
const bottomProgressiveBlurRef = useRef<BottomProgressiveBlurRef>(null)
useEffect(() => {
if (chatViewMode === ChatViewMode.Peek) {
bottomProgressiveBlurRef?.current?.setBlurHeight(Dimensions.get("window").height * 0.75)
}
}, [chatViewMode])
if (chatViewMode === ChatViewMode.FullChat) {
return <BlurBackground />
}
return <BottomProgressiveBlur ref={bottomProgressiveBlurRef} />
}
function BottomProgressiveBlur({ ref }: { ref?: React.Ref<BottomProgressiveBlurRef> }) {
const progressiveBlurHeight = useSharedValue(192)
const colorScheme = useColorScheme()
const { colors, locations } = useMemo(
() =>
easeGradient({
colorStops: {
0: { color: "transparent" },
0.7: { color: tw.color("bg-stone-100 dark:bg-stone-950")! },
1: { color: tw.color("bg-stone-100 dark:bg-stone-950")! },
},
}),
[],
)
const setBlurHeight = useCallback(
(height: number) => {
progressiveBlurHeight.value = withSpring(height)
},
[progressiveBlurHeight],
)
useImperativeHandle(ref, () => ({ setBlurHeight }), [setBlurHeight])
return (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
style={[tw`absolute bottom-0 left-0 right-0`, { height: progressiveBlurHeight }]}
>
<MaskedView
maskElement={
<LinearGradient
locations={locations as any}
colors={colors as any}
style={tw`absolute top-0 bottom-0 left-0 right-0`}
/>
}
style={[StyleSheet.absoluteFill, tw`z-[1]`]}
>
<BlurView
intensity={65}
tint={Platform.select({
ios:
colorScheme === "dark"
? "systemUltraThinMaterialDark"
: "systemUltraThinMaterialLight",
android: "systemMaterialDark",
})}
style={StyleSheet.absoluteFill}
/>
</MaskedView>
</Animated.View>
)
}
function BlurBackground() {
const colorScheme = useColorScheme()
return (
<Animated.View entering={FadeIn} exiting={FadeOut} style={StyleSheet.absoluteFill}>
<BlurView
intensity={65}
tint={Platform.select({
ios:
colorScheme === "dark" ? "systemUltraThinMaterialDark" : "systemUltraThinMaterialLight",
android: "systemMaterialDark",
})}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
)
}
function ChatInputContainer({ children }: React.PropsWithChildren) {
const keyboardHeight = useSharedValue(0)
const insets = useSafeAreaInsets()
useKeyboardHandler({
onMove: (event) => {
"worklet"
keyboardHeight.value = Math.max(event.height - insets.bottom + 8, 0)
},
})
return <View style={tw`absolute bottom-0 left-0 right-0 px-4 pb-10`}>{children}</View>
}
function ConversationListHeader() {
const safeAreaInsets = useSafeAreaInsets()
return <View style={{ height: safeAreaInsets.top }} />
}
function ConversationListFooter() {
const chatInputHeight = useAtomValue(chatInputHeightAtom)
const safeAreaInsets = useSafeAreaInsets()
return <View style={{ height: chatInputHeight + 24 + safeAreaInsets.bottom }} />
}

View File

@@ -0,0 +1,183 @@
import { AssistantMessagePayload, UserMessagePayload } from "@freya/core"
import { FlashList } from "@shopify/flash-list"
import { useQuery } from "@tanstack/react-query"
import { View, ViewStyle } from "react-native"
import tw from "twrnc"
import { SansSerifText } from "@/components/ui/sans-serif-text"
import { ConversationEntry } from "./conversations"
import { useListConversationEntriesQuery, useDefaultConversationQuery } from "./queries"
type ConversationListProps = Omit<
React.ComponentProps<typeof FlashList>,
"data" | "keyExtractor" | "renderItem" | "maintainVisibleContentPosition"
>
type PositionInGroup = "single" | "first" | "in-between" | "last"
const messageBubbleRadius = 18
const groupedMessageBubbleRadius = 6
export function ConversationList({
ListFooterComponent,
ListHeaderComponent,
onScrollBeginDrag,
}: ConversationListProps) {
const { data: conversation } = useQuery(useDefaultConversationQuery())
const { data: entries } = useQuery(useListConversationEntriesQuery(conversation?.id))
const conversationEntries = entries ?? []
return (
<FlashList
style={tw`size-full`}
maintainVisibleContentPosition={{ startRenderingFromBottom: true }}
data={conversationEntries}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => {
const previousEntryIsSameKind = conversationEntries[index - 1]?.kind === item.kind
const nextEntryIsSameKind = conversationEntries[index + 1]?.kind === item.kind
let position: PositionInGroup
if (!previousEntryIsSameKind && !nextEntryIsSameKind) {
position = "single"
} else if (!previousEntryIsSameKind) {
position = "first"
} else if (!nextEntryIsSameKind) {
position = "last"
} else {
position = "in-between"
}
return <MessageBubble entry={item} position={position} />
}}
onScrollBeginDrag={onScrollBeginDrag}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={ListFooterComponent}
/>
)
}
function MessageBubble({
entry,
position,
}: {
entry: typeof ConversationEntry.infer
position: PositionInGroup
}) {
if (entry.kind === "user_message") {
const payload = UserMessagePayload.assert(entry.payload)
return <UserMessageBubble payload={payload} position={position} />
}
if (entry.kind === "assistant_message") {
const payload = AssistantMessagePayload.assert(entry.payload)
return <AssistantMessageBubble payload={payload} position={position} />
}
return null
}
function UserMessageBubble({
payload,
position,
}: {
payload: UserMessagePayload
position: PositionInGroup
}) {
const content = payload.parts.reduce((final, part) => {
if (part.type === "text") {
return final + part.text
}
return final
}, "")
let corners: ViewStyle
switch (position) {
case "single":
case "first":
corners = {
borderRadius: messageBubbleRadius,
borderBottomRightRadius: groupedMessageBubbleRadius,
}
break
case "in-between":
corners = {
borderRadius: messageBubbleRadius,
borderTopRightRadius: groupedMessageBubbleRadius,
borderBottomRightRadius: groupedMessageBubbleRadius,
}
break
case "last":
corners = {
borderRadius: messageBubbleRadius,
borderTopRightRadius: groupedMessageBubbleRadius,
}
break
}
return (
<View style={tw`w-full flex-row justify-end mb-4 pr-4`}>
<View
style={tw.style("bg-teal-600 px-3 py-2 overflow-hidden max-w-56", corners, {
borderCurve: "circular",
})}
>
<SansSerifText style={tw`text-stone-100`}>{content}</SansSerifText>
</View>
</View>
)
}
function AssistantMessageBubble({
payload,
position,
}: {
payload: AssistantMessagePayload
position: PositionInGroup
}) {
const content = payload.parts.reduce((final, part) => {
if (part.type === "text") {
return final + part.text
}
return final
}, "")
let corners: ViewStyle
switch (position) {
case "single":
case "first":
corners = {
borderRadius: messageBubbleRadius,
borderBottomLeftRadius: groupedMessageBubbleRadius,
}
break
case "in-between":
corners = {
borderRadius: messageBubbleRadius,
borderTopLeftRadius: groupedMessageBubbleRadius,
borderBottomLeftRadius: groupedMessageBubbleRadius,
}
break
case "last":
corners = {
borderRadius: messageBubbleRadius,
borderTopLeftRadius: groupedMessageBubbleRadius,
}
break
}
return (
<View style={tw`w-full flex-row justify-start mb-4 pl-4`}>
<View
style={tw.style(
"bg-stone-200 dark:bg-stone-800 border border-stone-300 dark:border-stone-700 px-3 py-2 overflow-hidden max-w-56",
corners,
{
borderCurve: "circular",
},
)}
>
<SansSerifText style={tw`text-stone-950 dark:text-stone-100`}>{content}</SansSerifText>
</View>
</View>
)
}

View File

@@ -1,23 +0,0 @@
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import {
useListConversationEntriesQuery,
useDefaultConversationQuery,
useListConversationsQuery,
} from "./queries";
export function ConversationView() {
const { data: conversation } = useQuery(useDefaultConversationQuery());
const { data: entries } = useQuery(
useListConversationEntriesQuery(conversation?.id),
);
return (
<FlashList
data={entries ?? []}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <div key={item.id}>{item.kind}</div>}
/>
);
}

View File

@@ -5,6 +5,12 @@ import {
} from "@freya/core"
import { type } from "arktype"
export const Conversation = type({
id: "string.uuid",
createdAt: "string.date.iso",
updatedAt: "string.date.iso",
})
export const ConversationEntry = type({
id: "string.uuid",
sequence: "number",

View File

@@ -3,9 +3,13 @@ import { type } from "arktype"
import { useApiClient } from "@/api/client"
import { ConversationEntry } from "./conversations"
import { Conversation, ConversationEntry } from "./conversations"
const ConversationQueryResponse = type({
const ListConversationsResponse = type({
conversations: Conversation.array(),
})
const ConversationEntriesResponse = type({
entries: ConversationEntry.array(),
})
@@ -16,14 +20,16 @@ export function useListConversationsQuery() {
queryFn: async () =>
api
.request("/conversations", { method: "GET" })
.then(([, json]) => ConversationQueryResponse.assert(json)),
.then(([, json]) => ListConversationsResponse.assert(json)),
})
}
export function useDefaultConversationQuery() {
return queryOptions({
...useListConversationsQuery(),
select: (data) => (data.entries.length === 0 ? null : data.entries[0]),
select: (data) => {
return data.conversations.length === 0 ? null : data.conversations[0]
},
})
}
@@ -35,7 +41,7 @@ export function useListConversationEntriesQuery(id?: string) {
? async () =>
api
.request(`/conversations/${id}/entries`, { method: "GET" })
.then(([, json]) => ConversationQueryResponse.assert(json).entries)
.then(([, json]) => ConversationEntriesResponse.assert(json).entries)
: skipToken,
})
}