A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: fix toast & logout

+202 -64
+17
AGENTS.md
··· 137 137 - **Web**: Vitest with `.test.tsx` suffix 138 138 - **Mocking**: Use proper dependency injection for testability 139 139 140 + #### TanStack Query Anti-patterns 141 + 142 + **Avoid using simple string keys with generated API clients:** 143 + The API client generates complex query key objects. Using simple string arrays won't match: 144 + 145 + ```typescript 146 + // DON'T do this - wrong query key structure 147 + queryClient.setQueryData(["authControllerMe"], null); 148 + queryClient.removeQueries({ queryKey: ["authControllerMe"] }); 149 + 150 + // DO this instead - use the generated query key function 151 + import { authControllerMeQueryKey } from "@opnshelf/api"; 152 + const meQueryKey = authControllerMeQueryKey(); 153 + queryClient.setQueryData(meQueryKey, null); 154 + queryClient.removeQueries({ queryKey: meQueryKey }); 155 + ``` 156 + 140 157 ## Backend Testing 141 158 142 159 The backend uses **Jest** for testing with comprehensive test coverage for services, controllers, and guards.
+1 -1
apps/mobile/app/(tabs)/index.tsx
··· 4 4 import { SafeAreaView } from "react-native-safe-area-context"; 5 5 import { Button } from "@/components/ui/Button"; 6 6 import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 7 - import { colors, spacing, borderRadius } from "@/constants/theme"; 7 + import { colors, spacing } from "@/constants/theme"; 8 8 9 9 const features = [ 10 10 {
+2 -4
apps/mobile/app/(tabs)/search.tsx
··· 12 12 import { Check, Loader2, Plus } from "lucide-react-native"; 13 13 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 14 14 import { 15 - ActivityIndicator, 16 15 Pressable, 17 16 StyleSheet, 18 17 Text, ··· 21 20 import { SafeAreaView } from "react-native-safe-area-context"; 22 21 import { useAuth } from "@/contexts/auth"; 23 22 import { useToast } from "@/contexts/toast"; 24 - import { Badge } from "@/components/ui/Badge"; 25 23 import { SearchInput } from "@/components/ui/Input"; 26 24 import { Skeleton } from "@/components/ui/Skeleton"; 27 25 import { colors, spacing, borderRadius } from "@/constants/theme"; ··· 193 191 markMutation.mutate({ body: { movieId } }); 194 192 } 195 193 }, 196 - [user, markMutation, unmarkMutation, router, showToast] 194 + [user, markMutation, unmarkMutation, showToast] 197 195 ); 198 196 199 197 const handleMoviePress = useCallback( ··· 290 288 {data && data.results.length === 0 && debouncedQuery && ( 291 289 <View style={styles.centerContent}> 292 290 <Text style={styles.emptyText}> 293 - No results found for "{debouncedQuery}" 291 + No results found for &quot;{debouncedQuery}&quot; 294 292 </Text> 295 293 </View> 296 294 )}
+10 -3
apps/mobile/app/(tabs)/shelf.tsx
··· 13 13 import { SafeAreaView } from "react-native-safe-area-context"; 14 14 import { useAuth } from "@/contexts/auth"; 15 15 import { useToast } from "@/contexts/toast"; 16 - import { Badge } from "@/components/ui/Badge"; 17 16 import { Button } from "@/components/ui/Button"; 18 17 import { Card, CardContent, CardHeader } from "@/components/ui/Card"; 19 18 import { Skeleton } from "@/components/ui/Skeleton"; ··· 223 222 <Pressable 224 223 onPress={async () => { 225 224 await logout(); 226 - await queryClient.resetQueries({ queryKey: ["authControllerMe"] }); 227 225 showToast("Logged out successfully", "success"); 228 226 }} 229 227 style={styles.logoutButton} 230 228 > 231 229 <LogOut size={20} color={colors.textMuted} /> 230 + <Text style={styles.logoutButtonText}>Logout</Text> 232 231 </Pressable> 233 232 </View> 234 233 ··· 271 270 <BookOpen size={64} color={colors.textSecondary} style={styles.emptyIcon} /> 272 271 <Text style={styles.emptyTitle}>Your shelf is empty</Text> 273 272 <Text style={styles.emptyDescription}> 274 - Start tracking movies you've watched 273 + Start tracking movies you&apos;ve watched 275 274 </Text> 276 275 </CardHeader> 277 276 <CardContent> ··· 305 304 }, 306 305 logoutButton: { 307 306 padding: spacing.sm, 307 + flexDirection: "row", 308 + alignItems: "center", 309 + gap: spacing.sm, 310 + }, 311 + logoutButtonText: { 312 + color: colors.textMuted, 313 + fontSize: 16, 314 + fontWeight: "600", 308 315 }, 309 316 title: { 310 317 fontSize: 28,
+1 -1
apps/mobile/app/movie/[id].tsx
··· 29 29 import { useToast } from "@/contexts/toast"; 30 30 import { Badge } from "@/components/ui/Badge"; 31 31 import { Button } from "@/components/ui/Button"; 32 - import { colors, spacing, borderRadius } from "@/constants/theme"; 32 + import { colors, borderRadius } from "@/constants/theme"; 33 33 34 34 const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w500"; 35 35 const BACKDROP_BASE_URL = "https://image.tmdb.org/t/p/w1280";
+2 -2
apps/mobile/components/ui/Card.tsx
··· 1 1 import { StyleSheet, View, type ViewStyle } from "react-native"; 2 2 import { colors, borderRadius, spacing } from "@/constants/theme"; 3 3 4 + import { Text } from "react-native"; 5 + 4 6 interface CardProps { 5 7 children: React.ReactNode; 6 8 style?: ViewStyle; ··· 48 50 export function CardDescription({ children }: CardDescriptionProps) { 49 51 return <Text style={styles.description}>{children}</Text>; 50 52 } 51 - 52 - import { Text } from "react-native"; 53 53 54 54 const styles = StyleSheet.create({ 55 55 base: {
+7 -3
apps/mobile/contexts/auth.tsx
··· 1 1 import type { UserDto } from "@opnshelf/api"; 2 2 import { useQuery, useQueryClient } from "@tanstack/react-query"; 3 - import { authControllerMeOptions, getLoginUrl } from "@opnshelf/api"; 3 + import { authControllerMeOptions, authControllerMeQueryKey, getLoginUrl } from "@opnshelf/api"; 4 4 import * as WebBrowser from "expo-web-browser"; 5 5 import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; 6 6 import { loadSessionToken, saveSessionToken } from "@/lib/api"; ··· 51 51 52 52 const logout = useCallback(async () => { 53 53 await saveSessionToken(null); 54 - queryClient.removeQueries({ queryKey: ["authControllerMe"] }); 54 + // Set user to null immediately to update UI, then remove queries 55 + // Use the exact query key structure created by authControllerMeQueryKey() 56 + const meQueryKey = authControllerMeQueryKey(); 57 + queryClient.setQueryData(meQueryKey, null); 58 + queryClient.removeQueries({ queryKey: meQueryKey }); 55 59 queryClient.removeQueries({ queryKey: ["moviesControllerGetUserMovies"] }); 56 60 }, [queryClient]); 57 61 58 62 const handleAuthCallback = useCallback(async (token: string) => { 59 63 await saveSessionToken(token); 60 64 // Refetch user to update auth state 61 - await queryClient.invalidateQueries({ queryKey: ["authControllerMe"] }); 65 + await queryClient.invalidateQueries({ queryKey: authControllerMeQueryKey() }); 62 66 }, [queryClient]); 63 67 64 68 const value: AuthContextType = {
+161 -49
apps/mobile/contexts/toast.tsx
··· 1 - import { createContext, useContext, useState, useCallback, useRef, type ReactNode } from "react"; 2 - import { Animated, Text, StyleSheet } from "react-native"; 1 + import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react"; 2 + import { Animated, Text, StyleSheet, View, Dimensions, PanResponder } from "react-native"; 3 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 4 + import { Check, X, Info } from "lucide-react-native"; 3 5 import { colors, spacing, borderRadius } from "@/constants/theme"; 4 6 5 7 interface Toast { ··· 13 15 } 14 16 15 17 const ToastContext = createContext<ToastContextType | null>(null); 18 + const { width: SCREEN_WIDTH } = Dimensions.get("window"); 16 19 17 - export function ToastProvider({ children }: { children: ReactNode }) { 18 - const [toast, setToast] = useState<Toast | null>(null); 19 - const fadeAnim = useRef(new Animated.Value(0)).current; 20 + function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) { 21 + const slideAnim = useRef(new Animated.Value(100)).current; 22 + const opacityAnim = useRef(new Animated.Value(0)).current; 23 + const panAnim = useRef(new Animated.Value(0)).current; 20 24 21 - const showToast = useCallback((message: string, type: "success" | "error" | "info" = "info") => { 22 - const id = Math.random().toString(36).substring(7); 23 - setToast({ id, message, type }); 24 - 25 - Animated.sequence([ 26 - Animated.timing(fadeAnim, { 27 - toValue: 1, 28 - duration: 200, 29 - useNativeDriver: true, 30 - }), 31 - Animated.delay(3000), 32 - Animated.timing(fadeAnim, { 33 - toValue: 0, 34 - duration: 200, 35 - useNativeDriver: true, 36 - }), 37 - ]).start(() => { 38 - setToast(null); 39 - }); 40 - }, [fadeAnim]); 41 - 42 - const getBackgroundColor = (type: string) => { 25 + const getToastConfig = (type: string) => { 43 26 switch (type) { 44 27 case "success": 45 - return "#166534"; 28 + return { 29 + backgroundColor: "#ecfdf5", 30 + borderColor: "#22c55e", 31 + iconColor: "#16a34a", 32 + Icon: Check, 33 + }; 46 34 case "error": 47 - return "#991b1b"; 35 + return { 36 + backgroundColor: "#fef2f2", 37 + borderColor: "#ef4444", 38 + iconColor: "#dc2626", 39 + Icon: X, 40 + }; 48 41 default: 49 - return "#1f2937"; 42 + return { 43 + backgroundColor: "#f9fafb", 44 + borderColor: "#6b7280", 45 + iconColor: "#4b5563", 46 + Icon: Info, 47 + }; 50 48 } 51 49 }; 52 50 51 + const config = getToastConfig(toast.type); 52 + const IconComponent = config.Icon; 53 + 54 + const panResponder = useRef( 55 + PanResponder.create({ 56 + onStartShouldSetPanResponder: () => true, 57 + onPanResponderMove: (_, gestureState) => { 58 + if (gestureState.dy > 0) { 59 + panAnim.setValue(gestureState.dy); 60 + } 61 + }, 62 + onPanResponderRelease: (_, gestureState) => { 63 + if (gestureState.dy > 50) { 64 + Animated.timing(panAnim, { 65 + toValue: 200, 66 + duration: 150, 67 + useNativeDriver: true, 68 + }).start(onDismiss); 69 + } else { 70 + Animated.spring(panAnim, { 71 + toValue: 0, 72 + useNativeDriver: true, 73 + friction: 8, 74 + }).start(); 75 + } 76 + }, 77 + }) 78 + ).current; 79 + 80 + // Animate in on mount 81 + useEffect(() => { 82 + Animated.parallel([ 83 + Animated.timing(slideAnim, { 84 + toValue: 0, 85 + duration: 300, 86 + useNativeDriver: true, 87 + }), 88 + Animated.timing(opacityAnim, { 89 + toValue: 1, 90 + duration: 300, 91 + useNativeDriver: true, 92 + }), 93 + ]).start(); 94 + }, []); 95 + 96 + // Auto dismiss after 4 seconds 97 + useEffect(() => { 98 + const timer = setTimeout(() => { 99 + Animated.parallel([ 100 + Animated.timing(slideAnim, { 101 + toValue: 100, 102 + duration: 200, 103 + useNativeDriver: true, 104 + }), 105 + Animated.timing(opacityAnim, { 106 + toValue: 0, 107 + duration: 200, 108 + useNativeDriver: true, 109 + }), 110 + ]).start(onDismiss); 111 + }, 4000); 112 + return () => clearTimeout(timer); 113 + }, [onDismiss, slideAnim, opacityAnim]); 114 + 115 + return ( 116 + <Animated.View 117 + {...panResponder.panHandlers} 118 + style={[ 119 + styles.toastItem, 120 + { 121 + backgroundColor: config.backgroundColor, 122 + borderColor: config.borderColor, 123 + transform: [ 124 + { translateY: slideAnim }, 125 + { translateY: panAnim }, 126 + ], 127 + opacity: opacityAnim, 128 + }, 129 + ]} 130 + > 131 + <IconComponent size={20} color={config.iconColor} /> 132 + <Text style={[styles.toastText, { color: colors.background }]}> 133 + {toast.message} 134 + </Text> 135 + </Animated.View> 136 + ); 137 + } 138 + 139 + export function ToastProvider({ children }: { children: ReactNode }) { 140 + const [toasts, setToasts] = useState<Toast[]>([]); 141 + const insets = useSafeAreaInsets(); 142 + 143 + const showToast = useCallback((message: string, type: "success" | "error" | "info" = "info") => { 144 + const id = Math.random().toString(36).substring(7); 145 + setToasts((prev) => [...prev, { id, message, type }]); 146 + }, []); 147 + 148 + const removeToast = useCallback((id: string) => { 149 + setToasts((prev) => prev.filter((toast) => toast.id !== id)); 150 + }, []); 151 + 53 152 return ( 54 153 <ToastContext.Provider value={{ showToast }}> 55 154 {children} 56 - {toast && ( 57 - <Animated.View 58 - style={[ 59 - styles.toastContainer, 60 - { backgroundColor: getBackgroundColor(toast.type) }, 61 - { opacity: fadeAnim }, 62 - ]} 63 - > 64 - <Text style={styles.toastText}>{toast.message}</Text> 65 - </Animated.View> 66 - )} 155 + <View style={[styles.toastContainer, { bottom: insets.bottom }]} pointerEvents="box-none"> 156 + {toasts.map((toast) => ( 157 + <ToastItem 158 + key={toast.id} 159 + toast={toast} 160 + onDismiss={() => removeToast(toast.id)} 161 + /> 162 + ))} 163 + </View> 67 164 </ToastContext.Provider> 68 165 ); 69 166 } ··· 79 176 const styles = StyleSheet.create({ 80 177 toastContainer: { 81 178 position: "absolute", 82 - top: 60, 83 - left: 16, 84 - right: 16, 85 - paddingHorizontal: spacing.md, 86 - paddingVertical: spacing.sm, 87 - borderRadius: borderRadius.lg, 179 + bottom: 0, 180 + left: 0, 181 + right: 0, 182 + alignItems: "center", 88 183 zIndex: 9999, 184 + pointerEvents: "box-none", 185 + }, 186 + toastItem: { 187 + flexDirection: "row", 188 + alignItems: "center", 189 + gap: spacing.sm, 190 + paddingHorizontal: spacing.md, 191 + paddingVertical: spacing.md, 192 + marginBottom: spacing.sm, 193 + borderRadius: borderRadius.xl, 194 + borderWidth: 1, 195 + minWidth: SCREEN_WIDTH * 0.8, 196 + maxWidth: SCREEN_WIDTH - 32, 197 + shadowColor: "#000", 198 + shadowOffset: { width: 0, height: 4 }, 199 + shadowOpacity: 0.1, 200 + shadowRadius: 12, 201 + elevation: 5, 89 202 }, 90 203 toastText: { 91 - color: colors.text, 92 204 fontSize: 14, 93 205 fontWeight: "500", 94 - textAlign: "center", 206 + flex: 1, 95 207 }, 96 208 });
+1 -1
packages/api/src/generated/core/queryKeySerializer.gen.ts
··· 57 57 * Turns URLSearchParams into a sorted JSON object for deterministic keys. 58 58 */ 59 59 const serializeSearchParams = (params: URLSearchParams): JsonValue => { 60 - const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); 60 + const entries = Array.from(params as Iterable<[string, string]>).sort((a, b) => a[0].localeCompare(b[0])); 61 61 const result: Record<string, JsonValue> = {}; 62 62 63 63 for (const [key, value] of entries) {