Merge pull request from nativeFuncs

Native funcs
This commit is contained in:
Chris Sanden
2026-03-17 15:42:57 +01:00
committed by GitHub
31 changed files with 2869 additions and 710 deletions

16
FastNotes/.gitignore vendored
View File

@@ -30,14 +30,20 @@ yarn-error.*
.DS_Store .DS_Store
*.pem *.pem
# local env files
.env*.local
.env.local
.env
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
# generated native folders # generated native folders
/ios /ios
/android /android
# Supabase
.branches
.temp
config.toml
# dotenvx
.env.keys
.env.local
.env.*.local
.env

View File

@@ -6,5 +6,6 @@ export default ({ config }) => ({
...config.extra, ...config.extra,
supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL, supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL,
supabaseKey: process.env.EXPO_PUBLIC_SUPABASE_KEY, supabaseKey: process.env.EXPO_PUBLIC_SUPABASE_KEY,
easProjectId: process.env.EXPO_PUBLIC_EAS_PROJECT_ID,
}, },
}); });

View File

@@ -9,15 +9,20 @@
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"bundleIdentifier": "com.fastnotes.app"
}, },
"android": { "android": {
"package": "com.fastnotes.app",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png", "foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png", "backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png" "monochromeImage": "./assets/images/android-icon-monochrome.png"
}, },
"permissions": [
"POST_NOTIFICATIONS"
],
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false
}, },
@@ -27,6 +32,14 @@
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
[
"expo-image-picker",
{
"photosPermission": "Allow FastNotes to access your photo library so you can attach images to notes.",
"cameraPermission": "Allow FastNotes to use your camera so you can take photos for notes."
}
],
"expo-notifications",
[ [
"expo-splash-screen", "expo-splash-screen",
{ {

View File

@@ -3,8 +3,11 @@ import AuthProvider from '@/providers/auth-provider'
import { NotesProvider } from "@/src/notes/NotesContext" import { NotesProvider } from "@/src/notes/NotesContext"
import { AppThemeProvider, useAppTheme } from '@/src/theme/AppThemeProvider' import { AppThemeProvider, useAppTheme } from '@/src/theme/AppThemeProvider'
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native' import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
import Constants from "expo-constants"
import { Stack } from 'expo-router' import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar' import { StatusBar } from 'expo-status-bar'
import { ComponentType, PropsWithChildren, useEffect, useState } from "react"
import { Platform } from "react-native"
import 'react-native-reanimated' import 'react-native-reanimated'
import { SafeAreaProvider } from 'react-native-safe-area-context' import { SafeAreaProvider } from 'react-native-safe-area-context'
@@ -71,9 +74,11 @@ function ThemedRootLayout() {
<SafeAreaProvider> <SafeAreaProvider>
<ThemeProvider value={navigationTheme}> <ThemeProvider value={navigationTheme}>
<AuthProvider> <AuthProvider>
<NotesProvider> <NotificationProviderGate>
<RootNavigator /> <NotesProvider>
</NotesProvider> <RootNavigator />
</NotesProvider>
</NotificationProviderGate>
</AuthProvider> </AuthProvider>
<StatusBar <StatusBar
style={colorScheme === "dark" ? "light" : "dark"} style={colorScheme === "dark" ? "light" : "dark"}
@@ -84,6 +89,44 @@ function ThemedRootLayout() {
) )
} }
function NotificationProviderGate({ children }: PropsWithChildren) {
const isAndroidExpoGo = Platform.OS === "android" && Constants.executionEnvironment === "storeClient"
const [provider, setProvider] = useState<ComponentType<PropsWithChildren> | null>(null)
useEffect(() => {
if (isAndroidExpoGo) {
return
}
let isMounted = true
const loadProvider = async () => {
try {
const module = await import("@/src/notifications/PushNotificationsProvider")
if (isMounted) {
setProvider(() => module.default)
}
} catch (error) {
console.error("Failed to load push notifications provider:", error)
}
}
void loadProvider()
return () => {
isMounted = false
}
}, [isAndroidExpoGo])
if (isAndroidExpoGo || !provider) {
return children
}
const PushNotificationsProvider = provider
return <PushNotificationsProvider>{children}</PushNotificationsProvider>
}
export default function RootLayout() { export default function RootLayout() {
return ( return (
<AppThemeProvider> <AppThemeProvider>

View File

@@ -1,18 +1,30 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native" import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native"
import { router, useLocalSearchParams } from "expo-router" import { router, useLocalSearchParams } from "expo-router"
import { BlurView } from "expo-blur"
import { useHeaderHeight } from "@react-navigation/elements" import { useHeaderHeight } from "@react-navigation/elements"
import { useSafeAreaInsets } from "react-native-safe-area-context" import { useSafeAreaInsets } from "react-native-safe-area-context"
import NoteImagePanel from "@/components/note-image-panel"
import UploadProgressBar from "@/components/upload-progress-bar"
import { useAuthContext } from "@/hooks/use-auth-context"
import { NoteImageChange, useNotes } from "@/src/notes/NotesContext"
import { StagedNoteImage, validateStagedNoteImage } from "@/src/notes/image-utils"
import { pickImageFromCamera, pickImageFromLibrary } from "@/src/notes/native-image-picker"
import { detailScreenStyles as styles } from "@/src/styles/app-styles"
import { useAppTheme } from "@/src/theme/AppThemeProvider" import { useAppTheme } from "@/src/theme/AppThemeProvider"
import { useAuthContext } from "@/hooks/use-auth-context" export default function DetailScreen() {
import { useNotes } from "@/src/notes/NotesContext" const { id } = useLocalSearchParams<{
export default function DetailScreen()
{
const { id } = useLocalSearchParams<
{
id?: string id?: string
}>() }>()
const { claims } = useAuthContext() const { claims } = useAuthContext()
@@ -21,8 +33,11 @@ export default function DetailScreen()
const canEdit = note?.createdBy === claims?.sub const canEdit = note?.createdBy === claims?.sub
const [title, setTitle] = useState(note?.title ?? "") const [title, setTitle] = useState(note?.title ?? "")
const [content, setContent] = useState(note?.content ?? "") const [content, setContent] = useState(note?.content ?? "")
const [stagedImage, setStagedImage] = useState<StagedNoteImage | null>(null)
const [imageChange, setImageChange] = useState<NoteImageChange>({ type: "keep" })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null) const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
const [statusMessage, setStatusMessage] = useState<string | null>(null) const [statusMessage, setStatusMessage] = useState<string | null>(null)
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
@@ -42,7 +57,48 @@ export default function DetailScreen()
useEffect(() => { useEffect(() => {
setTitle(note?.title ?? "") setTitle(note?.title ?? "")
setContent(note?.content ?? "") setContent(note?.content ?? "")
}, [note?.content, note?.title]) setStagedImage(null)
setImageChange({ type: "keep" })
}, [note?.content, note?.id, note?.title])
const attachFromCamera = async () => {
try {
const image = await pickImageFromCamera()
if (image) {
validateStagedNoteImage(image)
setStagedImage(image)
setImageChange({ type: "replace", image })
setLocalErrorMessage(null)
setStatusMessage(null)
}
} catch (error) {
setLocalErrorMessage(error instanceof Error ? error.message : "The camera could not be opened.")
}
}
const attachFromGallery = async () => {
try {
const image = await pickImageFromLibrary()
if (image) {
validateStagedNoteImage(image)
setStagedImage(image)
setImageChange({ type: "replace", image })
setLocalErrorMessage(null)
setStatusMessage(null)
}
} catch (error) {
setLocalErrorMessage(error instanceof Error ? error.message : "The gallery could not be opened.")
}
}
const clearImage = () => {
setStagedImage(null)
setStatusMessage(null)
setLocalErrorMessage(null)
setImageChange(note?.imageUrl ? { type: "remove" } : { type: "keep" })
}
const onSave = async () => { const onSave = async () => {
if (!id) { if (!id) {
@@ -56,20 +112,27 @@ export default function DetailScreen()
} }
setIsSaving(true) setIsSaving(true)
setUploadProgress(null)
setLocalErrorMessage(null) setLocalErrorMessage(null)
setStatusMessage(null) setStatusMessage(null)
const wasSaved = await updateNote(id, title, content) const wasSaved = await updateNote(id, title, content, imageChange, {
onImageUploadProgress: (progress) => {
setUploadProgress(progress.progress)
},
})
setIsSaving(false) setIsSaving(false)
setUploadProgress(null)
if (wasSaved) { if (wasSaved) {
setStagedImage(null)
setImageChange({ type: "keep" })
setStatusMessage("Note updated.") setStatusMessage("Note updated.")
} }
} }
const confirmDelete = () => { const confirmDelete = () => {
// Require explicit confirmation before deleting the note.
Alert.alert( Alert.alert(
"Delete note", "Delete note",
"Are you sure you want to delete this note?", "Are you sure you want to delete this note?",
@@ -117,7 +180,17 @@ export default function DetailScreen()
) )
} }
return( const currentImageUrl = imageChange.type === "remove" ? null : note.imageUrl
const currentImageMimeType = imageChange.type === "remove" ? null : note.imageMimeType
const currentImageSizeBytes = imageChange.type === "remove" ? null : note.imageSizeBytes
const isUploading = uploadProgress !== null
const imageActionsDisabled = isSaving || isUploading
const saveDisabled = isSaving
const deleteDisabled = isDeleting || isUploading
const primaryButtonStyle = saveDisabled ? styles.disabledButton : styles.enabledButtonShadow
const deleteButtonStyle = deleteDisabled ? styles.disabledButton : styles.enabledButtonShadow
return (
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"} behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={headerHeight} keyboardVerticalOffset={headerHeight}
@@ -131,7 +204,10 @@ export default function DetailScreen()
<TextInput <TextInput
editable={canEdit} editable={canEdit}
onChangeText={setTitle} onChangeText={setTitle}
style={[styles.titleInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]} style={[
styles.titleInput,
{ color: palette.text, borderColor: palette.border, backgroundColor: palette.input },
]}
value={title} value={title}
placeholderTextColor={palette.mutedText} placeholderTextColor={palette.mutedText}
/> />
@@ -143,11 +219,40 @@ export default function DetailScreen()
editable={canEdit} editable={canEdit}
multiline multiline
onChangeText={setContent} onChangeText={setContent}
style={[styles.contentInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]} style={[
styles.contentInput,
{ color: palette.text, borderColor: palette.border, backgroundColor: palette.input },
]}
textAlignVertical="top" textAlignVertical="top"
value={content} value={content}
placeholderTextColor={palette.mutedText} placeholderTextColor={palette.mutedText}
/> />
<NoteImagePanel
canEdit={Boolean(canEdit)}
isBusy={imageActionsDisabled}
currentImageUrl={currentImageUrl}
currentImageMimeType={currentImageMimeType}
currentImageSizeBytes={currentImageSizeBytes}
stagedImage={stagedImage}
helperText={
canEdit
? "Replacing or removing the image only takes effect after you save changes."
: "This image is attached to the note and stored in Supabase."
}
palette={palette}
primaryTextColor={colorScheme === "dark" ? "#000" : "#fff"}
onTakePhoto={() => {
void attachFromCamera()
}}
onChooseFromLibrary={() => {
void attachFromGallery()
}}
onRemoveImage={clearImage}
/>
{uploadProgress !== null ? <UploadProgressBar progress={uploadProgress} palette={palette} /> : null}
{!canEdit ? ( {!canEdit ? (
<Text style={[styles.readOnlyText, { color: palette.mutedText }]}> <Text style={[styles.readOnlyText, { color: palette.mutedText }]}>
Only the creator of this note can update or delete it. Only the creator of this note can update or delete it.
@@ -158,95 +263,37 @@ export default function DetailScreen()
{statusMessage ? <Text style={styles.successText}>{statusMessage}</Text> : null} {statusMessage ? <Text style={styles.successText}>{statusMessage}</Text> : null}
</ScrollView> </ScrollView>
{canEdit ? ( {canEdit ? (
<View style={[styles.actions, { bottom: insets.bottom + 16 }]}> <View style={[styles.actions, { paddingBottom: insets.bottom + 16 }]}>
<Pressable disabled={isSaving} onPress={onSave} style={[styles.primaryButton, { backgroundColor: palette.accent }]}> <BlurView
<Text style={[styles.primaryButtonText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}> intensity={22}
{isSaving ? "Saving..." : "Save changes"} tint={colorScheme}
</Text> style={styles.actionsBlur}
</Pressable> />
<Pressable disabled={isDeleting} onPress={confirmDelete} style={[styles.deleteButton, { backgroundColor: palette.destructive }]}> <View style={[styles.actionsContent, { borderColor: palette.border }]}>
<Text style={styles.deleteButtonText}> <Pressable
{isDeleting ? "Deleting..." : "Delete note"} disabled={saveDisabled}
</Text> onPress={() => {
</Pressable> void onSave()
}}
style={[styles.primaryButton, primaryButtonStyle, { backgroundColor: palette.accent }]}
>
<Text style={[styles.primaryButtonText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>
{isSaving ? "Saving..." : "Save changes"}
</Text>
</Pressable>
<Pressable
disabled={deleteDisabled}
onPress={confirmDelete}
style={[styles.deleteButton, deleteButtonStyle, { backgroundColor: palette.destructive }]}
>
<Text style={styles.deleteButtonText}>
{isDeleting ? "Deleting..." : "Delete note"}
</Text>
</Pressable>
</View>
</View> </View>
) : null} ) : null}
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
) )
} }
const styles = StyleSheet.create(
{
keyboardAvoider: { flex: 1 },
container: { flex: 1 },
formContent: { padding: 16, gap: 12 },
title: { fontSize: 22, fontWeight:"700" },
content: { fontSize: 16 },
titleInput: {
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 22,
fontWeight: "700",
},
signature: {
fontSize: 12,
color: "#666",
},
contentInput: {
minHeight: 200,
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 16,
},
errorText: {
color: "#c62828",
},
successText: {
color: "#2e7d32",
},
readOnlyText: {
color: "#666",
},
actions: {
position: "absolute",
left: 16,
right: 16,
flexDirection: "row",
gap: 12,
},
primaryButton: {
flex: 1,
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
},
primaryButtonText: {
fontSize: 16,
fontWeight: "700",
},
deleteButton: {
flex: 1,
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
},
deleteButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "700",
},
})

View File

@@ -1,11 +1,13 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { Appearance, FlatList, Pressable, StyleSheet, Text, View } from "react-native" import { FlatList, Pressable, Text, View } from "react-native"
import { router } from "expo-router" import { router } from "expo-router"
import { useSafeAreaInsets } from "react-native-safe-area-context" import { useSafeAreaInsets } from "react-native-safe-area-context"
import { Image } from "expo-image"
import { useAuthContext } from "@/hooks/use-auth-context" import { useAuthContext } from "@/hooks/use-auth-context"
import { useNotes } from "@/src/notes/NotesContext" import { useNotes } from "@/src/notes/NotesContext"
import SignOutButton from '@/components/social-auth-buttons/sign-out-button' import SignOutButton from '@/components/social-auth-buttons/sign-out-button'
import { useAppTheme } from "@/src/theme/AppThemeProvider" import { useAppTheme } from "@/src/theme/AppThemeProvider"
import { homeScreenStyles as styles } from "@/src/styles/app-styles"
type TabKey = "my-notes" | "work-notes" type TabKey = "my-notes" | "work-notes"
@@ -109,12 +111,23 @@ export default function HomeScreen()
}) })
} }
> >
<Text style={[styles.noteTitle, { color: palette.text }]}>{item.title}</Text> <View style={styles.noteCardRow}>
<Text numberOfLines={2} style={[styles.notePreview, { color: palette.mutedText }]}>{item.content}</Text> <View style={styles.noteBody}>
<Text style={[styles.noteMeta, { color: palette.mutedText }]}>Created by {item.creatorLabel}</Text> <Text style={[styles.noteTitle, { color: palette.text }]}>{item.title}</Text>
<Text style={[styles.noteMeta, { color: palette.mutedText }]}> <Text numberOfLines={2} style={[styles.notePreview, { color: palette.mutedText }]}>
Last changed {formatTimestamp(item.lastChangedAt)} {item.content}
</Text> </Text>
<Text style={[styles.noteMeta, { color: palette.mutedText }]}>Created by {item.creatorLabel}</Text>
<Text style={[styles.noteMeta, { color: palette.mutedText }]}>
Last changed {formatTimestamp(item.lastChangedAt)}
</Text>
</View>
{item.imageUrl ? (
<View style={styles.noteThumbnailFrame}>
<Image source={{ uri: item.imageUrl }} style={styles.noteThumbnail} contentFit="contain" />
</View>
) : null}
</View>
</Pressable> </Pressable>
)} )}
/> />
@@ -130,70 +143,3 @@ export default function HomeScreen()
</View> </View>
) )
} }
const styles = StyleSheet.create({
container: { flex: 1 },
topBar: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingBottom: 8,
borderBottomWidth: 1,
},
screenTitle: {
fontSize: 24,
fontWeight: "700",
},
tabBar: {
flexDirection: "row",
gap: 8,
paddingHorizontal: 16,
paddingBottom: 8,
paddingTop: 12
},
tabButton: {
flex: 1,
borderWidth: 1,
borderRadius: 999,
paddingVertical: 10,
alignItems: "center",
},
tabButtonActive: {
backgroundColor: Appearance.getColorScheme() === "light" ? "#000000":"#8a8888",
},
tabButtonText: {
fontSize: 14,
fontWeight: "600",
},
tabButtonTextActive: {
color: "#fff",
},
list: { padding: 16, gap: 12, paddingTop: 8 },
noteItem: { padding: 16, borderWidth: 1, borderRadius:12, gap: 8 },
noteTitle: { fontSize: 16, fontWeight: "600" },
notePreview: { fontSize: 14 },
noteMeta: { fontSize: 12 },
emptyText: {
textAlign: "center",
paddingVertical: 32,
color: "#666",
},
errorText: {
color: "#c62828",
paddingHorizontal: 16,
paddingBottom: 8,
},
fab:
{
position: "absolute",
width: 56, height: 56, borderRadius: 28,
alignItems: "center", justifyContent: "center",
shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
},
fabText: { fontSize: 28, lineHeight: 28, fontWeight: "700"}
})

View File

@@ -1,8 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, Stack } from 'expo-router' import { Link, Stack } from 'expo-router'
import { Appearance, Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { Pressable, Text, TextInput, View } from 'react-native'
import { supabase } from '@/libs/supabase' import { supabase } from '@/libs/supabase'
import { useAppTheme } from '@/src/theme/AppThemeProvider' import { useAppTheme } from '@/src/theme/AppThemeProvider'
import { loginScreenStyles as styles } from '@/src/styles/app-styles'
export default function LoginScreen() { export default function LoginScreen() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -87,56 +88,3 @@ export default function LoginScreen() {
</> </>
) )
} }
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 24,
gap: 12,
},
title: {
fontSize: 28,
fontWeight: '700',
},
label: {
fontSize: 14,
fontWeight: '600',
},
input: {
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 16,
},
errorText: {
color: '#c62828',
fontSize: 14,
},
loginButton: {
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
backgroundColor: Appearance.getColorScheme() === "light" ? '#000000':'#696969',
marginTop: 8,
},
loginButtonPressed: {
opacity: 0.85,
},
loginButtonDisabled: {
opacity: 0.6,
},
loginButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '700',
},
link: {
alignSelf: 'center',
marginTop: 8,
},
linkText: {
color: '#0b57d0',
fontSize: 16,
},
})

View File

@@ -1,23 +1,33 @@
import import { useRef, useState } from "react"
{ import {
StyleSheet, Text, View, KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Pressable, TextInput, ScrollView Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native" } from "react-native"
import { router, } from "expo-router" import { router } from "expo-router"
import { useNotes } from "@/src/notes/NotesContext" import { BlurView } from "expo-blur"
import { useState, useRef } from "react"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useHeaderHeight } from "@react-navigation/elements" import { useHeaderHeight } from "@react-navigation/elements"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import NoteImagePanel from "@/components/note-image-panel"
import UploadProgressBar from "@/components/upload-progress-bar"
import { useNotes } from "@/src/notes/NotesContext"
import { StagedNoteImage, validateStagedNoteImage } from "@/src/notes/image-utils"
import { newNoteScreenStyles as styles } from "@/src/styles/app-styles"
import { pickImageFromCamera, pickImageFromLibrary } from "@/src/notes/native-image-picker"
import { useAppTheme } from "@/src/theme/AppThemeProvider" import { useAppTheme } from "@/src/theme/AppThemeProvider"
export default function NewNoteScreen() {
export default function NewNoteScreen()
{
const { addNote, errorMessage } = useNotes() const { addNote, errorMessage } = useNotes()
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [content, setContent] = useState("") const [content, setContent] = useState("")
const [stagedImage, setStagedImage] = useState<StagedNoteImage | null>(null)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null) const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const headerHeight = useHeaderHeight() const headerHeight = useHeaderHeight()
@@ -25,114 +35,163 @@ export default function NewNoteScreen()
const [contentHeight, setContentHeight] = useState(160) const [contentHeight, setContentHeight] = useState(160)
const scrollRef = useRef<ScrollView>(null) const scrollRef = useRef<ScrollView>(null)
const onSave = async () => const attachFromCamera = async () => {
{ try {
if(!title.trim() || !content.trim()) { const image = await pickImageFromCamera()
if (image) {
validateStagedNoteImage(image)
setStagedImage(image)
setLocalErrorMessage(null)
}
} catch (error) {
setLocalErrorMessage(error instanceof Error ? error.message : "The camera could not be opened.")
}
}
const attachFromGallery = async () => {
try {
const image = await pickImageFromLibrary()
if (image) {
validateStagedNoteImage(image)
setStagedImage(image)
setLocalErrorMessage(null)
}
} catch (error) {
setLocalErrorMessage(error instanceof Error ? error.message : "The gallery could not be opened.")
}
}
const onSave = async () => {
if (!title.trim() || !content.trim()) {
setLocalErrorMessage("Title and content are required.") setLocalErrorMessage("Title and content are required.")
return return
} }
setIsSaving(true) setIsSaving(true)
setUploadProgress(null)
setLocalErrorMessage(null) setLocalErrorMessage(null)
const wasSaved = await addNote(title, content) const wasSaved = await addNote(title, content, stagedImage, {
onImageUploadProgress: (progress) => {
setUploadProgress(progress.progress)
},
})
setIsSaving(false) setIsSaving(false)
setUploadProgress(null)
if (wasSaved) { if (wasSaved) {
router.back() if (router.canGoBack()) {
router.back()
return
}
router.replace("/index")
} }
} }
const saveDisabled = isSaving
const imageActionsDisabled = isSaving
const saveButtonStyle = saveDisabled ? styles.disabledButton : styles.enabledButtonShadow
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoider} style={styles.keyboardAvoider}
behavior={Platform.OS === "ios" ? "padding" : "height"} behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={headerHeight}> keyboardVerticalOffset={headerHeight}
<View style={[styles.container, { backgroundColor: palette.background }]}> >
<ScrollView ref={scrollRef} contentContainerStyle={[styles.formContent, { paddingBottom: insets.bottom + 112 }]} <View style={[styles.container, { backgroundColor: palette.background }]}>
keyboardShouldPersistTaps="handled"> <ScrollView
<TextInput value={title} onChangeText={setTitle} ref={scrollRef}
placeholder="Give it a title..." style={[styles.titleInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]} contentContainerStyle={[styles.formContent, { paddingBottom: insets.bottom + 112 }]}
placeholderTextColor={palette.mutedText} keyboardShouldPersistTaps="handled"
returnKeyType="next"/> >
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Give it a title..."
style={[
styles.titleInput,
{ color: palette.text, borderColor: palette.border, backgroundColor: palette.input },
]}
placeholderTextColor={palette.mutedText}
returnKeyType="next"
/>
<TextInput value={content} onChangeText={setContent} placeholder="Write your note..." <TextInput
style={[styles.contentInput, { minHeight: Math.max(200, contentHeight), color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]} multiline value={content}
placeholderTextColor={palette.mutedText} onChangeText={setContent}
textAlignVertical="top" placeholder="Write your note..."
onContentSizeChange={(e) => {setContentHeight(e.nativeEvent.contentSize.height) style={[
scrollRef.current?.scrollToEnd({ animated: true }) styles.contentInput,
}}/> {
minHeight: Math.max(200, contentHeight),
color: palette.text,
borderColor: palette.border,
backgroundColor: palette.input,
},
]}
multiline
placeholderTextColor={palette.mutedText}
textAlignVertical="top"
onContentSizeChange={(e) => {
setContentHeight(e.nativeEvent.contentSize.height)
scrollRef.current?.scrollToEnd({ animated: true })
}}
/>
{localErrorMessage ? ( <NoteImagePanel
<Text style={styles.errorText}>{localErrorMessage}</Text> canEdit
) : null} isBusy={imageActionsDisabled}
stagedImage={stagedImage}
helperText="Images upload when you save the note. Allowed formats: PNG, JPG, WEBP. Max 15 MB after compression."
palette={palette}
primaryTextColor={colorScheme === "dark" ? "#000" : "#fff"}
onTakePhoto={() => {
void attachFromCamera()
}}
onChooseFromLibrary={() => {
void attachFromGallery()
}}
onRemoveImage={() => {
setStagedImage(null)
setLocalErrorMessage(null)
}}
/>
{!localErrorMessage && errorMessage ? ( {uploadProgress !== null ? <UploadProgressBar progress={uploadProgress} palette={palette} /> : null}
<Text style={styles.errorText}>{errorMessage}</Text>
) : null}
</ScrollView> {localErrorMessage ? (
<View style={[styles.actions, { bottom: insets.bottom + 16 }]}> <Text style={styles.errorText}>{localErrorMessage}</Text>
<Pressable disabled={isSaving} onPress={onSave} ) : null}
style={[styles.saveButton, { backgroundColor: palette.accent }]}>
<Text style={[styles.saveFloatingText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}> {!localErrorMessage && errorMessage ? (
{isSaving ? "Saving..." : "Save note"} <Text style={styles.errorText}>{errorMessage}</Text>
</Text> ) : null}
</Pressable> </ScrollView>
<View style={[styles.actions, { paddingBottom: insets.bottom + 16 }]}>
<BlurView
intensity={22}
tint={colorScheme}
style={styles.actionsBlur}
/>
<View style={[styles.actionsContent, { borderColor: palette.border }]}>
<Pressable
disabled={saveDisabled}
onPress={() => {
void onSave()
}}
style={[styles.saveButton, saveButtonStyle, { backgroundColor: palette.accent }]}
>
<Text style={[styles.saveFloatingText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>
{isSaving ? "Saving..." : "Save note"}
</Text>
</Pressable>
</View>
</View>
</View> </View>
</View> </KeyboardAvoidingView>
</KeyboardAvoidingView>
) )
} }
const styles = StyleSheet.create(
{
keyboardAvoider: { flex: 1 },
container: { flex: 1 },
formContent: { padding: 16, gap: 12 },
titleInput:
{
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 22,
fontWeight: "700",
},
contentInput:
{
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 16,
},
actions: {
position: "absolute",
left: 16,
right: 16,
flexDirection: "row",
gap: 12,
},
saveButton:
{
flex: 1,
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
},
saveFloatingText:
{
fontSize: 16,
fontWeight: "700"
},
errorText: {
color: "#c62828"
},
}
)

View File

@@ -1,8 +1,9 @@
import { supabase } from "@/libs/supabase" import { supabase } from "@/libs/supabase"
import { Link, Stack } from "expo-router" import { Link, Stack } from "expo-router"
import { useState } from "react" import { useState } from "react"
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { Pressable, Text, TextInput, View } from 'react-native'
import { useAppTheme } from "@/src/theme/AppThemeProvider" import { useAppTheme } from "@/src/theme/AppThemeProvider"
import { signupScreenStyles as styles } from "@/src/styles/app-styles"
export default function SignupScreen(){ export default function SignupScreen(){
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -115,61 +116,3 @@ export default function SignupScreen(){
</> </>
) )
} }
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 24,
gap: 12,
},
title: {
fontSize: 28,
fontWeight: '700',
},
label: {
fontSize: 14,
fontWeight: '600',
},
input: {
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 16,
},
errorText: {
color: '#c62828',
fontSize: 14,
},
successText: {
color: '#2e7d32',
fontSize: 14,
},
actionButton: {
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
backgroundColor: '#111',
marginTop: 8,
},
actionButtonPressed: {
opacity: 0.85,
},
actionButtonDisabled: {
opacity: 0.6,
},
actionButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '700',
},
link: {
alignSelf: 'center',
marginTop: 8,
},
linkText: {
color: '#0b57d0',
fontSize: 16,
},
})

View File

@@ -1,19 +0,0 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}>
👋
</Animated.Text>
);
}

View File

@@ -0,0 +1,173 @@
import { Image } from "expo-image"
import { useState } from "react"
import { Modal, Pressable, Text, useWindowDimensions, View } from "react-native"
import { formatBytes, StagedNoteImage } from "@/src/notes/image-utils"
import { noteImagePanelStyles as styles } from "@/src/styles/app-styles"
type Palette = {
surface: string
elevated: string
text: string
mutedText: string
border: string
accent: string
destructive: string
}
type NoteImagePanelProps = {
canEdit: boolean
isBusy?: boolean
currentImageUrl?: string | null
currentImageMimeType?: string | null
currentImageSizeBytes?: number | null
stagedImage?: StagedNoteImage | null
helperText?: string | null
palette: Palette
primaryTextColor: string
onTakePhoto?: () => void
onChooseFromLibrary?: () => void
onRemoveImage?: () => void
}
export default function NoteImagePanel({
canEdit,
isBusy = false,
currentImageUrl,
currentImageMimeType,
currentImageSizeBytes,
stagedImage,
helperText,
palette,
primaryTextColor,
onTakePhoto,
onChooseFromLibrary,
onRemoveImage,
}: NoteImagePanelProps) {
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false)
const { width } = useWindowDimensions()
const previewUri = stagedImage?.uri ?? currentImageUrl ?? null
const mimeType = stagedImage?.mimeType ?? currentImageMimeType ?? null
const sizeBytes = stagedImage?.fileSize ?? currentImageSizeBytes ?? null
const useStackedLayout = width < 680
return (
<View style={styles.section}>
{previewUri ? (
<View
style={[
styles.previewCard,
styles.previewLayout,
useStackedLayout ? styles.previewLayoutStacked : null,
{ borderColor: palette.border, backgroundColor: palette.elevated },
]}
>
<View style={styles.previewDetails}>
<Text style={[styles.previewMetaLabel, { color: palette.text }]}>
{stagedImage ? "Staged image" : "Saved image"}
</Text>
<Text style={[styles.previewMeta, { color: palette.mutedText }]}>
{(mimeType ?? "Unknown type").toUpperCase()}
</Text>
<Text style={[styles.previewMeta, { color: palette.mutedText }]}>
{formatBytes(sizeBytes)}
</Text>
{!stagedImage && currentImageUrl ? (
<Text selectable numberOfLines={3} style={[styles.urlText, { color: palette.mutedText }]}>
{currentImageUrl}
</Text>
) : null}
</View>
<Pressable
disabled={!previewUri}
onPress={() => {
if (previewUri) {
setIsFullscreenOpen(true)
}
}}
style={styles.previewFrame}
>
<Image
source={{ uri: previewUri }}
style={styles.previewImage}
contentFit="contain"
/>
</Pressable>
</View>
) : (
<Text style={[styles.emptyText, { color: palette.mutedText }]}>
No image attached.
</Text>
)}
{helperText ? <Text style={[styles.helperText, { color: palette.mutedText }]}>{helperText}</Text> : null}
{canEdit ? (
<View style={styles.buttonRow}>
<Pressable
disabled={isBusy}
onPress={onTakePhoto}
style={[styles.actionButton, styles.enabledButtonShadow, isBusy ? styles.disabledButton : null, { backgroundColor: palette.accent }]}
>
<Text style={[styles.actionButtonText, { color: primaryTextColor }]}>Take photo</Text>
</Pressable>
<Pressable
disabled={isBusy}
onPress={onChooseFromLibrary}
style={[
styles.secondaryButton,
isBusy ? styles.disabledButton : null,
{ borderColor: palette.border, backgroundColor: palette.elevated },
]}
>
<Text style={[styles.secondaryButtonText, { color: palette.text }]}>Choose from gallery</Text>
</Pressable>
{previewUri ? (
<Pressable
disabled={isBusy}
onPress={onRemoveImage}
style={[
styles.secondaryButton,
isBusy ? styles.disabledButton : null,
{ borderColor: palette.destructive, backgroundColor: palette.surface },
]}
>
<Text style={[styles.removeButtonText, { color: palette.destructive }]}>Remove image</Text>
</Pressable>
) : null}
</View>
) : null}
<Modal
visible={isFullscreenOpen}
animationType="fade"
transparent
onRequestClose={() => {
setIsFullscreenOpen(false)
}}
>
<View style={styles.fullscreenOverlay}>
<Pressable
style={styles.fullscreenBackdrop}
onPress={() => {
setIsFullscreenOpen(false)
}}
/>
<View style={[styles.fullscreenCard, { backgroundColor: palette.surface, borderColor: palette.border }]}>
<Pressable
onPress={() => {
setIsFullscreenOpen(false)
}}
style={[styles.closeButton, { borderColor: palette.border, backgroundColor: palette.elevated }]}
>
<Text style={[styles.closeButtonText, { color: palette.text }]}>Close</Text>
</Pressable>
{previewUri ? (
<Image source={{ uri: previewUri }} style={styles.fullscreenImage} contentFit="contain" />
) : null}
</View>
</View>
</Modal>
</View>
)
}

View File

@@ -1,5 +1,4 @@
import type { PropsWithChildren, ReactElement } from 'react'; import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, { import Animated, {
interpolate, interpolate,
useAnimatedRef, useAnimatedRef,
@@ -10,6 +9,7 @@ import Animated, {
import { ThemedView } from '@/components/themed-view'; import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color'; import { useThemeColor } from '@/hooks/use-theme-color';
import { parallaxScrollViewStyles as styles } from '@/src/styles/app-styles';
const HEADER_HEIGHT = 250; const HEADER_HEIGHT = 250;
@@ -61,19 +61,3 @@ export default function ParallaxScrollView({
</Animated.ScrollView> </Animated.ScrollView>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});

View File

@@ -1,10 +1,27 @@
import { supabase } from '@/libs/supabase' import { supabase } from '@/libs/supabase'
import { router } from 'expo-router' import { router } from 'expo-router'
import Constants from "expo-constants"
import React from 'react' import React from 'react'
import { Pressable, StyleSheet, Text } from 'react-native' import { Platform, Pressable, Text } from 'react-native'
import { useAppTheme } from '@/src/theme/AppThemeProvider' import { useAppTheme } from '@/src/theme/AppThemeProvider'
import { signOutButtonStyles as styles } from '@/src/styles/app-styles'
async function onSignOutButtonPress() { async function onSignOutButtonPress() {
const {
data: { user },
} = await supabase.auth.getUser()
const isAndroidExpoGo = Platform.OS === "android" && Constants.executionEnvironment === "storeClient"
if (user?.id && !isAndroidExpoGo) {
const { unregisterPushNotifications } = await import('@/src/notifications/push-notifications')
const removed = await unregisterPushNotifications(user.id)
if (!removed) {
console.error('Failed to unregister push notifications before sign out.')
}
}
const { error } = await supabase.auth.signOut() const { error } = await supabase.auth.signOut()
if (error) { if (error) {
@@ -27,16 +44,3 @@ export default function SignOutButton() {
</Pressable> </Pressable>
) )
} }
const styles = StyleSheet.create({
button: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 8,
},
text: {
fontSize: 14,
fontWeight: '600',
},
})

View File

@@ -1,6 +1,7 @@
import { StyleSheet, Text, type TextProps } from 'react-native' import { Text, type TextProps } from 'react-native'
import { useThemeColor } from '@/hooks/use-theme-color' import { useThemeColor } from '@/hooks/use-theme-color'
import { themedTextStyles as styles } from '@/src/styles/app-styles'
export type ThemedTextProps = TextProps & { export type ThemedTextProps = TextProps & {
lightColor?: string lightColor?: string
@@ -32,29 +33,3 @@ export function ThemedText({
/> />
) )
} }
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
})

View File

@@ -1,11 +1,12 @@
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native'; import { TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text'; import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view'; import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol'; import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme'; import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
import { collapsibleStyles as styles } from '@/src/styles/app-styles';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -31,15 +32,3 @@ export function Collapsible({ children, title }: PropsWithChildren & { title: st
</ThemedView> </ThemedView>
); );
} }
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View File

@@ -0,0 +1,36 @@
import { Text, View } from "react-native"
import { uploadProgressBarStyles as styles } from "@/src/styles/app-styles"
type Palette = {
accent: string
border: string
elevated: string
mutedText: string
text: string
}
type UploadProgressBarProps = {
progress: number
label?: string
palette: Palette
}
export default function UploadProgressBar({
progress,
label = "Uploading image...",
palette,
}: UploadProgressBarProps) {
const clampedProgress = Math.max(0, Math.min(100, progress))
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={[styles.label, { color: palette.text }]}>{label}</Text>
<Text style={[styles.percentage, { color: palette.mutedText }]}>{clampedProgress}%</Text>
</View>
<View style={[styles.track, { borderColor: palette.border, backgroundColor: palette.elevated }]}>
<View style={[styles.fill, { width: `${clampedProgress}%`, backgroundColor: palette.accent }]} />
</View>
</View>
)
}

View File

@@ -1,25 +1,9 @@
import '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient } from '@supabase/supabase-js' import { createClient } from '@supabase/supabase-js'
import Constants from "expo-constants" import Constants from "expo-constants"
import { deleteItemAsync, getItemAsync, setItemAsync } from 'expo-secure-store'
import { Platform } from 'react-native' import { Platform } from 'react-native'
import 'react-native-url-polyfill/auto' import 'react-native-url-polyfill/auto'
const ExpoSecureStoreAdapter = {
getItem: (key: string) => {
return getItemAsync(key)
},
setItem: (key: string, value: string) => {
if (value.length > 2048) {
console.warn('Value being stored in SecureStore is larger than 2048 bytes and it may not be stored successfully. In a future SDK version, this call may throw an error.')
}
return setItemAsync(key, value)
},
removeItem: (key: string) => {
return deleteItemAsync(key)
},
}
const extra = (Constants.expoConfig?.extra ?? Constants.expoConfig?.extra) as { const extra = (Constants.expoConfig?.extra ?? Constants.expoConfig?.extra) as {
supabaseUrl?: string supabaseUrl?: string
supabaseKey?: string supabaseKey?: string
@@ -35,7 +19,7 @@ if(!supabaseUrl || !supabaseAnonKey){
const storage = ( const storage = (
Platform.OS === "web" Platform.OS === "web"
? window.localStorage ? window.localStorage
: ExpoSecureStoreAdapter : AsyncStorage
) )
export const supabase = createClient(supabaseUrl, supabaseAnonKey, { export const supabase = createClient(supabaseUrl, supabaseAnonKey, {

View File

@@ -16,11 +16,15 @@
"@supabase/supabase-js": "^2.98.0", "@supabase/supabase-js": "^2.98.0",
"async-storage": "^0.1.0", "async-storage": "^0.1.0",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-blur": "~15.0.8",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-image-manipulator": "^55.0.10",
"expo-image-picker": "^55.0.12",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",
"expo-notifications": "^55.0.12",
"expo-router": "~6.0.23", "expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
@@ -2128,9 +2132,9 @@
} }
}, },
"node_modules/@expo/image-utils": { "node_modules/@expo/image-utils": {
"version": "0.8.8", "version": "0.8.12",
"resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.8.tgz", "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.12.tgz",
"integrity": "sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA==", "integrity": "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@expo/spawn-async": "^1.7.2", "@expo/spawn-async": "^1.7.2",
@@ -2139,10 +2143,7 @@
"jimp-compact": "0.16.1", "jimp-compact": "0.16.1",
"parse-png": "^2.1.0", "parse-png": "^2.1.0",
"resolve-from": "^5.0.0", "resolve-from": "^5.0.0",
"resolve-global": "^1.0.0", "semver": "^7.6.0"
"semver": "^7.6.0",
"temp-dir": "~2.0.0",
"unique-string": "~2.0.0"
} }
}, },
"node_modules/@expo/image-utils/node_modules/semver": { "node_modules/@expo/image-utils/node_modules/semver": {
@@ -2158,24 +2159,15 @@
} }
}, },
"node_modules/@expo/json-file": { "node_modules/@expo/json-file": {
"version": "10.0.8", "version": "10.0.12",
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.12.tgz",
"integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", "integrity": "sha512-inbDycp1rMAelAofg7h/mMzIe+Owx6F7pur3XdQ3EPTy00tme+4P6FWgHKUcjN8dBSrnbRNpSyh5/shzHyVCyQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "~7.10.4", "@babel/code-frame": "^7.20.0",
"json5": "^2.2.3" "json5": "^2.2.3"
} }
}, },
"node_modules/@expo/json-file/node_modules/@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
"license": "MIT",
"dependencies": {
"@babel/highlight": "^7.10.4"
}
},
"node_modules/@expo/metro": { "node_modules/@expo/metro": {
"version": "54.2.0", "version": "54.2.0",
"resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz",
@@ -2365,6 +2357,25 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@expo/require-utils": {
"version": "55.0.2",
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.2.tgz",
"integrity": "sha512-dV5oCShQ1umKBKagMMT4B/N+SREsQe3lU4Zgmko5AO0rxKV0tynZT6xXs+e2JxuqT4Rz997atg7pki0BnZb4uw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.20.0",
"@babel/core": "^7.25.2",
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
},
"peerDependencies": {
"typescript": "^5.0.0 || ^5.0.0-0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@expo/schema-utils": { "node_modules/@expo/schema-utils": {
"version": "0.1.8", "version": "0.1.8",
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
@@ -4814,6 +4825,12 @@
"@babel/core": "^7.0.0" "@babel/core": "^7.0.0"
} }
}, },
"node_modules/badgin": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
"license": "MIT"
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -5462,15 +5479,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
"integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/css-in-js-utils": { "node_modules/css-in-js-utils": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
@@ -6489,6 +6497,15 @@
} }
} }
}, },
"node_modules/expo-application": {
"version": "55.0.9",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-55.0.9.tgz",
"integrity": "sha512-jXTaLKdW4cvGSUjF2UQed9ao4P/7TsEo/To7TjxM+jNa74xCSUCBSTxdQftm6hZWRzXG8KT7rSoQDEL51neh1w==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-asset": { "node_modules/expo-asset": {
"version": "12.0.12", "version": "12.0.12",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
@@ -6504,6 +6521,17 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-blur": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.8.tgz",
"integrity": "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": { "node_modules/expo-constants": {
"version": "18.0.13", "version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
@@ -6568,6 +6596,39 @@
} }
} }
}, },
"node_modules/expo-image-loader": {
"version": "55.0.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-55.0.0.tgz",
"integrity": "sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-manipulator": {
"version": "55.0.10",
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-55.0.10.tgz",
"integrity": "sha512-eEiHSznWa0i5I7iNFDRuHz663XiS26s8SEFigGbsvkFDibGI9x391Qb76DPSGtnqNkJa39etuFw42lbErHphHA==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~55.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "55.0.12",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-55.0.12.tgz",
"integrity": "sha512-ky8nzXTd5eLUDct5daAHng0xrWYRJyXfLCRmEdE9v/IUywYCnFU7aCnQ7PTQJvzGSzhePJJmP/POvTkVP//+qQ==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~55.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-keep-awake": { "node_modules/expo-keep-awake": {
"version": "15.0.8", "version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
@@ -6621,6 +6682,121 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-notifications": {
"version": "55.0.12",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-55.0.12.tgz",
"integrity": "sha512-AUAH1ipq7yChZqwp9P/gfmXNoaleKWvEhnIB6/dhtWtTnZZ5VDHdxqzQIbTemYQyIK6kpUc4JZpR9eU3d59K3g==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.12",
"abort-controller": "^3.0.0",
"badgin": "^1.1.5",
"expo-application": "~55.0.9",
"expo-constants": "~55.0.7"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-notifications/node_modules/@expo/config": {
"version": "55.0.8",
"resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.8.tgz",
"integrity": "sha512-D7RYYHfErCgEllGxNwdYdkgzLna7zkzUECBV3snbUpf7RvIpB5l1LpCgzuVoc5KVew5h7N1Tn4LnT/tBSUZsQg==",
"license": "MIT",
"dependencies": {
"@expo/config-plugins": "~55.0.6",
"@expo/config-types": "^55.0.5",
"@expo/json-file": "^10.0.12",
"@expo/require-utils": "^55.0.2",
"deepmerge": "^4.3.1",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"resolve-from": "^5.0.0",
"resolve-workspace-root": "^2.0.0",
"semver": "^7.6.0",
"slugify": "^1.3.4"
}
},
"node_modules/expo-notifications/node_modules/@expo/config-plugins": {
"version": "55.0.6",
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.6.tgz",
"integrity": "sha512-cIox6FjZlFaaX40rbQ3DvP9e87S5X85H9uw+BAxJE5timkMhuByy3GAlOsj1h96EyzSiol7Q6YIGgY1Jiz4M+A==",
"license": "MIT",
"dependencies": {
"@expo/config-types": "^55.0.5",
"@expo/json-file": "~10.0.12",
"@expo/plist": "^0.5.2",
"@expo/sdk-runtime-versions": "^1.0.0",
"chalk": "^4.1.2",
"debug": "^4.3.5",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"resolve-from": "^5.0.0",
"semver": "^7.5.4",
"slugify": "^1.6.6",
"xcode": "^3.0.1",
"xml2js": "0.6.0"
}
},
"node_modules/expo-notifications/node_modules/@expo/config-types": {
"version": "55.0.5",
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz",
"integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==",
"license": "MIT"
},
"node_modules/expo-notifications/node_modules/@expo/env": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@expo/env/-/env-2.1.1.tgz",
"integrity": "sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg==",
"license": "MIT",
"dependencies": {
"chalk": "^4.0.0",
"debug": "^4.3.4",
"getenv": "^2.0.0"
},
"engines": {
"node": ">=20.12.0"
}
},
"node_modules/expo-notifications/node_modules/@expo/plist": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.5.2.tgz",
"integrity": "sha512-o4xdVdBpe4aTl3sPMZ2u3fJH4iG1I768EIRk1xRZP+GaFI93MaR3JvoFibYqxeTmLQ1p1kNEVqylfUjezxx45g==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.8",
"base64-js": "^1.5.1",
"xmlbuilder": "^15.1.1"
}
},
"node_modules/expo-notifications/node_modules/expo-constants": {
"version": "55.0.7",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.7.tgz",
"integrity": "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ==",
"license": "MIT",
"dependencies": {
"@expo/config": "~55.0.8",
"@expo/env": "~2.1.1"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-notifications/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/expo-router": { "node_modules/expo-router": {
"version": "6.0.23", "version": "6.0.23",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz",
@@ -7475,18 +7651,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/global-dirs": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz",
"integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==",
"license": "MIT",
"dependencies": {
"ini": "^1.3.4"
},
"engines": {
"node": ">=4"
}
},
"node_modules/globals": { "node_modules/globals": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -11140,18 +11304,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/resolve-global": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz",
"integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==",
"license": "MIT",
"dependencies": {
"global-dirs": "^0.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/resolve-pkg-maps": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -12102,15 +12254,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
"integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/terminal-link": { "node_modules/terminal-link": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
@@ -12455,7 +12598,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -12565,18 +12708,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/unique-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
"integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==",
"license": "MIT",
"dependencies": {
"crypto-random-string": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -19,11 +19,15 @@
"@supabase/supabase-js": "^2.98.0", "@supabase/supabase-js": "^2.98.0",
"async-storage": "^0.1.0", "async-storage": "^0.1.0",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-blur": "~15.0.8",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-image-manipulator": "^55.0.10",
"expo-image-picker": "^55.0.12",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",
"expo-notifications": "^55.0.12",
"expo-router": "~6.0.23", "expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",

View File

@@ -1,5 +1,5 @@
import { AuthContext } from '@/hooks/use-auth-context' import { AuthContext } from '@/hooks/use-auth-context'
import { hasSecureRefreshToken, supabase } from '@/libs/supabase' import { supabase } from '@/libs/supabase'
import { PropsWithChildren, useEffect, useState } from 'react' import { PropsWithChildren, useEffect, useState } from 'react'
const buildClaims = (user?: { id: string; email?: string | null } | null) => const buildClaims = (user?: { id: string; email?: string | null } | null) =>
@@ -26,10 +26,6 @@ export default function AuthProvider({ children }: PropsWithChildren) {
console.error('Error hydrating session:', error) console.error('Error hydrating session:', error)
} }
if (!session && await hasSecureRefreshToken()) {
console.warn('Found an encrypted Supabase refresh token backup, but fallback session recovery is not implemented.')
}
setClaims(buildClaims(session?.user)) setClaims(buildClaims(session?.user))
setIsLoading(false) setIsLoading(false)
} }

View File

@@ -1,6 +1,9 @@
import React, { createContext, useContext, useEffect, useState } from "react" import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
import { useAuthContext } from "@/hooks/use-auth-context" import { useAuthContext } from "@/hooks/use-auth-context"
import { supabase } from "@/libs/supabase" import { supabase } from "@/libs/supabase"
import { deleteNoteImage, NoteImageUploadProgress, uploadNoteImage } from "@/src/notes/note-image-storage"
import { StagedNoteImage } from "@/src/notes/image-utils"
type NoteRow = { type NoteRow = {
id: number id: number
@@ -9,6 +12,10 @@ type NoteRow = {
content: string content: string
created_at: string created_at: string
updated_at?: string | null updated_at?: string | null
image_url?: string | null
image_path?: string | null
image_mime_type?: string | null
image_size_bytes?: number | null
} }
type ProfileRow = { type ProfileRow = {
@@ -26,20 +33,53 @@ export type Note = {
title: string title: string
content: string content: string
creatorLabel: string creatorLabel: string
imageUrl: string | null
imagePath: string | null
imageMimeType: string | null
imageSizeBytes: number | null
} }
export type NoteImageChange =
| { type: "keep" }
| { type: "remove" }
| { type: "replace"; image: StagedNoteImage }
type NotesContextValue = { type NotesContextValue = {
notes: Note[] notes: Note[]
isLoading: boolean isLoading: boolean
errorMessage: string | null errorMessage: string | null
refreshNotes: () => Promise<void> refreshNotes: () => Promise<void>
addNote: (title: string, content: string) => Promise<boolean> addNote: (
updateNote: (noteId: string, title: string, content: string) => Promise<boolean> title: string,
content: string,
image?: StagedNoteImage | null,
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
) => Promise<boolean>
updateNote: (
noteId: string,
title: string,
content: string,
imageChange?: NoteImageChange,
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
) => Promise<boolean>
deleteNote: (noteId: string) => Promise<boolean> deleteNote: (noteId: string) => Promise<boolean>
} }
const NotesContext = createContext<NotesContextValue | undefined>(undefined) const NotesContext = createContext<NotesContextValue | undefined>(undefined)
function normalizeImageSizeBytes(value: number | string | null | undefined) {
if (typeof value === "number") {
return value
}
if (typeof value === "string") {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
export function NotesProvider({ children }: { children: React.ReactNode }) { export function NotesProvider({ children }: { children: React.ReactNode }) {
const { claims, isLoggedIn, profile } = useAuthContext() const { claims, isLoggedIn, profile } = useAuthContext()
const [notes, setNotes] = useState<Note[]>([]) const [notes, setNotes] = useState<Note[]>([])
@@ -81,19 +121,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
}, {}) }, {})
} }
const mapNote = (row: NoteRow, labels: Record<string, string>): Note => ({ const loadNotes = useCallback(async () => {
id: String(row.id),
createdBy: row.created_by,
createdAt: row.created_at,
lastChangedAt: row.updated_at || row.created_at,
title: row.title,
content: row.content,
creatorLabel:
labels[row.created_by] ||
(row.created_by === userId ? creatorLabel : "Unknown user"),
})
const loadNotes = async () => {
if (!isLoggedIn) { if (!isLoggedIn) {
setNotes([]) setNotes([])
setErrorMessage(null) setErrorMessage(null)
@@ -106,7 +134,9 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
const { data, error } = await supabase const { data, error } = await supabase
.from("Notes") .from("Notes")
.select("id, created_by, title, content, created_at, updated_at") .select(
"id, created_by, title, content, created_at, updated_at, image_url, image_path, image_mime_type, image_size_bytes"
)
.order("updated_at", { ascending: false, nullsFirst: false }) .order("updated_at", { ascending: false, nullsFirst: false })
.order("created_at", { ascending: false }) .order("created_at", { ascending: false })
@@ -118,12 +148,27 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
} }
const rows = (data ?? []) as NoteRow[] const rows = (data ?? []) as NoteRow[]
// Resolve public creator labels separately so note reads do not depend on a view schema.
const labels = await buildCreatorLabels(rows) const labels = await buildCreatorLabels(rows)
setNotes(rows.map((row) => mapNote(row, labels))) setNotes(
rows.map((row) => ({
id: String(row.id),
createdBy: row.created_by,
createdAt: row.created_at,
lastChangedAt: row.updated_at || row.created_at,
title: row.title,
content: row.content,
creatorLabel:
labels[row.created_by] ||
(row.created_by === userId ? creatorLabel : "Unknown user"),
imageUrl: row.image_url ?? null,
imagePath: row.image_path ?? null,
imageMimeType: row.image_mime_type ?? null,
imageSizeBytes: normalizeImageSizeBytes(row.image_size_bytes),
}))
)
setIsLoading(false) setIsLoading(false)
} }, [creatorLabel, isLoggedIn, userId])
const refreshNotes = async () => { const refreshNotes = async () => {
await loadNotes() await loadNotes()
@@ -138,14 +183,13 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
} }
void loadNotes() void loadNotes()
}, [creatorLabel, isLoggedIn, userId]) }, [creatorLabel, isLoggedIn, loadNotes, userId])
useEffect(() => { useEffect(() => {
if (!isLoggedIn || !userId) { if (!isLoggedIn || !userId) {
return return
} }
// Poll for remote changes so other users' edits appear without a manual refresh.
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
void loadNotes() void loadNotes()
}, 30000) }, 30000)
@@ -153,9 +197,14 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
return () => { return () => {
clearInterval(intervalId) clearInterval(intervalId)
} }
}, [creatorLabel, isLoggedIn, userId]) }, [creatorLabel, isLoggedIn, loadNotes, userId])
const addNote = async (title: string, content: string) => { const addNote = async (
title: string,
content: string,
image?: StagedNoteImage | null,
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
) => {
const trimmedTitle = title.trim() const trimmedTitle = title.trim()
const trimmedContent = content.trim() const trimmedContent = content.trim()
@@ -171,15 +220,46 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
setErrorMessage(null) setErrorMessage(null)
const { error } = await supabase let uploadedImage:
.from("Notes") | {
.insert({ path: string
title: trimmedTitle, publicUrl: string
content: trimmedContent, mimeType: string
}) sizeBytes: number
}
| null = null
if (error) { try {
setErrorMessage(error.message) if (image) {
uploadedImage = await uploadNoteImage(userId, image, {
onProgress: options?.onImageUploadProgress,
})
}
const { error } = await supabase
.from("Notes")
.insert({
title: trimmedTitle,
content: trimmedContent,
image_url: uploadedImage?.publicUrl ?? null,
image_path: uploadedImage?.path ?? null,
image_mime_type: uploadedImage?.mimeType ?? null,
image_size_bytes: uploadedImage?.sizeBytes ?? null,
})
if (error) {
throw new Error(error.message)
}
} catch (error) {
if (uploadedImage?.path) {
try {
await deleteNoteImage(uploadedImage.path)
} catch (cleanupError) {
console.error("Failed to roll back uploaded note image:", cleanupError)
}
}
setErrorMessage(error instanceof Error ? error.message : "Failed to save note.")
return false return false
} }
@@ -187,7 +267,13 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
return true return true
} }
const updateNote = async (noteId: string, title: string, content: string) => { const updateNote = async (
noteId: string,
title: string,
content: string,
imageChange: NoteImageChange = { type: "keep" },
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
) => {
const trimmedTitle = title.trim() const trimmedTitle = title.trim()
const trimmedContent = content.trim() const trimmedContent = content.trim()
@@ -201,44 +287,109 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
return false return false
} }
const existingNote = notes.find((note) => note.id === noteId)
if (!existingNote) {
setErrorMessage("This note could not be found.")
return false
}
setErrorMessage(null) setErrorMessage(null)
const { data, error } = await supabase let uploadedImage:
.from("Notes") | {
.update({ path: string
title: trimmedTitle, publicUrl: string
content: trimmedContent, mimeType: string
updated_at: new Date().toISOString(), sizeBytes: number
}) }
.eq("id", Number(noteId)) | null = null
.eq("created_by", userId)
.select("id, title, content, updated_at")
.maybeSingle()
if (error) { const updates: Record<string, string | number | null> = {
setErrorMessage(error.message) title: trimmedTitle,
return false content: trimmedContent,
updated_at: new Date().toISOString(),
} }
if (!data) { try {
setErrorMessage("Update failed. You can only edit notes that you created.") if (imageChange.type === "replace") {
return false uploadedImage = await uploadNoteImage(userId, imageChange.image, {
} onProgress: options?.onImageUploadProgress,
})
updates.image_url = uploadedImage.publicUrl
updates.image_path = uploadedImage.path
updates.image_mime_type = uploadedImage.mimeType
updates.image_size_bytes = uploadedImage.sizeBytes
} else if (imageChange.type === "remove") {
updates.image_url = null
updates.image_path = null
updates.image_mime_type = null
updates.image_size_bytes = null
}
// Update the edited note locally so the new timestamp is visible immediately. const { data, error } = await supabase
setNotes((prev) => .from("Notes")
prev.map((note) => .update(updates)
note.id === noteId .eq("id", Number(noteId))
? { .eq("created_by", userId)
...note, .select(
title: data.title ?? trimmedTitle, "id, title, content, updated_at, image_url, image_path, image_mime_type, image_size_bytes"
content: data.content ?? trimmedContent, )
lastChangedAt: data.updated_at ?? new Date().toISOString(), .maybeSingle()
}
: note if (error) {
throw new Error(error.message)
}
if (!data) {
throw new Error("Update failed. You can only edit notes that you created.")
}
if (imageChange.type === "replace" && existingNote.imagePath) {
try {
await deleteNoteImage(existingNote.imagePath)
} catch (cleanupError) {
console.error("Failed to remove replaced note image:", cleanupError)
}
}
if (imageChange.type === "remove" && existingNote.imagePath) {
try {
await deleteNoteImage(existingNote.imagePath)
} catch (cleanupError) {
console.error("Failed to remove deleted note image:", cleanupError)
}
}
setNotes((prev) =>
prev.map((note) =>
note.id === noteId
? {
...note,
title: data.title ?? trimmedTitle,
content: data.content ?? trimmedContent,
lastChangedAt: data.updated_at ?? updates.updated_at ?? new Date().toISOString(),
imageUrl: data.image_url ?? null,
imagePath: data.image_path ?? null,
imageMimeType: data.image_mime_type ?? null,
imageSizeBytes: normalizeImageSizeBytes(data.image_size_bytes),
}
: note
)
) )
) return true
return true } catch (error) {
if (uploadedImage?.path) {
try {
await deleteNoteImage(uploadedImage.path)
} catch (cleanupError) {
console.error("Failed to roll back uploaded replacement image:", cleanupError)
}
}
setErrorMessage(error instanceof Error ? error.message : "Failed to update note.")
return false
}
} }
const deleteNote = async (noteId: string) => { const deleteNote = async (noteId: string) => {
@@ -247,6 +398,8 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
return false return false
} }
const existingNote = notes.find((note) => note.id === noteId)
setErrorMessage(null) setErrorMessage(null)
const { error } = await supabase const { error } = await supabase
@@ -260,6 +413,15 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
return false return false
} }
if (existingNote?.imagePath) {
try {
await deleteNoteImage(existingNote.imagePath)
} catch (cleanupError) {
console.error("Failed to remove note image during deletion:", cleanupError)
setErrorMessage("The note was deleted, but its stored image could not be cleaned up.")
}
}
setNotes((prev) => prev.filter((note) => note.id !== noteId)) setNotes((prev) => prev.filter((note) => note.id !== noteId))
return true return true
} }

View File

@@ -0,0 +1,199 @@
import * as ImageManipulator from "expo-image-manipulator"
export const NOTE_IMAGE_BUCKET = "note-images"
export const MAX_NOTE_IMAGE_BYTES = 15 * 1024 * 1024
export const SUPPORTED_NOTE_IMAGE_FORMATS = "PNG, JPG/JPEG, WEBP"
const JPEG_MIME = "image/jpeg"
const PNG_MIME = "image/png"
const WEBP_MIME = "image/webp"
export type SupportedMimeType =
| typeof JPEG_MIME
| typeof PNG_MIME
| typeof WEBP_MIME
export type StagedNoteImage = {
uri: string
fileName: string
mimeType: string | null
fileSize: number | null
width?: number | null
height?: number | null
}
export type UploadedNoteImage = {
path: string
publicUrl: string
mimeType: SupportedMimeType
sizeBytes: number
}
export type PreparedNoteImage = {
uri: string
mimeType: SupportedMimeType
sizeBytes: number
}
function normalizeFileExtension(fileName: string | null | undefined) {
const extension = fileName?.split(".").pop()?.toLowerCase()
if (!extension) {
return null
}
if (extension === "jpg") {
return "jpeg"
}
return extension
}
export function normalizeMimeType(
mimeType: string | null | undefined,
fileName?: string | null
): SupportedMimeType | null {
if (mimeType === JPEG_MIME || mimeType === PNG_MIME || mimeType === WEBP_MIME) {
return mimeType
}
if (mimeType === "image/jpg") {
return JPEG_MIME
}
const extension = normalizeFileExtension(fileName)
if (extension === "jpeg") {
return JPEG_MIME
}
if (extension === "png") {
return PNG_MIME
}
if (extension === "webp") {
return WEBP_MIME
}
return null
}
function getFileExtension(mimeType: SupportedMimeType) {
if (mimeType === PNG_MIME) {
return "png"
}
if (mimeType === WEBP_MIME) {
return "webp"
}
return "jpg"
}
function getSaveFormat(mimeType: SupportedMimeType) {
if (mimeType === PNG_MIME) {
return ImageManipulator.SaveFormat.PNG
}
if (mimeType === WEBP_MIME) {
return ImageManipulator.SaveFormat.WEBP
}
return ImageManipulator.SaveFormat.JPEG
}
export function formatBytes(bytes: number | null | undefined) {
if (!bytes || Number.isNaN(bytes) || bytes <= 0) {
return "Unknown size"
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function validateStagedNoteImage(image: StagedNoteImage) {
const normalizedMimeType = normalizeMimeType(image.mimeType, image.fileName)
if (!normalizedMimeType) {
throw new Error(`Unsupported image format. Allowed formats: ${SUPPORTED_NOTE_IMAGE_FORMATS}.`)
}
return normalizedMimeType
}
async function getFileSize(uri: string) {
const response = await fetch(uri)
const blob = await response.blob()
return blob.size
}
export async function readUriAsArrayBuffer(uri: string) {
const response = await fetch(uri)
if (!response.ok) {
throw new Error("The selected image could not be read.")
}
return response.arrayBuffer()
}
function buildActions(width: number, height: number) {
const largestSide = Math.max(width, height)
if (largestSide <= 1600) {
return []
}
const scale = 1600 / largestSide
return [
{
resize: {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
},
},
]
}
export async function prepareNoteImage(image: StagedNoteImage): Promise<PreparedNoteImage> {
const normalizedMimeType = validateStagedNoteImage(image)
const targetMimeType = normalizedMimeType === PNG_MIME ? PNG_MIME : JPEG_MIME
const saveFormat = getSaveFormat(targetMimeType)
const actions =
image.width && image.height
? buildActions(image.width, image.height)
: []
const compressions = targetMimeType === PNG_MIME ? [1] : [0.82, 0.7, 0.56, 0.42, 0.32]
for (const compression of compressions) {
const result = await ImageManipulator.manipulateAsync(image.uri, actions, {
compress: compression,
format: saveFormat,
})
const sizeBytes = await getFileSize(result.uri)
if (sizeBytes <= MAX_NOTE_IMAGE_BYTES) {
return {
uri: result.uri,
mimeType: targetMimeType,
sizeBytes,
}
}
}
throw new Error("Image too large. The selected image is still larger than 15 MB after compression.")
}
export function createStoragePath(userId: string, mimeType: SupportedMimeType) {
const id =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
return `${userId}/${id}.${getFileExtension(mimeType)}`
}

View File

@@ -0,0 +1,79 @@
import * as ImagePicker from "expo-image-picker"
import { Platform } from "react-native"
import { StagedNoteImage } from "@/src/notes/image-utils"
function buildStagedImage(asset: ImagePicker.ImagePickerAsset): StagedNoteImage {
return {
uri: asset.uri,
fileName: asset.fileName ?? `note-image-${Date.now()}.jpg`,
mimeType: asset.mimeType ?? null,
fileSize: asset.fileSize ?? null,
width: asset.width,
height: asset.height,
}
}
function unsupportedPlatformError(action: "camera" | "gallery") {
return Platform.OS === "web"
? `Native ${action} support is only enabled on iOS and Android in this app.`
: `This device cannot open the ${action}.`
}
export async function pickImageFromLibrary() {
if (Platform.OS === "web") {
throw new Error(unsupportedPlatformError("gallery"))
}
const permission = await ImagePicker.getMediaLibraryPermissionsAsync()
if (!permission.granted) {
const requested = await ImagePicker.requestMediaLibraryPermissionsAsync()
if (!requested.granted) {
throw new Error("Photo library access is required to choose an image.")
}
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: false,
mediaTypes: ["images"],
quality: 1,
selectionLimit: 1,
})
if (result.canceled || !result.assets?.length) {
return null
}
return buildStagedImage(result.assets[0])
}
export async function pickImageFromCamera() {
if (Platform.OS === "web") {
throw new Error(unsupportedPlatformError("camera"))
}
const permission = await ImagePicker.getCameraPermissionsAsync()
if (!permission.granted) {
const requested = await ImagePicker.requestCameraPermissionsAsync()
if (!requested.granted) {
throw new Error("Camera access is required to take a photo.")
}
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: false,
cameraType: ImagePicker.CameraType.back,
mediaTypes: ["images"],
quality: 1,
})
if (result.canceled || !result.assets?.length) {
return null
}
return buildStagedImage(result.assets[0])
}

View File

@@ -0,0 +1,153 @@
import { supabase, supabaseAnonKey, supabaseUrl } from "@/libs/supabase"
import {
createStoragePath,
NOTE_IMAGE_BUCKET,
prepareNoteImage,
StagedNoteImage,
UploadedNoteImage,
} from "@/src/notes/image-utils"
export type NoteImageUploadProgress = {
loaded: number
total: number
progress: number
}
type UploadNoteImageOptions = {
onProgress?: (progress: NoteImageUploadProgress) => void
}
function normalizeUploadFailureMessage(error: unknown) {
if (error instanceof Error) {
if (
error.message.startsWith("Unsupported image format.") ||
error.message.startsWith("Image too large.")
) {
return error.message
}
if (error.message.trim()) {
return `Image upload failed. ${error.message}`
}
}
return "Image upload failed. Check your connection and try again."
}
async function uploadWithProgress(
path: string,
fileUri: string,
mimeType: string,
onProgress?: (progress: NoteImageUploadProgress) => void
) {
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Image upload failed. Storage configuration is missing.")
}
const anonKey = supabaseAnonKey
const storageBaseUrl = supabaseUrl
const session = await supabase.auth.getSession()
const accessToken = session.data.session?.access_token
const uploadUrl = `${storageBaseUrl}/storage/v1/object/${NOTE_IMAGE_BUCKET}/${path}`
const response = await fetch(fileUri)
if (!response.ok) {
throw new Error("The selected image could not be read.")
}
const fileBlob = await response.blob()
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", uploadUrl)
xhr.setRequestHeader("apikey", anonKey)
xhr.setRequestHeader("Authorization", `Bearer ${accessToken ?? anonKey}`)
xhr.setRequestHeader("x-upsert", "false")
xhr.setRequestHeader("cache-control", "3600")
xhr.setRequestHeader("content-type", mimeType)
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable || !event.total) {
return
}
onProgress?.({
loaded: event.loaded,
total: event.total,
progress: Math.min(100, Math.round((event.loaded / event.total) * 100)),
})
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
onProgress?.({
loaded: fileBlob.size,
total: fileBlob.size,
progress: 100,
})
resolve()
return
}
try {
const parsed = JSON.parse(xhr.responseText) as { message?: string; error?: string }
reject(new Error(parsed.message || parsed.error || "Upload failed."))
} catch {
reject(new Error("Upload failed."))
}
}
xhr.onerror = () => {
reject(new Error("Check your connection and try again."))
}
xhr.onabort = () => {
reject(new Error("Upload cancelled."))
}
onProgress?.({
loaded: 0,
total: fileBlob.size,
progress: 0,
})
xhr.send(fileBlob)
})
}
export async function uploadNoteImage(
userId: string,
image: StagedNoteImage,
options: UploadNoteImageOptions = {}
): Promise<UploadedNoteImage> {
const preparedImage = await prepareNoteImage(image)
const path = createStoragePath(userId, preparedImage.mimeType)
try {
await uploadWithProgress(path, preparedImage.uri, preparedImage.mimeType, options.onProgress)
} catch (error) {
throw new Error(normalizeUploadFailureMessage(error))
}
const { data } = supabase.storage
.from(NOTE_IMAGE_BUCKET)
.getPublicUrl(path)
return {
path,
publicUrl: data.publicUrl,
mimeType: preparedImage.mimeType,
sizeBytes: preparedImage.sizeBytes,
}
}
export async function deleteNoteImage(path: string) {
const { error } = await supabase.storage
.from(NOTE_IMAGE_BUCKET)
.remove([path])
if (error) {
throw new Error(error.message)
}
}

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
}
}

View File

@@ -0,0 +1,568 @@
import { Appearance, StyleSheet } from "react-native"
export const uploadProgressBarStyles = StyleSheet.create({
container: {
gap: 8,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
label: {
fontSize: 13,
fontWeight: "600",
},
percentage: {
fontSize: 13,
fontWeight: "600",
},
track: {
height: 10,
borderWidth: 1,
borderRadius: 999,
overflow: "hidden",
},
fill: {
height: "100%",
borderRadius: 999,
},
})
export const parallaxScrollViewStyles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: 250,
overflow: "hidden",
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: "hidden",
},
})
export const themedTextStyles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
},
title: {
fontSize: 32,
fontWeight: "bold",
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: "bold",
},
link: {
lineHeight: 30,
fontSize: 16,
color: "#0a7ea4",
},
})
export const loginScreenStyles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
padding: 24,
gap: 12,
},
title: {
fontSize: 28,
fontWeight: "700",
},
label: {
fontSize: 14,
fontWeight: "600",
},
input: {
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 16,
},
errorText: {
color: "#c62828",
fontSize: 14,
},
loginButton: {
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
backgroundColor: Appearance.getColorScheme() === "light" ? "#000000" : "#696969",
marginTop: 8,
},
loginButtonPressed: {
opacity: 0.85,
},
loginButtonDisabled: {
opacity: 0.6,
},
loginButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "700",
},
link: {
alignSelf: "center",
marginTop: 8,
},
linkText: {
color: "#0b57d0",
fontSize: 16,
},
})
export const signupScreenStyles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
padding: 24,
gap: 12,
},
title: {
fontSize: 28,
fontWeight: "700",
},
label: {
fontSize: 14,
fontWeight: "600",
},
input: {
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 16,
},
errorText: {
color: "#c62828",
fontSize: 14,
},
successText: {
color: "#2e7d32",
fontSize: 14,
},
actionButton: {
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
backgroundColor: "#111",
marginTop: 8,
},
actionButtonPressed: {
opacity: 0.85,
},
actionButtonDisabled: {
opacity: 0.6,
},
actionButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "700",
},
link: {
alignSelf: "center",
marginTop: 8,
},
linkText: {
color: "#0b57d0",
fontSize: 16,
},
})
export const signOutButtonStyles = StyleSheet.create({
button: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 8,
},
text: {
fontSize: 14,
fontWeight: "600",
},
})
export const collapsibleStyles = StyleSheet.create({
heading: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
})
export const homeScreenStyles = StyleSheet.create({
container: { flex: 1 },
topBar: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingBottom: 8,
borderBottomWidth: 1,
},
screenTitle: {
fontSize: 24,
fontWeight: "700",
},
tabBar: {
flexDirection: "row",
gap: 8,
paddingHorizontal: 16,
paddingBottom: 8,
paddingTop: 12,
},
tabButton: {
flex: 1,
borderWidth: 1,
borderRadius: 999,
paddingVertical: 10,
alignItems: "center",
},
tabButtonActive: {
backgroundColor: Appearance.getColorScheme() === "light" ? "#000000" : "#8a8888",
},
tabButtonText: {
fontSize: 14,
fontWeight: "600",
},
tabButtonTextActive: {
color: "#fff",
},
list: { padding: 16, gap: 12, paddingTop: 8 },
noteItem: { padding: 16, borderWidth: 1, borderRadius: 12, gap: 8 },
noteCardRow: {
flexDirection: "row",
alignItems: "stretch",
gap: 12,
},
noteBody: {
flex: 1,
gap: 8,
},
noteTitle: { fontSize: 16, fontWeight: "600" },
noteThumbnailFrame: {
width: 110,
height: 110,
alignItems: "center",
justifyContent: "center",
},
noteThumbnail: {
width: "100%",
height: "100%",
},
notePreview: { fontSize: 14 },
noteMeta: { fontSize: 12 },
emptyText: {
textAlign: "center",
paddingVertical: 32,
color: "#666",
},
errorText: {
color: "#c62828",
paddingHorizontal: 16,
paddingBottom: 8,
},
fab: {
position: "absolute",
width: 56,
height: 56,
borderRadius: 28,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
},
fabText: { fontSize: 28, lineHeight: 28, fontWeight: "700" },
})
export const noteImagePanelStyles = StyleSheet.create({
section: {
gap: 12,
},
previewCard: {
borderWidth: 1,
borderRadius: 12,
padding: 12,
},
previewLayout: {
flexDirection: "row",
gap: 12,
alignItems: "stretch",
},
previewLayoutStacked: {
flexDirection: "column",
},
previewDetails: {
flex: 0.95,
gap: 6,
minWidth: 0,
},
previewFrame: {
flex: 1.7,
minHeight: 220,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
},
previewImage: {
width: "100%",
height: "100%",
borderRadius: 8,
backgroundColor: "#ffffff",
},
previewMetaLabel: {
fontSize: 13,
fontWeight: "700",
},
previewMeta: {
fontSize: 13,
},
urlText: {
fontSize: 12,
},
emptyText: {
fontSize: 14,
},
helperText: {
fontSize: 12,
},
buttonRow: {
gap: 10,
},
actionButton: {
borderRadius: 10,
paddingVertical: 12,
alignItems: "center",
},
enabledButtonShadow: {
shadowColor: "#000",
shadowOpacity: 0.14,
shadowOffset: { width: 0, height: 6 },
shadowRadius: 12,
elevation: 6,
},
disabledButton: {
opacity: 0.45,
shadowOpacity: 0,
elevation: 0,
},
actionButtonText: {
fontSize: 15,
fontWeight: "700",
},
secondaryButton: {
borderWidth: 1,
borderRadius: 10,
paddingVertical: 12,
alignItems: "center",
},
secondaryButtonText: {
fontSize: 14,
fontWeight: "600",
},
removeButtonText: {
fontSize: 14,
fontWeight: "700",
},
fullscreenOverlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
backgroundColor: "rgba(0, 0, 0, 0.82)",
},
fullscreenBackdrop: {
...StyleSheet.absoluteFillObject,
},
fullscreenCard: {
width: "100%",
maxWidth: 900,
height: "82%",
borderWidth: 1,
borderRadius: 16,
padding: 12,
gap: 12,
},
closeButton: {
alignSelf: "flex-end",
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 8,
},
closeButtonText: {
fontSize: 14,
fontWeight: "700",
},
fullscreenImage: {
flex: 1,
width: "100%",
borderRadius: 12,
backgroundColor: "#d7d7d7",
},
})
export const newNoteScreenStyles = StyleSheet.create({
keyboardAvoider: { flex: 1 },
container: { flex: 1 },
formContent: { padding: 16, gap: 12 },
titleInput: {
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 22,
fontWeight: "700",
},
contentInput: {
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 16,
},
actions: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
paddingTop: 12,
paddingHorizontal: 16,
},
actionsBlur: {
...StyleSheet.absoluteFillObject,
overflow: "hidden",
},
actionsContent: {
borderTopWidth: 1,
paddingTop: 10,
},
saveButton: {
flex: 1,
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
},
enabledButtonShadow: {
shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
},
disabledButton: {
opacity: 0.45,
shadowOpacity: 0,
elevation: 0,
},
saveFloatingText: {
fontSize: 16,
fontWeight: "700",
},
errorText: {
color: "#c62828",
},
})
export const detailScreenStyles = StyleSheet.create({
keyboardAvoider: { flex: 1 },
container: { flex: 1 },
formContent: { padding: 16, gap: 12 },
title: { fontSize: 22, fontWeight: "700" },
content: { fontSize: 16 },
titleInput: {
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 22,
fontWeight: "700",
},
signature: {
fontSize: 12,
color: "#666",
},
contentInput: {
minHeight: 200,
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 16,
},
errorText: {
color: "#c62828",
},
successText: {
color: "#2e7d32",
},
readOnlyText: {
color: "#666",
},
actions: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
paddingTop: 12,
paddingHorizontal: 16,
},
actionsBlur: {
...StyleSheet.absoluteFillObject,
overflow: "hidden",
},
actionsContent: {
flexDirection: "row",
gap: 12,
borderTopWidth: 1,
paddingTop: 10,
},
primaryButton: {
flex: 1,
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
},
enabledButtonShadow: {
shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
},
primaryButtonText: {
fontSize: 16,
fontWeight: "700",
},
deleteButton: {
flex: 1,
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
},
disabledButton: {
opacity: 0.45,
shadowOpacity: 0,
elevation: 0,
},
deleteButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "700",
},
})

View File

@@ -14,8 +14,8 @@ const lightPalette = {
} }
const darkPalette = { const darkPalette = {
background: "#000000", background: "#191919",
surface: "#0b0b0b", surface: "#343232",
elevated: "#111111", elevated: "#111111",
text: "#ffffff", text: "#ffffff",
mutedText: "#b8b8b8", mutedText: "#b8b8b8",

View File

@@ -0,0 +1,388 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "FastNotes"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"

View File

@@ -0,0 +1,164 @@
import { createClient } from "npm:@supabase/supabase-js@2"
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
const EXPO_ACCESS_TOKEN = Deno.env.get("EXPO_ACCESS_TOKEN") ?? ""
type NoteRecord = {
id: number | string
created_by: string
title: string
}
type DatabaseWebhookPayload = {
type: "INSERT" | "UPDATE" | "DELETE"
table: string
schema: string
record?: NoteRecord | null
old_record?: NoteRecord | null
}
type ExpoPushMessage = {
to: string
title: string
body: string
sound: "default"
data: {
type: "new-note"
noteId: string
title: string
}
}
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
function jsonResponse(status: number, body: Record<string, unknown>) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
})
}
function chunkMessages<T>(items: T[], size: number) {
const chunks: T[][] = []
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size))
}
return chunks
}
async function loadCreatorEmail(userId: string) {
const { data: profile } = await supabase
.from("profiles")
.select("email")
.eq("id", userId)
.maybeSingle()
if (profile?.email) {
return profile.email as string
}
const { data, error } = await supabase.auth.admin.getUserById(userId)
if (error) {
console.error("Failed to load note creator:", error.message)
return "unknown user"
}
return data.user.email ?? "unknown user"
}
async function loadRecipientTokens(userId: string) {
const { data, error } = await supabase
.from("user_push_tokens")
.select("push_token")
.neq("user_id", userId)
.eq("is_active", true)
if (error) {
throw new Error(error.message)
}
return Array.from(new Set((data ?? []).map((row) => row.push_token as string).filter(Boolean)))
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
}
if (EXPO_ACCESS_TOKEN) {
headers.Authorization = `Bearer ${EXPO_ACCESS_TOKEN}`
}
for (const chunk of chunkMessages(messages, 100)) {
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers,
body: JSON.stringify(chunk),
})
if (!response.ok) {
const errorBody = await response.text()
throw new Error(`Expo push request failed with ${response.status}: ${errorBody}`)
}
}
}
Deno.serve(async (request) => {
if (request.method !== "POST") {
return jsonResponse(405, { error: "Method not allowed" })
}
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
return jsonResponse(500, { error: "Missing Supabase environment variables." })
}
let payload: DatabaseWebhookPayload
try {
payload = await request.json()
} catch {
return jsonResponse(400, { error: "Invalid JSON payload." })
}
if (payload.type !== "INSERT" || payload.table !== "Notes" || !payload.record) {
return jsonResponse(200, { ignored: true })
}
try {
const note = payload.record
const [creatorEmail, recipientTokens] = await Promise.all([
loadCreatorEmail(note.created_by),
loadRecipientTokens(note.created_by),
])
if (recipientTokens.length === 0) {
return jsonResponse(200, { sent: 0 })
}
const body = `New note: "${note.title}" by ${creatorEmail}`
const messages: ExpoPushMessage[] = recipientTokens.map((token) => ({
to: token,
title: "FastNotes",
body,
sound: "default",
data: {
type: "new-note",
noteId: String(note.id),
title: note.title,
},
}))
await sendExpoPushNotifications(messages)
return jsonResponse(200, { sent: messages.length })
} catch (error) {
const message = error instanceof Error ? error.message : "Unexpected error."
console.error("Push notification webhook failed:", message)
return jsonResponse(500, { error: message })
}
})

View File

@@ -1,56 +0,0 @@
import { Image } from 'expo-image'
import { StyleSheet } from 'react-native'
import { HelloWave } from '@/components/hello-wave'
import ParallaxScrollView from '@/components/parallax-scroll-view'
import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import SignOutButton from '@/components/social-auth-buttons/sign-out-button'
import { useAuthContext } from '@/hooks/use-auth-context'
export default function HomeScreen() {
const { profile } = useAuthContext()
return (
<ParallaxScrollView
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
headerImage={
<Image
source={require('@/assets/images/partial-react-logo.png')}
style={styles.reactLogo}
/>
}
>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">Welcome!</ThemedText>
<HelloWave />
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Username</ThemedText>
<ThemedText>{profile?.username}</ThemedText>
<ThemedText type="subtitle">Full name</ThemedText>
<ThemedText>{profile?.full_name}</ThemedText>
</ThemedView>
<SignOutButton />
</ParallaxScrollView>
)
}
const styles = StyleSheet.create({
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
stepContainer: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
position: 'absolute',
},
})