Files
Timmy-time-dashboard/mobile-app/components/chat-bubble.tsx
Manus AI b4b508ff5a feat: add Timmy Chat mobile app (Expo/React Native)
- Single-screen chat interface with Timmy's sovereign AI personality
- Text messaging with real-time AI responses via server chat API
- Voice recording and playback with waveform visualization
- Image sharing (camera + photo library) with full-screen viewer
- File attachments via document picker
- Dark arcane theme matching the Timmy Time dashboard
- Custom app icon with glowing T circuit design
- Timmy system prompt ported from dashboard prompts.py
- Unit tests for chat utilities and message types
2026-02-26 21:55:41 -05:00

215 lines
5.6 KiB
TypeScript

import { useMemo } from "react";
import { Text, View, StyleSheet, Image, Platform } from "react-native";
import Pressable from "@/components/ui/pressable-fix";
import { useColors } from "@/hooks/use-colors";
import type { ChatMessage } from "@/shared/types";
import { formatBytes, formatDuration } from "@/lib/chat-store";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
interface ChatBubbleProps {
message: ChatMessage;
onImagePress?: (uri: string) => void;
onPlayVoice?: (message: ChatMessage) => void;
isPlayingVoice?: boolean;
}
export function ChatBubble({ message, onImagePress, onPlayVoice, isPlayingVoice }: ChatBubbleProps) {
const colors = useColors();
const isUser = message.role === "user";
// Stable waveform bar heights based on message id
const waveHeights = useMemo(() => {
let seed = 0;
for (let i = 0; i < message.id.length; i++) seed = (seed * 31 + message.id.charCodeAt(i)) | 0;
return Array.from({ length: 12 }, (_, i) => {
seed = (seed * 16807 + i * 1013) % 2147483647;
return 4 + (seed % 15);
});
}, [message.id]);
const bubbleStyle = [
styles.bubble,
{
backgroundColor: isUser ? colors.primary : colors.surface,
borderColor: isUser ? colors.primary : colors.border,
alignSelf: isUser ? "flex-end" as const : "flex-start" as const,
},
];
const textColor = isUser ? "#fff" : colors.foreground;
const mutedColor = isUser ? "rgba(255,255,255,0.6)" : colors.muted;
const timeStr = new Date(message.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<View style={[styles.row, isUser ? styles.rowUser : styles.rowAssistant]}>
{!isUser && (
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={styles.avatarText}>T</Text>
</View>
)}
<View style={bubbleStyle}>
{message.contentType === "text" && (
<Text style={[styles.text, { color: textColor }]}>{message.text}</Text>
)}
{message.contentType === "image" && (
<Pressable
onPress={() => message.uri && onImagePress?.(message.uri)}
style={({ pressed }) => [pressed && { opacity: 0.8 }]}
>
<Image
source={{ uri: message.uri }}
style={styles.image}
resizeMode="cover"
/>
{message.text ? (
<Text style={[styles.text, { color: textColor, marginTop: 6 }]}>
{message.text}
</Text>
) : null}
</Pressable>
)}
{message.contentType === "voice" && (
<Pressable
onPress={() => onPlayVoice?.(message)}
style={({ pressed }) => [styles.voiceRow, pressed && { opacity: 0.7 }]}
>
<MaterialIcons
name={isPlayingVoice ? "pause" : "play-arrow"}
size={24}
color={textColor}
/>
<View style={[styles.waveform, { backgroundColor: isUser ? "rgba(255,255,255,0.3)" : colors.border }]}>
{waveHeights.map((h, i) => (
<View
key={i}
style={[
styles.waveBar,
{
height: h,
backgroundColor: textColor,
opacity: 0.6,
},
]}
/>
))}
</View>
<Text style={[styles.duration, { color: mutedColor }]}>
{formatDuration(message.duration ?? 0)}
</Text>
</Pressable>
)}
{message.contentType === "file" && (
<View style={styles.fileRow}>
<MaterialIcons name="insert-drive-file" size={28} color={textColor} />
<View style={styles.fileInfo}>
<Text style={[styles.fileName, { color: textColor }]} numberOfLines={1}>
{message.fileName ?? "File"}
</Text>
<Text style={[styles.fileSize, { color: mutedColor }]}>
{formatBytes(message.fileSize ?? 0)}
</Text>
</View>
</View>
)}
<Text style={[styles.time, { color: mutedColor }]}>{timeStr}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
marginBottom: 8,
paddingHorizontal: 12,
alignItems: "flex-end",
},
rowUser: {
justifyContent: "flex-end",
},
rowAssistant: {
justifyContent: "flex-start",
},
avatar: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: "center",
justifyContent: "center",
marginRight: 8,
},
avatarText: {
color: "#fff",
fontWeight: "700",
fontSize: 14,
},
bubble: {
maxWidth: "78%",
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 10,
},
text: {
fontSize: 15,
lineHeight: 21,
},
time: {
fontSize: 10,
marginTop: 4,
textAlign: "right",
},
image: {
width: 220,
height: 180,
borderRadius: 10,
},
voiceRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
minWidth: 160,
},
waveform: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: 2,
height: 24,
borderRadius: 4,
paddingHorizontal: 4,
},
waveBar: {
width: 3,
borderRadius: 1.5,
},
duration: {
fontSize: 12,
minWidth: 32,
},
fileRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
fileInfo: {
flex: 1,
},
fileName: {
fontSize: 14,
fontWeight: "600",
},
fileSize: {
fontSize: 11,
marginTop: 2,
},
});