diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 6cbb1c3..ebf2a7b 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,16 +1,16 @@ -import { Slot, Tabs } from "expo-router"; +import { Tabs } from "expo-router"; import React from "react"; -import { Platform, View } from "react-native"; +import { Platform } from "react-native"; -import { HapticTab } from "@/components/HapticTab"; -import { IconSymbol } from "@/components/ui/IconSymbol"; -import TabBarBackground from "@/components/ui/TabBarBackground"; -import { Colors } from "@/constants/Colors"; -import { useColorScheme } from "@/hooks/useColorScheme"; import { useAuth } from "@/contexts/AuthContext"; import AuthScreen from "@/components/AuthScreen"; -import { SafeAreaView } from "react-native-safe-area-context"; -import TopNavbar from "@/components/TopNavBar"; +import { useColorScheme } from "@/hooks/useColorScheme"; +import { Colors } from "@/constants/Colors"; +import { HapticTab } from "@/components/HapticTab"; +import TabBarBackground from "@/components/ui/TabBarBackground"; +import { IconSymbol } from "@/components/ui/IconSymbol"; +import { ActiveUserProvider } from "@/contexts/ActiveUserContext"; +import { FriendStatusProvider } from "@/contexts/FriendStatusContext"; type IconName = React.ComponentProps["name"]; @@ -21,73 +21,48 @@ interface NavItem { } const navigations: NavItem[] = [ - { - name: "index", - title: "Home", - icon: "house.fill", - }, - // { - // name: "explore", - // title: "Explore", - // icon: "paperplane.fill", - // }, - { - name: "training", - title: "Training", - icon: "figure.walk", - }, - { - name: "multiPlayer", - title: "Multiplayer", - icon: "person.3.fill", - }, - { - name: "lockerRoom", - title: "Locker Room", - icon: "lock.fill", - }, - { - name: "shop", - title: "Shop", - icon: "cart.fill", - }, + { name: "index", title: "Home", icon: "house.fill" }, + { name: "training", title: "Training", icon: "figure.walk" }, + { name: "multiPlayer", title: "Multiplayer", icon: "person.3.fill" }, + { name: "lockerRoom", title: "Locker Room", icon: "lock.fill" }, + { name: "shop", title: "Shop", icon: "cart.fill" }, ]; export default function TabLayout() { - const { user } = useAuth(); + const { user: authUser } = useAuth(); const colorScheme = useColorScheme(); - if (!user) { - return ; - } + if (!authUser) return ; + return ( - - {navigations.map((nav) => ( - ( - - ), + + + - ))} - + > + {navigations.map(({ name, title, icon }) => ( + ( + + ), + }} + /> + ))} + + + ); } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 0ff49cd..2cd3ae2 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -5,9 +5,11 @@ import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; import FontAwesome5 from "@expo/vector-icons/FontAwesome5"; +import { useFriendStatusContext } from "@/contexts/FriendStatusContext"; export default function HomeScreen() { const { user, account, loading } = useAuth(); + const { getStatus, onlineCount } = useFriendStatusContext(); if (loading) { return ( @@ -46,7 +48,9 @@ export default function HomeScreen() { - COUNT + + {onlineCount} + diff --git a/components/FriendList.tsx b/components/FriendList.tsx index b27c8e9..acc72a1 100644 --- a/components/FriendList.tsx +++ b/components/FriendList.tsx @@ -4,6 +4,8 @@ import { supabase } from "@/lib/supabase"; import { FlatList, Image, Text, TouchableOpacity, View } from "react-native"; import AntDesign from "@expo/vector-icons/AntDesign"; import { getInitials } from "@/lib/utils"; +import { StatusDot } from "./StatusDot"; +import { FontAwesome5 } from "@expo/vector-icons"; async function getFriends(userId: number, requestedCols: string | null = null) { // 1) grab all accepted friendships where this user is either requester or addressee @@ -44,7 +46,7 @@ export default function FriendList({ loading, error, reset, - } = useFetch(() => getFriends(account.id)); + } = useFetch(() => getFriends(account.last_active_user_id)); if (loading) { return ( @@ -56,7 +58,25 @@ export default function FriendList({ } if (!friends || friends.length === 0) { - return null; + return ( + + + Close + + + + List of Friends + + + + There is no friend yet, Let make some friends 👋 + + + + ); } return ( @@ -81,18 +101,23 @@ export default function FriendList({ renderItem={({ item }) => ( - {item.profile_picture ? ( - - ) : ( - - - {getInitials(item.name)} - + + {item.profile_picture ? ( + + ) : ( + + + {getInitials(item.name)} + + + )} + + - )} + {item.name} diff --git a/components/StatusDot.tsx b/components/StatusDot.tsx new file mode 100644 index 0000000..a0b0264 --- /dev/null +++ b/components/StatusDot.tsx @@ -0,0 +1,28 @@ +// src/components/StatusDot.tsx +import React from "react"; +import { View } from "react-native"; +import { useFriendStatusContext } from "@/contexts/FriendStatusContext"; + +const COLOR_CLASSES: Record = { + online: "bg-green-500", + "recently active": "bg-yellow-400", + offline: "bg-gray-400", +}; + +interface StatusDotProps { + userId: number; + /** Tailwind size key: 'w-2 h-2', 'w-3 h-3', 'w-4 h-4', etc. */ + sizeClass?: string; +} + +export function StatusDot({ userId, sizeClass = "w-3 h-3" }: StatusDotProps) { + const { getStatus } = useFriendStatusContext(); + const status = getStatus(userId); + const colorClass = COLOR_CLASSES[status] || COLOR_CLASSES.offline; + + return ( + + ); +} diff --git a/contexts/ActiveUserContext.tsx b/contexts/ActiveUserContext.tsx new file mode 100644 index 0000000..683df78 --- /dev/null +++ b/contexts/ActiveUserContext.tsx @@ -0,0 +1,120 @@ +import React, { + createContext, + useContext, + useEffect, + useState, + ReactNode, +} from "react"; +import { supabase } from "@/lib/supabase"; +import { useAuth } from "@/contexts/AuthContext"; +import { User } from "@/interfaces/interfaces"; + +interface ActiveUserContextType { + /** The currently active DB user */ + activeUser: User | null; + /** Manually switch active user (and persist to account) */ + setActiveUser: (user: User) => Promise; +} + +const ActiveUserContext = createContext(null); + +export function useActiveUser() { + const ctx = useContext(ActiveUserContext); + if (!ctx) + throw new Error("useActiveUser must be used within ActiveUserProvider"); + return ctx; +} + +/** + * ActiveUserProvider: + * - Reads account.last_active_user_id + * - If null, picks the first user of the account and writes it back + * - Exposes that user as activeUser, plus setter to switch and persist + */ +export const ActiveUserProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const { user: authUser } = useAuth(); + const [activeUser, setActive] = useState(null); + + useEffect(() => { + if (!authUser) { + setActive(null); + return; + } + + async function loadActive() { + // 1) fetch the account row, including last_active_user_id + const { data: account, error: accErr } = await supabase + .from("accounts") + .select("id, last_active_user_id") + .eq("auth_user_id", authUser?.id) + .single(); + if (accErr || !account) { + console.error("Error loading account", accErr); + return; + } + + let userId = account.last_active_user_id; + + if (!userId) { + // 2) no active set: fetch first child user of account + const { data: users, error: uErr } = await supabase + .from("users") + .select("id") + .eq("account_id", account.id) + .order("id", { ascending: true }) + .limit(1); + if (uErr || !users || users.length === 0) { + console.error("No users found for account", uErr); + return; + } + userId = users[0].id; + // 3) persist back to account + await supabase + .from("accounts") + .update({ last_active_user_id: userId }) + .eq("id", account.id); + } + + // 4) load the active user's full record + const { data: userRec, error: uRecErr } = await supabase + .from("users") + .select("*") + .eq("id", userId) + .single(); + if (uRecErr) console.error("Error loading active user", uRecErr); + setActive(userRec ?? null); + } + + loadActive(); + }, [authUser]); + + /** + * Switch active user and persist on accounts table + */ + const setActiveUser = async (user: User) => { + const { data: account, error: accErr } = await supabase + .from("accounts") + .select("id") + .eq("auth_user_id", authUser?.id) + .single(); + if (accErr || !account) { + console.error("Error finding account for switch", accErr); + return; + } + // persist + const { error: updErr } = await supabase + .from("accounts") + .update({ last_active_user_id: user.id }) + .eq("id", account.id); + if (updErr) console.error("Error updating last_active_user_id", updErr); + setActive(user); + }; + + return ( + + {children} + + ); +}; diff --git a/contexts/FriendStatusContext.tsx b/contexts/FriendStatusContext.tsx new file mode 100644 index 0000000..27de941 --- /dev/null +++ b/contexts/FriendStatusContext.tsx @@ -0,0 +1,122 @@ +// src/contexts/FriendStatusContext.tsx +import React, { + createContext, + useContext, + useEffect, + useState, + ReactNode, +} from "react"; +import { supabase } from "@/lib/supabase"; +import { useActiveUser } from "@/contexts/ActiveUserContext"; +import { usePresenceHeartbeat } from "@/hooks/usePresenceHeartbeat"; +import type { User } from "@/interfaces/interfaces"; + +export type Status = "online" | "recently active" | "offline"; + +interface FriendStatusContextType { + getStatus: (id: number) => Status; + onlineCount: number; +} + +const FriendStatusContext = createContext(null); +export function useFriendStatusContext() { + const ctx = useContext(FriendStatusContext); + if (!ctx) + throw new Error( + "useFriendStatusContext must be used inside FriendStatusProvider" + ); + return ctx; +} + +export const FriendStatusProvider: React.FC<{ + children: ReactNode; + pollIntervalMs?: number; +}> = ({ children, pollIntervalMs = 60_000 }) => { + const { activeUser } = useActiveUser(); + const [friendUsers, setFriendUsers] = useState< + Pick[] + >([]); + const [statusMap, setStatusMap] = useState>({}); + const [onlineCount, setOnlineCount] = useState(0); + + // keep activeUser.last_active fresh + usePresenceHeartbeat(pollIntervalMs); + + useEffect(() => { + if (!activeUser) { + setFriendUsers([]); + setStatusMap({}); + setOnlineCount(0); + return; + } + + let mounted = true; + const fetchAndCompute = async () => { + // 1) load accepted friendships + const { data: fr, error: frErr } = await supabase + .from("friendships") + .select("requester_id,addressee_id") + .eq("status", "accepted") + .or( + `requester_id.eq.${activeUser.id},addressee_id.eq.${activeUser.id}` + ); + if (frErr || !mounted) return; + + const ids = fr.map((f: any) => + f.requester_id === activeUser.id ? f.addressee_id : f.requester_id + ); + if (ids.length === 0) { + setFriendUsers([]); + setStatusMap({}); + setOnlineCount(0); + return; + } + + // 2) fetch their last_active timestamps + const { data: us, error: usErr } = await supabase + .from("users") + .select("id,last_active") + .in("id", ids); + if (usErr || !mounted) return; + + setFriendUsers(us); + // 3) compute statuses + const now = Date.now(); + const onlineThreshold = 5 * 60_000; // 5 minutes + const recentThreshold = 30 * 60_000; // 30 minutes + const map: Record = {}; + us.forEach((u) => { + const diff = now - new Date(u.last_active).getTime(); + if (diff <= onlineThreshold) map[u.id] = "online"; + else if (diff <= recentThreshold) map[u.id] = "recently active"; + else map[u.id] = "offline"; + }); + + const newOnlineCount = Object.values(map).filter( + (s) => s === "online" + ).length; + + setStatusMap(map); + setOnlineCount(newOnlineCount); + }; + + // initial + polling + fetchAndCompute(); + const iv = setInterval(fetchAndCompute, pollIntervalMs); + return () => { + mounted = false; + clearInterval(iv); + }; + }, [activeUser, pollIntervalMs]); + + return ( + statusMap[id] ?? "offline", + onlineCount, + }} + > + {children} + + ); +}; diff --git a/hooks/usePresenceHeartbeat.tsx b/hooks/usePresenceHeartbeat.tsx new file mode 100644 index 0000000..c15b051 --- /dev/null +++ b/hooks/usePresenceHeartbeat.tsx @@ -0,0 +1,32 @@ +// src/hooks/usePresenceHeartbeat.ts +import { useEffect } from "react"; +import { supabase } from "@/lib/supabase"; +import { useActiveUser } from "@/contexts/ActiveUserContext"; + +export function usePresenceHeartbeat(intervalMs = 60_000) { + const { activeUser } = useActiveUser(); + + useEffect(() => { + if (!activeUser) return; + + let mounted = true; + const tick = async () => { + if (!mounted) return; + await supabase + .from("users") + .update({ last_active: new Date().toISOString() }) + .eq("id", activeUser.id); + }; + + // initial post + tick(); + // then every minute + const iv = setInterval(tick, intervalMs); + console.log(intervalMs); + console.log("refresh user state"); + return () => { + mounted = false; + clearInterval(iv); + }; + }, [activeUser, intervalMs]); +} diff --git a/interfaces/interfaces.d.ts b/interfaces/interfaces.d.ts index 0759832..d48132e 100644 --- a/interfaces/interfaces.d.ts +++ b/interfaces/interfaces.d.ts @@ -8,6 +8,7 @@ export interface Account { last_name: string; created_at: string; // ISO timestamp updated_at: string; // ISO timestamp + last_active_user_id: number; } export interface SubscriptionType { @@ -41,6 +42,8 @@ export interface User { equipped_avatar_id: number | null; equipped_profile_banner_id: number | null; created_at: string; // ISO timestamp + status_preference: "online" | "dnd"; + last_active: string; // ISO timestamp } export type FriendshipStatus = "pending" | "accepted" | "rejected" | "blocked"; diff --git a/online_status_update.sql b/online_status_update.sql new file mode 100644 index 0000000..778f3b4 --- /dev/null +++ b/online_status_update.sql @@ -0,0 +1,16 @@ +ALTER TABLE users + ADD COLUMN status_preference TEXT NOT NULL DEFAULT 'online', + ADD CONSTRAINT status_preference_check + CHECK (status_preference IN ('online','dnd')); + +ALTER TABLE users + ADD COLUMN last_active TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +ALTER TABLE accounts +ADD COLUMN last_active_user_id INTEGER; + +ALTER TABLE accounts +ADD CONSTRAINT fk_accounts_last_active_user + FOREIGN KEY (last_active_user_id) + REFERENCES users(id) + ON DELETE SET NULL; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 216a5d2..6bc2244 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1970,6 +1970,18 @@ "getenv": "^2.0.0" } }, + "node_modules/@expo/env/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@expo/fingerprint": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.13.4.tgz", @@ -2113,6 +2125,18 @@ "balanced-match": "^1.0.0" } }, + "node_modules/@expo/metro-config/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@expo/metro-config/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -6026,11 +6050,14 @@ "node": ">=0.10.0" } }, - "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, "engines": { "node": ">=12" }, @@ -6038,14 +6065,11 @@ "url": "https://dotenvx.com" } }, - "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, "engines": { "node": ">=12" }, diff --git a/tsconfig.json b/tsconfig.json index 2f35dd4..f6a4d7c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,9 +3,7 @@ "compilerOptions": { "strict": true, "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] } }, "include": [ @@ -13,6 +11,7 @@ "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", - "nativewind-env.d.ts" + "nativewind-env.d.ts", + "testing/simulate-presence.js" ] -} \ No newline at end of file +}