finished assignment 3 - 100%
This commit is contained in:
105
FastNotes/src/notifications/PushNotificationsProvider.tsx
Normal file
105
FastNotes/src/notifications/PushNotificationsProvider.tsx
Normal 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
|
||||
}
|
||||
134
FastNotes/src/notifications/push-notifications.ts
Normal file
134
FastNotes/src/notifications/push-notifications.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user