finished assignment 3 - 100%

This commit is contained in:
Christopher Sanden
2026-03-16 17:42:22 +01:00
parent a4c2d55aea
commit ca915ec8e8
33 changed files with 2869 additions and 711 deletions

View File

@@ -0,0 +1,105 @@
import { PropsWithChildren, useEffect } from "react"
import * as Notifications from "expo-notifications"
import { useAuthContext } from "@/hooks/use-auth-context"
import { supabase } from "@/libs/supabase"
import { registerPushNotifications } from "@/src/notifications/push-notifications"
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
})
async function loadCreatorEmail(userId: string) {
const { data, error } = await supabase
.from("profiles")
.select("email")
.eq("id", userId)
.maybeSingle()
if (error) {
console.error("Failed to load note creator email:", error.message)
return "unknown user"
}
return typeof data?.email === "string" && data.email.trim() ? data.email : "unknown user"
}
export default function PushNotificationsProvider({ children }: PropsWithChildren) {
const { claims, isLoading, isLoggedIn } = useAuthContext()
useEffect(() => {
if (isLoading || !isLoggedIn || !claims?.sub) {
return
}
let isCancelled = false
let teardownRealtimeFallback: (() => void) | undefined
const register = async () => {
const result = await registerPushNotifications(claims.sub as string)
if (isCancelled || result.status === "registered" || result.status === "denied" || result.status === "unsupported") {
return
}
if (result.status === "local-only" || result.status === "missing-project-id" || result.status === "error") {
const channel = supabase
.channel(`notes-local-notifications-${claims.sub}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "Notes",
},
async (payload) => {
const note = payload.new as { id?: string | number; title?: string; created_by?: string }
if (!note?.created_by || note.created_by === claims.sub) {
return
}
const creatorEmail = await loadCreatorEmail(note.created_by)
void Notifications.scheduleNotificationAsync({
content: {
title: "FastNotes",
body: `New note: "${note.title ?? "Untitled"}" by ${creatorEmail}`,
data: {
type: "new-note",
noteId: note.id ? String(note.id) : "",
title: note.title ?? "Untitled",
},
},
trigger: null,
})
}
)
.subscribe()
teardownRealtimeFallback = () => {
void supabase.removeChannel(channel)
}
}
if (result.status === "missing-project-id" || result.status === "error") {
console.error("Push notification setup failed:", result.message)
}
}
void register()
return () => {
isCancelled = true
teardownRealtimeFallback?.()
}
}, [claims?.sub, isLoading, isLoggedIn])
return children
}

View File

@@ -0,0 +1,134 @@
import AsyncStorage from "@react-native-async-storage/async-storage"
import Constants from "expo-constants"
import * as Notifications from "expo-notifications"
import { Platform } from "react-native"
import { supabase } from "@/libs/supabase"
const INSTALLATION_ID_STORAGE_KEY = "fastnotes.push.installation-id"
const PUSH_TOKEN_TABLE = "user_push_tokens"
type PushRegistrationResult =
| { status: "unsupported" | "denied" | "registered" | "local-only" }
| { status: "missing-project-id" | "error"; message: string }
function getEasProjectId() {
return (
Constants.easConfig?.projectId ??
Constants.expoConfig?.extra?.easProjectId ??
Constants.expoConfig?.extra?.eas?.projectId ??
null
)
}
async function getInstallationId() {
const existingInstallationId = await AsyncStorage.getItem(INSTALLATION_ID_STORAGE_KEY)
if (existingInstallationId) {
return existingInstallationId
}
const nextInstallationId = `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
await AsyncStorage.setItem(INSTALLATION_ID_STORAGE_KEY, nextInstallationId)
return nextInstallationId
}
async function ensureAndroidChannel() {
if (Platform.OS !== "android") {
return
}
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#1f6feb",
})
}
function isPhysicalDevice() {
return Constants.isDevice ?? false
}
export async function registerPushNotifications(userId: string): Promise<PushRegistrationResult> {
if (Platform.OS === "web") {
return { status: "unsupported" }
}
try {
await ensureAndroidChannel()
const existingPermissions = await Notifications.getPermissionsAsync()
let finalStatus = existingPermissions.status
if (finalStatus !== "granted") {
const requestedPermissions = await Notifications.requestPermissionsAsync()
finalStatus = requestedPermissions.status
}
if (finalStatus !== "granted") {
return { status: "denied" }
}
if (!isPhysicalDevice()) {
return { status: "local-only" }
}
const projectId = getEasProjectId()
if (!projectId) {
return {
status: "missing-project-id",
message: "Missing Expo EAS project ID. Set EXPO_PUBLIC_EAS_PROJECT_ID before building the app.",
}
}
const expoPushToken = await Notifications.getExpoPushTokenAsync({ projectId })
const installationId = await getInstallationId()
const { error } = await supabase.from(PUSH_TOKEN_TABLE).upsert(
{
installation_id: installationId,
user_id: userId,
push_token: expoPushToken.data,
platform: Platform.OS,
is_active: true,
updated_at: new Date().toISOString(),
},
{
onConflict: "installation_id",
}
)
if (error) {
return { status: "error", message: error.message }
}
return { status: "registered" }
} catch (error) {
return {
status: "error",
message: error instanceof Error ? error.message : "Push notification registration failed.",
}
}
}
export async function unregisterPushNotifications(userId?: string) {
if (Platform.OS === "web") {
return true
}
try {
const installationId = await getInstallationId()
let query = supabase.from(PUSH_TOKEN_TABLE).delete().eq("installation_id", installationId)
if (userId) {
query = query.eq("user_id", userId)
}
const { error } = await query
return !error
} catch {
return false
}
}