diff --git a/README.md b/README.md index 48dd63f..208e39c 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,119 @@ -# Welcome to your Expo app 👋 +# Notifier app -This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). +A React Native mobile application for receiving push notifications. -## Get started +## Features -1. Install dependencies +- **Secure API Authentication** - API key-based authentication to connect to the `backend-api` +- **Notification Storage** - Notifications are saved locally and deleted after 60 days +- **Category Organization** - Notifications are organized into categories and a complete history is displayed in Home +- **Drawer Navigation** - Easy navigation between different notification categories +- **Interactive Links** - Tap on notification links to open them in your device's browser +- **Logout Functionality** - Securely unregister your device and clear all local data - ```bash - npm install - ``` +

+ Main Menu + Side Menu + RR Category +

-2. Start the app +## Prerequisites - ```bash - npx expo start - ``` +- Node.js (v14 or higher) +- Firebase account with google-services.json +- Expo account with Firebase Admin SDK private key imported +- Installed eas-cli +- Android SDK 36 +- Physical Android device (push notifications don't work on simulators/emulators) +- API key -In the output, you'll find options to open the app in a -- [development build](https://docs.expo.dev/develop/development-builds/introduction/) -- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) -- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) -- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo -You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). - -## Get a fresh project - -When you're ready, run: +## Building from scratch +1. Clone the repository: ```bash -npm run reset-project +git clone +cd ``` -This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. +2. Install dependencies: +```bash +npm install +``` -## Learn more +3. Copy google-services.json to root directory and delete .gitignore file (or comment out google-services.json) -To learn more about developing your project with Expo, look at the following resources: +4. Use EAS to build and follow along +```bash +eas build --profile release --local --platform android +``` -- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). -- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. +## Configuration -## Join the community +### Backend URL -Join our community of developers creating universal apps. +The app connects to the notification backend at: +``` +https://notifier.gansejunge.com +``` -- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. -- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. +To change this, modify the `API_URL` constant in `index.tsx`. + +### Categories + +Categories are defined in `types/Category.ts`. Special categories: +- **Home** - View all notifications +- **Utility** - Default category for uncategorized notifications + + + +## Technical Details + +### Notification Data Structure + +Notifications should include the following data fields from your backend: + +```json +{ + "title": "Notification Title", + "body": "Notification message text", + "data": { + "category": "utility", + "link": "https://example.com", + "timestamp": 1760734800 + } +} +``` + +- `category` - Optional. Defaults to "utility" if not provided +- `link` - Optional. Set to "#" if no link should be shown +- `timestamp` - Optional. Defaults to when the notification was received when empty + + +## Project Structure + +``` +├── index.tsx # Main app component +├── hooks/ +│ ├── usePushNotifications.ts # Push notification registration +│ └── useNotificationListener.ts # Notification receiving and storage +├── types/ +│ ├── Category.ts # Category definitions +│ └── Item.ts # Notification item type +└── styles/ + └── styles.ts # App styling +``` + + +## API Endpoints + +The app communicates with these backend endpoints: + +- **POST /register-token** - Register device for push notifications + - Headers: `X-API-Key: ` + - Body: `{ token, platform, app_ver, locale, topics }` + +- **POST /unregister-token** - Unregister device + - Headers: `X-API-Key: ` + - Body: `{ token, platform, app_ver, locale, topics }` diff --git a/app/index.tsx b/app/index.tsx index 34f7d02..b6f5da3 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,13 +1,13 @@ -import React, { useEffect, useState } from 'react'; -import { View, Text, TextInput, Button, Linking, TouchableOpacity, ScrollView } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createDrawerNavigator, DrawerContentScrollView, DrawerItemList } from '@react-navigation/drawer'; -import { categories, categoryTitles, Category } from '../types/Category'; -import { Item } from "../types/Item"; -import { styles } from '../styles/styles'; +import React, { useEffect, useState } from 'react'; +import { Button, Linking, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { STORAGE_KEY, useNotificationListener } from '../hooks/useNotificationListener'; import { usePushNotifications } from '../hooks/usePushNotifications'; -import { useNotificationListener, STORAGE_KEY } from '../hooks/useNotificationListener'; import { version as appVersion } from '../package.json'; +import { styles } from '../styles/styles'; +import { categories, Category, categoryTitles } from '../types/Category'; +import { Item } from "../types/Item"; const Drawer = createDrawerNavigator(); const API_KEY_STORAGE = 'API_KEY'; @@ -86,7 +86,7 @@ function CategoryScreen({ )} - + {categoryTitles[category]} @@ -116,14 +116,14 @@ function CategoryScreen({ function CustomDrawerContent({ onLogout, ...props }: any) { return ( - - + + {/* Logout Button */} - + Logout @@ -188,6 +188,7 @@ export default function App() { drawerActiveTintColor: '#fff', drawerInactiveTintColor: '#ccc', drawerLabelStyle: { textAlign: 'center', fontSize: 16 }, + drawerItemStyle: { paddingVertical: 5, marginVertical: 2 }, }} > {categories.map(c => ( diff --git a/assets/images/favicon.png b/assets/images/favicon.png deleted file mode 100644 index d113773..0000000 Binary files a/assets/images/favicon.png and /dev/null differ diff --git a/assets/images/main_menu.jpg b/assets/images/main_menu.jpg new file mode 100644 index 0000000..0b71eb3 Binary files /dev/null and b/assets/images/main_menu.jpg differ diff --git a/assets/images/rr_category.jpg b/assets/images/rr_category.jpg new file mode 100644 index 0000000..e0b815c Binary files /dev/null and b/assets/images/rr_category.jpg differ diff --git a/assets/images/sidemenu.jpg b/assets/images/sidemenu.jpg new file mode 100644 index 0000000..65d1d57 Binary files /dev/null and b/assets/images/sidemenu.jpg differ diff --git a/hooks/useNotificationListener.ts b/hooks/useNotificationListener.ts index c7fa03c..8baf2ab 100644 --- a/hooks/useNotificationListener.ts +++ b/hooks/useNotificationListener.ts @@ -1,53 +1,159 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import * as Notifications from "expo-notifications"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { Category, categoryKeys } from "../types/Category"; import { Item } from "../types/Item"; export const STORAGE_KEY = "notifications-data"; +const MAX_STORED_NOTIFICATIONS = 100; +const MAX_AGE_DAYS = 60; + +// Simple queue to prevent race conditions +class StorageQueue { + private queue: Array<() => Promise> = []; + private processing = false; + + async add(task: () => Promise) { + this.queue.push(task); + if (!this.processing) { + await this.process(); + } + } + + private async process() { + this.processing = true; + while (this.queue.length > 0) { + const task = this.queue.shift(); + if (task) { + try { + await task(); + } catch (e) { + console.error("Storage task failed:", e); + } + } + } + this.processing = false; + } +} + +const storageQueue = new StorageQueue(); -// Hook: listens for incoming notifications → Item + persists them export function useNotificationListener(onItems: (items: Item[]) => void) { + const onItemsRef = useRef(onItems); + const mountedRef = useRef(true); + + // Keep ref updated but don't trigger effect re-runs useEffect(() => { - // Load saved notifications on mount + onItemsRef.current = onItems; + }, [onItems]); + + useEffect(() => { + mountedRef.current = true; + (async () => { try { const saved = await AsyncStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed: Item[] = JSON.parse(saved); - // sort newest first - onItems(parsed.sort((a, b) => b.timestamp - a.timestamp)); + let existing: Item[] = saved ? JSON.parse(saved) : []; + + // Parse timestamp correctly + existing = existing.map((i) => ({ + ...i, + timestamp: Number(i.timestamp), + })); + + // Load delivered notifications from the system + const delivered = await Notifications.getPresentedNotificationsAsync?.(); + const deliveredItems: Item[] = delivered + ?.map(mapNotificationToItem) + .filter((i): i is Item => i !== null) ?? []; + + // Combine, deduplicate, and clean + const combined = deduplicateItems([...deliveredItems, ...existing]); + const cleaned = cleanOldItems(combined); + + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(cleaned)); + + if (mountedRef.current) { + onItemsRef.current(cleaned); } } catch (e) { - console.error("Failed to load notifications from storage:", e); + console.error("Failed to load notifications:", e); } })(); - // Listen for notification received while app is in foreground const receivedSub = Notifications.addNotificationReceivedListener(async (notification) => { const item = mapNotificationToItem(notification); - if (item) { - await addItem(item, onItems); + if (item && mountedRef.current) { + await addItem(item, (items) => { + if (mountedRef.current) { + onItemsRef.current(items); + } + }); } }); - // Listen for when user taps a notification const responseSub = Notifications.addNotificationResponseReceivedListener(async (response) => { const item = mapNotificationToItem(response.notification); - if (item) { - await addItem(item, onItems); + if (item && mountedRef.current) { + await addItem(item, (items) => { + if (mountedRef.current) { + onItemsRef.current(items); + } + }); } }); - // Cleanup on unmount return () => { + mountedRef.current = false; receivedSub.remove(); responseSub.remove(); }; - }, [onItems]); + }, []); // Empty deps - only run once +} + +function normalizeTimestamp(timestamp: any): number { + const num = Number(timestamp); + + if (!num || isNaN(num)) { + return Date.now(); + } + + // If timestamp appears to be in seconds, convert to milliseconds + if (num < 1000000000000) { + return num * 1000; + } + + return num; +} + +function generateItemId(item: Item): string { + // Create a unique ID based on timestamp, title, and info + return `${item.timestamp}-${item.title}-${item.info}`.substring(0, 100); +} + +function deduplicateItems(items: Item[]): Item[] { + const seen = new Set(); + const unique: Item[] = []; + + for (const item of items) { + const id = generateItemId(item); + if (!seen.has(id)) { + seen.add(id); + unique.push(item); + } + } + + return unique.sort((a, b) => b.timestamp - a.timestamp); +} + +function cleanOldItems(items: Item[]): Item[] { + const maxAge = Date.now() - (MAX_AGE_DAYS * 24 * 60 * 60 * 1000); + + return items + .filter(item => item.timestamp > maxAge) + .slice(0, MAX_STORED_NOTIFICATIONS); } -// Convert Expo notification → Item export function mapNotificationToItem(notification: Notifications.Notification): Item | null { try { const content = notification.request.content; @@ -58,8 +164,12 @@ export function mapNotificationToItem(notification: Notifications.Notification): ? (rawCategory as Category) : "utility"; + const timestamp = data.timestamp + ? normalizeTimestamp(data.timestamp) + : Date.now(); + return { - timestamp: data.timestamp ? Number(data.timestamp) : Date.now(), + timestamp, category, title: content.title ?? "No title", info: content.body ?? "No description", @@ -71,19 +181,27 @@ export function mapNotificationToItem(notification: Notifications.Notification): } } -// Save a new item into storage and update state async function addItem(item: Item, onItems: (items: Item[]) => void) { - try { - const saved = await AsyncStorage.getItem(STORAGE_KEY); - const prev: Item[] = saved ? JSON.parse(saved) : []; + await storageQueue.add(async () => { + try { + const saved = await AsyncStorage.getItem(STORAGE_KEY); + const prev: Item[] = saved ? JSON.parse(saved) : []; - const updated = [item, ...prev].sort((a, b) => b.timestamp - a.timestamp); + const combined = [item, ...prev] + .map((i) => ({ ...i, timestamp: Number(i.timestamp) })); - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); - onItems(updated); - } catch (e) { - console.error("Failed to persist notification:", e); - } + // Deduplicate and clean + const deduplicated = deduplicateItems(combined); + const cleaned = cleanOldItems(deduplicated); + + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(cleaned)); + onItems(cleaned); + } catch (e) { + console.error("Failed to persist notification:", e); + // Still call onItems with the new item so UI updates even if persistence fails + onItems([item]); + } + }); } export default null; \ No newline at end of file