From 8ddef14acac023f8d25b49452c749b463f5c162b Mon Sep 17 00:00:00 2001 From: Nathan Grilliot Date: Sat, 28 Feb 2026 14:02:30 -0500 Subject: [PATCH 1/8] wip: add messaging --- AGENTS.md | 208 +++++++++++ frontend/app/(tabs)/_layout.tsx | 9 + frontend/app/(tabs)/message.tsx | 410 ++++++++++++++++++++++ frontend/app/conversation/[username].tsx | 364 +++++++++++++++++++ frontend/components/MessageBubble.tsx | 118 +++++++ frontend/components/MessageComposer.tsx | 129 +++++++ frontend/components/MessageThreadItem.tsx | 161 +++++++++ frontend/index.js | 1 + frontend/package-lock.json | 195 ---------- 9 files changed, 1400 insertions(+), 195 deletions(-) create mode 100644 AGENTS.md create mode 100644 frontend/app/(tabs)/message.tsx create mode 100644 frontend/app/conversation/[username].tsx create mode 100644 frontend/components/MessageBubble.tsx create mode 100644 frontend/components/MessageComposer.tsx create mode 100644 frontend/components/MessageThreadItem.tsx create mode 100644 frontend/index.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..070a786 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,208 @@ +# DevBits Agent Guidelines + +This document provides essential information for AI coding agents working on the DevBits codebase. + +## Project Overview + +DevBits is a social media mobile application for developers, built with: +- **Backend**: Go 1.24 + Gin framework + PostgreSQL/SQLite +- **Frontend**: React Native 0.81.5 + Expo SDK 54 + TypeScript 5.3 +- **Architecture**: Monorepo with separate `/backend` and `/frontend` directories + +## Build, Lint, and Test Commands + +### Frontend (run from `/frontend` directory) + +```bash +# Development +npm run frontend # Start Expo dev server +npm run android # Start on Android device/emulator +npm run ios # Start on iOS device/simulator + +# Testing +npm test # Run Jest tests +npm test -- ComponentName # Run tests for specific component + +# Linting +npm run lint # Run ESLint with Expo config + +# Building +npx eas build -p android --profile production # Build Android APK/AAB +npx eas build -p ios --profile production # Build iOS app +``` + +### Backend (run from `/backend` directory) + +```bash +# Development +go run ./api # Start API server locally + +# Testing +cd backend +go test ./api/internal/tests/ # Run all tests +go test ./api/internal/tests/ -v # Run with verbose output +go test ./api/internal/tests/ -run TestUsers # Run specific test + +# Building +go build -o bin/api ./api # Build binary + +# Docker +docker compose up -d # Start all services (Postgres, API, Nginx) +docker compose up -d --build # Rebuild and restart +docker compose logs -f backend # View backend logs +``` + +## Code Style Guidelines + +### TypeScript/React Native (Frontend) + +#### File Organization +- Use PascalCase for component files: `Post.tsx`, `UserCard.tsx` +- Use camelCase for utility/hook files: `useAppColors.ts`, `api.ts` +- Components go in `/frontend/components/` +- Pages use Expo Router file-based routing in `/frontend/app/` +- Custom hooks in `/frontend/hooks/` +- API services in `/frontend/services/` + +#### Imports +- Group imports: React/React Native → third-party → local +- Use path alias `@/` for imports: `import { useAppColors } from "@/hooks/useAppColors"` +- Order: components, hooks, contexts, services, types, constants + +```typescript +import React, { useCallback, useEffect, useState } from "react"; +import { View, StyleSheet, Pressable } from "react-native"; +import { useRouter } from "expo-router"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; +import { getPostsFeed } from "@/services/api"; +import { UiPost } from "@/constants/Types"; +``` + +#### TypeScript Types +- Define interfaces in `/frontend/constants/Types.ts` for API models +- Use type inference where obvious: `const [count, setCount] = useState(0)` +- Explicit types for function parameters and returns: + ```typescript + const mapPostToUi = (post: PostProps): UiPost => { ... } + ``` +- Use `interface` for object shapes, `type` for unions/intersections + +#### Component Style +- Functional components with hooks +- Export default for page components, named exports for reusable components +- Use `useMemo` and `useCallback` for performance optimization +- Prefer StyleSheet.create() for styles at bottom of file +- Use Reanimated for complex animations, Animated API for simple ones + +#### Naming Conventions +- Components: PascalCase (`Post`, `UserCard`) +- Hooks: camelCase with `use` prefix (`useAppColors`, `useMotionConfig`) +- Functions/variables: camelCase (`getCachedCommentCount`, `postData`) +- Constants: UPPER_SNAKE_CASE or camelCase for config objects +- Event handlers: `onPress`, `handleSubmit`, etc. + +#### Error Handling +- Use try/catch for async operations +- Show user-friendly error messages via Alert.alert() +- Log errors for debugging: `console.error("Failed to fetch:", error)` +- Handle loading and error states in UI + +### Go (Backend) + +#### File Organization +- Package per feature: `handlers/`, `database/`, `auth/`, `logger/` +- Route handlers in `/backend/api/internal/handlers/*_routes.go` +- Database queries in `/backend/api/internal/database/*_queries.go` +- Test files in `/backend/api/internal/tests/` + +#### Imports +- Group: standard library → third-party → local +- Use explicit package names for local imports: + ```go + import ( + "database/sql" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "backend/api/internal/database" + "backend/api/internal/logger" + ) + ``` + +#### Naming Conventions +- Exported functions/types: PascalCase (`GetUserById`, `ApiUser`) +- Unexported: camelCase (`setupTestRouter`, `respondWithError`) +- Interfaces: PascalCase, often with `-er` suffix (`Handler`, `Querier`) +- Constants: PascalCase or ALL_CAPS for package-level + +#### Error Handling +- Return errors explicitly: `func GetUser(id int) (*User, error)` +- Use `fmt.Errorf` with `%w` for error wrapping: `fmt.Errorf("failed to parse: %w", err)` +- Check errors immediately: `if err != nil { return err }` +- Use `RespondWithError(context, status, message)` in handlers +- Log errors using the logger package + +#### Types and Structs +- Use struct tags for JSON/DB mapping: + ```go + type ApiUser struct { + Id int `json:"id"` + Username string `json:"username" binding:"required"` + } + ``` +- Pointer receivers for methods that modify state +- Value receivers for read-only methods + +#### Database +- Use parameterized queries with `$1, $2, ...` placeholders (PostgreSQL style) +- Always check `sql.ErrNoRows` when expecting single results +- Use `json.Marshal/Unmarshal` for JSON columns (links, settings) +- Transaction handling for multi-step operations + +#### Testing +- Use table-driven tests with `TestCase` structs +- Test setup: create in-memory SQLite DB, initialize tables +- Use `httptest.NewServer` for HTTP testing (no external network) +- Use `testify/assert` for assertions: `assert.Equal(t, expected, actual)` +- Sequential test execution to avoid race conditions + +## Common Patterns + +### Frontend +- Context for global state (Auth, Notifications, Preferences, Saved) +- Custom hooks for reusable logic (colors, motion, auto-refresh) +- Event emitters for real-time updates (`postEvents.ts`, `projectEvents.ts`) +- Caching strategies for performance (comment counts, media URLs) + +### Backend +- JWT middleware: `handlers.RequireAuth()` protects routes +- CORS configured for local and production origins +- File uploads to `/uploads` directory with media ingestion +- WebSocket support for real-time features +- Health check endpoint: `/health` + +## Testing Guidelines + +- **Write tests** for new API endpoints and database queries +- **Frontend**: Test components with Jest + React Test Renderer +- **Backend**: Use table-driven tests, test both success and error cases +- **Run tests** before committing significant changes +- Test file naming: `*_test.go` (Go), `*-test.tsx` (TypeScript) + +## Important Notes + +- **Never commit** `.env` files or credentials +- **Database migrations**: Schema in `create_tables.sql` +- **Media files**: Images/videos go to `/backend/uploads/` +- **API base URL**: Production uses `https://devbits.ddns.net` +- **Deep linking**: Custom scheme `devbits://` +- **Version**: Frontend v1.0.2, Android versionCode 14 + +## Documentation + +- Main instructions: `/INSTRUCTIONS.md` +- Database scripts: `/backend/scripts/README.md` +- Publishing guide: `/README_PUBLISHING.md` diff --git a/frontend/app/(tabs)/_layout.tsx b/frontend/app/(tabs)/_layout.tsx index 4657488..3d23a18 100644 --- a/frontend/app/(tabs)/_layout.tsx +++ b/frontend/app/(tabs)/_layout.tsx @@ -54,6 +54,15 @@ export default function TabLayout() { ), }} /> + ( + + ), + }} + /> ([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [showNewChatModal, setShowNewChatModal] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + const reveal = useRef(new Animated.Value(0.08)).current; + + // Reveal animation + useEffect(() => { + if (motion.prefersReducedMotion) { + reveal.setValue(1); + return; + } + + Animated.timing(reveal, { + toValue: 1, + duration: motion.duration(360), + useNativeDriver: true, + }).start(); + }, [motion, reveal]); + + const fetchThreads = useCallback(async () => { + if (!user?.username) return; + + try { + setError(null); + const response = await getDirectMessageThreads(user.username, 0, 50); + setThreads(response.threads || []); + } catch (err) { + console.error("Failed to fetch message threads:", err); + setError("Failed to load messages"); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, [user?.username]); + + // Initial load + useEffect(() => { + fetchThreads(); + }, [fetchThreads]); + + // Refresh on focus + useFocusEffect( + useCallback(() => { + if (!isLoading) { + fetchThreads(); + } + }, [fetchThreads, isLoading]) + ); + + const handleRefresh = () => { + setIsRefreshing(true); + fetchThreads(); + }; + + const formatTimestamp = (dateString: string) => { + const now = new Date(); + const date = new Date(dateString); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "now"; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + }; + + const renderEmpty = () => { + if (isLoading) return null; + + return ( + + + No messages yet.{"\n"} + Tap the + button to start a new conversation. + + + ); + }; + + const renderError = () => { + if (!error) return null; + + return ( + + + {error} + + + ); + }; + + const renderItem = ({ item }: { item: ApiDirectMessageThread }) => ( + + ); + + const renderSeparator = () => ; + + const handleNewChat = () => { + setShowNewChatModal(true); + }; + + const handleStartChat = () => { + const username = searchQuery.trim(); + if (!username) { + Alert.alert("Enter username", "Please enter a username to start a conversation."); + return; + } + + setShowNewChatModal(false); + setSearchQuery(""); + router.push(`/conversation/${username}`); + }; + + return ( + + + {/* Header */} + + + Messages + + {isLoading + ? "Loading..." + : threads.length === 0 + ? "No conversations yet" + : `${threads.length} conversation${threads.length === 1 ? "" : "s"}`} + + + + + {/* Thread List */} + {isLoading ? ( + + + + ) : error ? ( + renderError() + ) : ( + item.peer_username} + contentContainerStyle={[ + styles.content, + { paddingBottom: 96 + insets.bottom }, + ]} + ItemSeparatorComponent={renderSeparator} + ListEmptyComponent={renderEmpty} + refreshControl={ + + } + removeClippedSubviews={Platform.OS === "android"} + windowSize={8} + initialNumToRender={10} + maxToRenderPerBatch={5} + keyboardShouldPersistTaps="handled" + /> + )} + + {/* FAB for New Conversation */} + [ + styles.fab, + { + backgroundColor: colors.tint, + bottom: insets.bottom + 80, // Above tab bar + }, + pressed && styles.fabPressed, + ]} + > + + + + + {/* New Chat Modal */} + setShowNewChatModal(false)} + > + setShowNewChatModal(false)} + > + e.stopPropagation()} + > + + New Conversation + setShowNewChatModal(false)} + style={[styles.closeButton, { backgroundColor: colors.surfaceAlt }]} + > + + + + + + Enter the username of the person you want to message + + + + + + setShowNewChatModal(false)} + style={[ + styles.modalButton, + { + backgroundColor: colors.surfaceAlt, + borderColor: colors.border, + }, + ]} + > + + Cancel + + + + + Start Chat + + + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + headerSection: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 12, + }, + content: { + paddingHorizontal: 16, + }, + emptyState: { + marginTop: 48, + alignItems: "center", + }, + fab: { + position: "absolute", + right: 16, + width: 56, + height: 56, + borderRadius: 28, + alignItems: "center", + justifyContent: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + fabPressed: { + transform: [{ scale: 0.95 }], + }, + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "center", + alignItems: "center", + }, + modalContent: { + width: "85%", + maxWidth: 400, + borderRadius: 14, + borderWidth: 1, + padding: 20, + }, + modalHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 8, + }, + closeButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: "center", + justifyContent: "center", + }, + searchInput: { + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + fontFamily: "SpaceMono", + marginBottom: 16, + }, + modalActions: { + flexDirection: "row", + gap: 10, + }, + modalButton: { + flex: 1, + borderRadius: 10, + borderWidth: 1, + paddingVertical: 12, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/frontend/app/conversation/[username].tsx b/frontend/app/conversation/[username].tsx new file mode 100644 index 0000000..e7db1b2 --- /dev/null +++ b/frontend/app/conversation/[username].tsx @@ -0,0 +1,364 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { + View, + StyleSheet, + FlatList, + KeyboardAvoidingView, + Platform, + ActivityIndicator, + Pressable, + Alert, +} from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Feather } from "@expo/vector-icons"; +import { ThemedText } from "@/components/ThemedText"; +import { MessageBubble } from "@/components/MessageBubble"; +import { MessageComposer } from "@/components/MessageComposer"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useAuth } from "@/contexts/AuthContext"; +import { getDirectMessages, createDirectMessage } from "@/services/api"; +import { ApiDirectMessage } from "@/constants/Types"; + +type MessageWithState = ApiDirectMessage & { + isPending?: boolean; + isError?: boolean; + tempId?: string; +}; + +export default function ConversationScreen() { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { username: recipientUsername } = useLocalSearchParams<{ username: string }>(); + const { user } = useAuth(); + + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const flatListRef = useRef(null); + const wsRef = useRef(null); + + // Fetch messages + const fetchMessages = useCallback(async () => { + if (!user?.username || !recipientUsername) return; + + try { + setError(null); + const response = await getDirectMessages( + user.username, + recipientUsername as string, + 0, + 100 + ); + // Reverse to show oldest first (for inverted FlatList) + setMessages(response.reverse()); + } catch (err) { + console.error("Failed to fetch messages:", err); + setError("Failed to load messages"); + } finally { + setIsLoading(false); + } + }, [user?.username, recipientUsername]); + + // Initial load + useEffect(() => { + fetchMessages(); + }, [fetchMessages]); + + // WebSocket connection for real-time messages + useEffect(() => { + if (!user?.username) return; + + // Connect to WebSocket (reuse existing connection pattern from terminal) + const protocol = __DEV__ ? "ws" : "wss"; + const host = __DEV__ ? "10.0.2.2:8080" : "devbits.ddns.net"; + const wsUrl = `${protocol}://${host}/ws?username=${encodeURIComponent(user.username)}`; + + try { + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log("WebSocket connected for conversation"); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // Handle incoming direct message + if (data.type === "direct_message") { + const newMessage = data.direct_message as ApiDirectMessage; + + // Only add if it's from the current conversation + if ( + newMessage.sender_name === recipientUsername || + newMessage.recipient_name === recipientUsername + ) { + setMessages((prev) => { + // Check if message already exists (avoid duplicates) + const exists = prev.some((m) => m.id === newMessage.id); + if (exists) return prev; + return [...prev, newMessage]; + }); + + // Scroll to bottom + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + } + } + } catch (err) { + console.error("Failed to parse WebSocket message:", err); + } + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + }; + + ws.onclose = () => { + console.log("WebSocket closed"); + }; + + return () => { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + }; + } catch (err) { + console.error("Failed to connect WebSocket:", err); + } + }, [user?.username, recipientUsername]); + + // Send message with optimistic UI + const handleSendMessage = async (content: string) => { + if (!user?.username || !recipientUsername) return; + + const tempId = `temp-${Date.now()}`; + const optimisticMessage: MessageWithState = { + id: -1, + sender_id: user.id || 0, + recipient_id: 0, + sender_name: user.username, + recipient_name: recipientUsername as string, + content, + created_at: new Date().toISOString(), + isPending: true, + tempId, + }; + + // Add optimistic message + setMessages((prev) => [...prev, optimisticMessage]); + + // Scroll to bottom + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 50); + + try { + const response = await createDirectMessage( + user.username, + recipientUsername as string, + content + ); + + // Replace optimistic message with real one + setMessages((prev) => + prev.map((msg) => + msg.tempId === tempId ? response.direct_message : msg + ) + ); + } catch (err) { + console.error("Failed to send message:", err); + + // Mark message as error + setMessages((prev) => + prev.map((msg) => + msg.tempId === tempId ? { ...msg, isPending: false, isError: true } : msg + ) + ); + + Alert.alert("Failed to send", "Your message could not be sent. Please try again.", [ + { + text: "Retry", + onPress: () => { + // Remove failed message and retry + setMessages((prev) => prev.filter((msg) => msg.tempId !== tempId)); + handleSendMessage(content); + }, + }, + { + text: "Cancel", + style: "cancel", + onPress: () => { + // Remove failed message + setMessages((prev) => prev.filter((msg) => msg.tempId !== tempId)); + }, + }, + ]); + } + }; + + const handleBack = () => { + router.back(); + }; + + const renderMessage = ({ item }: { item: MessageWithState }) => { + const isSent = item.sender_name === user?.username; + return ( + + ); + }; + + const renderEmpty = () => { + if (isLoading) return null; + + return ( + + + No messages yet.{"\n"} + Start the conversation! + + + ); + }; + + const renderError = () => { + if (!error) return null; + + return ( + + + {error} + + + ); + }; + + return ( + + + {/* Header */} + + + + + + + {recipientUsername} + + + + {/* Placeholder for future actions (e.g., settings, info) */} + + + + {/* Messages */} + {isLoading ? ( + + + + ) : error ? ( + renderError() + ) : ( + item.tempId || item.id?.toString() || index.toString()} + contentContainerStyle={[ + styles.messageList, + messages.length === 0 && { flex: 1 }, + ]} + ListEmptyComponent={renderEmpty} + onContentSizeChange={() => { + if (messages.length > 0) { + flatListRef.current?.scrollToEnd({ animated: false }); + } + }} + onLayout={() => { + if (messages.length > 0) { + flatListRef.current?.scrollToEnd({ animated: false }); + } + }} + keyboardShouldPersistTaps="handled" + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + autoscrollToTopThreshold: 10, + }} + /> + )} + + {/* Message Input */} + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + keyboardView: { + flex: 1, + }, + header: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingBottom: 12, + borderBottomWidth: 1, + gap: 12, + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: "center", + justifyContent: "center", + }, + headerContent: { + flex: 1, + }, + headerRight: { + width: 36, + }, + messageList: { + paddingTop: 16, + paddingBottom: 8, + }, + emptyState: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 32, + }, +}); diff --git a/frontend/components/MessageBubble.tsx b/frontend/components/MessageBubble.tsx new file mode 100644 index 0000000..a692e49 --- /dev/null +++ b/frontend/components/MessageBubble.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { View, StyleSheet } from "react-native"; +import { ThemedText } from "@/components/ThemedText"; +import { useAppColors } from "@/hooks/useAppColors"; + +interface MessageBubbleProps { + content: string; + timestamp: string; + isSent: boolean; + isPending?: boolean; + isError?: boolean; +} + +export function MessageBubble({ + content, + timestamp, + isSent, + isPending = false, + isError = false, +}: MessageBubbleProps) { + const colors = useAppColors(); + + const formatTime = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + }; + + return ( + + + + {content} + + + {formatTime(timestamp)} + {isPending && " • Sending..."} + {isError && " • Failed"} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + marginBottom: 8, + }, + sentContainer: { + alignItems: "flex-end", + }, + receivedContainer: { + alignItems: "flex-start", + }, + bubble: { + maxWidth: "75%", + borderRadius: 12, + padding: 12, + gap: 4, + }, + sentBubble: { + borderBottomRightRadius: 4, + }, + receivedBubble: { + borderBottomLeftRadius: 4, + borderWidth: 1, + }, + pendingBubble: { + opacity: 0.7, + }, + errorBubble: { + opacity: 0.5, + }, + content: { + fontSize: 15, + lineHeight: 20, + }, + timestamp: { + fontSize: 11, + marginTop: 2, + }, +}); diff --git a/frontend/components/MessageComposer.tsx b/frontend/components/MessageComposer.tsx new file mode 100644 index 0000000..798d76e --- /dev/null +++ b/frontend/components/MessageComposer.tsx @@ -0,0 +1,129 @@ +import React, { useState } from "react"; +import { View, StyleSheet, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; +import { Feather } from "@expo/vector-icons"; +import { useAppColors } from "@/hooks/useAppColors"; + +interface MessageComposerProps { + onSend: (message: string) => Promise; + placeholder?: string; + autoFocus?: boolean; +} + +export function MessageComposer({ + onSend, + placeholder = "Type a message...", + autoFocus = false, +}: MessageComposerProps) { + const colors = useAppColors(); + const [text, setText] = useState(""); + const [height, setHeight] = useState(40); + const [isLoading, setIsLoading] = useState(false); + + const handleSend = async () => { + const trimmed = text.trim(); + if (!trimmed || isLoading) return; + + setIsLoading(true); + try { + await onSend(trimmed); + setText(""); + setHeight(40); // Reset height after sending + } catch (error) { + console.error("Failed to send message:", error); + } finally { + setIsLoading(false); + } + }; + + const handleContentSizeChange = (event: any) => { + const newHeight = event.nativeEvent.contentSize.height; + // Min 40, max 120 + setHeight(Math.min(Math.max(40, newHeight), 120)); + }; + + const canSend = text.trim().length > 0 && !isLoading; + + return ( + + + + {isLoading ? ( + + ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "flex-end", + gap: 10, + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + marginHorizontal: 16, + marginBottom: 12, + }, + input: { + flex: 1, + fontFamily: "SpaceMono", + fontSize: 15, + paddingTop: Platform.OS === "ios" ? 8 : 6, + paddingBottom: Platform.OS === "ios" ? 8 : 6, + }, + sendButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: "center", + justifyContent: "center", + marginBottom: 2, + }, +}); diff --git a/frontend/components/MessageThreadItem.tsx b/frontend/components/MessageThreadItem.tsx new file mode 100644 index 0000000..806082b --- /dev/null +++ b/frontend/components/MessageThreadItem.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import { View, StyleSheet, Pressable } from "react-native"; +import { useRouter } from "expo-router"; +import { ThemedText } from "@/components/ThemedText"; +import { FadeInImage } from "@/components/FadeInImage"; +import { useAppColors } from "@/hooks/useAppColors"; + +interface MessageThreadItemProps { + username: string; + lastMessage: string; + timestamp: string; + avatarUrl?: string; + unreadCount?: number; + isOnline?: boolean; +} + +export function MessageThreadItem({ + username, + lastMessage, + timestamp, + avatarUrl, + unreadCount = 0, + isOnline = false, +}: MessageThreadItemProps) { + const colors = useAppColors(); + const router = useRouter(); + + const handlePress = () => { + router.push(`/conversation/${username}`); + }; + + return ( + [ + styles.container, + { + backgroundColor: colors.surface, + borderColor: colors.border, + }, + pressed && styles.pressed, + ]} + > + + + {isOnline && ( + + )} + + + + + + {username} + + + {timestamp} + + + + + 0 ? colors.text : colors.muted }, + ]} + > + {lastMessage} + + {unreadCount > 0 && ( + + + {unreadCount > 99 ? "99+" : unreadCount} + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + borderRadius: 14, + borderWidth: 1, + padding: 12, + gap: 12, + }, + pressed: { + opacity: 0.8, + transform: [{ scale: 0.98 }], + }, + avatarContainer: { + position: "relative", + }, + avatar: { + width: 44, + height: 44, + borderRadius: 22, + }, + onlineIndicator: { + position: "absolute", + bottom: 0, + right: 0, + width: 14, + height: 14, + borderRadius: 7, + borderWidth: 2, + }, + content: { + flex: 1, + gap: 4, + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + }, + username: { + flex: 1, + }, + messageRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + }, + lastMessage: { + flex: 1, + }, + unreadBadge: { + minWidth: 20, + height: 20, + borderRadius: 10, + paddingHorizontal: 6, + alignItems: "center", + justifyContent: "center", + }, + unreadText: { + fontSize: 11, + fontWeight: "600", + }, +}); diff --git a/frontend/index.js b/frontend/index.js new file mode 100644 index 0000000..5b83418 --- /dev/null +++ b/frontend/index.js @@ -0,0 +1 @@ +import 'expo-router/entry'; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8fcd3fc..8e2f6a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2457,17 +2457,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -2527,17 +2516,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/globals": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", @@ -3417,128 +3395,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@testing-library/react-native": { - "version": "13.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", - "integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "jest-matcher-utils": "^30.0.5", - "picocolors": "^1.1.1", - "pretty-format": "^30.0.5", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "jest": ">=29.0.0", - "react": ">=18.2.0", - "react-native": ">=0.71", - "react-test-renderer": ">=18.2.0" - }, - "peerDependenciesMeta": { - "jest": { - "optional": true - } - } - }, - "node_modules/@testing-library/react-native/node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@testing-library/react-native/node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@testing-library/react-native/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/react-native/node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@testing-library/react-native/node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@testing-library/react-native/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@testing-library/react-native/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -8551,17 +8407,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -11277,17 +11122,6 @@ "node": ">=6" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz", @@ -12910,21 +12744,6 @@ "react": "^19.1.0" } }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -14063,20 +13882,6 @@ "node": ">=6" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", From 4bd48ddcf558c78830e48894c9eb469753364688 Mon Sep 17 00:00:00 2001 From: Nathan Grilliot Date: Sun, 1 Mar 2026 17:21:52 -0500 Subject: [PATCH 2/8] feat: add messages tab --- .../database/direct_message_queries.go | 5 +- backend/api/internal/database/user_queries.go | 43 +++- backend/api/internal/handlers/user_routes.go | 32 +++ backend/api/main.go | 1 + backend/devbits.db | 0 frontend/app/(tabs)/message.tsx | 225 +++++++++++------- frontend/app/conversation/[username].tsx | 39 ++- frontend/components/MessageThreadItem.tsx | 40 +++- frontend/components/Post.tsx | 1 - frontend/package-lock.json | 162 ++++++------- frontend/services/api.ts | 8 + 11 files changed, 357 insertions(+), 199 deletions(-) create mode 100644 backend/devbits.db diff --git a/backend/api/internal/database/direct_message_queries.go b/backend/api/internal/database/direct_message_queries.go index ce69219..599efb6 100644 --- a/backend/api/internal/database/direct_message_queries.go +++ b/backend/api/internal/database/direct_message_queries.go @@ -19,6 +19,7 @@ type DirectMessage struct { type DirectMessageThread struct { PeerUsername string `json:"peer_username"` + PeerPicture string `json:"peer_picture"` LastContent string `json:"last_content"` LastAt time.Time `json:"last_at"` } @@ -209,7 +210,7 @@ func QueryDirectMessageThreads(username string, start int, count int) ([]DirectM FROM directmessages dm WHERE dm.sender_id = $1 OR dm.recipient_id = $1 ) - SELECT u.username, rt.content, rt.creation_date + SELECT u.username, COALESCE(u.picture, ''), rt.content, rt.creation_date FROM ranked_threads rt JOIN users u ON u.id = rt.peer_id WHERE rt.rank_in_thread = 1 @@ -225,7 +226,7 @@ func QueryDirectMessageThreads(username string, start int, count int) ([]DirectM threads := make([]DirectMessageThread, 0) for rows.Next() { var thread DirectMessageThread - if err := rows.Scan(&thread.PeerUsername, &thread.LastContent, &thread.LastAt); err != nil { + if err := rows.Scan(&thread.PeerUsername, &thread.PeerPicture, &thread.LastContent, &thread.LastAt); err != nil { return nil, http.StatusInternalServerError, fmt.Errorf("failed to scan direct message thread: %w", err) } threads = append(threads, thread) diff --git a/backend/api/internal/database/user_queries.go b/backend/api/internal/database/user_queries.go index 701637d..5c0c876 100644 --- a/backend/api/internal/database/user_queries.go +++ b/backend/api/internal/database/user_queries.go @@ -228,6 +228,48 @@ func DeleteUser(username string) error { return nil } +// SearchUsers retrieves users whose username starts with the given prefix (case-insensitive), limited to 10 results. +func SearchUsers(prefix string, limit int) ([]*ApiUser, error) { + query := ` + SELECT id, username, picture, bio, links, settings, creation_date + FROM users + WHERE LOWER(username) LIKE LOWER($1) + ORDER BY username ASC + LIMIT $2; + ` + rows, err := DB.Query(query, prefix+"%", limit) + if err != nil { + return nil, fmt.Errorf("failed to search users: %w", err) + } + defer rows.Close() + + var users []*ApiUser + for rows.Next() { + user := &ApiUser{} + var links, settings []byte + if err := rows.Scan( + &user.Id, + &user.Username, + &user.Picture, + &user.Bio, + &links, + &settings, + &user.CreationDate, + ); err != nil { + return nil, fmt.Errorf("failed to scan user row: %w", err) + } + if err := json.Unmarshal(links, &user.Links); err != nil { + log.Printf("WARN: could not unmarshal user links: %v", err) + } + if err := json.Unmarshal(settings, &user.Settings); err != nil { + log.Printf("WARN: could not unmarshal user settings: %v", err) + } + users = append(users, user) + } + + return users, nil +} + // GetUsers retrieves a list of all users func GetUsers() ([]*ApiUser, error) { query := ` @@ -537,4 +579,3 @@ func CreateUserLoginInfo(info *UserLoginInfo) error { } return nil } - diff --git a/backend/api/internal/handlers/user_routes.go b/backend/api/internal/handlers/user_routes.go index ec7d0d2..d41e9b4 100644 --- a/backend/api/internal/handlers/user_routes.go +++ b/backend/api/internal/handlers/user_routes.go @@ -18,6 +18,38 @@ import ( "github.com/gin-gonic/gin" ) +// SearchUsers handles GET /users/search?q=&count= +// Returns up to `count` users (max 20, default 10) whose username starts with `q`. +func SearchUsers(context *gin.Context) { + q := strings.TrimSpace(context.Query("q")) + if q == "" { + context.JSON(http.StatusOK, []*database.ApiUser{}) + return + } + + limit := 10 + if strCount := context.Query("count"); strCount != "" { + if parsed, err := strconv.Atoi(strCount); err == nil && parsed > 0 { + if parsed > 20 { + parsed = 20 + } + limit = parsed + } + } + + users, err := database.SearchUsers(q, limit) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, fmt.Sprintf("Failed to search users: %v", err)) + return + } + + if users == nil { + users = []*database.ApiUser{} + } + + context.JSON(http.StatusOK, users) +} + // GetUsernameById handles GET requests to fetch a user by their username. // It expects the `username` parameter in the URL. // Returns: diff --git a/backend/api/main.go b/backend/api/main.go index 1f56942..8a1fc7c 100644 --- a/backend/api/main.go +++ b/backend/api/main.go @@ -119,6 +119,7 @@ func main() { router.POST("/media/upload", handlers.RequireAuth(), handlers.UploadMedia) router.GET("/users", handlers.GetUsers) + router.GET("/users/search", handlers.SearchUsers) router.GET("/users/:username", handlers.GetUserByUsername) router.GET("/users/id/:user_id", handlers.GetUserById) router.POST("/users", handlers.RequireAuth(), handlers.CreateUser) diff --git a/backend/devbits.db b/backend/devbits.db new file mode 100644 index 0000000..e69de29 diff --git a/frontend/app/(tabs)/message.tsx b/frontend/app/(tabs)/message.tsx index 9d4fec7..864348e 100644 --- a/frontend/app/(tabs)/message.tsx +++ b/frontend/app/(tabs)/message.tsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useCallback, useEffect, useRef } from "react"; import { View, StyleSheet, - Animated, + FlatList, RefreshControl, ActivityIndicator, Platform, @@ -10,6 +10,7 @@ import { Modal, TextInput, Alert, + KeyboardAvoidingView, } from "react-native"; import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; import { Feather } from "@expo/vector-icons"; @@ -17,15 +18,13 @@ import { useRouter, useFocusEffect } from "expo-router"; import { ThemedText } from "@/components/ThemedText"; import { MessageThreadItem } from "@/components/MessageThreadItem"; import { useAppColors } from "@/hooks/useAppColors"; -import { useMotionConfig } from "@/hooks/useMotionConfig"; import { useAuth } from "@/contexts/AuthContext"; -import { getDirectMessageThreads } from "@/services/api"; -import { ApiDirectMessageThread } from "@/services/api"; +import { getDirectMessageThreads, searchUsers, ApiDirectMessageThread } from "@/services/api"; +import { ApiUser } from "@/constants/Types"; export default function MessageScreen() { const colors = useAppColors(); const insets = useSafeAreaInsets(); - const motion = useMotionConfig(); const router = useRouter(); const { user } = useAuth(); @@ -35,30 +34,41 @@ export default function MessageScreen() { const [error, setError] = useState(null); const [showNewChatModal, setShowNewChatModal] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [isSuggestionsLoading, setIsSuggestionsLoading] = useState(false); + const debounceRef = useRef | null>(null); - const reveal = useRef(new Animated.Value(0.08)).current; - - // Reveal animation + // Debounced autocomplete useEffect(() => { - if (motion.prefersReducedMotion) { - reveal.setValue(1); + if (debounceRef.current) clearTimeout(debounceRef.current); + const q = searchQuery.trim(); + if (!q) { + setSuggestions([]); return; } - - Animated.timing(reveal, { - toValue: 1, - duration: motion.duration(360), - useNativeDriver: true, - }).start(); - }, [motion, reveal]); + debounceRef.current = setTimeout(async () => { + setIsSuggestionsLoading(true); + try { + const results = await searchUsers(q, 8); + setSuggestions(results); + } catch { + setSuggestions([]); + } finally { + setIsSuggestionsLoading(false); + } + }, 250); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [searchQuery]); const fetchThreads = useCallback(async () => { if (!user?.username) return; try { setError(null); - const response = await getDirectMessageThreads(user.username, 0, 50); - setThreads(response.threads || []); + const threads = await getDirectMessageThreads(user.username, 0, 50); + setThreads(threads); } catch (err) { console.error("Failed to fetch message threads:", err); setError("Failed to load messages"); @@ -68,18 +78,11 @@ export default function MessageScreen() { } }, [user?.username]); - // Initial load - useEffect(() => { - fetchThreads(); - }, [fetchThreads]); - - // Refresh on focus + // Load on mount and refresh on focus useFocusEffect( useCallback(() => { - if (!isLoading) { - fetchThreads(); - } - }, [fetchThreads, isLoading]) + fetchThreads(); + }, [fetchThreads]) ); const handleRefresh = () => { @@ -132,11 +135,25 @@ export default function MessageScreen() { username={item.peer_username} lastMessage={item.last_content} timestamp={formatTimestamp(item.last_at)} + avatarUrl={item.peer_picture} /> ); const renderSeparator = () => ; + const handleCloseModal = () => { + setShowNewChatModal(false); + setSearchQuery(""); + setSuggestions([]); + }; + + const handleSelectSuggestion = (username: string) => { + setShowNewChatModal(false); + setSearchQuery(""); + setSuggestions([]); + router.push(`/conversation/${username}`); + }; + const handleNewChat = () => { setShowNewChatModal(true); }; @@ -150,25 +167,13 @@ export default function MessageScreen() { setShowNewChatModal(false); setSearchQuery(""); + setSuggestions([]); router.push(`/conversation/${username}`); }; return ( - + {/* Header */} @@ -191,10 +196,11 @@ export default function MessageScreen() { ) : error ? ( renderError() ) : ( - item.peer_username} + extraData={threads} contentContainerStyle={[ styles.content, { paddingBottom: 96 + insets.bottom }, @@ -218,46 +224,53 @@ export default function MessageScreen() { )} {/* FAB for New Conversation */} - [ - styles.fab, - { - backgroundColor: colors.tint, - bottom: insets.bottom + 80, // Above tab bar - }, - pressed && styles.fabPressed, - ]} - > - - - + + [ + styles.fab, + { + backgroundColor: colors.tint, + borderColor: colors.border, + bottom: Math.max(14, insets.bottom + 10), + }, + pressed && styles.fabPressed, + ]} + > + + + + {/* New Chat Modal */} setShowNewChatModal(false)} + onRequestClose={handleCloseModal} > - setShowNewChatModal(false)} + e.stopPropagation()} + style={styles.modalOverlay} + onPress={handleCloseModal} > + e.stopPropagation()} + > New Conversation setShowNewChatModal(false)} + onPress={handleCloseModal} style={[styles.closeButton, { backgroundColor: colors.surfaceAlt }]} > @@ -279,6 +292,7 @@ export default function MessageScreen() { color: colors.text, backgroundColor: colors.surfaceAlt, borderColor: colors.border, + marginBottom: suggestions.length > 0 || isSuggestionsLoading ? 8 : 16, }, ]} autoCapitalize="none" @@ -288,9 +302,37 @@ export default function MessageScreen() { onSubmitEditing={handleStartChat} /> + {/* Autocomplete suggestions */} + {(suggestions.length > 0 || isSuggestionsLoading) && ( + + {isSuggestionsLoading ? ( + + ) : ( + suggestions.map((s, i) => ( + handleSelectSuggestion(s.username)} + style={({ pressed }) => [ + styles.suggestionItem, + i < suggestions.length - 1 && { borderBottomWidth: 1, borderBottomColor: colors.border }, + pressed && { backgroundColor: colors.surface }, + ]} + > + {s.username} + + )) + )} + + )} + setShowNewChatModal(false)} + onPress={handleCloseModal} style={[ styles.modalButton, { @@ -318,7 +360,8 @@ export default function MessageScreen() { - + + ); @@ -338,6 +381,7 @@ const styles = StyleSheet.create({ }, content: { paddingHorizontal: 16, + flexGrow: 1, }, emptyState: { marginTop: 48, @@ -346,29 +390,31 @@ const styles = StyleSheet.create({ fab: { position: "absolute", right: 16, - width: 56, - height: 56, - borderRadius: 28, + width: 46, + height: 46, + borderRadius: 14, alignItems: "center", justifyContent: "center", + borderWidth: 1, shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 8, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, }, fabPressed: { - transform: [{ scale: 0.95 }], + opacity: 0.8, + transform: [{ scale: 0.98 }], }, modalOverlay: { flex: 1, backgroundColor: "rgba(0, 0, 0, 0.5)", - justifyContent: "center", + justifyContent: "flex-end", alignItems: "center", + paddingBottom: 24, }, modalContent: { - width: "85%", - maxWidth: 400, + width: "100%", borderRadius: 14, borderWidth: 1, padding: 20, @@ -393,7 +439,16 @@ const styles = StyleSheet.create({ paddingVertical: 10, fontSize: 15, fontFamily: "SpaceMono", + }, + suggestionsContainer: { + borderRadius: 12, + borderWidth: 1, marginBottom: 16, + overflow: "hidden", + }, + suggestionItem: { + paddingHorizontal: 12, + paddingVertical: 11, }, modalActions: { flexDirection: "row", diff --git a/frontend/app/conversation/[username].tsx b/frontend/app/conversation/[username].tsx index e7db1b2..22c73af 100644 --- a/frontend/app/conversation/[username].tsx +++ b/frontend/app/conversation/[username].tsx @@ -31,7 +31,7 @@ export default function ConversationScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); const { username: recipientUsername } = useLocalSearchParams<{ username: string }>(); - const { user } = useAuth(); + const { user, token } = useAuth(); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -52,8 +52,7 @@ export default function ConversationScreen() { 0, 100 ); - // Reverse to show oldest first (for inverted FlatList) - setMessages(response.reverse()); + setMessages(response ?? []); } catch (err) { console.error("Failed to fetch messages:", err); setError("Failed to load messages"); @@ -69,14 +68,19 @@ export default function ConversationScreen() { // WebSocket connection for real-time messages useEffect(() => { - if (!user?.username) return; + if (!user?.username || !token) return; // Connect to WebSocket (reuse existing connection pattern from terminal) const protocol = __DEV__ ? "ws" : "wss"; const host = __DEV__ ? "10.0.2.2:8080" : "devbits.ddns.net"; - const wsUrl = `${protocol}://${host}/ws?username=${encodeURIComponent(user.username)}`; + const wsUrl = `${protocol}://${host}/messages/${encodeURIComponent(user.username)}/stream?token=${encodeURIComponent(token)}`; try { + // Close any existing socket from a previous run before creating a new one + if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { + wsRef.current.close(); + } + const ws = new WebSocket(wsUrl); wsRef.current = ws; @@ -131,7 +135,7 @@ export default function ConversationScreen() { } catch (err) { console.error("Failed to connect WebSocket:", err); } - }, [user?.username, recipientUsername]); + }, [user?.username, recipientUsername, token]); // Send message with optimistic UI const handleSendMessage = async (content: string) => { @@ -183,18 +187,9 @@ export default function ConversationScreen() { Alert.alert("Failed to send", "Your message could not be sent. Please try again.", [ { - text: "Retry", - onPress: () => { - // Remove failed message and retry - setMessages((prev) => prev.filter((msg) => msg.tempId !== tempId)); - handleSendMessage(content); - }, - }, - { - text: "Cancel", - style: "cancel", + text: "OK", onPress: () => { - // Remove failed message + // Remove failed message so the user can re-send from the composer setMessages((prev) => prev.filter((msg) => msg.tempId !== tempId)); }, }, @@ -246,11 +241,11 @@ export default function ConversationScreen() { return ( - + {/* Header */} { + return username + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + }, [username]); const handlePress = () => { router.push(`/conversation/${username}`); @@ -42,12 +55,19 @@ export function MessageThreadItem({ ]} > - + {resolvedUserPicture && !userPicFailed ? ( + setUserPicFailed(true)} + /> + ) : ( + + + {initials} + + + )} {isOnline && ( =6" @@ -5118,7 +5118,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5184,7 +5184,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cli-cursor": { @@ -5244,7 +5244,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "iojs": ">= 1.0.0", @@ -5255,7 +5255,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/color": { @@ -5473,7 +5473,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -5692,7 +5692,7 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" @@ -5825,7 +5825,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5841,7 +5841,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -5931,7 +5931,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5981,7 +5981,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -6779,7 +6779,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", @@ -6803,7 +6803,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "devOptional": true, + "dev": true, "engines": { "node": ">= 0.8.0" } @@ -6812,7 +6812,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/expect-utils": "^29.7.0", @@ -7909,7 +7909,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -8217,7 +8217,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/http-errors": { @@ -8282,7 +8282,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.17.0" @@ -8382,7 +8382,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "pkg-dir": "^4.2.0", @@ -8501,7 +8501,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -8696,7 +8696,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8860,7 +8860,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9003,7 +9003,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.23.9", @@ -9020,7 +9020,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9033,7 +9033,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", @@ -9048,7 +9048,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "debug": "^4.1.1", @@ -9063,7 +9063,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", @@ -9095,7 +9095,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", @@ -9122,7 +9122,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "execa": "^5.0.0", @@ -9137,7 +9137,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -9169,7 +9169,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", @@ -9203,7 +9203,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -9249,7 +9249,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -9261,7 +9261,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -9282,7 +9282,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9295,7 +9295,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -9311,7 +9311,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "detect-newline": "^3.0.0" @@ -9324,7 +9324,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -9456,7 +9456,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3", @@ -9470,7 +9470,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -9520,7 +9520,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9547,7 +9547,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -9568,7 +9568,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "^29.6.3", @@ -9582,7 +9582,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", @@ -9615,7 +9615,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -9649,7 +9649,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -9661,7 +9661,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -9682,7 +9682,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9695,7 +9695,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -9727,7 +9727,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9916,7 +9916,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", @@ -10062,7 +10062,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -10593,7 +10593,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -10609,7 +10609,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11116,7 +11116,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11234,7 +11234,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/negotiator": { @@ -11355,7 +11355,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.0.0" @@ -11559,7 +11559,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -11800,7 +11800,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -11938,7 +11938,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "find-up": "^4.0.0" @@ -12150,7 +12150,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -12734,7 +12734,7 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz", "integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "react-is": "^19.1.0", @@ -12918,7 +12918,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "resolve-from": "^5.0.0" @@ -13563,7 +13563,7 @@ "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -13728,7 +13728,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "char-regex": "^1.0.2", @@ -13866,7 +13866,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13876,7 +13876,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13886,7 +13886,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14766,7 +14766,7 @@ "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 8297d54..8860023 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -29,6 +29,7 @@ type CachedEntry = { export type ApiDirectMessageThread = { peer_username: string; + peer_picture: string; last_content: string; last_at: string; }; @@ -940,6 +941,13 @@ export const getAllUsers = async (start = 0, count = 50) => { return users.map(normalizeUser); }; +export const searchUsers = async (q: string, count = 10) => { + const users = await request( + `/users/search?q=${encodeURIComponent(q)}&count=${count}` + ); + return (users ?? []).map(normalizeUser); +}; + export const getProjectById = async (projectId: number) => { return request(`/projects/${projectId}`); }; From 38194daf950e33a495e61249461bad1d6a01181a Mon Sep 17 00:00:00 2001 From: Nathan Grilliot Date: Sun, 1 Mar 2026 17:36:21 -0500 Subject: [PATCH 3/8] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/api/internal/database/user_queries.go | 5 +- frontend/app/conversation/[username].tsx | 175 ++++++++++++------ frontend/components/MessageThreadItem.tsx | 5 +- 3 files changed, 129 insertions(+), 56 deletions(-) diff --git a/backend/api/internal/database/user_queries.go b/backend/api/internal/database/user_queries.go index 5c0c876..ebbb944 100644 --- a/backend/api/internal/database/user_queries.go +++ b/backend/api/internal/database/user_queries.go @@ -228,7 +228,7 @@ func DeleteUser(username string) error { return nil } -// SearchUsers retrieves users whose username starts with the given prefix (case-insensitive), limited to 10 results. +// SearchUsers retrieves users whose username starts with the given prefix (case-insensitive), limited to the specified limit. func SearchUsers(prefix string, limit int) ([]*ApiUser, error) { query := ` SELECT id, username, picture, bio, links, settings, creation_date @@ -267,6 +267,9 @@ func SearchUsers(prefix string, limit int) ([]*ApiUser, error) { users = append(users, user) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate over user search results: %w", err) + } return users, nil } diff --git a/frontend/app/conversation/[username].tsx b/frontend/app/conversation/[username].tsx index 22c73af..8ed71d3 100644 --- a/frontend/app/conversation/[username].tsx +++ b/frontend/app/conversation/[username].tsx @@ -70,71 +70,138 @@ export default function ConversationScreen() { useEffect(() => { if (!user?.username || !token) return; + // Track reconnection state within this effect + let reconnectAttempts = 0; + let reconnectTimeoutId: ReturnType | null = null; + let isActive = true; + // Connect to WebSocket (reuse existing connection pattern from terminal) const protocol = __DEV__ ? "ws" : "wss"; const host = __DEV__ ? "10.0.2.2:8080" : "devbits.ddns.net"; - const wsUrl = `${protocol}://${host}/messages/${encodeURIComponent(user.username)}/stream?token=${encodeURIComponent(token)}`; + const wsUrl = `${protocol}://${host}/messages/${encodeURIComponent( + user.username + )}/stream?token=${encodeURIComponent(token)}`; - try { - // Close any existing socket from a previous run before creating a new one - if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { - wsRef.current.close(); + const connect = () => { + if (!isActive) { + return; } - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - console.log("WebSocket connected for conversation"); - }; - - ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - - // Handle incoming direct message - if (data.type === "direct_message") { - const newMessage = data.direct_message as ApiDirectMessage; - - // Only add if it's from the current conversation - if ( - newMessage.sender_name === recipientUsername || - newMessage.recipient_name === recipientUsername - ) { - setMessages((prev) => { - // Check if message already exists (avoid duplicates) - const exists = prev.some((m) => m.id === newMessage.id); - if (exists) return prev; - return [...prev, newMessage]; - }); - - // Scroll to bottom - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); - }, 100); + try { + // Close any existing socket from a previous run before creating a new one + if ( + wsRef.current && + (wsRef.current.readyState === WebSocket.OPEN || + wsRef.current.readyState === WebSocket.CONNECTING) + ) { + wsRef.current.close(); + } + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log("WebSocket connected for conversation"); + // Reset reconnect attempts on successful connection + reconnectAttempts = 0; + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // Handle incoming direct message + if (data.type === "direct_message") { + const newMessage = data.direct_message as ApiDirectMessage; + + // Only add if it's from the current conversation + if ( + newMessage.sender_name === recipientUsername || + newMessage.recipient_name === recipientUsername + ) { + setMessages((prev) => { + // Check if message already exists (avoid duplicates) + const exists = prev.some((m) => m.id === newMessage.id); + if (exists) return prev; + return [...prev, newMessage]; + }); + + // Scroll to bottom + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + } } + } catch (err) { + console.error("Failed to parse WebSocket message:", err); + } + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + }; + + ws.onclose = (event) => { + console.log( + "WebSocket closed", + event?.code, + event?.reason ?? "" + ); + + if (!isActive) { + // Component unmounted or effect cleaned up; do not attempt to reconnect + return; } - } catch (err) { - console.error("Failed to parse WebSocket message:", err); - } - }; - ws.onerror = (error) => { - console.error("WebSocket error:", error); - }; + const maxAttempts = 5; + if (reconnectAttempts >= maxAttempts) { + console.log("Max WebSocket reconnect attempts reached; giving up."); + return; + } - ws.onclose = () => { - console.log("WebSocket closed"); - }; + const baseDelayMs = 1000; + const maxDelayMs = 10000; + const delayMs = Math.min( + baseDelayMs * Math.pow(2, reconnectAttempts), + maxDelayMs + ); + reconnectAttempts += 1; + + console.log( + `Attempting WebSocket reconnect #${reconnectAttempts} in ${delayMs}ms` + ); + + reconnectTimeoutId = setTimeout(() => { + reconnectTimeoutId = null; + connect(); + }, delayMs); + }; + } catch (err) { + console.error("Failed to connect WebSocket:", err); + } + }; - return () => { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(); - } - }; - } catch (err) { - console.error("Failed to connect WebSocket:", err); - } + // Initial connection + connect(); + + return () => { + // Prevent any further reconnect attempts + isActive = false; + + if (reconnectTimeoutId !== null) { + clearTimeout(reconnectTimeoutId); + reconnectTimeoutId = null; + } + + const ws = wsRef.current; + if ( + ws && + (ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING) + ) { + ws.close(); + } + }; }, [user?.username, recipientUsername, token]); // Send message with optimistic UI diff --git a/frontend/components/MessageThreadItem.tsx b/frontend/components/MessageThreadItem.tsx index fe1c373..103bdac 100644 --- a/frontend/components/MessageThreadItem.tsx +++ b/frontend/components/MessageThreadItem.tsx @@ -39,7 +39,10 @@ export function MessageThreadItem({ }, [username]); const handlePress = () => { - router.push(`/conversation/${username}`); + router.push({ + pathname: "/conversation/[username]", + params: { username }, + }); }; return ( From 0fe6af80a0fa91bbc098fd02402e8a6b40e4a4c3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:07:48 -0500 Subject: [PATCH 4/8] fix: derive WebSocket URL from API_BASE_URL in conversation screen (#130) * Initial plan * fix: use API_BASE_URL for WebSocket URL in conversation screen Co-authored-by: grillinr <169214325+grillinr@users.noreply.github.com> * fix: merge reconnection logic from base branch while keeping API_BASE_URL approach Co-authored-by: elifouts <116454864+elifouts@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: grillinr <169214325+grillinr@users.noreply.github.com> Co-authored-by: elifouts <116454864+elifouts@users.noreply.github.com> Co-authored-by: Eli Fouts --- frontend/app/conversation/[username].tsx | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/frontend/app/conversation/[username].tsx b/frontend/app/conversation/[username].tsx index 8ed71d3..1dca23c 100644 --- a/frontend/app/conversation/[username].tsx +++ b/frontend/app/conversation/[username].tsx @@ -17,7 +17,7 @@ import { MessageBubble } from "@/components/MessageBubble"; import { MessageComposer } from "@/components/MessageComposer"; import { useAppColors } from "@/hooks/useAppColors"; import { useAuth } from "@/contexts/AuthContext"; -import { getDirectMessages, createDirectMessage } from "@/services/api"; +import { API_BASE_URL, getDirectMessages, createDirectMessage } from "@/services/api"; import { ApiDirectMessage } from "@/constants/Types"; type MessageWithState = ApiDirectMessage & { @@ -26,6 +26,19 @@ type MessageWithState = ApiDirectMessage & { tempId?: string; }; +const getWebSocketBaseUrl = (baseUrl?: string) => { + if (!baseUrl) { + return ""; + } + if (baseUrl.startsWith("https://")) { + return `wss://${baseUrl.slice("https://".length)}`; + } + if (baseUrl.startsWith("http://")) { + return `ws://${baseUrl.slice("http://".length)}`; + } + return baseUrl; +}; + export default function ConversationScreen() { const colors = useAppColors(); const insets = useSafeAreaInsets(); @@ -74,14 +87,11 @@ export default function ConversationScreen() { let reconnectAttempts = 0; let reconnectTimeoutId: ReturnType | null = null; let isActive = true; - - // Connect to WebSocket (reuse existing connection pattern from terminal) - const protocol = __DEV__ ? "ws" : "wss"; - const host = __DEV__ ? "10.0.2.2:8080" : "devbits.ddns.net"; - const wsUrl = `${protocol}://${host}/messages/${encodeURIComponent( + // Connect to WebSocket using API_BASE_URL + const wsBase = getWebSocketBaseUrl(API_BASE_URL); + const wsUrl = `${wsBase}/messages/${encodeURIComponent( user.username )}/stream?token=${encodeURIComponent(token)}`; - const connect = () => { if (!isActive) { return; From 2c4a40452ac572614bf800ddca65c1769ac54deb Mon Sep 17 00:00:00 2001 From: Eli Fouts Date: Tue, 3 Mar 2026 21:36:28 -0500 Subject: [PATCH 5/8] feat: enhance MessageBubble and MessageComposer components - Added authorLabel prop to MessageBubble for displaying message authors. - Improved styling in MessageBubble for better visual consistency. - Updated MessageComposer to include layout animations for dynamic height adjustments. - Refactored input handling in MessageComposer to optimize performance. - Removed unused terminal animation code from MyHeader component. - Added terminal icon mapping in IconSymbol for better icon management. --- frontend/app/(tabs)/_layout.tsx | 2 +- frontend/app/(tabs)/index.tsx | 1 - frontend/app/(tabs)/message.tsx | 474 ++++++-- frontend/app/_layout.tsx | 32 +- frontend/app/conversation/[username].tsx | 246 ++-- frontend/app/notifications.tsx | 4 +- frontend/app/settings/help-navigation.tsx | 6 +- frontend/app/terminal.tsx | 1318 +-------------------- frontend/components/MessageBubble.tsx | 29 +- frontend/components/MessageComposer.tsx | 49 +- frontend/components/header.tsx | 75 -- frontend/components/ui/IconSymbol.tsx | 1 + 12 files changed, 626 insertions(+), 1611 deletions(-) diff --git a/frontend/app/(tabs)/_layout.tsx b/frontend/app/(tabs)/_layout.tsx index a8f0d43..5c457b3 100644 --- a/frontend/app/(tabs)/_layout.tsx +++ b/frontend/app/(tabs)/_layout.tsx @@ -62,7 +62,7 @@ export default function TabLayout() { options={{ title: "Messages", tabBarIcon: ({ color }) => ( - + ), }} /> diff --git a/frontend/app/(tabs)/index.tsx b/frontend/app/(tabs)/index.tsx index 13ebecc..4f4a3d9 100644 --- a/frontend/app/(tabs)/index.tsx +++ b/frontend/app/(tabs)/index.tsx @@ -138,7 +138,6 @@ export default function HomeScreen() { const task = InteractionManager.runAfterInteractions(() => { router.prefetch("/streams"); router.prefetch("/bytes"); - router.prefetch("/terminal"); }); return () => { diff --git a/frontend/app/(tabs)/message.tsx b/frontend/app/(tabs)/message.tsx index 864348e..e9a2ae9 100644 --- a/frontend/app/(tabs)/message.tsx +++ b/frontend/app/(tabs)/message.tsx @@ -12,16 +12,132 @@ import { Alert, KeyboardAvoidingView, } from "react-native"; -import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; import { Feather } from "@expo/vector-icons"; import { useRouter, useFocusEffect } from "expo-router"; import { ThemedText } from "@/components/ThemedText"; import { MessageThreadItem } from "@/components/MessageThreadItem"; +import { FadeInImage } from "@/components/FadeInImage"; import { useAppColors } from "@/hooks/useAppColors"; import { useAuth } from "@/contexts/AuthContext"; -import { getDirectMessageThreads, searchUsers, ApiDirectMessageThread } from "@/services/api"; +import { + getDirectMessageThreads, + getAllUsers, + searchUsers, + resolveMediaUrl, + ApiDirectMessageThread, +} from "@/services/api"; import { ApiUser } from "@/constants/Types"; +const dedupeThreadsByPeer = (items: ApiDirectMessageThread[]) => { + const byPeer = new Map(); + items.forEach((item) => { + const peer = (item.peer_username || "").trim().toLowerCase(); + if (!peer) return; + const existing = byPeer.get(peer); + if (!existing) { + byPeer.set(peer, item); + return; + } + const existingAt = new Date(existing.last_at).getTime(); + const nextAt = new Date(item.last_at).getTime(); + if ( + Number.isFinite(nextAt) && + (!Number.isFinite(existingAt) || nextAt >= existingAt) + ) { + byPeer.set(peer, item); + } + }); + return Array.from(byPeer.values()).sort( + (a, b) => new Date(b.last_at).getTime() - new Date(a.last_at).getTime(), + ); +}; + +const normalizeUsernameInput = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ""; + const withoutAt = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; + return withoutAt.replace(/@devbits$/i, ""); +}; + +const sortSuggestions = (items: ApiUser[], query: string) => { + const q = query.toLowerCase(); + return [...items].sort((a, b) => { + const aName = a.username.toLowerCase(); + const bName = b.username.toLowerCase(); + const aStarts = aName.startsWith(q) ? 0 : 1; + const bStarts = bName.startsWith(q) ? 0 : 1; + if (aStarts !== bStarts) { + return aStarts - bStarts; + } + return aName.localeCompare(bName); + }); +}; + +const filterUsersByQuery = ( + items: ApiUser[], + query: string, + currentUsername?: string, +) => { + const q = query.trim().toLowerCase(); + if (!q) { + return [] as ApiUser[]; + } + + return items.filter((item) => { + const username = (item.username || "").trim().toLowerCase(); + if (!username) return false; + if (currentUsername && username === currentUsername.toLowerCase()) + return false; + return username.startsWith(q) || username.includes(q); + }); +}; + +function SuggestionAvatar({ + pictureUrl, + initial, + colors, +}: { + pictureUrl: string; + initial: string; + colors: ReturnType; +}) { + const [loadFailed, setLoadFailed] = useState(false); + + useEffect(() => { + setLoadFailed(false); + }, [pictureUrl]); + + const showPicture = Boolean(pictureUrl) && !loadFailed; + + return ( + + {showPicture ? ( + setLoadFailed(true)} + /> + ) : ( + + {initial} + + )} + + ); +} + export default function MessageScreen() { const colors = useAppColors(); const insets = useSafeAreaInsets(); @@ -37,30 +153,70 @@ export default function MessageScreen() { const [suggestions, setSuggestions] = useState([]); const [isSuggestionsLoading, setIsSuggestionsLoading] = useState(false); const debounceRef = useRef | null>(null); + const latestSearchRequestRef = useRef(0); + const allUsersCacheRef = useRef(null); + const normalizedQuery = normalizeUsernameInput(searchQuery); // Debounced autocomplete useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); - const q = searchQuery.trim(); + const q = normalizedQuery; if (!q) { + latestSearchRequestRef.current += 1; setSuggestions([]); + setIsSuggestionsLoading(false); return; } + + setIsSuggestionsLoading(true); + debounceRef.current = setTimeout(async () => { - setIsSuggestionsLoading(true); + const requestId = latestSearchRequestRef.current + 1; + latestSearchRequestRef.current = requestId; try { - const results = await searchUsers(q, 8); - setSuggestions(results); + const results = await searchUsers(q, 12); + if (requestId !== latestSearchRequestRef.current) { + return; + } + + let filtered = filterUsersByQuery(results ?? [], q, user?.username); + + if (filtered.length === 0) { + let allUsers = allUsersCacheRef.current; + if (!allUsers) { + allUsers = await getAllUsers(0, 200); + allUsersCacheRef.current = allUsers; + } + filtered = filterUsersByQuery(allUsers, q, user?.username); + } + + setSuggestions(sortSuggestions(filtered, q).slice(0, 8)); } catch { - setSuggestions([]); + if (requestId !== latestSearchRequestRef.current) { + return; + } + + try { + let allUsers = allUsersCacheRef.current; + if (!allUsers) { + allUsers = await getAllUsers(0, 200); + allUsersCacheRef.current = allUsers; + } + const fallback = filterUsersByQuery(allUsers, q, user?.username); + setSuggestions(sortSuggestions(fallback, q).slice(0, 8)); + } catch { + setSuggestions([]); + } } finally { - setIsSuggestionsLoading(false); + if (requestId === latestSearchRequestRef.current) { + setIsSuggestionsLoading(false); + } } - }, 250); + }, 150); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [searchQuery]); + }, [normalizedQuery, user?.username]); const fetchThreads = useCallback(async () => { if (!user?.username) return; @@ -68,7 +224,7 @@ export default function MessageScreen() { try { setError(null); const threads = await getDirectMessageThreads(user.username, 0, 50); - setThreads(threads); + setThreads(dedupeThreadsByPeer(Array.isArray(threads) ? threads : [])); } catch (err) { console.error("Failed to fetch message threads:", err); setError("Failed to load messages"); @@ -82,7 +238,7 @@ export default function MessageScreen() { useFocusEffect( useCallback(() => { fetchThreads(); - }, [fetchThreads]) + }, [fetchThreads]), ); const handleRefresh = () => { @@ -110,7 +266,10 @@ export default function MessageScreen() { return ( - + No messages yet.{"\n"} Tap the + button to start a new conversation. @@ -123,7 +282,10 @@ export default function MessageScreen() { return ( - + {error} @@ -155,13 +317,25 @@ export default function MessageScreen() { }; const handleNewChat = () => { + if (!allUsersCacheRef.current) { + getAllUsers(0, 200) + .then((users) => { + allUsersCacheRef.current = users; + }) + .catch(() => { + // Ignore; search endpoint or future retries can still populate suggestions. + }); + } setShowNewChatModal(true); }; const handleStartChat = () => { - const username = searchQuery.trim(); + const username = normalizeUsernameInput(searchQuery); if (!username) { - Alert.alert("Enter username", "Please enter a username to start a conversation."); + Alert.alert( + "Enter username", + "Please enter a username to start a conversation.", + ); return; } @@ -171,8 +345,49 @@ export default function MessageScreen() { router.push(`/conversation/${username}`); }; + const renderSuggestion = ({ + item, + index, + }: { + item: ApiUser; + index: number; + }) => { + const pictureUrl = resolveMediaUrl(item.picture); + const initial = item.username?.[0]?.toUpperCase() || "?"; + + return ( + handleSelectSuggestion(item.username)} + style={({ pressed }) => [ + styles.suggestionItem, + index < suggestions.length - 1 && { + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + pressed && styles.suggestionItemPressed, + ]} + > + + + + + {item.username} + + + + ); + }; + return ( - + {/* Header */} @@ -253,10 +468,7 @@ export default function MessageScreen() { behavior={Platform.OS === "ios" ? "padding" : "height"} style={{ flex: 1 }} > - + e.stopPropagation()} > - - New Conversation - - - - - - - Enter the username of the person you want to message - + + New Conversation + + + + - 0 || isSuggestionsLoading ? 8 : 16, - }, - ]} - autoCapitalize="none" - autoCorrect={false} - autoFocus - returnKeyType="go" - onSubmitEditing={handleStartChat} - /> - - {/* Autocomplete suggestions */} - {(suggestions.length > 0 || isSuggestionsLoading) && ( - - {isSuggestionsLoading ? ( - - ) : ( - suggestions.map((s, i) => ( - handleSelectSuggestion(s.username)} - style={({ pressed }) => [ - styles.suggestionItem, - i < suggestions.length - 1 && { borderBottomWidth: 1, borderBottomColor: colors.border }, - pressed && { backgroundColor: colors.surface }, - ]} - > - {s.username} - - )) - )} - - )} + Enter the username of the person you want to message + - - - - Cancel - - - - - Start Chat - - - - + autoCapitalize="none" + autoCorrect={false} + autoFocus + returnKeyType="go" + onSubmitEditing={handleStartChat} + /> + + {/* Autocomplete suggestions */} + {normalizedQuery.length > 0 && ( + + {isSuggestionsLoading ? ( + + ) : ( + String(item.id ?? item.username)} + renderItem={renderSuggestion} + keyboardShouldPersistTaps="always" + nestedScrollEnabled + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={3} + ListEmptyComponent={ + + + No users found + + + } + /> + )} + + )} + + + + + Cancel + + + + + Start Chat + + + + @@ -445,10 +677,40 @@ const styles = StyleSheet.create({ borderWidth: 1, marginBottom: 16, overflow: "hidden", + maxHeight: 250, }, suggestionItem: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingHorizontal: 10, + paddingVertical: 9, + minHeight: 50, + }, + suggestionItemPressed: { + opacity: 0.85, + transform: [{ scale: 0.99 }], + }, + suggestionAvatar: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + borderWidth: 1, + }, + suggestionAvatarImage: { + width: "100%", + height: "100%", + }, + suggestionTextWrap: { + flex: 1, + }, + suggestionsEmpty: { paddingHorizontal: 12, - paddingVertical: 11, + paddingVertical: 12, + alignItems: "center", }, modalActions: { flexDirection: "row", diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index 66db708..b332dcb 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -105,6 +105,31 @@ function RootLayoutNav() { [fontError, fontFallbackReady, loaded, showBoot], ); const fontsReady = loaded || !!fontError || fontFallbackReady; + const onMessagingScreen = useMemo(() => { + const first = String(segments[0] ?? ""); + const second = String(segments[1] ?? ""); + + if (first === "(tabs)" && second === "message") { + return true; + } + if (first === "conversation") { + return true; + } + return false; + }, [segments]); + + const suppressDirectMessageBanner = useMemo(() => { + const type = String( + (inAppBanner?.payload as Record | undefined)?.type ?? "", + ).toLowerCase(); + return onMessagingScreen && type === "direct_message"; + }, [inAppBanner?.payload, onMessagingScreen]); + + useEffect(() => { + if (suppressDirectMessageBanner && inAppBanner) { + dismissInAppBanner(); + } + }, [dismissInAppBanner, inAppBanner, suppressDirectMessageBanner]); const stackAnimation = useMemo(() => { switch (preferences.pageTransitionEffect) { case "none": @@ -132,7 +157,10 @@ function RootLayoutNav() { }; if (type === "direct_message" && actorName) { - router.push({ pathname: "/terminal", params: { chat: actorName } }); + router.push({ + pathname: "/conversation/[username]", + params: { username: actorName }, + }); return; } @@ -315,7 +343,7 @@ function RootLayoutNav() { /> ) : null} { + const seen = new Set(); + const output: MessageWithState[] = []; + + items.forEach((item) => { + const idKey = + typeof item.id === "number" && item.id > 0 ? `id:${item.id}` : null; + const key = + item.tempId || + idKey || + `fallback:${item.sender_name}:${item.recipient_name}:${item.created_at}:${item.content}`; + + if (seen.has(key)) { + return; + } + seen.add(key); + output.push(item); + }); + + return output; +}; + const getWebSocketBaseUrl = (baseUrl?: string) => { if (!baseUrl) { return ""; @@ -43,7 +70,9 @@ export default function ConversationScreen() { const colors = useAppColors(); const insets = useSafeAreaInsets(); const router = useRouter(); - const { username: recipientUsername } = useLocalSearchParams<{ username: string }>(); + const { username: recipientUsername } = useLocalSearchParams<{ + username: string; + }>(); const { user, token } = useAuth(); const [messages, setMessages] = useState([]); @@ -63,9 +92,9 @@ export default function ConversationScreen() { user.username, recipientUsername as string, 0, - 100 + 100, ); - setMessages(response ?? []); + setMessages(dedupeMessages(response ?? [])); } catch (err) { console.error("Failed to fetch messages:", err); setError("Failed to load messages"); @@ -90,7 +119,7 @@ export default function ConversationScreen() { // Connect to WebSocket using API_BASE_URL const wsBase = getWebSocketBaseUrl(API_BASE_URL); const wsUrl = `${wsBase}/messages/${encodeURIComponent( - user.username + user.username, )}/stream?token=${encodeURIComponent(token)}`; const connect = () => { if (!isActive) { @@ -133,7 +162,7 @@ export default function ConversationScreen() { // Check if message already exists (avoid duplicates) const exists = prev.some((m) => m.id === newMessage.id); if (exists) return prev; - return [...prev, newMessage]; + return dedupeMessages([...prev, newMessage]); }); // Scroll to bottom @@ -152,11 +181,7 @@ export default function ConversationScreen() { }; ws.onclose = (event) => { - console.log( - "WebSocket closed", - event?.code, - event?.reason ?? "" - ); + console.log("WebSocket closed", event?.code, event?.reason ?? ""); if (!isActive) { // Component unmounted or effect cleaned up; do not attempt to reconnect @@ -173,12 +198,12 @@ export default function ConversationScreen() { const maxDelayMs = 10000; const delayMs = Math.min( baseDelayMs * Math.pow(2, reconnectAttempts), - maxDelayMs + maxDelayMs, ); reconnectAttempts += 1; console.log( - `Attempting WebSocket reconnect #${reconnectAttempts} in ${delayMs}ms` + `Attempting WebSocket reconnect #${reconnectAttempts} in ${delayMs}ms`, ); reconnectTimeoutId = setTimeout(() => { @@ -232,7 +257,7 @@ export default function ConversationScreen() { }; // Add optimistic message - setMessages((prev) => [...prev, optimisticMessage]); + setMessages((prev) => dedupeMessages([...prev, optimisticMessage])); // Scroll to bottom setTimeout(() => { @@ -243,34 +268,43 @@ export default function ConversationScreen() { const response = await createDirectMessage( user.username, recipientUsername as string, - content + content, ); // Replace optimistic message with real one - setMessages((prev) => - prev.map((msg) => - msg.tempId === tempId ? response.direct_message : msg - ) - ); + setMessages((prev) => { + const replaced = prev.map((msg) => + msg.tempId === tempId ? response.direct_message : msg, + ); + return dedupeMessages(replaced); + }); } catch (err) { console.error("Failed to send message:", err); // Mark message as error setMessages((prev) => prev.map((msg) => - msg.tempId === tempId ? { ...msg, isPending: false, isError: true } : msg - ) + msg.tempId === tempId + ? { ...msg, isPending: false, isError: true } + : msg, + ), ); - Alert.alert("Failed to send", "Your message could not be sent. Please try again.", [ - { - text: "OK", - onPress: () => { - // Remove failed message so the user can re-send from the composer - setMessages((prev) => prev.filter((msg) => msg.tempId !== tempId)); + Alert.alert( + "Failed to send", + "Your message could not be sent. Please try again.", + [ + { + text: "OK", + onPress: () => { + // Remove failed message so the user can re-send from the composer + setMessages((prev) => + prev.filter((msg) => msg.tempId !== tempId), + ); + }, }, - }, - ]); + ], + ); } }; @@ -287,6 +321,7 @@ export default function ConversationScreen() { isSent={isSent} isPending={item.isPending} isError={item.isError} + authorLabel={isSent ? "you" : (recipientUsername as string)} /> ); }; @@ -296,7 +331,10 @@ export default function ConversationScreen() { return ( - + No messages yet.{"\n"} Start the conversation! @@ -309,7 +347,10 @@ export default function ConversationScreen() { return ( - + {error} @@ -318,75 +359,87 @@ export default function ConversationScreen() { return ( - + - {/* Header */} + + + + + {recipientUsername} + + + + {/* Placeholder for future actions (e.g., settings, info) */} + + + + {/* Messages */} + {isLoading ? ( - - - - - - {recipientUsername} - - - - {/* Placeholder for future actions (e.g., settings, info) */} - + - - {/* Messages */} - {isLoading ? ( - - - - ) : error ? ( - renderError() - ) : ( - item.tempId || item.id?.toString() || index.toString()} - contentContainerStyle={[ - styles.messageList, - messages.length === 0 && { flex: 1 }, - ]} - ListEmptyComponent={renderEmpty} - onContentSizeChange={() => { - if (messages.length > 0) { - flatListRef.current?.scrollToEnd({ animated: false }); - } - }} - onLayout={() => { - if (messages.length > 0) { - flatListRef.current?.scrollToEnd({ animated: false }); - } - }} - keyboardShouldPersistTaps="handled" - maintainVisibleContentPosition={{ - minIndexForVisible: 0, - autoscrollToTopThreshold: 10, - }} - /> - )} - - {/* Message Input */} + ) : error ? ( + renderError() + ) : ( + + item.tempId || item.id?.toString() || index.toString() + } + contentContainerStyle={[ + styles.messageList, + messages.length === 0 && { flex: 1 }, + ]} + ListEmptyComponent={renderEmpty} + onContentSizeChange={() => { + if (messages.length > 0) { + flatListRef.current?.scrollToEnd({ animated: false }); + } + }} + onLayout={() => { + if (messages.length > 0) { + flatListRef.current?.scrollToEnd({ animated: false }); + } + }} + keyboardShouldPersistTaps="always" + keyboardDismissMode={ + Platform.OS === "ios" ? "interactive" : "on-drag" + } + onScrollBeginDrag={() => { + Keyboard.dismiss(); + }} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + autoscrollToTopThreshold: 10, + }} + /> + )} + + {/* Message Input */} + @@ -399,9 +452,6 @@ const styles = StyleSheet.create({ screen: { flex: 1, }, - keyboardView: { - flex: 1, - }, header: { flexDirection: "row", alignItems: "center", diff --git a/frontend/app/notifications.tsx b/frontend/app/notifications.tsx index 4b15426..aa28947 100644 --- a/frontend/app/notifications.tsx +++ b/frontend/app/notifications.tsx @@ -96,8 +96,8 @@ export default function NotificationsScreen() { await markRead(item.id); if (item.type === "direct_message" && item.actor_name) { router.push({ - pathname: "/terminal", - params: { chat: item.actor_name }, + pathname: "/conversation/[username]", + params: { username: item.actor_name }, }); return; } diff --git a/frontend/app/settings/help-navigation.tsx b/frontend/app/settings/help-navigation.tsx index 8a22987..d3bf2cc 100644 --- a/frontend/app/settings/help-navigation.tsx +++ b/frontend/app/settings/help-navigation.tsx @@ -50,9 +50,9 @@ export default function SettingsHelpNavigationScreen() { onPress={() => router.push("/notifications")} /> router.push("/terminal")} + label="Messages" + detail="Open conversations" + onPress={() => router.push("/message")} /> - value.replace(/^@+/, "").trim().toLowerCase(); - -const formatTime = (dateValue: string) => { - const date = new Date(dateValue); - if (Number.isNaN(date.getTime())) { - return "now"; - } - return date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - }); -}; - -const mapMessageToChatEntry = ( - me: string, - message: ApiDirectMessage, -): ChatEntry => { - const myName = normalizeUsername(me); - const sender = normalizeUsername(message.sender_name); - return { - id: message.id, - author: sender === myName ? "me" : "them", - text: message.content, - timestamp: formatTime(message.created_at), - }; -}; - -const getPeerForMessage = (me: string, message: ApiDirectMessage) => { - const myName = normalizeUsername(me); - const sender = normalizeUsername(message.sender_name); - const recipient = normalizeUsername(message.recipient_name); - return sender === myName ? recipient : sender; -}; - -const getWebSocketBaseUrl = (baseUrl?: string) => { - if (!baseUrl) { - return ""; - } - if (baseUrl.startsWith("https://")) { - return `wss://${baseUrl.slice("https://".length)}`; - } - if (baseUrl.startsWith("http://")) { - return `ws://${baseUrl.slice("http://".length)}`; - } - return baseUrl; -}; - -const truncatePreview = (text: string, max = 46) => { - const value = text.trim(); - if (value.length <= max) { - return value; - } - return `${value.slice(0, max - 1)}…`; -}; - -const formatInboxTime = (value: string) => { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return "now"; - } - - const diffMs = Date.now() - date.getTime(); - const minute = 60 * 1000; - const hour = 60 * minute; - const day = 24 * hour; - - if (diffMs < minute) { - return "now"; - } - if (diffMs < hour) { - return `${Math.floor(diffMs / minute)}m`; - } - if (diffMs < day) { - return `${Math.floor(diffMs / hour)}h`; - } - return `${Math.floor(diffMs / day)}d`; -}; - -const parseApiErrorMessage = (error: unknown, fallback: string) => { - if (!(error instanceof Error)) { - return fallback; - } - - const rawMessage = error.message?.trim(); - if (!rawMessage) { - return fallback; - } - - try { - const parsed = JSON.parse(rawMessage) as { - error?: string; - message?: string; - }; - const value = parsed.error ?? parsed.message; - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - } catch { - // Keep raw error text when message is not JSON. - } - - return rawMessage; -}; - -const isMissingDirectMessageUserError = (error: unknown) => { - const message = parseApiErrorMessage(error, "").toLowerCase(); - return ( - message.includes("failed to fetch direct messages") && - message.includes("not found") - ); -}; - -export default function TerminalScreen() { - const colors = useAppColors(); +export default function TerminalScreenRedirect() { const router = useRouter(); const params = useLocalSearchParams<{ chat?: string | string[] }>(); - const { user, token } = useAuth(); - const { showInAppBanner } = useNotifications(); - const insets = useSafeAreaInsets(); - const outputRef = useRef(null); - const outputScrollRafRef = useRef(null); - const autoOpenedChatRef = useRef(null); - const activeChatRef = useRef(null); - const wsRef = useRef(null); - const reconnectTimeoutRef = useRef | null>( - null, - ); - const seenMessageIdsRef = useRef>(new Set()); - const spamWindowByPeerRef = useRef>({}); - const spamCooldownByPeerRef = useRef>({}); - const [input, setInput] = useState(""); - const [activeChat, setActiveChat] = useState(null); - const [chatThreads, setChatThreads] = useState>( - {}, - ); - const [allUsers, setAllUsers] = useState([]); - const [friendUsers, setFriendUsers] = useState([]); - const [chatPeers, setChatPeers] = useState([]); - const [lines, setLines] = useState([ - { id: 1, type: "out", text: "DevBits Terminal v1" }, - { id: 2, type: "out", text: "Type 'help' to list commands." }, - { id: 3, type: "out", text: "Open your inbox: inbox" }, - { id: 4, type: "out", text: "Start a chat: chat username" }, - { id: 5, type: "out", text: "" }, - ]); - - const movePeerToTop = useCallback((peerUsername: string) => { - const normalizedPeer = normalizeUsername(peerUsername); - if (!normalizedPeer) { - return; - } - setChatPeers((prev) => [ - normalizedPeer, - ...prev.filter((entry) => entry !== normalizedPeer), - ]); - }, []); - - const commandHelp = useMemo( - () => [ - "help show available commands", - "clear clear terminal output", - "echo print text", - "inbox show direct-message threads", - "friends list your follows and active chats", - "chat open terminal chat session", - "exit leave active chat session", - "msg @user send message to a user", - "open home|explore navigate app sections", - "status show current session status", - ], - [], - ); - - const commandNames = useMemo( - () => [ - "help", - "clear", - "echo", - "inbox", - "friends", - "chat", - "exit", - "msg", - "open", - "status", - ], - [], - ); useEffect(() => { - let active = true; - if (!user?.username) { - setAllUsers([]); - setFriendUsers([]); - setChatPeers([]); - return; - } - - void Promise.all([ - getAllUsers(0, 200).catch(() => []), - getUsersFollowingUsernames(user.username).catch(() => []), - getDirectMessageThreads(user.username, 0, 100).catch(() => []), - getDirectChatPeers(user.username).catch(() => []), - ]).then(([users, following, threads, peers]) => { - if (!active) { - return; - } - const usernames = Array.isArray(users) - ? users - .map((entry) => entry.username) - .filter((name): name is string => Boolean(name)) - .map((name) => normalizeUsername(name)) - : []; - setAllUsers(Array.from(new Set(usernames)).sort()); - const follows = Array.isArray(following) - ? following.map((name) => normalizeUsername(name)).filter(Boolean) - : []; - setFriendUsers(Array.from(new Set(follows)).sort()); - const threadPeers = Array.isArray(threads) - ? threads - .map((thread) => normalizeUsername(thread.peer_username)) - .filter(Boolean) - : []; - const peerNames = Array.isArray(peers) - ? peers.map((name) => normalizeUsername(name)).filter(Boolean) - : []; - setChatPeers(Array.from(new Set([...threadPeers, ...peerNames]))); - }); - - return () => { - active = false; - }; - }, [user?.username]); - - const suggestions = useMemo(() => { - const trimmed = input.trim(); - if (!trimmed) { - return []; - } - - if (activeChat) { - const chatCommands = ["/help", "/exit"]; - return chatCommands - .filter((item) => item.startsWith(trimmed.toLowerCase())) - .map((item) => ({ - id: `chat-${item}`, - label: item, - value: item, - })) - .slice(0, 5); - } - - const [commandRaw, ...args] = trimmed.split(/\s+/); - const command = commandRaw.toLowerCase(); - - if (!trimmed.includes(" ")) { - return commandNames - .filter((item) => item.startsWith(command)) - .map((item) => ({ - id: `cmd-${item}`, - label: item, - value: item, - })) - .slice(0, 5); - } - - if (command === "open") { - const target = (args[0] ?? "").toLowerCase(); - return ["home", "explore"] - .filter((item) => item.startsWith(target)) - .map((item) => ({ - id: `open-${item}`, - label: `open ${item}`, - value: `open ${item}`, - })); - } - - if (command === "chat") { - const typed = normalizeUsername(args[0] ?? ""); - const source = Array.from( - new Set([ - ...friendUsers, - ...chatPeers, - ...Object.keys(chatThreads), - ...allUsers, - ]), - ); - return source - .filter((name) => !typed || name.startsWith(typed)) - .slice(0, 5) - .map((name) => ({ - id: `chat-user-${name}`, - label: `chat ${name}`, - value: `chat ${name}`, - })); - } - - if (command === "msg") { - const targetRaw = args[0] ?? ""; - const typed = normalizeUsername(targetRaw); - const source = Array.from(new Set([...friendUsers, ...allUsers])); - const base = source - .filter((name) => !typed || name.startsWith(typed)) - .slice(0, 5) - .map((name) => ({ - id: `msg-user-${name}`, - label: `msg @${name}`, - value: `msg @${name} `, - })); - - if (args.length > 1) { - return []; - } - return base; - } - - return []; - }, [ - activeChat, - allUsers, - chatPeers, - chatThreads, - commandNames, - friendUsers, - input, - ]); + const chatParam = Array.isArray(params.chat) ? params.chat[0] : params.chat; + const username = (chatParam || "").trim(); - const appendLines = (entries: Omit[]) => { - setLines((prev) => { - const start = prev.length ? prev[prev.length - 1].id + 1 : 1; - const mapped = entries.map((entry, index) => ({ - id: start + index, - ...entry, - })); - return [...prev, ...mapped]; - }); - }; - - const scheduleOutputScroll = useCallback((animated: boolean) => { - if (outputScrollRafRef.current !== null) { - cancelAnimationFrame(outputScrollRafRef.current); - outputScrollRafRef.current = null; - } - outputScrollRafRef.current = requestAnimationFrame(() => { - outputRef.current?.scrollToEnd({ animated }); - outputScrollRafRef.current = null; - }); - }, []); - - const notifyIncomingMessage = useCallback( - (peer: string, text: string) => { - const now = Date.now(); - const windowMs = 5000; - const spamThreshold = 10; - const spamCooldownMs = 15000; - const spamBannerHoldMs = 4000; - - const existing = spamWindowByPeerRef.current[peer] ?? []; - const recent = existing.filter( - (timestamp) => now - timestamp <= windowMs, - ); - recent.push(now); - spamWindowByPeerRef.current[peer] = recent; - - const lastSpam = spamCooldownByPeerRef.current[peer] ?? 0; - if (recent.length >= spamThreshold && now - lastSpam > spamCooldownMs) { - spamCooldownByPeerRef.current[peer] = now; - showInAppBanner({ - title: "Spam detected", - body: `@${peer} is spamming you — Stack overflow: inbox hit 10 msgs in 5s.`, - payload: { type: "direct_message", actor_name: peer }, - incrementUnread: true, - }); - return; - } - - if (now - lastSpam < spamBannerHoldMs) { - return; - } - - showInAppBanner({ - title: "New message", - body: `@${peer}: ${text}`, - payload: { type: "direct_message", actor_name: peer }, - incrementUnread: true, + if (username) { + router.replace({ + pathname: "/conversation/[username]", + params: { username }, }); - }, - [showInAppBanner], - ); - - const appendChatEntries = (username: string, entries: ChatEntry[]) => { - const normalizedUser = normalizeUsername(username); - if (!normalizedUser || entries.length === 0) { - return [] as ChatEntry[]; - } - - const uniqueEntries = entries.filter((entry) => { - if (seenMessageIdsRef.current.has(entry.id)) { - return false; - } - seenMessageIdsRef.current.add(entry.id); - return true; - }); - if (!uniqueEntries.length) { - return [] as ChatEntry[]; - } - - setChatThreads((prev) => { - const existing = prev[normalizedUser] ?? []; - return { - ...prev, - [normalizedUser]: [...existing, ...uniqueEntries], - }; - }); - - return uniqueEntries; - }; - - const renderChatHistory = useCallback( - (username: string, entries?: ChatEntry[]) => { - const normalizedUser = normalizeUsername(username); - const thread = entries ?? chatThreads[normalizedUser] ?? []; - if (!thread.length) { - appendLines([ - { type: "out", text: `chat @${normalizedUser}` }, - { type: "out", text: "No previous messages. Say hello." }, - ]); - return; - } - - appendLines([ - { type: "out", text: `chat @${normalizedUser}` }, - ...thread.map((entry) => ({ - type: "out" as const, - chatRole: entry.author, - text: - entry.author === "me" - ? `[${entry.timestamp}] you: ${entry.text}` - : `[${entry.timestamp}] @${normalizedUser}: ${entry.text}`, - })), - ]); - }, - [chatThreads], - ); - - const loadChatHistory = useCallback( - async (username: string) => { - const normalizedUser = normalizeUsername(username); - if (!user?.username) { - appendLines([{ type: "err", text: "Sign in to open chat." }]); - return; - } - - try { - const messages = await getDirectMessages( - user.username, - normalizedUser, - 0, - 200, - ); - const mapped = messages.map((message) => - mapMessageToChatEntry(user.username ?? "", message), - ); - const historyIds = messages.map((message) => message.id); - for (const messageID of historyIds) { - seenMessageIdsRef.current.add(messageID); - } - setChatThreads((prev) => ({ - ...prev, - [normalizedUser]: mapped, - })); - renderChatHistory(normalizedUser, mapped); - } catch (error) { - if (isMissingDirectMessageUserError(error)) { - appendLines([ - { - type: "err", - text: `Chat unavailable: @${normalizedUser} does not exist.`, - }, - ]); - setActiveChat((current) => - current === normalizedUser ? null : current, - ); - return; - } - - const message = parseApiErrorMessage( - error, - `Failed to load chat with @${normalizedUser}`, - ); - appendLines([{ type: "err", text: message }]); - } - }, - [renderChatHistory, user?.username], - ); - - const openChatSession = useCallback( - async (username: string) => { - const target = normalizeUsername(username); - if (!target) { - return; - } - setActiveChat(target); - appendLines([ - { type: "out", text: `Chat mode: @${target} (type /exit to leave)` }, - ]); - await loadChatHistory(target); - }, - [loadChatHistory], - ); - - const renderInbox = useCallback(async () => { - if (!user?.username) { - appendLines([{ type: "err", text: "Sign in to view inbox." }]); - return; - } - - const threads = await getDirectMessageThreads(user.username, 0, 50).catch( - () => [] as ApiDirectMessageThread[], - ); - - if (!threads.length) { - appendLines([ - { type: "out", text: "Inbox" }, - { type: "out", text: "No direct messages yet." }, - ]); - return; - } - - const normalizedPeers = threads - .map((thread) => normalizeUsername(thread.peer_username)) - .filter(Boolean); - setChatPeers((prev) => Array.from(new Set([...normalizedPeers, ...prev]))); - - appendLines([ - { type: "out", text: "Inbox" }, - ...threads.map((thread) => { - const peer = normalizeUsername(thread.peer_username); - const preview = truncatePreview(thread.last_content || ""); - const when = formatInboxTime(thread.last_at); - return { - type: "out" as const, - text: `@${peer.padEnd(18, " ")} ${when.padStart(3, " ")} ${preview}`, - }; - }), - { type: "out", text: "Use: chat " }, - ]); - }, [user?.username]); - - const handleSendChatMessage = async (targetUser: string, message: string) => { - const normalizedUser = normalizeUsername(targetUser); - const trimmedMessage = message.trim(); - if (!normalizedUser || !trimmedMessage || !user?.username) { - return; - } - - const optimisticTimestamp = formatTime(new Date().toISOString()); - appendLines([ - { - type: "out", - chatRole: "me", - text: `[${optimisticTimestamp}] you: ${trimmedMessage}`, - }, - ]); - - try { - const response = await createDirectMessage( - user.username, - normalizedUser, - trimmedMessage, - ); - const created = response.direct_message; - const entry = mapMessageToChatEntry(user.username, created); - appendChatEntries(normalizedUser, [entry]); - movePeerToTop(normalizedUser); - } catch (error) { - const message = parseApiErrorMessage( - error, - `Failed to send message to @${normalizedUser}`, - ); - appendLines([{ type: "err", text: message }]); - } - }; - - const runCommand = async (raw: string) => { - const trimmed = raw.trim(); - if (!trimmed) { - return; - } - - if (activeChat) { - const loweredInput = trimmed.toLowerCase(); - if (loweredInput === "exit" || loweredInput === "/exit") { - appendLines([{ type: "out", text: `Closed chat @${activeChat}` }]); - setActiveChat(null); - return; - } - if (loweredInput === "help" || loweredInput === "/help") { - appendLines([ - { type: "out", text: "Chat mode commands:" }, - { type: "out", text: " /exit close current chat" }, - { type: "out", text: " /help show this help" }, - ]); - return; - } - - void handleSendChatMessage(activeChat, trimmed); - return; - } - - appendLines([{ type: "cmd", text: `$ ${trimmed}` }]); - - const [command, ...args] = trimmed.split(/\s+/); - const lowered = command.toLowerCase(); - - if (lowered === "help") { - appendLines(commandHelp.map((text) => ({ type: "out" as const, text }))); - return; - } - - if (lowered === "inbox") { - await renderInbox(); - return; - } - - if (lowered === "friends" || lowered === "lsfriends") { - if (!user?.username) { - appendLines([{ type: "err", text: "Sign in to load friends." }]); - return; - } - - try { - const following = await getUsersFollowingUsernames(user.username); - const peers = await getDirectChatPeers(user.username).catch(() => []); - const activeUsers = Array.from( - new Set([ - ...peers.map((name) => normalizeUsername(name)), - ...Object.keys(chatThreads), - ]), - ); - if (!following.length && !activeUsers.length) { - appendLines([ - { type: "out", text: "No friends or active chats yet." }, - ]); - return; - } - - if (following.length) { - appendLines([ - { type: "out", text: "Friends (following):" }, - ...following.map((name) => ({ - type: "out" as const, - text: `- @${name}`, - })), - ]); - } - - if (activeUsers.length) { - setChatPeers((prev) => - Array.from(new Set([...prev, ...activeUsers])), - ); - appendLines([ - { type: "out", text: "Active chat threads:" }, - ...activeUsers.map((name) => ({ - type: "out" as const, - text: `- @${name}`, - })), - ]); - } - } catch { - appendLines([{ type: "err", text: "Could not load friends list." }]); - } - return; - } - - if (lowered === "chat") { - const target = normalizeUsername(args[0] ?? ""); - if (!target) { - appendLines([{ type: "err", text: "Usage: chat " }]); - return; - } - await openChatSession(target); - return; - } - - if (lowered === "exit") { - appendLines([{ type: "err", text: "No active chat to exit." }]); - return; - } - - if (lowered === "clear") { - setLines([]); - return; - } - - if (lowered === "echo") { - appendLines([{ type: "out", text: args.join(" ") }]); - return; - } - - if (lowered === "status") { - appendLines([ - { type: "out", text: "session: active" }, - { - type: "out", - text: `chat: ${activeChat ? `@${activeChat}` : "none"}`, - }, - { type: "out", text: "notifications: enabled" }, - { type: "out", text: "messaging gateway: online" }, - ]); - return; - } - - if (lowered === "open") { - const target = (args[0] ?? "").toLowerCase(); - if (target === "home") { - appendLines([{ type: "out", text: "Opening home..." }]); - router.push("/(tabs)"); - return; - } - if (target === "explore") { - appendLines([{ type: "out", text: "Opening explore..." }]); - router.push("/(tabs)/explore"); - return; - } - appendLines([{ type: "err", text: "Usage: open home|explore" }]); - return; - } - - if (lowered === "msg") { - if (args.length < 2 || !args[0].startsWith("@")) { - appendLines([{ type: "err", text: "Usage: msg @user " }]); - return; - } - const target = args[0].slice(1); - const message = args.slice(1).join(" "); - if (!target || !message) { - appendLines([{ type: "err", text: "Usage: msg @user " }]); - return; - } - const normalizedTarget = normalizeUsername(target); - if (activeChat !== normalizedTarget) { - setActiveChat(normalizedTarget); - appendLines([ - { - type: "out", - text: `Chat mode: @${normalizedTarget} (type /exit to leave)`, - }, - ]); - void loadChatHistory(normalizedTarget); - } - movePeerToTop(normalizedTarget); - void handleSendChatMessage(normalizedTarget, message); - return; - } - - appendLines([{ type: "err", text: `Unknown command: ${command}` }]); - }; - - const handleSubmit = () => { - const suggestion = suggestions[0]; - const normalizedInput = input.trim().toLowerCase(); - if ( - suggestion && - suggestion.value.trim().toLowerCase() !== normalizedInput - ) { - setInput(suggestion.value); - return; - } - - const value = input; - setInput(""); - void runCommand(value); - }; - - useEffect(() => { - scheduleOutputScroll(true); - }, [lines, scheduleOutputScroll]); - - useEffect(() => { - if (!input.length) { - return; - } - scheduleOutputScroll(false); - }, [input, scheduleOutputScroll]); - - useEffect(() => { - return () => { - if (outputScrollRafRef.current !== null) { - cancelAnimationFrame(outputScrollRafRef.current); - outputScrollRafRef.current = null; - } - }; - }, []); - - useEffect(() => { - activeChatRef.current = activeChat; - }, [activeChat]); - - useEffect(() => { - const rawParam = params.chat; - const chatParam = Array.isArray(rawParam) - ? (rawParam[0] ?? "") - : (rawParam ?? ""); - const target = normalizeUsername(chatParam); - if (!target || !user?.username) { - return; - } - - if (autoOpenedChatRef.current === target) { - return; - } - - autoOpenedChatRef.current = target; - void openChatSession(target); - }, [openChatSession, params.chat, user?.username]); - - useEffect(() => { - if (!user?.username || !token) { - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } return; } - let cancelled = false; - let reconnectAttempt = 0; - - const connect = () => { - if (cancelled || !user.username || !token) { - return; - } - - const wsBase = getWebSocketBaseUrl(API_BASE_URL); - const url = `${wsBase}/messages/${encodeURIComponent(user.username)}/stream?token=${encodeURIComponent(token)}`; - const socket = new WebSocket(url); - wsRef.current = socket; - - socket.onopen = () => { - reconnectAttempt = 0; - }; - - socket.onmessage = (event) => { - const raw = typeof event.data === "string" ? event.data : ""; - if (!raw) { - return; - } - - try { - const payload = JSON.parse(raw) as DirectMessageStreamEvent; - if (payload.type !== "direct_message" || !payload.direct_message) { - return; - } - if (!user.username) { - return; - } - - const message = payload.direct_message; - const normalizedMe = normalizeUsername(user.username); - const sender = normalizeUsername(message.sender_name); - const recipient = normalizeUsername(message.recipient_name); - if (sender !== normalizedMe && recipient !== normalizedMe) { - return; - } - - const peer = getPeerForMessage(user.username, message); - if (!peer) { - return; - } + router.replace("/message"); + }, [params.chat, router]); - const entry = mapMessageToChatEntry(user.username, message); - const appended = appendChatEntries(peer, [entry]); - if (!appended.length) { - return; - } - movePeerToTop(peer); - - if (entry.author === "them") { - notifyIncomingMessage(peer, entry.text); - } - - if (activeChatRef.current === peer && entry.author === "them") { - appendLines([ - { - type: "out", - chatRole: entry.author, - text: `[${entry.timestamp}] @${peer}: ${entry.text}`, - }, - ]); - return; - } - - if (entry.author === "them") { - appendLines([ - { - type: "out", - text: `New message from @${peer}: ${entry.text}`, - }, - ]); - } - } catch { - // Ignore malformed stream payloads. - } - }; - - socket.onclose = () => { - wsRef.current = null; - if (cancelled) { - return; - } - - reconnectAttempt += 1; - const backoffMs = Math.min(1000 * reconnectAttempt, 5000); - reconnectTimeoutRef.current = setTimeout(connect, backoffMs); - }; - - socket.onerror = () => { - socket.close(); - }; - }; - - connect(); - - return () => { - cancelled = true; - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }; - }, [movePeerToTop, notifyIncomingMessage, token, user?.username]); - - useEffect(() => { - if (!activeChat || !user?.username) { - return; - } - - const interval = setInterval(() => { - void getDirectMessages(user.username, activeChat, 0, 200) - .then((messages) => { - const incoming = messages - .filter((message) => !seenMessageIdsRef.current.has(message.id)) - .map((message) => mapMessageToChatEntry(user.username, message)); - if (!incoming.length) { - return; - } - - const appended = appendChatEntries(activeChat, incoming); - if (!appended.length) { - return; - } - appended - .filter((entry) => entry.author === "them") - .forEach((entry) => { - notifyIncomingMessage(activeChat, entry.text); - }); - appendLines( - appended.map((entry) => ({ - type: "out" as const, - chatRole: entry.author, - text: - entry.author === "me" - ? `[${entry.timestamp}] you: ${entry.text}` - : `[${entry.timestamp}] @${activeChat}: ${entry.text}`, - })), - ); - }) - .catch((error) => { - if (isMissingDirectMessageUserError(error)) { - appendLines([ - { - type: "err", - text: `Chat closed: @${activeChat} no longer exists.`, - }, - ]); - setActiveChat((current) => - current === activeChat ? null : current, - ); - } - }); - }, 3000); - - return () => { - clearInterval(interval); - }; - }, [activeChat, notifyIncomingMessage, user?.username]); - - return ( - - - - - router.back()} style={styles.backButton}> - - - Terminal - - - - - - - - {activeChat ? `chat @${activeChat}` : "command mode"} - - - peers {chatPeers.length} - - - - scheduleOutputScroll(true)} - > - {lines.map((line) => ( - - {line.text} - - ))} - - - {suggestions.length ? ( - - {suggestions.map((suggestion, index) => ( - setInput(suggestion.value)} - style={[ - styles.suggestionItem, - index === suggestions.length - 1 && - styles.suggestionItemLast, - { - borderColor: colors.border, - }, - ]} - > - - {suggestion.label} - - - ))} - - ) : null} - - - scheduleOutputScroll(true)} - onSubmitEditing={handleSubmit} - placeholder={ - activeChat - ? `Message @${activeChat} (/exit to close)` - : "Enter command" - } - placeholderTextColor={colors.muted} - style={[styles.input, { color: colors.text }]} - autoCapitalize="none" - autoCorrect={false} - returnKeyType="send" - /> - - - - - - - - ); + return null; } - -const styles = StyleSheet.create({ - screen: { - flex: 1, - }, - header: { - marginHorizontal: 16, - marginTop: 8, - borderWidth: 1, - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - }, - backButton: { - width: 28, - height: 28, - borderRadius: 14, - alignItems: "center", - justifyContent: "center", - }, - headerDotWrap: { - width: 28, - height: 28, - alignItems: "center", - justifyContent: "center", - }, - headerDot: { - width: 8, - height: 8, - borderRadius: 4, - }, - outputWrap: { - flex: 1, - marginHorizontal: 16, - marginTop: 10, - borderWidth: 1, - borderRadius: 12, - paddingHorizontal: 10, - paddingVertical: 10, - }, - outputContent: { - gap: 6, - paddingTop: 2, - paddingBottom: 16, - }, - statusStrip: { - marginHorizontal: 16, - marginTop: 8, - borderWidth: 1, - borderRadius: 10, - paddingHorizontal: 10, - paddingVertical: 6, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - }, - suggestionWrap: { - marginHorizontal: 16, - borderWidth: 1, - borderRadius: 10, - marginBottom: 8, - overflow: "hidden", - }, - suggestionItem: { - paddingHorizontal: 10, - paddingVertical: 8, - borderBottomWidth: 1, - }, - suggestionItemLast: { - borderBottomWidth: 0, - }, - line: { - fontFamily: "SpaceMono", - fontSize: 13, - lineHeight: 18, - }, - inputRow: { - marginHorizontal: 16, - borderWidth: 1, - borderRadius: 12, - flexDirection: "row", - alignItems: "center", - paddingHorizontal: 10, - paddingVertical: 8, - gap: 10, - }, - input: { - flex: 1, - fontFamily: "SpaceMono", - fontSize: 14, - }, - sendButton: { - width: 30, - height: 30, - borderRadius: 8, - alignItems: "center", - justifyContent: "center", - }, -}); diff --git a/frontend/components/MessageBubble.tsx b/frontend/components/MessageBubble.tsx index a692e49..a669e94 100644 --- a/frontend/components/MessageBubble.tsx +++ b/frontend/components/MessageBubble.tsx @@ -9,6 +9,7 @@ interface MessageBubbleProps { isSent: boolean; isPending?: boolean; isError?: boolean; + authorLabel?: string; } export function MessageBubble({ @@ -17,6 +18,7 @@ export function MessageBubble({ isSent, isPending = false, isError = false, + authorLabel, }: MessageBubbleProps) { const colors = useAppColors(); @@ -41,7 +43,10 @@ export function MessageBubble({ styles.bubble, isSent ? [styles.sentBubble, { backgroundColor: colors.tint }] - : [styles.receivedBubble, { backgroundColor: colors.surface, borderColor: colors.border }], + : [ + styles.receivedBubble, + { backgroundColor: colors.surface, borderColor: colors.border }, + ], isPending && styles.pendingBubble, isError && styles.errorBubble, ]} @@ -54,6 +59,7 @@ export function MessageBubble({ isPending && { opacity: 0.6 }, ]} > + {authorLabel ? `${authorLabel}: ` : ""} {content} Promise; @@ -15,10 +25,33 @@ export function MessageComposer({ autoFocus = false, }: MessageComposerProps) { const colors = useAppColors(); + const motion = useMotionConfig(); const [text, setText] = useState(""); const [height, setHeight] = useState(40); const [isLoading, setIsLoading] = useState(false); + useEffect(() => { + if ( + Platform.OS === "android" && + UIManager.setLayoutAnimationEnabledExperimental + ) { + UIManager.setLayoutAnimationEnabledExperimental(true); + } + }, []); + + const animateResize = () => { + LayoutAnimation.configureNext({ + duration: motion.duration(140), + update: { + type: LayoutAnimation.Types.easeInEaseOut, + }, + create: { + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, + }); + }; + const handleSend = async () => { const trimmed = text.trim(); if (!trimmed || isLoading) return; @@ -26,6 +59,7 @@ export function MessageComposer({ setIsLoading(true); try { await onSend(trimmed); + animateResize(); setText(""); setHeight(40); // Reset height after sending } catch (error) { @@ -38,7 +72,12 @@ export function MessageComposer({ const handleContentSizeChange = (event: any) => { const newHeight = event.nativeEvent.contentSize.height; // Min 40, max 120 - setHeight(Math.min(Math.max(40, newHeight), 120)); + const nextHeight = Math.min(Math.max(40, newHeight), 120); + if (Math.abs(nextHeight - height) < 2) { + return; + } + animateResize(); + setHeight(nextHeight); }; const canSend = text.trim().length > 0 && !isLoading; @@ -72,8 +111,8 @@ export function MessageComposer({ autoFocus={autoFocus} blurOnSubmit={false} returnKeyType="default" + scrollEnabled={height >= 120} onContentSizeChange={handleContentSizeChange} - editable={!isLoading} /> 0 ? 1 : 0.25), ).current; - const terminalScale = useRef(new Animated.Value(1)).current; - const terminalGlow = useRef(new Animated.Value(0.45)).current; - - useEffect(() => { - const pulse = Animated.loop( - Animated.sequence([ - Animated.timing(terminalGlow, { - toValue: 0.8, - duration: 900, - useNativeDriver: false, - }), - Animated.timing(terminalGlow, { - toValue: 0.35, - duration: 900, - useNativeDriver: false, - }), - ]), - ); - pulse.start(); - return () => { - pulse.stop(); - }; - }, [terminalGlow]); useEffect(() => { Animated.timing(bellGlow, { @@ -58,15 +35,6 @@ export function MyHeader() { }).start(); }; - const animateTerminal = (toValue: number) => { - Animated.spring(terminalScale, { - toValue, - speed: 22, - bounciness: 5, - useNativeDriver: true, - }).start(); - }; - return ( @@ -133,46 +101,6 @@ export function MyHeader() { - - - animateTerminal(0.93)} - onPressOut={() => animateTerminal(1)} - style={[ - styles.iconButton, - { - borderColor: colors.tint, - backgroundColor: colors.surfaceAlt, - shadowColor: colors.tint, - }, - ]} - onPress={() => { - Haptics.selectionAsync(); - router.push("/terminal"); - }} - > - - - - ); @@ -217,9 +145,6 @@ const styles = StyleSheet.create({ shadowOffset: { width: 0, height: 0 }, elevation: 2, }, - iconShell: { - borderRadius: IconButton.borderRadius, - }, badge: { position: "absolute", top: -6, diff --git a/frontend/components/ui/IconSymbol.tsx b/frontend/components/ui/IconSymbol.tsx index 5c6b624..9796030 100644 --- a/frontend/components/ui/IconSymbol.tsx +++ b/frontend/components/ui/IconSymbol.tsx @@ -13,6 +13,7 @@ const MAPPING = { magnifyingglass: "search", "person.fill": "person", "paperplane.fill": "send", + "terminal.fill": "terminal", "chevron.left.forwardslash.chevron.right": "code", "chevron.right": "chevron-right", } as Partial< From e294a80b7f0067729d58a2a3f68705d63b76a4d8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:22:18 -0500 Subject: [PATCH 6/8] fix: safe router navigation encoding and /users/search test coverage (#133) --- backend/api/internal/tests/main_test.go | 1 + backend/api/internal/tests/user_test.go | 22 ++++++++++++++++++++++ frontend/app/(tabs)/message.tsx | 4 ++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/backend/api/internal/tests/main_test.go b/backend/api/internal/tests/main_test.go index 38baf53..fdf06e0 100644 --- a/backend/api/internal/tests/main_test.go +++ b/backend/api/internal/tests/main_test.go @@ -80,6 +80,7 @@ func setupTestRouter() *gin.Engine { router.GET("/auth/me", handlers.RequireAuth(), handlers.GetMe) router.GET("/users", handlers.GetUsers) + router.GET("/users/search", handlers.SearchUsers) router.GET("/users/:username", handlers.GetUserByUsername) router.GET("/users/id/:user_id", handlers.GetUserById) router.POST("/users", handlers.RequireAuth(), handlers.CreateUser) diff --git a/backend/api/internal/tests/user_test.go b/backend/api/internal/tests/user_test.go index 27a187e..79f5214 100644 --- a/backend/api/internal/tests/user_test.go +++ b/backend/api/internal/tests/user_test.go @@ -199,4 +199,26 @@ var user_tests = []TestCase{ ExpectedStatus: http.StatusOK, ExpectedBody: `null`, }, + + // search users – empty q returns empty array + { + Method: http.MethodGet, + Endpoint: "/users/search?q=", + ExpectedStatus: http.StatusOK, + ExpectedBody: `[]`, + }, + // search users – prefix match returns matching users + { + Method: http.MethodGet, + Endpoint: "/users/search?q=dev", + ExpectedStatus: http.StatusOK, + ExpectedBody: `[{"bio":"Full-stack developer passionate about open-source projects.","creation_date":"2023-12-13T00:00:00Z","id":1,"links":["https://github.com/dev_user1","https://devuser1.com"],"picture":"https://example.com/dev_user1.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"dev_user1"}]`, + }, + // search users – count is capped at 20 + { + Method: http.MethodGet, + Endpoint: "/users/search?q=d&count=100", + ExpectedStatus: http.StatusOK, + ExpectedBody: `[{"bio":"Data scientist with a passion for machine learning.","creation_date":"2023-06-13T00:00:00Z","id":3,"links":["https://github.com/data_scientist3","https://datascientist3.com"],"picture":"https://example.com/data_scientist3.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"data_scientist3"},{"bio":"Full-stack developer passionate about open-source projects.","creation_date":"2023-12-13T00:00:00Z","id":1,"links":["https://github.com/dev_user1","https://devuser1.com"],"picture":"https://example.com/dev_user1.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"dev_user1"}]`, + }, } diff --git a/frontend/app/(tabs)/message.tsx b/frontend/app/(tabs)/message.tsx index e9a2ae9..dcb4294 100644 --- a/frontend/app/(tabs)/message.tsx +++ b/frontend/app/(tabs)/message.tsx @@ -313,7 +313,7 @@ export default function MessageScreen() { setShowNewChatModal(false); setSearchQuery(""); setSuggestions([]); - router.push(`/conversation/${username}`); + router.push({ pathname: "/conversation/[username]", params: { username } }); }; const handleNewChat = () => { @@ -342,7 +342,7 @@ export default function MessageScreen() { setShowNewChatModal(false); setSearchQuery(""); setSuggestions([]); - router.push(`/conversation/${username}`); + router.push({ pathname: "/conversation/[username]", params: { username } }); }; const renderSuggestion = ({ From c0fd89db136aa7628e096104a0c19fdd0fa49df3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:17:42 -0500 Subject: [PATCH 7/8] fix: exclude sentinel user from search results and correct stale test expectations (#136) * Initial plan * fix: exclude deleted_user sentinel from search results and update test expectations Co-authored-by: grillinr <169214325+grillinr@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: grillinr <169214325+grillinr@users.noreply.github.com> --- backend/api/internal/database/user_queries.go | 2 +- backend/api/internal/tests/user_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/internal/database/user_queries.go b/backend/api/internal/database/user_queries.go index 0d2a94f..68a7e66 100644 --- a/backend/api/internal/database/user_queries.go +++ b/backend/api/internal/database/user_queries.go @@ -233,7 +233,7 @@ func SearchUsers(prefix string, limit int) ([]*ApiUser, error) { query := ` SELECT id, username, picture, bio, links, settings, creation_date FROM users - WHERE LOWER(username) LIKE LOWER($1) + WHERE LOWER(username) LIKE LOWER($1) AND id > 0 ORDER BY username ASC LIMIT $2; ` diff --git a/backend/api/internal/tests/user_test.go b/backend/api/internal/tests/user_test.go index 79f5214..2d9cf22 100644 --- a/backend/api/internal/tests/user_test.go +++ b/backend/api/internal/tests/user_test.go @@ -212,13 +212,13 @@ var user_tests = []TestCase{ Method: http.MethodGet, Endpoint: "/users/search?q=dev", ExpectedStatus: http.StatusOK, - ExpectedBody: `[{"bio":"Full-stack developer passionate about open-source projects.","creation_date":"2023-12-13T00:00:00Z","id":1,"links":["https://github.com/dev_user1","https://devuser1.com"],"picture":"https://example.com/dev_user1.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"dev_user1"}]`, + ExpectedBody: `[{"bio":"Updated developer bio.","creation_date":"2023-12-13T00:00:00Z","id":1,"links":["https://github.com/dev_user1","https://devuser1.com"],"picture":"https://example.com/dev_user1.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"dev_user1"}]`, }, // search users – count is capped at 20 { Method: http.MethodGet, Endpoint: "/users/search?q=d&count=100", ExpectedStatus: http.StatusOK, - ExpectedBody: `[{"bio":"Data scientist with a passion for machine learning.","creation_date":"2023-06-13T00:00:00Z","id":3,"links":["https://github.com/data_scientist3","https://datascientist3.com"],"picture":"https://example.com/data_scientist3.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"data_scientist3"},{"bio":"Full-stack developer passionate about open-source projects.","creation_date":"2023-12-13T00:00:00Z","id":1,"links":["https://github.com/dev_user1","https://devuser1.com"],"picture":"https://example.com/dev_user1.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"dev_user1"}]`, + ExpectedBody: `[{"bio":"Data scientist with a passion for machine learning.","creation_date":"2023-06-13T00:00:00Z","id":3,"links":["https://github.com/data_scientist3","https://datascientist3.com"],"picture":"https://example.com/data_scientist3.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"data_scientist3"},{"bio":"Updated developer bio.","creation_date":"2023-12-13T00:00:00Z","id":1,"links":["https://github.com/dev_user1","https://devuser1.com"],"picture":"https://example.com/dev_user1.jpg","settings":{"accentColor":"","backgroundRefreshEnabled":false,"compactMode":false,"refreshIntervalMs":120000,"zenMode":false},"username":"dev_user1"}]`, }, } From 65739e2b3842c303d7ad9c24286033877d2c7751 Mon Sep 17 00:00:00 2001 From: Nathan Grilliot Date: Fri, 6 Mar 2026 20:13:49 -0500 Subject: [PATCH 8/8] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/app/(tabs)/message.tsx | 9 ++++++- frontend/app/conversation/[username].tsx | 34 ++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/frontend/app/(tabs)/message.tsx b/frontend/app/(tabs)/message.tsx index dcb4294..15330ee 100644 --- a/frontend/app/(tabs)/message.tsx +++ b/frontend/app/(tabs)/message.tsx @@ -219,7 +219,14 @@ export default function MessageScreen() { }, [normalizedQuery, user?.username]); const fetchThreads = useCallback(async () => { - if (!user?.username) return; + if (!user?.username) { + // No authenticated user: stop any loading/refresh indicators + setIsLoading(false); + setIsRefreshing(false); + setThreads([]); + setError(null); + return; + } try { setError(null); diff --git a/frontend/app/conversation/[username].tsx b/frontend/app/conversation/[username].tsx index ff09840..f629144 100644 --- a/frontend/app/conversation/[username].tsx +++ b/frontend/app/conversation/[username].tsx @@ -84,7 +84,12 @@ export default function ConversationScreen() { // Fetch messages const fetchMessages = useCallback(async () => { - if (!user?.username || !recipientUsername) return; + if (!user?.username || !recipientUsername) { + // If we don't have the necessary identifiers, stop loading and show an error/empty state + setIsLoading(false); + setError("Unable to load conversation."); + return; + } try { setError(null); @@ -159,7 +164,32 @@ export default function ConversationScreen() { newMessage.recipient_name === recipientUsername ) { setMessages((prev) => { - // Check if message already exists (avoid duplicates) + // First, try to reconcile against any pending optimistic message + const pendingIndex = prev.findIndex( + (m) => + m.isPending && + m.id === -1 && + m.sender_name === newMessage.sender_name && + m.recipient_name === newMessage.recipient_name && + m.content === newMessage.content + ); + + if (pendingIndex !== -1) { + const updated = [...prev]; + const pendingMessage = updated[pendingIndex]; + + // Replace the optimistic message with the real one, preserving client-only fields + updated[pendingIndex] = { + ...pendingMessage, + ...newMessage, + isPending: false, + isError: false, + }; + + return dedupeMessages(updated); + } + + // Check if message already exists (avoid duplicates by server id) const exists = prev.some((m) => m.id === newMessage.id); if (exists) return prev; return dedupeMessages([...prev, newMessage]);