diff --git a/FastNotes/README.md b/FastNotes/README.md index b5b8d42..45d1583 100644 --- a/FastNotes/README.md +++ b/FastNotes/README.md @@ -1,42 +1,188 @@ -#Requirements +# FastNotes - Node.js (LTS recommended) - npm (comes with Node.js) - Expo CLI (used via npx command) - Expo Go (for running on device / emulator) +This project is an Expo React Native note-taking app built for a CS assignment submission. It supports: -#Installation +- Email/password authentication with Supabase Auth +- Creating, viewing, editing, and deleting notes +- Optional image upload for notes using Supabase Storage +- Push notification support through Expo and a Supabase Edge Function fallback path - 1. Extract folder - 2. Open terminal in project root (where you find 'package.json') - 3. Install dependencies: - ```bash - npm install - ``` +## Requirements -#Running the project +To build and run this project locally, you need: - Start Expo dev server: - ```bash - npx expo start - ``` +- Node.js (LTS recommended) +- npm +- Expo Go on a physical device, or an Android/iOS emulator +- A Supabase project that you configure yourself - Then: - - Scan the QR code using Expo Go app on mobile device - **or** - - Run the app in an emulator from the Expo developer tools +The repository does not include a committed `.env` file. That is intentional. The `.env` file is ignored by Git by design, so anyone running this project must create their own local `.env` file with their own Supabase and Expo values. -#Running tests +## Installation - Run the Jest test suite from the project root: - ```bash - npm test - ``` +1. Clone or extract the project. +2. Open a terminal in the project root. +3. Install dependencies: - Run each test file one by one: - ```bash - npx jest __tests__/detail-screen.test.tsx - npx jest __tests__/auth-guard.test.tsx - npx jest __tests__/new-note.test.tsx - ``` +```bash +npm install +``` +## Environment Variables + +Create a `.env` file in the project root and define the following variables: + +```bash +EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url +EXPO_PUBLIC_SUPABASE_KEY=your_supabase_anon_key +EXPO_PUBLIC_EAS_PROJECT_ID=your_expo_eas_project_id +``` + +Notes: + +- `EXPO_PUBLIC_SUPABASE_URL` is the URL of your Supabase project. +- `EXPO_PUBLIC_SUPABASE_KEY` is the public anonymous key for your Supabase project. +- `EXPO_PUBLIC_EAS_PROJECT_ID` is optional and is only used for Expo push notification registration and related build/push flows. +- The app requires `EXPO_PUBLIC_SUPABASE_URL` and `EXPO_PUBLIC_SUPABASE_KEY` at runtime. If those two values are missing, the app will not start correctly. + +## Build And Run Instructions + +Start the Expo development server: + +```bash +npm start +``` + +You can also start a specific platform directly: + +```bash +npm run android +npm run ios +npm run web +``` + +After the development server starts: + +- Scan the QR code with Expo Go on a physical device, or +- Open the app in an emulator/simulator + +## Test And Validation Commands + +Run the Jest test suite: + +```bash +npm test +``` + +Run tests in watch mode: + +```bash +npm run test:watch +``` + +Run linting: + +```bash +npm run lint +``` + +Run TypeScript checks for the app: + +```bash +npm run typecheck +``` + +Run type checks for the included Supabase Edge Function: + +```bash +npm run typecheck:functions +``` + +## Supabase Configuration Expected By The App + +This app is not fully standalone. It expects your Supabase project to already contain the database tables and storage resources used by the code. + +### 1. Auth + +The app uses Supabase Auth with email/password sign-up and login. + +### 2. `profiles` table + +The app expects a `profiles` table that stores user profile information. Based on the code, it uses these columns: + +- `id` +- `email` +- `username` +- `full_name` + +The app upserts into `profiles` when a user signs up or when an authenticated session is restored. + +### 3. `Notes` table + +The app expects a table named `Notes` with this exact capitalization. Based on the code, it uses these columns: + +- `id` +- `created_by` +- `title` +- `content` +- `created_at` +- `updated_at` +- `image_url` +- `image_path` +- `image_mime_type` +- `image_size_bytes` + +Application behavior assumes: + +- Each note belongs to a user through `created_by` +- Users can create notes +- Users can edit and delete only their own notes +- Notes are ordered by `updated_at` and `created_at` + +### 4. Storage bucket + +The app expects a public Supabase Storage bucket named: + +```text +note-images +``` + +This bucket is used to upload note images. Stored image paths are then saved in the `Notes` table. + +### 5. `user_push_tokens` table + +For push notifications, the app expects a table named `user_push_tokens` with fields used for registering device tokens. Based on the code, it uses: + +- `installation_id` +- `user_id` +- `push_token` +- `platform` +- `is_active` +- `updated_at` + +## Supabase Edge Function + +This repository includes a Supabase Edge Function at: + +```text +supabase/functions/push/index.ts +``` + +That function is responsible for sending push notifications when notes are created. + +If you want to use that function, your Supabase function environment will need its own server-side values, including: + +```bash +SUPABASE_URL=your_supabase_project_url +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key +EXPO_ACCESS_TOKEN=your_expo_access_token +``` + +Notes: + +- `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are required by the function code. +- `EXPO_ACCESS_TOKEN` may be needed depending on how you configure Expo push notification delivery. + +## Important Submission Note + +Because `.env` is intentionally ignored by Git, this submission does not include live secrets or a working personal backend configuration. To run the project successfully, the evaluator must create their own `.env` file and connect the app to their own Supabase project configured with the expected tables, columns, and storage bucket described above. diff --git a/FastNotes/__tests__/detail-screen.test.tsx b/FastNotes/__tests__/detail-screen.test.tsx index 3a39fcf..3ad323e 100644 --- a/FastNotes/__tests__/detail-screen.test.tsx +++ b/FastNotes/__tests__/detail-screen.test.tsx @@ -28,6 +28,49 @@ function createDeferred(): Deferred { return { promise, resolve, reject } } +type DetailNoteResult = Promise<{ + data: { + id: number + created_by: string + title: string + content: string + created_at: string + updated_at: string + image_url: null + image_path: null + image_mime_type: null + image_size_bytes: null + } | null + error: null +}> + +type NotesQueryMock = { + order: jest.Mock + eq: jest.Mock + neq: jest.Mock + range: jest.Mock, []> + maybeSingle: jest.Mock +} + +function createNotesSelectMock( + noteResult: DetailNoteResult | (() => DetailNoteResult) +) { + let query!: NotesQueryMock + + query = { + order: jest.fn(() => query), + eq: jest.fn((_: string) => query), + neq: jest.fn((_: string) => query), + range: jest.fn(() => Promise.resolve({ data: [], error: null })), + maybeSingle: jest.fn(() => { + const result = typeof noteResult === "function" ? noteResult() : noteResult + return result + }), + } + + return query +} + jest.mock("expo-router", () => ({ router: { replace: jest.fn(), @@ -125,32 +168,23 @@ describe("DetailScreen", () => { }) async function renderLoadedDetailScreen() { - const notesQuery = { - order: jest.fn(), - eq: jest.fn(), - maybeSingle: jest.fn(() => - Promise.resolve({ - data: { - id: 42, - created_by: "user-1", - title: "Fetched note", - content: "Loaded from Supabase for the integration test", - created_at: "2026-03-18T10:00:00.000Z", - updated_at: "2026-03-18T10:05:00.000Z", - image_url: null, - image_path: null, - image_mime_type: null, - image_size_bytes: null, - }, - error: null, - }) - ), - } - - notesQuery.order - .mockImplementationOnce(() => notesQuery) - .mockImplementationOnce(() => Promise.resolve({ data: [], error: null })) - notesQuery.eq.mockReturnValue(notesQuery) + const notesQuery = createNotesSelectMock( + Promise.resolve({ + data: { + id: 42, + created_by: "user-1", + title: "Fetched note", + content: "Loaded from Supabase for the integration test", + created_at: "2026-03-18T10:00:00.000Z", + updated_at: "2026-03-18T10:05:00.000Z", + image_url: null, + image_path: null, + image_mime_type: null, + image_size_bytes: null, + }, + error: null, + }) + ) mockSupabase.from.mockImplementation((table: string) => { if (table === "Notes") { @@ -209,16 +243,7 @@ describe("DetailScreen", () => { } | null error: null }>() - const notesQuery = { - order: jest.fn(), - eq: jest.fn(), - maybeSingle: jest.fn(() => deferredNote.promise), - } - - notesQuery.order - .mockImplementationOnce(() => notesQuery) - .mockImplementationOnce(() => Promise.resolve({ data: [], error: null })) - notesQuery.eq.mockReturnValue(notesQuery) + const notesQuery = createNotesSelectMock(() => deferredNote.promise) mockSupabase.from.mockImplementation((table: string) => { if (table === "Notes") { diff --git a/FastNotes/__tests__/new-note.test.tsx b/FastNotes/__tests__/new-note.test.tsx index 4c964da..70ce19f 100644 --- a/FastNotes/__tests__/new-note.test.tsx +++ b/FastNotes/__tests__/new-note.test.tsx @@ -98,7 +98,12 @@ describe("NewNoteScreen", () => { mockUseNotes.mockReturnValue({ notes: [], isLoading: false, + isLoadingMoreMyNotes: false, + isLoadingMoreWorkNotes: false, refreshNotes: jest.fn(), + loadMoreNotes: jest.fn(), + hasMoreMyNotes: false, + hasMoreWorkNotes: false, fetchNoteById: jest.fn(), addNote: mockAddNote, updateNote: jest.fn(), diff --git a/FastNotes/app/index.tsx b/FastNotes/app/index.tsx index 4f8d5d8..603ba3a 100644 --- a/FastNotes/app/index.tsx +++ b/FastNotes/app/index.tsx @@ -1,25 +1,45 @@ -import { useMemo, useState } from "react" -import { FlatList, Pressable, Text, View } from "react-native" +import { useEffect, useMemo, useRef, useState } from "react" +import { ActivityIndicator, Animated, FlatList, NativeScrollEvent, NativeSyntheticEvent, Pressable, Text, View } from "react-native" import { router } from "expo-router" +import { Ionicons } from "@expo/vector-icons" import { useSafeAreaInsets } from "react-native-safe-area-context" import { Image } from "expo-image" import { useAuthContext } from "@/hooks/use-auth-context" -import { useNotes } from "@/src/notes/NotesContext" -import SignOutButton from '@/components/social-auth-buttons/sign-out-button' +import { type Note, useNotes } from "@/src/notes/NotesContext" +import SignOutButton from "@/components/social-auth-buttons/sign-out-button" import { useAppTheme } from "@/src/theme/AppThemeProvider" import { homeScreenStyles as styles } from "@/src/styles/app-styles" - type TabKey = "my-notes" | "work-notes" -export default function HomeScreen() -{ +const PULL_DISTANCE = 120 +const LOAD_MORE_TRIGGER_DISTANCE = 24 + +export default function HomeScreen() { const { claims } = useAuthContext() - const { errorMessage, isLoading, notes } = useNotes() + const { + errorMessage, + isLoading, + notes, + loadMoreNotes, + hasMoreMyNotes, + hasMoreWorkNotes, + isLoadingMoreMyNotes, + isLoadingMoreWorkNotes, + } = useNotes() const [activeTab, setActiveTab] = useState("my-notes") + const [showNoMoreNotesBubble, setShowNoMoreNotesBubble] = useState(false) const insets = useSafeAreaInsets() const { colorScheme, palette } = useAppTheme() const userId = claims?.sub + const listRef = useRef>(null) + const pullProgress = useRef(new Animated.Value(0)).current + const isLoadingMoreRequest = useRef>({ + "my-notes": false, + "work-notes": false, + }) + const shouldLoadMoreOnRelease = useRef(false) + const hideNoMoreNotesTimer = useRef | null>(null) const filteredNotes = useMemo( () => @@ -29,6 +49,10 @@ export default function HomeScreen() [activeTab, notes, userId] ) + const activeHasMore = activeTab === "my-notes" ? hasMoreMyNotes : hasMoreWorkNotes + const activeIsLoadingMore = + activeTab === "my-notes" ? isLoadingMoreMyNotes : isLoadingMoreWorkNotes + const emptyText = activeTab === "my-notes" ? "No personal notes yet. Create your first note." @@ -44,9 +68,102 @@ export default function HomeScreen() return parsed.toLocaleString() } + const handleScroll = (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent + const distanceFromBottom = + contentSize.height - (contentOffset.y + layoutMeasurement.height) + const progress = Math.max(0, Math.min(1, (PULL_DISTANCE - distanceFromBottom) / PULL_DISTANCE)) + const canScroll = contentSize.height > layoutMeasurement.height + const isNearBottom = distanceFromBottom <= LOAD_MORE_TRIGGER_DISTANCE + + pullProgress.setValue(progress) + shouldLoadMoreOnRelease.current = canScroll && contentOffset.y > 0 && isNearBottom + } + + const handleLoadMore = () => { + if (!activeHasMore || activeIsLoadingMore || isLoadingMoreRequest.current[activeTab]) { + if (!activeHasMore) { + setShowNoMoreNotesBubble(true) + } + return + } + + isLoadingMoreRequest.current[activeTab] = true + + void loadMoreNotes(activeTab).finally(() => { + isLoadingMoreRequest.current[activeTab] = false + }) + } + + const handleScrollEndDrag = () => { + if (shouldLoadMoreOnRelease.current) { + if (!activeHasMore) { + setShowNoMoreNotesBubble(true) + } + handleLoadMore() + } + } + + useEffect(() => { + pullProgress.setValue(0) + shouldLoadMoreOnRelease.current = false + setShowNoMoreNotesBubble(false) + listRef.current?.scrollToOffset({ offset: 0, animated: false }) + }, [activeTab, pullProgress]) + + useEffect(() => { + if (!showNoMoreNotesBubble) { + if (hideNoMoreNotesTimer.current) { + clearTimeout(hideNoMoreNotesTimer.current) + hideNoMoreNotesTimer.current = null + } + return + } + + hideNoMoreNotesTimer.current = setTimeout(() => { + setShowNoMoreNotesBubble(false) + }, 5000) + + return () => { + if (hideNoMoreNotesTimer.current) { + clearTimeout(hideNoMoreNotesTimer.current) + hideNoMoreNotesTimer.current = null + } + } + }, [showNoMoreNotesBubble]) + + const stemHeight = pullProgress.interpolate({ + inputRange: [0, 1], + outputRange: [4, 28], + extrapolate: "clamp", + }) + + const arrowScale = pullProgress.interpolate({ + inputRange: [0, 1], + outputRange: [0.85, 1.2], + extrapolate: "clamp", + }) + + const arrowOpacity = pullProgress.interpolate({ + inputRange: [0, 0.2, 1], + outputRange: [0, 0.4, 1], + extrapolate: "clamp", + }) + + const hintOpacity = activeIsLoadingMore ? 1 : arrowOpacity + return ( - + FastNotes @@ -93,9 +210,13 @@ export default function HomeScreen() {errorMessage ? {errorMessage} : null} n.id} - contentContainerStyle={[styles.list, { paddingBottom: 120 }]} + contentContainerStyle={[styles.list, { paddingBottom: 160 }]} + onScroll={handleScroll} + onScrollEndDrag={handleScrollEndDrag} + scrollEventThrottle={16} ListEmptyComponent={ {isLoading ? "Loading notes..." : emptyText} @@ -117,7 +238,9 @@ export default function HomeScreen() {item.content} - Created by {item.creatorLabel} + + Created by {item.creatorLabel} + Last changed {formatTimestamp(item.lastChangedAt)} @@ -132,9 +255,75 @@ export default function HomeScreen() )} /> + + {activeHasMore || activeIsLoadingMore ? ( + + + + + {activeIsLoadingMore ? ( + + ) : ( + + )} + + + + ) : showNoMoreNotesBubble ? ( + + No more notes + + ) : null} + + {activeTab === "my-notes" ? ( router.push("/newNote")} > + diff --git a/FastNotes/package-lock.json b/FastNotes/package-lock.json index e3bdc17..c11f763 100644 --- a/FastNotes/package-lock.json +++ b/FastNotes/package-lock.json @@ -22,10 +22,10 @@ "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", - "expo-image-manipulator": "^55.0.10", - "expo-image-picker": "^55.0.12", + "expo-image-manipulator": "~14.0.8", + "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.11", - "expo-notifications": "^55.0.12", + "expo-notifications": "^0.32.16", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", @@ -2448,25 +2448,6 @@ "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": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", @@ -2580,6 +2561,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -5101,6 +5088,19 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -5138,7 +5138,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -5624,7 +5623,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -5643,7 +5641,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5657,7 +5654,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6336,7 +6332,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -6363,7 +6358,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -6527,7 +6521,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -6699,7 +6692,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6709,7 +6701,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6747,7 +6738,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7465,9 +7455,9 @@ } }, "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==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", + "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", "license": "MIT", "peerDependencies": { "expo": "*" @@ -7564,33 +7554,33 @@ } }, "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==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", + "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==", "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==", + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz", + "integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==", "license": "MIT", "dependencies": { - "expo-image-loader": "~55.0.0" + "expo-image-loader": "~6.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==", + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz", + "integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==", "license": "MIT", "dependencies": { - "expo-image-loader": "~55.0.0" + "expo-image-loader": "~6.0.0" }, "peerDependencies": { "expo": "*" @@ -7650,16 +7640,18 @@ } }, "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==", + "version": "0.32.16", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz", + "integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.8.12", + "@expo/image-utils": "^0.8.8", + "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", + "assert": "^2.0.0", "badgin": "^1.1.5", - "expo-application": "~55.0.9", - "expo-constants": "~55.0.7" + "expo-application": "~7.0.8", + "expo-constants": "~18.0.13" }, "peerDependencies": { "expo": "*", @@ -7667,103 +7659,6 @@ "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": { "version": "6.0.23", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", @@ -8325,7 +8220,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -8448,7 +8342,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8476,7 +8369,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8519,7 +8411,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -8682,7 +8573,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8723,7 +8613,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -8752,7 +8641,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8765,7 +8653,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9131,6 +9018,22 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -9235,7 +9138,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9358,7 +9260,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.4", @@ -9400,6 +9301,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -9459,7 +9376,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -9555,7 +9471,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -11537,7 +11452,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12243,11 +12157,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12257,7 +12186,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -12833,7 +12761,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13887,7 +13814,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -14057,7 +13983,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -15168,7 +15093,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -15444,6 +15369,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -15892,7 +15830,6 @@ "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", diff --git a/FastNotes/package.json b/FastNotes/package.json index f4d423c..9dc2704 100644 --- a/FastNotes/package.json +++ b/FastNotes/package.json @@ -29,10 +29,10 @@ "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", - "expo-image-manipulator": "^55.0.10", - "expo-image-picker": "^55.0.12", + "expo-image-manipulator": "~14.0.8", + "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.11", - "expo-notifications": "^55.0.12", + "expo-notifications": "^0.32.16", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", diff --git a/FastNotes/src/notes/NotesContext.tsx b/FastNotes/src/notes/NotesContext.tsx index 90fd33e..fbadcb0 100644 --- a/FastNotes/src/notes/NotesContext.tsx +++ b/FastNotes/src/notes/NotesContext.tsx @@ -1,10 +1,14 @@ -import React, { createContext, useCallback, useContext, useEffect, useState } from "react" +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react" import { useAuthContext } from "@/hooks/use-auth-context" import { supabase } from "@/libs/supabase" import { deleteNoteImage, NoteImageUploadProgress, uploadNoteImage } from "@/src/notes/note-image-storage" import { StagedNoteImage } from "@/src/notes/image-utils" +const NOTES_PAGE_SIZE = 5 + +export type NoteListKey = "my-notes" | "work-notes" + type NoteRow = { id: number created_by: string @@ -47,8 +51,13 @@ export type NoteImageChange = type NotesContextValue = { notes: Note[] isLoading: boolean + isLoadingMoreMyNotes: boolean + isLoadingMoreWorkNotes: boolean errorMessage: string | null refreshNotes: () => Promise + loadMoreNotes: (listKey: NoteListKey) => Promise + hasMoreMyNotes: boolean + hasMoreWorkNotes: boolean fetchNoteById: (noteId: string) => Promise addNote: ( title: string, @@ -83,9 +92,18 @@ function normalizeImageSizeBytes(value: number | string | null | undefined) { export function NotesProvider({ children }: { children: React.ReactNode }) { const { claims, isLoggedIn, profile } = useAuthContext() - const [notes, setNotes] = useState([]) + const [pagedNotes, setPagedNotes] = useState([]) + const [fetchedNotes, setFetchedNotes] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [isLoadingMoreMyNotes, setIsLoadingMoreMyNotes] = useState(false) + const [isLoadingMoreWorkNotes, setIsLoadingMoreWorkNotes] = useState(false) + const [myNotesPage, setMyNotesPage] = useState(0) + const [workNotesPage, setWorkNotesPage] = useState(0) + const [hasMoreMyNotes, setHasMoreMyNotes] = useState(true) + const [hasMoreWorkNotes, setHasMoreWorkNotes] = useState(true) const [errorMessage, setErrorMessage] = useState(null) + const myNotesPageRef = useRef(0) + const workNotesPageRef = useRef(0) const userId = claims?.sub as string | undefined const creatorLabel = @@ -138,9 +156,72 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { imageSizeBytes: normalizeImageSizeBytes(row.image_size_bytes), }), [creatorLabel, userId]) - const loadNotes = useCallback(async () => { + const mergeNotes = useCallback((existingNotes: Note[], incomingNotes: Note[]) => { + const notesById = new Map() + + for (const note of existingNotes) { + notesById.set(note.id, note) + } + + for (const note of incomingNotes) { + notesById.set(note.id, note) + } + + return Array.from(notesById.values()).sort((left, right) => { + const leftTime = new Date(left.lastChangedAt).getTime() + const rightTime = new Date(right.lastChangedAt).getTime() + + return rightTime - leftTime + }) + }, []) + + const notes = useMemo(() => mergeNotes(pagedNotes, fetchedNotes), [fetchedNotes, mergeNotes, pagedNotes]) + + const fetchNotesPage = useCallback(async (listKey: NoteListKey, page: number, pageSize = NOTES_PAGE_SIZE) => { + if (!userId) { + return [] + } + + const rangeStart = page * NOTES_PAGE_SIZE + const rangeEnd = rangeStart + pageSize - 1 + + let query = supabase + .from("Notes") + .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("created_at", { ascending: false }) + + query = + listKey === "my-notes" + ? query.eq("created_by", userId) + : query.neq("created_by", userId) + + const { data, error } = await query.range(rangeStart, rangeEnd) + + if (error) { + throw new Error(error.message) + } + + const rows = (data ?? []) as NoteRow[] + const labels = await buildCreatorLabels(rows) + + return rows.map((row) => mapNoteRow(row, labels)) + }, [buildCreatorLabels, mapNoteRow, userId]) + + const loadNotes = useCallback(async (preserveLoadedPages = false) => { if (!isLoggedIn) { - setNotes([]) + setPagedNotes([]) + setFetchedNotes([]) + setIsLoadingMoreMyNotes(false) + setIsLoadingMoreWorkNotes(false) + setMyNotesPage(0) + setWorkNotesPage(0) + myNotesPageRef.current = 0 + workNotesPageRef.current = 0 + setHasMoreMyNotes(true) + setHasMoreWorkNotes(true) setErrorMessage(null) setIsLoading(false) return @@ -149,33 +230,97 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { setIsLoading(true) setErrorMessage(null) - const { data, error } = await supabase - .from("Notes") - .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("created_at", { ascending: false }) + try { + const myPageCount = preserveLoadedPages ? Math.max(1, myNotesPageRef.current) : 1 + const workPageCount = preserveLoadedPages ? Math.max(1, workNotesPageRef.current) : 1 - if (error) { - setErrorMessage(error.message) - setNotes([]) + const [myNotes, workNotes] = await Promise.all([ + fetchNotesPage("my-notes", 0, myPageCount * NOTES_PAGE_SIZE), + fetchNotesPage("work-notes", 0, workPageCount * NOTES_PAGE_SIZE), + ]) + + setPagedNotes(mergeNotes(myNotes, workNotes)) + setMyNotesPage(myPageCount) + setWorkNotesPage(workPageCount) + myNotesPageRef.current = myPageCount + workNotesPageRef.current = workPageCount + setHasMoreMyNotes(myNotes.length === myPageCount * NOTES_PAGE_SIZE) + setHasMoreWorkNotes(workNotes.length === workPageCount * NOTES_PAGE_SIZE) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to load notes.") + setPagedNotes([]) + } finally { setIsLoading(false) + } + }, [fetchNotesPage, isLoggedIn, mergeNotes]) + + const refreshNotes = async () => { + await loadNotes(true) + } + + const loadMoreNotes = useCallback(async (listKey: NoteListKey) => { + if (!isLoggedIn || !userId) { return } - const rows = (data ?? []) as NoteRow[] - const labels = await buildCreatorLabels(rows) + const isMyNotes = listKey === "my-notes" + const nextPage = isMyNotes ? myNotesPage : workNotesPage + const hasMoreNotes = isMyNotes ? hasMoreMyNotes : hasMoreWorkNotes + const isAlreadyLoading = isMyNotes ? isLoadingMoreMyNotes : isLoadingMoreWorkNotes - setNotes( - rows.map((row) => mapNoteRow(row, labels)) - ) - setIsLoading(false) - }, [buildCreatorLabels, isLoggedIn, mapNoteRow]) + if (!hasMoreNotes || isAlreadyLoading) { + return + } - const refreshNotes = async () => { - await loadNotes() - } + setErrorMessage(null) + + if (isMyNotes) { + setIsLoadingMoreMyNotes(true) + } else { + setIsLoadingMoreWorkNotes(true) + } + + try { + const nextNotes = await fetchNotesPage(listKey, nextPage) + + setPagedNotes((prev) => mergeNotes(prev, nextNotes)) + + if (isMyNotes) { + setMyNotesPage((prev) => { + const nextValue = prev + 1 + myNotesPageRef.current = nextValue + return nextValue + }) + setHasMoreMyNotes(nextNotes.length === NOTES_PAGE_SIZE) + } else { + setWorkNotesPage((prev) => { + const nextValue = prev + 1 + workNotesPageRef.current = nextValue + return nextValue + }) + setHasMoreWorkNotes(nextNotes.length === NOTES_PAGE_SIZE) + } + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to load more notes.") + } finally { + if (isMyNotes) { + setIsLoadingMoreMyNotes(false) + } else { + setIsLoadingMoreWorkNotes(false) + } + } + }, [ + fetchNotesPage, + hasMoreMyNotes, + hasMoreWorkNotes, + isLoadingMoreMyNotes, + isLoadingMoreWorkNotes, + isLoggedIn, + mergeNotes, + myNotesPage, + userId, + workNotesPage, + ]) const fetchNoteById = useCallback(async (noteId: string) => { if (!isLoggedIn || !noteId) { @@ -205,17 +350,15 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { const labels = await buildCreatorLabels([row]) const fetchedNote = mapNoteRow(row, labels) - setNotes((prev) => { - const nextNotes = prev.filter((existingNote) => existingNote.id !== fetchedNote.id) - return [fetchedNote, ...nextNotes] - }) + setFetchedNotes((prev) => mergeNotes(prev, [fetchedNote])) return fetchedNote }, [buildCreatorLabels, isLoggedIn, mapNoteRow]) useEffect(() => { if (!isLoggedIn || !userId) { - setNotes([]) + setPagedNotes([]) + setFetchedNotes([]) setErrorMessage(null) setIsLoading(false) return @@ -230,7 +373,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { } const intervalId = setInterval(() => { - void loadNotes() + void loadNotes(true) }, 30000) return () => { @@ -400,7 +543,23 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { } } - setNotes((prev) => + setPagedNotes((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 + ) + ) + setFetchedNotes((prev) => prev.map((note) => note.id === noteId ? { @@ -461,7 +620,8 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { } } - setNotes((prev) => prev.filter((note) => note.id !== noteId)) + setPagedNotes((prev) => prev.filter((note) => note.id !== noteId)) + setFetchedNotes((prev) => prev.filter((note) => note.id !== noteId)) return true } @@ -470,8 +630,13 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { value={{ notes, isLoading, + isLoadingMoreMyNotes, + isLoadingMoreWorkNotes, errorMessage, refreshNotes, + loadMoreNotes, + hasMoreMyNotes, + hasMoreWorkNotes, fetchNoteById, addNote, updateNote, diff --git a/FastNotes/src/styles/app-styles.ts b/FastNotes/src/styles/app-styles.ts index bd1a42a..465af79 100644 --- a/FastNotes/src/styles/app-styles.ts +++ b/FastNotes/src/styles/app-styles.ts @@ -326,6 +326,77 @@ export const homeScreenStyles = StyleSheet.create({ elevation: 8, }, fabText: { fontSize: 28, lineHeight: 28, fontWeight: "700" }, + loadMoreHint: { + position: "absolute", + left: 16, + right: 16, + alignItems: "center", + }, + loadMoreHintCard: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + width: "100%", + borderWidth: 1, + borderRadius: 20, + paddingHorizontal: 16, + paddingVertical: 14, + shadowColor: "#000", + shadowOpacity: 0.12, + shadowOffset: { width: 0, height: 6 }, + shadowRadius: 12, + elevation: 6, + }, + loadMoreHintCardCompact: { + width: undefined, + minWidth: 0, + maxWidth: 220, + justifyContent: "center", + alignSelf: "center", + }, + loadMoreHintIconCard: { + width: 64, + minWidth: 64, + maxWidth: 64, + justifyContent: "center", + alignSelf: "center", + paddingHorizontal: 12, + paddingVertical: 12, + }, + loadMoreHintArrowOnly: { + alignItems: "center", + justifyContent: "flex-end", + alignSelf: "center", + }, + loadMoreHintTextBlock: { + flex: 1, + gap: 4, + }, + loadMoreHintTitle: { + fontSize: 14, + fontWeight: "700", + }, + loadMoreHintSubtitle: { + fontSize: 12, + lineHeight: 16, + }, + loadMoreHintGlyphColumn: { + alignItems: "center", + justifyContent: "flex-end", + width: 30, + }, + loadMoreHintStem: { + width: 2, + borderRadius: 999, + marginBottom: 4, + }, + loadMoreHintGlyph: { + width: 30, + height: 30, + alignItems: "center", + justifyContent: "center", + }, }) export const noteImagePanelStyles = StyleSheet.create({