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:
2025-10-18 19:40:16 +02:00
parent 26e5b0ae6f
commit ed7c6d630c
7 changed files with 257 additions and 69 deletions

View File

@@ -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<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) {
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<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 {
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;