mirror of
https://github.com/kennethnym/freya
synced 2026-07-04 15:11:15 +01:00
wip convo ui
This commit is contained in:
284
apps/freya-client/src/conversations/chat-overlay.tsx
Normal file
284
apps/freya-client/src/conversations/chat-overlay.tsx
Normal 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 }} />
|
||||
}
|
||||
183
apps/freya-client/src/conversations/conversation-list.tsx
Normal file
183
apps/freya-client/src/conversations/conversation-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user