Fixed notifications not being saved unless you clicked on them
- Added automatic cleanup of old notifications after 60 days - Added deduplicateItems function to prevent the same notification to appear multiple times - Fixed timestamp
This commit is contained in:
parent
26e5b0ae6f
commit
ed7c6d630c
131
README.md
131
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
|
<p align="center">
|
||||||
npm install
|
<img src="assets/images/main_menu.jpg" alt="Main Menu" width="30%"/>
|
||||||
```
|
<img src="assets/images/sidemenu.jpg" alt="Side Menu" width="30%"/>
|
||||||
|
<img src="assets/images/rr_category.jpg" alt="RR Category" width="30%"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
2. Start the app
|
## Prerequisites
|
||||||
|
|
||||||
```bash
|
- Node.js (v14 or higher)
|
||||||
npx expo start
|
- 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).
|
## Building from scratch
|
||||||
|
|
||||||
## Get a fresh project
|
|
||||||
|
|
||||||
When you're ready, run:
|
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
npm run reset-project
|
git clone <repository-url>
|
||||||
|
cd <project-directory>
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
## Configuration
|
||||||
- [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.
|
|
||||||
|
|
||||||
## 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.
|
To change this, modify the `API_URL` constant in `index.tsx`.
|
||||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
|
||||||
|
### 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: <your-api-key>`
|
||||||
|
- Body: `{ token, platform, app_ver, locale, topics }`
|
||||||
|
|
||||||
|
- **POST /unregister-token** - Unregister device
|
||||||
|
- Headers: `X-API-Key: <your-api-key>`
|
||||||
|
- Body: `{ token, platform, app_ver, locale, topics }`
|
||||||
|
|||||||
@ -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 AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { createDrawerNavigator, DrawerContentScrollView, DrawerItemList } from '@react-navigation/drawer';
|
import { createDrawerNavigator, DrawerContentScrollView, DrawerItemList } from '@react-navigation/drawer';
|
||||||
import { categories, categoryTitles, Category } from '../types/Category';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Item } from "../types/Item";
|
import { Button, Linking, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
import { styles } from '../styles/styles';
|
import { STORAGE_KEY, useNotificationListener } from '../hooks/useNotificationListener';
|
||||||
import { usePushNotifications } from '../hooks/usePushNotifications';
|
import { usePushNotifications } from '../hooks/usePushNotifications';
|
||||||
import { useNotificationListener, STORAGE_KEY } from '../hooks/useNotificationListener';
|
|
||||||
import { version as appVersion } from '../package.json';
|
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 Drawer = createDrawerNavigator();
|
||||||
const API_KEY_STORAGE = 'API_KEY';
|
const API_KEY_STORAGE = 'API_KEY';
|
||||||
@ -86,7 +86,7 @@ function CategoryScreen({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text style={{ fontSize: 20, marginBottom: 16, color: "#fff", textAlign: "center" }}>
|
<Text style={{ fontSize: 30, marginBottom: 16, color: "#000", textAlign: "center" }}>
|
||||||
{categoryTitles[category]}
|
{categoryTitles[category]}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@ -116,14 +116,14 @@ function CategoryScreen({
|
|||||||
function CustomDrawerContent({ onLogout, ...props }: any) {
|
function CustomDrawerContent({ onLogout, ...props }: any) {
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: '#333' }}>
|
<View style={{ flex: 1, backgroundColor: '#333' }}>
|
||||||
<DrawerContentScrollView {...props} contentContainerStyle={{ paddingTop: 0 }}>
|
<DrawerContentScrollView {...props} contentContainerStyle={{ paddingTop: 20 }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1, paddingVertical: 10 }}>
|
||||||
<DrawerItemList {...props} />
|
<DrawerItemList {...props} />
|
||||||
</View>
|
</View>
|
||||||
</DrawerContentScrollView>
|
</DrawerContentScrollView>
|
||||||
|
|
||||||
{/* Logout Button */}
|
{/* Logout Button */}
|
||||||
<View style={{ padding: 20, borderTopWidth: 1, borderTopColor: '#444' }}>
|
<View style={{ padding: 20,paddingBottom: 30, borderTopWidth: 1, borderTopColor: '#444' }}>
|
||||||
<TouchableOpacity onPress={onLogout} style={styles.logoutButton}>
|
<TouchableOpacity onPress={onLogout} style={styles.logoutButton}>
|
||||||
<Text style={styles.logoutText}>Logout</Text>
|
<Text style={styles.logoutText}>Logout</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -188,6 +188,7 @@ export default function App() {
|
|||||||
drawerActiveTintColor: '#fff',
|
drawerActiveTintColor: '#fff',
|
||||||
drawerInactiveTintColor: '#ccc',
|
drawerInactiveTintColor: '#ccc',
|
||||||
drawerLabelStyle: { textAlign: 'center', fontSize: 16 },
|
drawerLabelStyle: { textAlign: 'center', fontSize: 16 },
|
||||||
|
drawerItemStyle: { paddingVertical: 5, marginVertical: 2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{categories.map(c => (
|
{categories.map(c => (
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/main_menu.jpg
Normal file
BIN
assets/images/main_menu.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
BIN
assets/images/rr_category.jpg
Normal file
BIN
assets/images/rr_category.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
BIN
assets/images/sidemenu.jpg
Normal file
BIN
assets/images/sidemenu.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@ -1,53 +1,159 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Category, categoryKeys } from "../types/Category";
|
import { Category, categoryKeys } from "../types/Category";
|
||||||
import { Item } from "../types/Item";
|
import { Item } from "../types/Item";
|
||||||
|
|
||||||
export const STORAGE_KEY = "notifications-data";
|
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<void>> = [];
|
||||||
|
private processing = false;
|
||||||
|
|
||||||
|
async add(task: () => Promise<void>) {
|
||||||
|
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) {
|
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(() => {
|
useEffect(() => {
|
||||||
// Load saved notifications on mount
|
onItemsRef.current = onItems;
|
||||||
|
}, [onItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const saved = await AsyncStorage.getItem(STORAGE_KEY);
|
const saved = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
if (saved) {
|
let existing: Item[] = saved ? JSON.parse(saved) : [];
|
||||||
const parsed: Item[] = JSON.parse(saved);
|
|
||||||
// sort newest first
|
// Parse timestamp correctly
|
||||||
onItems(parsed.sort((a, b) => b.timestamp - a.timestamp));
|
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) {
|
} 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 receivedSub = Notifications.addNotificationReceivedListener(async (notification) => {
|
||||||
const item = mapNotificationToItem(notification);
|
const item = mapNotificationToItem(notification);
|
||||||
if (item) {
|
if (item && mountedRef.current) {
|
||||||
await addItem(item, onItems);
|
await addItem(item, (items) => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
onItemsRef.current(items);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for when user taps a notification
|
|
||||||
const responseSub = Notifications.addNotificationResponseReceivedListener(async (response) => {
|
const responseSub = Notifications.addNotificationResponseReceivedListener(async (response) => {
|
||||||
const item = mapNotificationToItem(response.notification);
|
const item = mapNotificationToItem(response.notification);
|
||||||
if (item) {
|
if (item && mountedRef.current) {
|
||||||
await addItem(item, onItems);
|
await addItem(item, (items) => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
onItemsRef.current(items);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
receivedSub.remove();
|
receivedSub.remove();
|
||||||
responseSub.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<string>();
|
||||||
|
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 {
|
export function mapNotificationToItem(notification: Notifications.Notification): Item | null {
|
||||||
try {
|
try {
|
||||||
const content = notification.request.content;
|
const content = notification.request.content;
|
||||||
@ -58,8 +164,12 @@ export function mapNotificationToItem(notification: Notifications.Notification):
|
|||||||
? (rawCategory as Category)
|
? (rawCategory as Category)
|
||||||
: "utility";
|
: "utility";
|
||||||
|
|
||||||
|
const timestamp = data.timestamp
|
||||||
|
? normalizeTimestamp(data.timestamp)
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timestamp: data.timestamp ? Number(data.timestamp) : Date.now(),
|
timestamp,
|
||||||
category,
|
category,
|
||||||
title: content.title ?? "No title",
|
title: content.title ?? "No title",
|
||||||
info: content.body ?? "No description",
|
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) {
|
async function addItem(item: Item, onItems: (items: Item[]) => void) {
|
||||||
try {
|
await storageQueue.add(async () => {
|
||||||
const saved = await AsyncStorage.getItem(STORAGE_KEY);
|
try {
|
||||||
const prev: Item[] = saved ? JSON.parse(saved) : [];
|
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));
|
// Deduplicate and clean
|
||||||
onItems(updated);
|
const deduplicated = deduplicateItems(combined);
|
||||||
} catch (e) {
|
const cleaned = cleanOldItems(deduplicated);
|
||||||
console.error("Failed to persist notification:", e);
|
|
||||||
}
|
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;
|
export default null;
|
||||||
Loading…
x
Reference in New Issue
Block a user