Fixed notifications vanishing when app wasn't ready and sometimes showing up twice
This commit is contained in:
1 parent
ed7c6d630c
commit
78769604a5
6 files changed
+2638
-6762
No files matched your search
@@ -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
@@ -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([]);
|
||||||
|
|||||||
@@ -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;
|
||||||
Generated
+2568
-6633
File diff suppressed because it is too large.
Load diff
+17
-13
@@ -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,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;
|
||||||
|
|||||||
Reference in new issue
Block a user