Fixed notifications vanishing when app wasn't ready and sometimes showing up twice

This commit is contained in:
florian committed 2026-10-27 17:16:29 +01:00
1 parent ed7c6d630c
commit 78769604a5
6 files changed
+2638 -6762

No files matched your search

+3 -2
View File
@@ -12,7 +12,6 @@
"supportsTablet": true "supportsTablet": true
}, },
"android": { "android": {
"useNextNotificationsApi": true,
"googleServicesFile": "./google-services.json", "googleServicesFile": "./google-services.json",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
@@ -39,7 +38,9 @@
"backgroundColor": "#000000" "backgroundColor": "#000000"
} }
} }
] ],
"expo-font",
"expo-web-browser"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
+3 -3
View File
@@ -1,8 +1,8 @@
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 React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Linking, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { Button, Linking, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { STORAGE_KEY, useNotificationListener } from '../hooks/useNotificationListener'; import { clearStoredNotifications, useNotificationListener } from '../hooks/useNotificationListener';
import { usePushNotifications } from '../hooks/usePushNotifications'; import { usePushNotifications } from '../hooks/usePushNotifications';
import { version as appVersion } from '../package.json'; import { version as appVersion } from '../package.json';
import { styles } from '../styles/styles'; import { styles } from '../styles/styles';
@@ -156,7 +156,7 @@ export default function App() {
const handleLogout = async () => { const handleLogout = async () => {
if (unregisterToken) await unregisterToken(); if (unregisterToken) await unregisterToken();
await AsyncStorage.removeItem(API_KEY_STORAGE); await AsyncStorage.removeItem(API_KEY_STORAGE);
await AsyncStorage.removeItem(STORAGE_KEY); await clearStoredNotifications();
setApiKey(null); setApiKey(null);
setApiKeySaved(false); setApiKeySaved(false);
setNotifications([]); setNotifications([]);
+46 -111
View File
@@ -1,6 +1,7 @@
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, useRef } from "react"; import { useEffect, useRef } from "react";
import { AppState } from "react-native";
import { Category, categoryKeys } from "../types/Category"; import { Category, categoryKeys } from "../types/Category";
import { Item } from "../types/Item"; import { Item } from "../types/Item";
@@ -8,107 +9,62 @@ export const STORAGE_KEY = "notifications-data";
const MAX_STORED_NOTIFICATIONS = 100; const MAX_STORED_NOTIFICATIONS = 100;
const MAX_AGE_DAYS = 60; const MAX_AGE_DAYS = 60;
// Simple queue to prevent race conditions let chain: Promise<void> = Promise.resolve();
class StorageQueue { function enqueue(task: () => Promise<void>) {
private queue: Array<() => Promise<void>> = []; chain = chain.then(task).catch((e) => console.error("Storage task failed:", e));
private processing = false; return chain;
async add(task: () => Promise<void>) {
this.queue.push(task);
if (!this.processing) {
await this.process();
}
} }
private async process() { function mergeItems(newItems: Item[], emit: (items: Item[]) => void) {
this.processing = true; return enqueue(async () => {
while (this.queue.length > 0) { const saved = await AsyncStorage.getItem(STORAGE_KEY);
const task = this.queue.shift(); const prev: Item[] = saved ? JSON.parse(saved) : [];
if (task) { const merged = cleanOldItems(
try { deduplicateItems([...newItems, ...prev].map((i) => ({ ...i, timestamp: Number(i.timestamp) })))
await task(); );
} catch (e) { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
console.error("Storage task failed:", e); emit(merged);
});
} }
}
}
this.processing = false;
}
}
const storageQueue = new StorageQueue();
export function useNotificationListener(onItems: (items: Item[]) => void) { export function useNotificationListener(onItems: (items: Item[]) => void) {
const onItemsRef = useRef(onItems); const onItemsRef = useRef(onItems);
const mountedRef = useRef(true); useEffect(() => { onItemsRef.current = onItems; }, [onItems]);
// Keep ref updated but don't trigger effect re-runs
useEffect(() => {
onItemsRef.current = onItems;
}, [onItems]);
useEffect(() => { useEffect(() => {
mountedRef.current = true; let active = true;
const emit = (items: Item[]) => { if (active) onItemsRef.current(items); };
const add = (n: Notifications.Notification) => {
const item = mapNotificationToItem(n);
if (item) mergeItems([item], emit);
};
(async () => { const sync = async () => {
try { const incoming: Item[] = [];
const saved = await AsyncStorage.getItem(STORAGE_KEY); const delivered = await Notifications.getPresentedNotificationsAsync();
let existing: Item[] = saved ? JSON.parse(saved) : []; incoming.push(...delivered.map(mapNotificationToItem).filter((i): i is Item => !!i));
// Parse timestamp correctly const last = await Notifications.getLastNotificationResponseAsync();
existing = existing.map((i) => ({ const lastItem = last && mapNotificationToItem(last.notification);
...i, if (lastItem) incoming.push(lastItem);
timestamp: Number(i.timestamp),
}));
// Load delivered notifications from the system await mergeItems(incoming, emit);
const delivered = await Notifications.getPresentedNotificationsAsync?.(); };
const deliveredItems: Item[] = delivered sync().catch(console.error);
?.map(mapNotificationToItem)
.filter((i): i is Item => i !== null) ?? [];
// Combine, deduplicate, and clean const receivedSub = Notifications.addNotificationReceivedListener(add);
const combined = deduplicateItems([...deliveredItems, ...existing]); const responseSub = Notifications.addNotificationResponseReceivedListener((r) => add(r.notification));
const cleaned = cleanOldItems(combined); const appStateSub = AppState.addEventListener("change", (s) => {
if (s === "active") sync().catch(console.error);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(cleaned));
if (mountedRef.current) {
onItemsRef.current(cleaned);
}
} catch (e) {
console.error("Failed to load notifications:", e);
}
})();
const receivedSub = Notifications.addNotificationReceivedListener(async (notification) => {
const item = mapNotificationToItem(notification);
if (item && mountedRef.current) {
await addItem(item, (items) => {
if (mountedRef.current) {
onItemsRef.current(items);
}
});
}
});
const responseSub = Notifications.addNotificationResponseReceivedListener(async (response) => {
const item = mapNotificationToItem(response.notification);
if (item && mountedRef.current) {
await addItem(item, (items) => {
if (mountedRef.current) {
onItemsRef.current(items);
}
});
}
}); });
return () => { return () => {
mountedRef.current = false; active = false;
receivedSub.remove(); receivedSub.remove();
responseSub.remove(); responseSub.remove();
appStateSub.remove();
}; };
}, []); // Empty deps - only run once }, []);
} }
function normalizeTimestamp(timestamp: any): number { function normalizeTimestamp(timestamp: any): number {
@@ -126,11 +82,6 @@ function normalizeTimestamp(timestamp: any): number {
return num; 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[] { function deduplicateItems(items: Item[]): Item[] {
const seen = new Set<string>(); const seen = new Set<string>();
const unique: Item[] = []; const unique: Item[] = [];
@@ -164,11 +115,10 @@ export function mapNotificationToItem(notification: Notifications.Notification):
? (rawCategory as Category) ? (rawCategory as Category)
: "utility"; : "utility";
const timestamp = data.timestamp const timestamp = normalizeTimestamp(data.timestamp ?? notification.date);
? normalizeTimestamp(data.timestamp)
: Date.now();
return { return {
id: notification.request.identifier,
timestamp, timestamp,
category, category,
title: content.title ?? "No title", title: content.title ?? "No title",
@@ -181,27 +131,12 @@ export function mapNotificationToItem(notification: Notifications.Notification):
} }
} }
async function addItem(item: Item, onItems: (items: Item[]) => void) { export function clearStoredNotifications() {
await storageQueue.add(async () => { return enqueue(async () => { await AsyncStorage.removeItem(STORAGE_KEY); });
try {
const saved = await AsyncStorage.getItem(STORAGE_KEY);
const prev: Item[] = saved ? JSON.parse(saved) : [];
const combined = [item, ...prev]
.map((i) => ({ ...i, timestamp: Number(i.timestamp) }));
// 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]);
} }
});
function generateItemId(item: Item): string {
return item.id ?? `${item.timestamp}-${item.title}-${item.info}`.substring(0, 100);
} }
export default null; export default null;
+2568 -6633
View File
File diff suppressed because it is too large. Load diff
+17 -13
View File
@@ -1,7 +1,12 @@
{ {
"name": "notifier", "name": "notifier",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "1.0.0",
"overrides": {
"expo": "~54.0.13",
"expo-router": "~6.0.11"
},
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo start --android",
@@ -10,30 +15,29 @@
"lint": "expo lint" "lint": "expo lint"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/drawer": "^7.5.10", "@react-navigation/drawer": "^7.5.10",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.18", "@react-navigation/native": "^7.1.18",
"eas-cli": "^16.23.0",
"expo": "~54.0.13", "expo": "~54.0.13",
"expo-constants": "~18.0.9", "expo-constants": "~18.0.9",
"expo-device": "^8.0.9", "expo-device": "~8.0.10",
"expo-font": "~14.0.9", "expo-font": "~14.0.12",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.9", "expo-image": "~3.0.11",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-notifications": "^0.32.12", "expo-notifications": "~0.32.17",
"expo-router": "~6.0.11", "expo-router": "~6.0.11",
"expo-splash-screen": "~31.0.10", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.7", "expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.7", "expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.11",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.4", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
+1
View File
@@ -1,6 +1,7 @@
import { Category } from "./Category"; import { Category } from "./Category";
export type Item = { export type Item = {
id?: string;
timestamp: number; timestamp: number;
category: Category; category: Category;
title: string; title: string;