mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Update
This commit is contained in:
1 parent
e7379ccdb1
commit
d18f9d32b0
49 files changed
+3739
-665
No files matched your search
@@ -93,7 +93,7 @@ android {
|
||||
applicationId 'com.sauci.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionCode 2
|
||||
versionName "1.0.0"
|
||||
}
|
||||
signingConfigs {
|
||||
|
||||
@@ -5,7 +5,7 @@ buildscript {
|
||||
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
|
||||
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
|
||||
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
|
||||
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
|
||||
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '35')
|
||||
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||
|
||||
ndkVersion = "26.1.10909125"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"foregroundImage": "./assets/splash-icon.png",
|
||||
"backgroundColor": "#1a1a2e"
|
||||
},
|
||||
"package": "com.sauci.app",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Tabs, useRouter, useSegments } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { View, ActivityIndicator, StyleSheet, Platform, Modal, Text, Pressable, Animated, AppState, AppStateStatus } from "react-native";
|
||||
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState, AppStateStatus } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
@@ -17,6 +17,8 @@ import type { Database } from "../../src/types/supabase";
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
function TabBarBackground() {
|
||||
// Only use BlurView on iOS - Android experimental blur causes white overlay artifacts
|
||||
// The solid rgba background in tabBarStyle provides the visual effect on Android
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<BlurView
|
||||
@@ -26,15 +28,7 @@ function TabBarBackground() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Android fallback - use experimental blur method
|
||||
return (
|
||||
<BlurView
|
||||
intensity={80}
|
||||
tint="dark"
|
||||
style={StyleSheet.absoluteFill}
|
||||
experimentalBlurMethod="dimezisBlurView"
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PlayTabButtonProps {
|
||||
@@ -82,6 +76,7 @@ export default function AppLayout() {
|
||||
const { fetchEnabledPacks } = usePacksStore();
|
||||
const { initializeRevenueCat } = useSubscriptionStore();
|
||||
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
|
||||
const matchModalAnim = useRef(new Animated.Value(0)).current;
|
||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
@@ -384,9 +379,27 @@ export default function AppLayout() {
|
||||
};
|
||||
}, [lastMessage]);
|
||||
|
||||
// Animate match modal when notification changes
|
||||
useEffect(() => {
|
||||
if (matchNotification) {
|
||||
Animated.spring(matchModalAnim, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
tension: 80,
|
||||
friction: 10,
|
||||
}).start();
|
||||
}
|
||||
}, [matchNotification, matchModalAnim]);
|
||||
|
||||
const dismissNotification = useCallback(() => {
|
||||
setMatchNotification(null);
|
||||
}, []);
|
||||
Animated.timing(matchModalAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setMatchNotification(null);
|
||||
});
|
||||
}, [matchModalAnim]);
|
||||
|
||||
const dismissMessageToast = useCallback(() => {
|
||||
Animated.timing(messageToastAnim, {
|
||||
@@ -507,15 +520,32 @@ export default function AppLayout() {
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Match Notification Modal */}
|
||||
<Modal
|
||||
visible={!!matchNotification}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={dismissNotification}
|
||||
>
|
||||
<Pressable style={styles.modalOverlay} onPress={dismissNotification}>
|
||||
<View style={styles.modalContent}>
|
||||
{/* Match Notification Overlay - Using Animated.View instead of Modal for Android compatibility */}
|
||||
{matchNotification && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.matchOverlay,
|
||||
{
|
||||
opacity: matchModalAnim,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable style={styles.matchOverlayBackground} onPress={dismissNotification} />
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale: matchModalAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.8, 1],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.modalHeader}>
|
||||
<Ionicons name="heart" size={48} color={colors.primary} />
|
||||
<Text style={styles.modalTitle}>It's a Match!</Text>
|
||||
@@ -534,9 +564,9 @@ export default function AppLayout() {
|
||||
<Pressable style={styles.dismissButton} onPress={dismissNotification}>
|
||||
<Text style={styles.dismissButtonText}>Nice!</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Message Toast Notification */}
|
||||
{lastMessage && (
|
||||
@@ -581,19 +611,23 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
matchOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
zIndex: 9999,
|
||||
elevation: 9999,
|
||||
},
|
||||
matchOverlayBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.xl,
|
||||
padding: spacing.xl,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
width: '90%',
|
||||
maxWidth: 340,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { supabase } from "../../src/lib/supabase";
|
||||
import { usePacksStore, useAuthStore } from "../../src/store";
|
||||
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
|
||||
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
|
||||
import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler";
|
||||
import SwipeCard from "../../src/components/SwipeCard";
|
||||
import SwipeTutorial from "../../src/components/SwipeTutorial";
|
||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||
@@ -120,21 +121,11 @@ export default function SwipeScreen() {
|
||||
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
if (!session?.access_token) {
|
||||
console.error("No valid session - user may need to re-login");
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.functions.invoke("submit-response", {
|
||||
const { error } = await invokeWithAuthRetry("submit-response", {
|
||||
body: {
|
||||
question_id: question.id,
|
||||
answer,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -63,7 +63,7 @@ export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
|
||||
const handlePickScreenshot = async () => {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
allowsEditing: false,
|
||||
quality: 0.7,
|
||||
});
|
||||
|
||||
@@ -178,6 +178,16 @@ export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={handleClose}
|
||||
disabled={isSubmitting}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -375,6 +385,13 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 1,
|
||||
padding: spacing.xs,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ import Animated, {
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
withSequence,
|
||||
withDelay,
|
||||
interpolate,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
@@ -19,44 +21,43 @@ interface ShimmerEffectProps {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
|
||||
|
||||
export function ShimmerEffect({
|
||||
children,
|
||||
style,
|
||||
shimmerColor = colors.premium.gold,
|
||||
duration = 2500,
|
||||
duration = 4000,
|
||||
enabled = true,
|
||||
}: ShimmerEffectProps) {
|
||||
const shimmerTranslate = useSharedValue(-1);
|
||||
const glowIntensity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
shimmerTranslate.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
}),
|
||||
-1, // infinite repeat
|
||||
false // don't reverse
|
||||
// Gentle breathing glow - slow fade in, hold, slow fade out, pause
|
||||
glowIntensity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, {
|
||||
duration: duration * 0.4,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}),
|
||||
withDelay(
|
||||
duration * 0.2,
|
||||
withTiming(0, {
|
||||
duration: duration * 0.4,
|
||||
easing: Easing.in(Easing.cubic),
|
||||
})
|
||||
)
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
shimmerTranslate.value = -1;
|
||||
glowIntensity.value = 0;
|
||||
}
|
||||
}, [enabled, duration]);
|
||||
|
||||
const shimmerStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
shimmerTranslate.value,
|
||||
[-1, 1],
|
||||
[-200, 200]
|
||||
);
|
||||
|
||||
const glowStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{ translateX },
|
||||
{ rotate: '25deg' },
|
||||
],
|
||||
opacity: interpolate(glowIntensity.value, [0, 1], [0, 0.6]),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -64,18 +65,47 @@ export function ShimmerEffect({
|
||||
<View style={[styles.container, style]}>
|
||||
{children}
|
||||
{enabled && (
|
||||
<Animated.View style={[styles.shimmerContainer, shimmerStyle]}>
|
||||
<Animated.View style={[styles.glowContainer, glowStyle]} pointerEvents="none">
|
||||
{/* Top edge glow */}
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'transparent',
|
||||
`${shimmerColor}40`, // 25% opacity
|
||||
`${shimmerColor}60`, // 37% opacity
|
||||
`${shimmerColor}40`, // 25% opacity
|
||||
'transparent',
|
||||
]}
|
||||
colors={[`${shimmerColor}30`, 'transparent']}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
style={styles.edgeGlowTop}
|
||||
/>
|
||||
{/* Bottom edge glow */}
|
||||
<LinearGradient
|
||||
colors={['transparent', `${shimmerColor}20`]}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
style={styles.edgeGlowBottom}
|
||||
/>
|
||||
{/* Left edge glow */}
|
||||
<LinearGradient
|
||||
colors={[`${shimmerColor}25`, 'transparent']}
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
style={styles.shimmerGradient}
|
||||
style={styles.edgeGlowLeft}
|
||||
/>
|
||||
{/* Right edge glow */}
|
||||
<LinearGradient
|
||||
colors={['transparent', `${shimmerColor}25`]}
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
style={styles.edgeGlowRight}
|
||||
/>
|
||||
{/* Corner accents for refined look */}
|
||||
<LinearGradient
|
||||
colors={[`${shimmerColor}35`, 'transparent']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.cornerTopLeft}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={[`${shimmerColor}35`, 'transparent']}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={styles.cornerTopRight}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
@@ -87,17 +117,50 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
shimmerContainer: {
|
||||
position: 'absolute',
|
||||
top: -50,
|
||||
bottom: -50,
|
||||
width: 100,
|
||||
left: '50%',
|
||||
marginLeft: -50,
|
||||
glowContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
shimmerGradient: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
edgeGlowTop: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 24,
|
||||
},
|
||||
edgeGlowBottom: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 16,
|
||||
},
|
||||
edgeGlowLeft: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: 16,
|
||||
},
|
||||
edgeGlowRight: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 16,
|
||||
},
|
||||
cornerTopLeft: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
cornerTopRight: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { router } from "expo-router";
|
||||
import { supabase } from "./supabase";
|
||||
import { useAuthStore } from "../store";
|
||||
|
||||
/**
|
||||
* Handles authentication errors from edge function calls.
|
||||
*
|
||||
* When a 401 error occurs, this function:
|
||||
* 1. Attempts to refresh the session
|
||||
* 2. If refresh fails (session was deleted server-side), signs the user out and navigates to login
|
||||
*
|
||||
* @returns true if the error was handled and user should retry, false if signed out
|
||||
*/
|
||||
export async function handleAuthError(error: any): Promise<boolean> {
|
||||
// Check if this is a 401 authentication error
|
||||
const is401 = error?.status === 401 ||
|
||||
error?.message?.includes("401") ||
|
||||
error?.message?.includes("session") ||
|
||||
error?.message?.includes("unauthorized");
|
||||
|
||||
if (!is401) {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("[AuthErrorHandler] Detected auth error, attempting session refresh");
|
||||
|
||||
try {
|
||||
// Try to refresh the session
|
||||
const { data, error: refreshError } = await supabase.auth.refreshSession();
|
||||
|
||||
if (refreshError || !data.session) {
|
||||
console.log("[AuthErrorHandler] Session refresh failed, signing out:", refreshError?.message);
|
||||
// Session is invalid - sign the user out and navigate to login
|
||||
await useAuthStore.getState().signOut();
|
||||
// Explicitly navigate to login to ensure user doesn't get stuck
|
||||
router.replace("/(auth)/login");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("[AuthErrorHandler] Session refreshed successfully");
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[AuthErrorHandler] Error during refresh:", e);
|
||||
await useAuthStore.getState().signOut();
|
||||
router.replace("/(auth)/login");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for edge function invocation with automatic auth error handling.
|
||||
*
|
||||
* @param functionName - Name of the edge function to invoke
|
||||
* @param options - Options including body and headers
|
||||
* @param retryOnAuthError - Whether to retry after refreshing auth (default: true)
|
||||
* @returns The result of the function invocation
|
||||
*/
|
||||
export async function invokeWithAuthRetry(
|
||||
functionName: string,
|
||||
options: { body?: any; headers?: Record<string, string> } = {},
|
||||
retryOnAuthError = true
|
||||
): Promise<{ data: any; error: any }> {
|
||||
// Get current session
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
if (!session?.access_token) {
|
||||
console.error("[AuthErrorHandler] No valid session for function call");
|
||||
return { data: null, error: { message: "No valid session" } };
|
||||
}
|
||||
|
||||
// Make the request
|
||||
const result = await supabase.functions.invoke(functionName, {
|
||||
body: options.body,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Check for auth error
|
||||
if (result.error) {
|
||||
const is401 = result.error?.status === 401 ||
|
||||
result.error?.message?.includes("401") ||
|
||||
result.error?.context?.status === 401;
|
||||
|
||||
if (is401 && retryOnAuthError) {
|
||||
console.log("[AuthErrorHandler] Got 401, attempting refresh and retry");
|
||||
const canRetry = await handleAuthError(result.error);
|
||||
|
||||
if (canRetry) {
|
||||
// Get new session and retry
|
||||
const { data: { session: newSession } } = await supabase.auth.getSession();
|
||||
if (newSession?.access_token) {
|
||||
return supabase.functions.invoke(functionName, {
|
||||
body: options.body,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${newSession.access_token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
fetchUser: async () => {
|
||||
try {
|
||||
// First check if we have a session in storage
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
if (!session?.user) {
|
||||
@@ -41,10 +42,28 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the session by fetching the user from the server
|
||||
// This will fail if the session was deleted server-side
|
||||
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !authUser) {
|
||||
console.log("[Auth] Session invalid, signing out:", authError?.message);
|
||||
// Session is invalid - clear everything
|
||||
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
|
||||
// Clear other stores
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
// Sign out from Supabase (clears storage)
|
||||
await supabase.auth.signOut();
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.eq("id", session.user.id)
|
||||
.eq("id", authUser.id)
|
||||
.maybeSingle();
|
||||
|
||||
set({
|
||||
|
||||
Reference in new issue
Block a user