This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/mobile-app/app/(tabs)/index.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

97 lines
3.2 KiB
TypeScript

import { useCallback, useRef, useState } from "react";
import { FlatList, KeyboardAvoidingView, Platform, StyleSheet, View } from "react-native";
import { ScreenContainer } from "@/components/screen-container";
import { ChatHeader } from "@/components/chat-header";
import { ChatBubble } from "@/components/chat-bubble";
import { ChatInput } from "@/components/chat-input";
import { TypingIndicator } from "@/components/typing-indicator";
import { ImageViewer } from "@/components/image-viewer";
import { EmptyChat } from "@/components/empty-chat";
import { useChat } from "@/lib/chat-store";
import { useColors } from "@/hooks/use-colors";
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
import type { ChatMessage } from "@/shared/types";
export default function ChatScreen() {
const { messages, isTyping } = useChat();
const colors = useColors();
const flatListRef = useRef<FlatList>(null);
const [viewingImage, setViewingImage] = useState<string | null>(null);
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const handlePlayVoice = useCallback(async (msg: ChatMessage) => {
if (!msg.uri) return;
try {
if (playingVoiceId === msg.id) {
setPlayingVoiceId(null);
return;
}
await setAudioModeAsync({ playsInSilentMode: true });
const player = createAudioPlayer({ uri: msg.uri });
player.play();
setPlayingVoiceId(msg.id);
// Auto-reset after estimated duration
const dur = (msg.duration ?? 5) * 1000;
setTimeout(() => {
setPlayingVoiceId(null);
player.remove();
}, dur + 500);
} catch (err) {
console.warn("Voice playback error:", err);
setPlayingVoiceId(null);
}
}, [playingVoiceId]);
const renderItem = useCallback(
({ item }: { item: ChatMessage }) => (
<ChatBubble
message={item}
onImagePress={setViewingImage}
onPlayVoice={handlePlayVoice}
isPlayingVoice={playingVoiceId === item.id}
/>
),
[playingVoiceId, handlePlayVoice],
);
const keyExtractor = useCallback((item: ChatMessage) => item.id, []);
return (
<ScreenContainer edges={["top", "left", "right"]} containerClassName="bg-background">
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={0}
>
<ChatHeader />
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderItem}
keyExtractor={keyExtractor}
contentContainerStyle={styles.listContent}
style={{ flex: 1, backgroundColor: colors.background }}
onContentSizeChange={() => {
flatListRef.current?.scrollToEnd({ animated: true });
}}
ListFooterComponent={isTyping ? <TypingIndicator /> : null}
ListEmptyComponent={!isTyping ? <EmptyChat /> : null}
showsVerticalScrollIndicator={false}
/>
<ChatInput />
</KeyboardAvoidingView>
<ImageViewer uri={viewingImage} onClose={() => setViewingImage(null)} />
</ScreenContainer>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
listContent: {
paddingVertical: 12,
},
});