push notifications not working for some reason
CI/CD / Backend Unit Tests (push) Successful in 1m48s
CI/CD / Deploy (push) Successful in 1m38s

This commit is contained in:
2026-06-11 01:29:48 +02:00
parent 30d2abd4c5
commit 14725e180a
10 changed files with 429 additions and 63 deletions
@@ -56,7 +56,7 @@ public class SecurityConfig {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:3000",
"http://localhost:8081",
"https://etf-oglasi.ksan.dev"
));
@@ -31,9 +31,11 @@ public class NotificationService {
.build();
try {
firebaseMessaging.send(message);
String id = firebaseMessaging.send(message);
System.out.println("Send:" + id);
} catch (FirebaseMessagingException ex) {
ex.printStackTrace();
if (ex.getMessagingErrorCode()
== MessagingErrorCode.UNREGISTERED) {
deviceTokenRepo.delete(token);
+138
View File
@@ -0,0 +1,138 @@
// components/UpdatePrompt.tsx
// ─────────────────────────────────────────────────────────────────────────────
// Modal that appears when a newer build is available.
// "Update now" opens the Gitea release page in the browser.
// "Later" dismisses it for the current session (it reappears next launch).
// ─────────────────────────────────────────────────────────────────────────────
import React from 'react';
import {
Linking,
Modal,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import type { UpdateInfo } from '../hooks/useUpdatecheck';
import { version as currentVersion } from '../version.json';
interface Props {
updateInfo: UpdateInfo;
onDismiss: () => void;
}
export function UpdatePrompt({ updateInfo, onDismiss }: Props) {
function openRelease() {
Linking.openURL(updateInfo.releaseUrl).catch(() => {
// If the device can't open the URL, just dismiss so the app isn't stuck.
onDismiss();
});
}
return (
<Modal
visible
transparent
animationType="fade"
onRequestClose={onDismiss}
>
<View style={styles.overlay}>
<View style={styles.card}>
<Text style={styles.title}>Update available</Text>
<Text style={styles.body}>
A new version of the app is available.{'\n'}
You are on build <Text style={styles.bold}>{currentVersion}</Text>,
the latest is build{' '}
<Text style={styles.bold}>{updateInfo.latestVersion}</Text>.
</Text>
<View style={styles.actions}>
<Pressable
style={[styles.btn, styles.btnSecondary]}
onPress={onDismiss}
accessibilityRole="button"
accessibilityLabel="Remind me later"
>
<Text style={styles.btnSecondaryText}>Later</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnPrimary]}
onPress={openRelease}
accessibilityRole="button"
accessibilityLabel="Open the release page to update"
>
<Text style={styles.btnPrimaryText}>Update now</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
card: {
width: '100%',
maxWidth: 380,
backgroundColor: '#fff',
borderRadius: 12,
padding: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#111',
marginBottom: 12,
},
body: {
fontSize: 15,
color: '#444',
lineHeight: 22,
marginBottom: 24,
},
bold: {
fontWeight: '700',
color: '#111',
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
btn: {
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 8,
},
btnPrimary: {
backgroundColor: '#2563EB',
},
btnPrimaryText: {
color: '#fff',
fontWeight: '600',
fontSize: 15,
},
btnSecondary: {
backgroundColor: '#F3F4F6',
},
btnSecondaryText: {
color: '#374151',
fontWeight: '600',
fontSize: 15,
},
});
+10
View File
@@ -0,0 +1,10 @@
export const GITEA_URL = 'https://git.ksan.dev';
export const GITEA_OWNER = 'ksan';
export const GITEA_REPO = 'etf-oglasi';
export const RELEASE_TAG_PREFIX = 'mobile-v';
export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
+46
View File
@@ -0,0 +1,46 @@
import { useEffect } from "react";
import messaging from "@react-native-firebase/messaging";
import notifee, { AndroidImportance, EventType } from "@notifee/react-native";
async function displayLocalNotification(title?: string, body?: string) {
if (!title && !body) return;
const channelId = await notifee.createChannel({
id: "default",
name: "General",
importance: AndroidImportance.HIGH,
});
await notifee.displayNotification({
title,
body,
android: {
channelId,
pressAction: { id: "default" },
},
});
}
export function usePushNotifications() {
useEffect(() => {
const unsubscribeMessage = messaging().onMessage(async remoteMessage => {
const title = remoteMessage.data?.title as string | undefined;
const body = remoteMessage.data?.body as string | undefined;
await displayLocalNotification(title, body);
});
const unsubscribeNotifee = notifee.onForegroundEvent(({ type, detail }) => {
if (type === EventType.PRESS) {
console.log("Notification tapped:", detail.notification);
}
});
return () => {
unsubscribeMessage();
unsubscribeNotifee();
};
}, []);
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import {
GITEA_URL,
GITEA_OWNER,
GITEA_REPO,
RELEASE_TAG_PREFIX,
UPDATE_CHECK_TIMEOUT_MS,
} from '../config/gitea';
import { version as currentVersion } from '../version.json';
export interface UpdateInfo {
/** Build number of the latest release (same unit as currentVersion). */
latestVersion: number;
/** Direct URL the user can open to read the release notes / download the APK. */
releaseUrl: string;
/** Tag name as stored on Gitea, e.g. "mobile-v42". */
tagName: string;
}
export interface UseUpdateCheckResult {
/** True while the API call is in flight. */
checking: boolean;
/** Populated once the check completes and a newer build exists. */
updateInfo: UpdateInfo | null;
/** Non-null when the check failed (network error, timeout, bad response). */
error: Error | null;
/** Re-run the check manually (e.g. from a "check again" button). */
recheck: () => void;
}
// ─── Gitea releases API ──────────────────────────────────────────────────────
interface GiteaRelease {
id: number;
tag_name: string;
name: string;
html_url: string;
draft: boolean;
prerelease: boolean;
}
async function fetchLatestRelease(): Promise<GiteaRelease> {
const url = `${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Gitea API returned HTTP ${res.status}`);
}
return (await res.json()) as GiteaRelease;
} finally {
clearTimeout(timer);
}
}
/** Parse the integer build number out of a tag like "mobile-v42". Returns NaN if it can't. */
function parseBuildNumber(tagName: string): number {
if (!tagName.startsWith(RELEASE_TAG_PREFIX)) return NaN;
return parseInt(tagName.slice(RELEASE_TAG_PREFIX.length), 10);
}
export function useUpdatecheck(): UseUpdateCheckResult {
const [checking, setChecking] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [error, setError] = useState<Error | null>(null);
// A counter we bump to re-trigger the effect without changing the dep array shape.
const [trigger, setTrigger] = useState(0);
useEffect(() => {
let cancelled = false;
async function check() {
setChecking(true);
setError(null);
try {
const release = await fetchLatestRelease();
if (cancelled) return;
// Skip drafts and pre-releases.
if (release.draft || release.prerelease) {
setUpdateInfo(null);
return;
}
const latestVersion = parseBuildNumber(release.tag_name);
if (isNaN(latestVersion)) {
// The tag doesn't follow our scheme — ignore it silently.
setUpdateInfo(null);
return;
}
if (latestVersion > currentVersion) {
setUpdateInfo({
latestVersion,
releaseUrl: release.html_url,
tagName: release.tag_name,
});
} else {
setUpdateInfo(null);
}
} catch (err) {
if (cancelled) return;
// Don't surface AbortError as a real error — it's just a timeout.
if (err instanceof Error && err.name === 'AbortError') {
setError(new Error('Update check timed out. Check your connection.'));
} else {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (!cancelled) setChecking(false);
}
}
check();
return () => {
cancelled = true;
};
}, [trigger]);
return {
checking,
updateInfo,
error,
recheck: () => setTrigger(t => t + 1),
};
}
+80 -61
View File
@@ -2,7 +2,7 @@
import "./globals.css";
import React from "react";
import React, {useState} from "react";
import { useColorScheme } from "react-native";
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
@@ -16,7 +16,11 @@ import {SafeAreaProvider} from "react-native-safe-area-context";
import AuthGate from "@/screens/AuthGate";
import {AuthProvider, useAuth} from "@/context/AuthContext";
import messaging, {setBackgroundMessageHandler} from "@react-native-firebase/messaging";
import {getMessaging} from "@firebase/messaging";
import { useUpdatecheck } from "./hooks/useUpdatecheck";
import { UpdatePrompt } from './components/UpdatePrompt';
import { usePushNotifications } from "./hooks/usePushNotifications";
messaging().setBackgroundMessageHandler(async () => {});
// Must be called before any navigator renders
enableScreens();
@@ -45,6 +49,7 @@ function ProfileTab() {
return user ? <Profile /> : <AuthGate />;
}
export default function App() {
const scheme = useColorScheme();
const dark = scheme === "dark";
@@ -54,72 +59,86 @@ export default function App() {
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
const { updateInfo } = useUpdatecheck();
const [updateDismissed, setUpdateDismissed] = useState(false);
usePushNotifications();
return (
<AuthProvider>
<SafeAreaProvider>
<NavigationContainer theme={navTheme}>
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
<SafeAreaProvider>
<NavigationContainer theme={navTheme}>
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarStyle: {
backgroundColor: tab.bg,
borderTopColor: tab.border,
borderTopWidth: 1,
height: 60,
paddingBottom: 8,
paddingTop: 6,
},
tabBarStyle: {
backgroundColor: tab.bg,
borderTopColor: tab.border,
borderTopWidth: 1,
height: 60,
paddingBottom: 8,
paddingTop: 6,
},
tabBarActiveTintColor: tab.active,
tabBarInactiveTintColor: tab.inactive,
tabBarActiveTintColor: tab.active,
tabBarInactiveTintColor: tab.inactive,
tabBarLabelStyle: {
fontSize: 10,
fontWeight: "600",
letterSpacing: 0.3,
},
tabBarLabelStyle: {
fontSize: 10,
fontWeight: "600",
letterSpacing: 0.3,
},
tabBarIcon: ({ focused, color, size }) => {
const icons: Record<
string,
{ active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }
> = {
"Subscribed": { active: "bookmark", inactive: "bookmark-outline" },
"Discover": { active: "compass", inactive: "compass-outline" },
"Profile": { active: "person-circle", inactive: "person-circle-outline" },
};
const set = icons[route.name];
tabBarIcon: ({ focused, color, size }) => {
const icons: Record<
string,
{ active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }
> = {
"Subscribed": { active: "bookmark", inactive: "bookmark-outline" },
"Discover": { active: "compass", inactive: "compass-outline" },
"Profile": { active: "person-circle", inactive: "person-circle-outline" },
};
const set = icons[route.name];
return (
<Ionicons
name={focused ? set.active : set.inactive}
size={focused ? size + 1 : size}
color={color}
/>
);
},
})}
>
<Tab.Screen
name="Subscribed"
component={SubscribedFeed}
options={{ title: "My Feed" }}
return (
<Ionicons
name={focused ? set.active : set.inactive}
size={focused ? size + 1 : size}
color={color}
/>
);
},
})}
>
<Tab.Screen
name="Subscribed"
component={SubscribedFeed}
options={{ title: "My Feed" }}
/>
<Tab.Screen
name="Discover"
component={AllFeed}
options={{ title: "Discover" }}
/>
<Tab.Screen
name="Profile"
component={ProfileTab}
options={{ title: "Profile" }}
/>
</Tab.Navigator>
</NavigationContainer>
{/* Overlays the entire app, including the nav bar */}
{updateInfo && !updateDismissed && (
<UpdatePrompt
updateInfo={updateInfo}
onDismiss={() => setUpdateDismissed(true)}
/>
<Tab.Screen
name="Discover"
component={AllFeed}
options={{ title: "Discover" }}
/>
<Tab.Screen
name="Profile"
component={ProfileTab}
options={{ title: "Profile" }}
/>
</Tab.Navigator>
</NavigationContainer>
</SafeAreaProvider>
)}
</AuthProvider>
</SafeAreaProvider>
</AuthProvider>
);
}
}
+10
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/messaging": "^24.0.0",
@@ -3583,6 +3584,15 @@
"node": ">=12.4.0"
}
},
"node_modules/@notifee/react-native": {
"version": "9.1.8",
"resolved": "https://registry.npmjs.org/@notifee/react-native/-/react-native-9.1.8.tgz",
"integrity": "sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native": "*"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+1
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/messaging": "^24.0.0",
+3
View File
@@ -0,0 +1,3 @@
{
"version": 0
}