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/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 5e2a993..68a7e66 100644 --- a/backend/api/internal/database/user_queries.go +++ b/backend/api/internal/database/user_queries.go @@ -228,6 +228,51 @@ func DeleteUser(username string) error { return nil } +// 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 + FROM users + WHERE LOWER(username) LIKE LOWER($1) AND id > 0 + 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) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate over user search results: %w", err) + } + return users, nil +} + // GetUsers retrieves a list of all users func GetUsers() ([]*ApiUser, error) { query := ` 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/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..2d9cf22 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":"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":"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"}]`, + }, } 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)/_layout.tsx b/frontend/app/(tabs)/_layout.tsx index 90ae5d3..5c457b3 100644 --- a/frontend/app/(tabs)/_layout.tsx +++ b/frontend/app/(tabs)/_layout.tsx @@ -57,6 +57,15 @@ export default function TabLayout() { ), }} /> + ( + + ), + }} + /> { router.prefetch("/streams"); router.prefetch("/bytes"); - router.prefetch("/terminal"); }); return () => { diff --git a/frontend/app/(tabs)/message.tsx b/frontend/app/(tabs)/message.tsx new file mode 100644 index 0000000..15330ee --- /dev/null +++ b/frontend/app/(tabs)/message.tsx @@ -0,0 +1,734 @@ +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { + View, + StyleSheet, + FlatList, + RefreshControl, + ActivityIndicator, + Platform, + Pressable, + Modal, + TextInput, + Alert, + KeyboardAvoidingView, +} from "react-native"; +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, + 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(); + const router = useRouter(); + const { user } = useAuth(); + + const [threads, setThreads] = useState([]); + 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 [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 = normalizedQuery; + if (!q) { + latestSearchRequestRef.current += 1; + setSuggestions([]); + setIsSuggestionsLoading(false); + return; + } + + setIsSuggestionsLoading(true); + + debounceRef.current = setTimeout(async () => { + const requestId = latestSearchRequestRef.current + 1; + latestSearchRequestRef.current = requestId; + try { + 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 { + 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 { + if (requestId === latestSearchRequestRef.current) { + setIsSuggestionsLoading(false); + } + } + }, 150); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [normalizedQuery, user?.username]); + + const fetchThreads = useCallback(async () => { + if (!user?.username) { + // No authenticated user: stop any loading/refresh indicators + setIsLoading(false); + setIsRefreshing(false); + setThreads([]); + setError(null); + return; + } + + try { + setError(null); + const threads = await getDirectMessageThreads(user.username, 0, 50); + setThreads(dedupeThreadsByPeer(Array.isArray(threads) ? threads : [])); + } catch (err) { + console.error("Failed to fetch message threads:", err); + setError("Failed to load messages"); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, [user?.username]); + + // Load on mount and refresh on focus + useFocusEffect( + useCallback(() => { + fetchThreads(); + }, [fetchThreads]), + ); + + 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 handleCloseModal = () => { + setShowNewChatModal(false); + setSearchQuery(""); + setSuggestions([]); + }; + + const handleSelectSuggestion = (username: string) => { + setShowNewChatModal(false); + setSearchQuery(""); + setSuggestions([]); + router.push({ pathname: "/conversation/[username]", params: { username } }); + }; + + 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 = normalizeUsernameInput(searchQuery); + if (!username) { + Alert.alert( + "Enter username", + "Please enter a username to start a conversation.", + ); + return; + } + + setShowNewChatModal(false); + setSearchQuery(""); + setSuggestions([]); + router.push({ pathname: "/conversation/[username]", params: { 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 */} + + + Messages + + {isLoading + ? "Loading..." + : threads.length === 0 + ? "No conversations yet" + : `${threads.length} conversation${threads.length === 1 ? "" : "s"}`} + + + + + {/* Thread List */} + {isLoading ? ( + + + + ) : error ? ( + renderError() + ) : ( + item.peer_username} + extraData={threads} + 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, + borderColor: colors.border, + bottom: Math.max(14, insets.bottom + 10), + }, + pressed && styles.fabPressed, + ]} + > + + + + + + {/* New Chat Modal */} + + + + e.stopPropagation()} + > + + New Conversation + + + + + + + Enter the username of the person you want to message + + + + + {/* 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 + + + + + + + + + ); +} + +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, + flexGrow: 1, + }, + emptyState: { + marginTop: 48, + alignItems: "center", + }, + fab: { + position: "absolute", + right: 16, + width: 46, + height: 46, + borderRadius: 14, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + fabPressed: { + opacity: 0.8, + transform: [{ scale: 0.98 }], + }, + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + alignItems: "center", + paddingBottom: 24, + }, + modalContent: { + width: "100%", + 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", + }, + suggestionsContainer: { + borderRadius: 12, + 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: 12, + alignItems: "center", + }, + modalActions: { + flexDirection: "row", + gap: 10, + }, + modalButton: { + flex: 1, + borderRadius: 10, + borderWidth: 1, + paddingVertical: 12, + alignItems: "center", + justifyContent: "center", + }, +}); 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 ""; + } + 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(); + const router = useRouter(); + const { username: recipientUsername } = useLocalSearchParams<{ + username: string; + }>(); + const { user, token } = 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) { + // 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); + const response = await getDirectMessages( + user.username, + recipientUsername as string, + 0, + 100, + ); + setMessages(dedupeMessages(response ?? [])); + } 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 || !token) return; + + // Track reconnection state within this effect + let reconnectAttempts = 0; + let reconnectTimeoutId: ReturnType | null = null; + let isActive = true; + // 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; + } + + 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) => { + // 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]); + }); + + // 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; + } + + const maxAttempts = 5; + if (reconnectAttempts >= maxAttempts) { + console.log("Max WebSocket reconnect attempts reached; giving up."); + return; + } + + 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); + } + }; + + // 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 + 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) => dedupeMessages([...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) => { + 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, + ), + ); + + 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), + ); + }, + }, + ], + ); + } + }; + + 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="always" + keyboardDismissMode={ + Platform.OS === "ios" ? "interactive" : "on-drag" + } + onScrollBeginDrag={() => { + Keyboard.dismiss(); + }} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + autoscrollToTopThreshold: 10, + }} + /> + )} + + {/* Message Input */} + + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + 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/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 new file mode 100644 index 0000000..a669e94 --- /dev/null +++ b/frontend/components/MessageBubble.tsx @@ -0,0 +1,125 @@ +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; + authorLabel?: string; +} + +export function MessageBubble({ + content, + timestamp, + isSent, + isPending = false, + isError = false, + authorLabel, +}: 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 ( + + + + {authorLabel ? `${authorLabel}: ` : ""} + {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: "92%", + borderRadius: 10, + paddingVertical: 10, + paddingHorizontal: 12, + gap: 4, + }, + sentBubble: { + borderBottomRightRadius: 3, + }, + receivedBubble: { + borderBottomLeftRadius: 3, + borderWidth: 1, + }, + pendingBubble: { + opacity: 0.7, + }, + errorBubble: { + opacity: 0.5, + }, + content: { + fontFamily: "SpaceMono", + fontSize: 14, + lineHeight: 20, + }, + timestamp: { + fontFamily: "SpaceMono", + fontSize: 10, + marginTop: 2, + }, +}); diff --git a/frontend/components/MessageComposer.tsx b/frontend/components/MessageComposer.tsx new file mode 100644 index 0000000..07d07c2 --- /dev/null +++ b/frontend/components/MessageComposer.tsx @@ -0,0 +1,168 @@ +import React, { useEffect, useState } from "react"; +import { + View, + StyleSheet, + TextInput, + Pressable, + ActivityIndicator, + Platform, + LayoutAnimation, + UIManager, +} from "react-native"; +import { Feather } from "@expo/vector-icons"; +import { useAppColors } from "@/hooks/useAppColors"; +import { useMotionConfig } from "@/hooks/useMotionConfig"; + +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 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; + + setIsLoading(true); + try { + await onSend(trimmed); + animateResize(); + 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 + 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; + + return ( + + = 120} + onContentSizeChange={handleContentSizeChange} + /> + + {isLoading ? ( + + ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "flex-end", + gap: 10, + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + marginHorizontal: 16, + marginBottom: 0, + }, + 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..103bdac --- /dev/null +++ b/frontend/components/MessageThreadItem.tsx @@ -0,0 +1,190 @@ +import React, { useMemo, useState } 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"; +import { resolveMediaUrl } from "@/services/api"; + +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 [userPicFailed, setUserPicFailed] = useState(false); + + const resolvedUserPicture = resolveMediaUrl(avatarUrl); + + const initials = useMemo(() => { + return username + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + }, [username]); + + const handlePress = () => { + router.push({ + pathname: "/conversation/[username]", + params: { username }, + }); + }; + + return ( + [ + styles.container, + { + backgroundColor: colors.surface, + borderColor: colors.border, + }, + pressed && styles.pressed, + ]} + > + + {resolvedUserPicture && !userPicFailed ? ( + setUserPicFailed(true)} + /> + ) : ( + + + {initials} + + + )} + {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, + overflow: "hidden", + alignItems: "center", + justifyContent: "center", + }, + avatarText: { + lineHeight: undefined, + }, + 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/components/Post.tsx b/frontend/components/Post.tsx index e80b7d9..c8c5129 100644 --- a/frontend/components/Post.tsx +++ b/frontend/components/Post.tsx @@ -22,7 +22,6 @@ import { useAuth } from "@/contexts/AuthContext"; import { useSaved } from "@/contexts/SavedContext"; import { useRouter } from "expo-router"; import { - getPostById, getCommentsByPostId, isPostLiked, likePost, diff --git a/frontend/components/header.tsx b/frontend/components/header.tsx index 2b64891..6b87f02 100644 --- a/frontend/components/header.tsx +++ b/frontend/components/header.tsx @@ -17,29 +17,6 @@ export function MyHeader() { const bellGlow = useRef( new Animated.Value(unreadCount > 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< 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/services/api.ts b/frontend/services/api.ts index 2f6d11c..b31fe29 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -24,6 +24,7 @@ type ApiUserWire = ApiUser & { export type ApiDirectMessageThread = { peer_username: string; + peer_picture: string; last_content: string; last_at: string; }; @@ -1068,6 +1069,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}`); };