Compare commits
12 Commits
14725e180a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dcf65840c0 | |||
| 194d598d6d | |||
| d76434014f | |||
| 266802088a | |||
| 6c98e4a469 | |||
| 5d2898dd13 | |||
| 694b68cbc5 | |||
| 0aa633a483 | |||
| 8404bcafd0 | |||
| e3529f892c | |||
| 7d7e641f5b | |||
| 3afe5e2931 |
@@ -82,3 +82,117 @@ jobs:
|
|||||||
ssh -i ~/.ssh/key \
|
ssh -i ~/.ssh/key \
|
||||||
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
||||||
"cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d"
|
"cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d"
|
||||||
|
|
||||||
|
mobile-release:
|
||||||
|
name: Mobile Release
|
||||||
|
needs: backend-unit-tests
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Accept Android SDK licenses
|
||||||
|
run: yes | sdkmanager --licenses > /dev/null || true
|
||||||
|
|
||||||
|
- name: Determine next mobile version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
git fetch --tags
|
||||||
|
LAST=$(git tag -l 'mobile-v*' | sed 's/mobile-v//' | sort -n | tail -1)
|
||||||
|
NEXT=$(( ${LAST:-0} + 1 ))
|
||||||
|
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=mobile-v$NEXT" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Stamp app version
|
||||||
|
run: node scripts/set-mobile-version.js ${{ steps.version.outputs.next }}
|
||||||
|
|
||||||
|
- name: Restore Firebase config
|
||||||
|
run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > google-services.json
|
||||||
|
|
||||||
|
- name: Restore env
|
||||||
|
run: echo "EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }}" > .env
|
||||||
|
|
||||||
|
- name: Restore signing keystore
|
||||||
|
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ksan.dev.keystore
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Prebuild Android project
|
||||||
|
run: npx expo prebuild -p android --no-install
|
||||||
|
|
||||||
|
- name: Increase Gradle memory
|
||||||
|
run: |
|
||||||
|
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> android/gradle.properties
|
||||||
|
|
||||||
|
|
||||||
|
- name: Build signed release APK
|
||||||
|
working-directory: frontend/android
|
||||||
|
run: |
|
||||||
|
./gradlew assembleRelease --no-daemon \
|
||||||
|
-Pandroid.injected.signing.store.file="$PWD/../ksan.dev.keystore" \
|
||||||
|
-Pandroid.injected.signing.store.password="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
|
||||||
|
-Pandroid.injected.signing.key.alias="${{ secrets.ANDROID_KEY_ALIAS }}" \
|
||||||
|
-Pandroid.injected.signing.key.password="${{ secrets.ANDROID_KEY_PASSWORD }}"
|
||||||
|
|
||||||
|
- name: Locate APK
|
||||||
|
id: apk
|
||||||
|
run: |
|
||||||
|
APK=$(find android/app/build/outputs/apk/release -name "*.apk" | head -n1)
|
||||||
|
OUT="etfoglasi-${{ steps.version.outputs.tag }}.apk"
|
||||||
|
cp "$APK" "$OUT"
|
||||||
|
echo "path=$OUT" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "name=$OUT" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
run: |
|
||||||
|
API="https://git.${{ secrets.DOMAIN }}/api/v1/repos/${{ secrets.REGISTRY_USER }}/etf-oglasi"
|
||||||
|
AUTH="Authorization: token ${{ secrets.GITEARELEASES_TOKEN }}"
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl -s -o release.json -w "%{http_code}" -X POST \
|
||||||
|
-H "$AUTH" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${{ steps.version.outputs.tag }}\",\"target_commitish\":\"${{ github.sha }}\",\"name\":\"${{ steps.version.outputs.tag }}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
|
"$API/releases")
|
||||||
|
|
||||||
|
echo "HTTP $HTTP_CODE"
|
||||||
|
cat release.json
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "Failed to create release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_ID=$(node -pe "JSON.parse(require('fs').readFileSync('release.json','utf8')).id")
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl -s -o asset.json -w "%{http_code}" -X POST \
|
||||||
|
-H "$AUTH" \
|
||||||
|
-F "attachment=@${{ steps.apk.outputs.path }}" \
|
||||||
|
"$API/releases/$RELEASE_ID/assets?name=${{ steps.apk.outputs.name }}")
|
||||||
|
|
||||||
|
echo "HTTP $HTTP_CODE"
|
||||||
|
cat asset.json
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "Failed to upload asset"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
@@ -87,3 +87,8 @@ frontend/android/
|
|||||||
|
|
||||||
# Expo
|
# Expo
|
||||||
frontend/expo-env.d.ts
|
frontend/expo-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
|
ksan.dev.keystore
|
||||||
|
|||||||
@@ -86,3 +86,6 @@ This project is currently under semi-active development as a learning and experi
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful
|
This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful
|
||||||
|
|
||||||
|
|
||||||
|
need to made .creds .env and init directory with subjects.txt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package dev.ksan.etfoglasiserver.config;
|
|||||||
|
|
||||||
import dev.ksan.etfoglasiserver.service.JWTService;
|
import dev.ksan.etfoglasiserver.service.JWTService;
|
||||||
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
|
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
|
||||||
import dev.ksan.etfoglasiserver.service.UserService;
|
import io.jsonwebtoken.JwtException;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -36,7 +36,12 @@ public class JwtFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
token = authHeader.substring(7);
|
token = authHeader.substring(7);
|
||||||
username = jwtService.extractEmail(token);
|
try {
|
||||||
|
username = jwtService.extractEmail(token);
|
||||||
|
} catch (JwtException e) {
|
||||||
|
// expired, malformed, or otherwise invalid token - treat as unauthenticated
|
||||||
|
username = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class EntryController {
|
|||||||
return service.getEntryById(entryId);
|
return service.getEntryById(entryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
//ignore this for now
|
// TODO only for testing
|
||||||
@PostMapping("/entries")
|
@PostMapping("/entries")
|
||||||
public void addEntry(@RequestBody EntryDTO entry) {
|
public void addEntry(@RequestBody EntryDTO entry) {
|
||||||
service.addEntry(entry);
|
service.addEntry(entry);
|
||||||
@@ -62,13 +62,14 @@ public class EntryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@PutMapping("/entries")
|
// @PutMapping("/entries")
|
||||||
public void updateEntry(@RequestBody EntryDTO entry) {
|
// public void updateEntry(@RequestBody EntryDTO entry) {
|
||||||
service.updateEntry(entry);
|
// service.updateEntry(entry);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@DeleteMapping("/entries/{entryId}")
|
// @DeleteMapping("/entries/{entryId}")
|
||||||
public void deleteEntry(@PathVariable String entryId) {
|
// public void deleteEntry(@PathVariable String entryId) {
|
||||||
service.deleteEntry(entryId);
|
// service.deleteEntry(entryId);
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
package dev.ksan.etfoglasiserver.service;
|
package dev.ksan.etfoglasiserver.service;
|
||||||
|
|
||||||
import dev.ksan.etfoglasiserver.model.User;
|
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import javax.crypto.KeyGenerator;
|
|
||||||
import javax.crypto.SecretKey;
|
import javax.crypto.SecretKey;
|
||||||
|
|
||||||
import io.jsonwebtoken.io.Decoders;
|
import io.jsonwebtoken.io.Decoders;
|
||||||
import io.jsonwebtoken.security.Keys;
|
import io.jsonwebtoken.security.Keys;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -29,7 +25,7 @@ public class JWTService {
|
|||||||
|
|
||||||
|
|
||||||
return Jwts.builder().claims().add(claims).subject(email)
|
return Jwts.builder().claims().add(claims).subject(email)
|
||||||
.issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+60*60*300))
|
.issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+1000L*60*60*24*60))
|
||||||
.and().signWith(getKey()).compact();
|
.and().signWith(getKey()).compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,14 +139,15 @@ class EntryControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
//we removed this from controller so test is pointless
|
||||||
@WithMockUser
|
// @Test
|
||||||
void deleteEntry_authenticated_returns200() throws Exception {
|
// @WithMockUser
|
||||||
doNothing().when(entryService).deleteEntry("entry-1");
|
// void deleteEntry_authenticated_returns200() throws Exception {
|
||||||
|
// doNothing().when(entryService).deleteEntry("entry-1");
|
||||||
mvc.perform(delete("/api/entries/entry-1"))
|
//
|
||||||
.andExpect(status().isOk());
|
// mvc.perform(delete("/api/entries/entry-1"))
|
||||||
}
|
// .andExpect(status().isOk());
|
||||||
|
// }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteEntry_unauthenticated_returns401or403() throws Exception {
|
void deleteEntry_unauthenticated_returns401or403() throws Exception {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
import {
|
import {
|
||||||
@@ -7,11 +7,22 @@ import {
|
|||||||
onMessage,
|
onMessage,
|
||||||
onNotificationOpenedApp,
|
onNotificationOpenedApp,
|
||||||
getInitialNotification,
|
getInitialNotification,
|
||||||
|
setBackgroundMessageHandler,
|
||||||
} from '@react-native-firebase/messaging';
|
} from '@react-native-firebase/messaging';
|
||||||
import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications';
|
import { registerDeviceToken, listenForTokenRefresh, displayLocalNotification } from '@/services/notifications';
|
||||||
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
||||||
|
import { useUpdatecheck } from '@/hooks/useUpdatecheck';
|
||||||
|
import { UpdatePrompt } from '@/components/UpdatePrompt';
|
||||||
import Toast from 'react-native-toast-message';
|
import Toast from 'react-native-toast-message';
|
||||||
|
|
||||||
|
// Registered at module scope so it's installed as soon as this entry file
|
||||||
|
// loads, which is required for it to fire while the app is backgrounded/killed.
|
||||||
|
setBackgroundMessageHandler(getMessaging(), async (remoteMessage) => {
|
||||||
|
const title = remoteMessage.data?.title as string | undefined;
|
||||||
|
const body = remoteMessage.data?.body as string | undefined;
|
||||||
|
await displayLocalNotification(title, body);
|
||||||
|
});
|
||||||
|
|
||||||
function NotificationSetup() {
|
function NotificationSetup() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
@@ -27,7 +38,8 @@ function NotificationSetup() {
|
|||||||
console.log('Foreground notification:', msg);
|
console.log('Foreground notification:', msg);
|
||||||
Toast.show({
|
Toast.show({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text1: msg.notification?.title ?? 'New Notification',
|
text1: (msg.data?.title as string | undefined) ?? 'New Notification',
|
||||||
|
text2: msg.data?.body as string | undefined,
|
||||||
position: 'top',
|
position: 'top',
|
||||||
visibilityTime: 4000,
|
visibilityTime: 4000,
|
||||||
});
|
});
|
||||||
@@ -50,12 +62,22 @@ function NotificationSetup() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UpdateCheck() {
|
||||||
|
const { updateInfo } = useUpdatecheck();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
if (!updateInfo || dismissed) return null;
|
||||||
|
|
||||||
|
return <UpdatePrompt updateInfo={updateInfo} onDismiss={() => setDismissed(true)} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<NotificationSetup />
|
<NotificationSetup />
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
|
<UpdateCheck />
|
||||||
<Toast />
|
<Toast />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import Toast from "react-native-toast-message";
|
||||||
import {
|
import {
|
||||||
authApi,
|
authApi,
|
||||||
|
|
||||||
LoginPayload, RegisterPayload, subscriptionsApi, userApi, UserUpdateDTO,
|
LoginPayload, RegisterPayload, setUnauthorizedHandler, subscriptionsApi, userApi, UserUpdateDTO,
|
||||||
} from "@/services/api";
|
} from "@/services/api";
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
@@ -45,6 +46,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(u);
|
setUser(u);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
persist(null);
|
||||||
|
Toast.show({
|
||||||
|
type: 'info',
|
||||||
|
text1: 'Session expired',
|
||||||
|
text2: 'Please sign in again.',
|
||||||
|
position: 'top',
|
||||||
|
visibilityTime: 4000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => setUnauthorizedHandler(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => {
|
const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
await userApi.updateNotificationType(type, user.token);
|
await userApi.updateNotificationType(type, user.token);
|
||||||
|
|||||||
+56
-34
@@ -2,46 +2,69 @@
|
|||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
import React, {useState} from "react";
|
import React, { useState } from "react";
|
||||||
import { useColorScheme } from "react-native";
|
import { useColorScheme } from "react-native";
|
||||||
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
|
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
|
||||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { enableScreens } from "react-native-screens";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import messaging from "@react-native-firebase/messaging";
|
||||||
|
import notifee, { AndroidImportance } from "@notifee/react-native";
|
||||||
|
|
||||||
import SubscribedFeed from "@/screens/SubscribedFeed";
|
import SubscribedFeed from "@/screens/SubscribedFeed";
|
||||||
import AllFeed from "@/screens/AllFeed";
|
import AllFeed from "@/screens/AllFeed";
|
||||||
import Profile from "@/screens/Profile";
|
import Profile from "@/screens/Profile";
|
||||||
import {enableScreens} from "react-native-screens";
|
|
||||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
|
||||||
import AuthGate from "@/screens/AuthGate";
|
import AuthGate from "@/screens/AuthGate";
|
||||||
import {AuthProvider, useAuth} from "@/context/AuthContext";
|
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
||||||
import messaging, {setBackgroundMessageHandler} from "@react-native-firebase/messaging";
|
|
||||||
|
|
||||||
import { useUpdatecheck } from "./hooks/useUpdatecheck";
|
import { useUpdatecheck } from "./hooks/useUpdatecheck";
|
||||||
import { UpdatePrompt } from './components/UpdatePrompt';
|
import { UpdatePrompt } from "./components/UpdatePrompt";
|
||||||
import { usePushNotifications } from "./hooks/usePushNotifications";
|
import { usePushNotifications } from "./hooks/usePushNotifications";
|
||||||
|
|
||||||
messaging().setBackgroundMessageHandler(async () => {});
|
// Handles data-only FCM messages when the app is in the background or closed.
|
||||||
|
messaging().setBackgroundMessageHandler(async remoteMessage => {
|
||||||
|
const title = remoteMessage.data?.title as string | undefined;
|
||||||
|
const body = remoteMessage.data?.body as string | undefined;
|
||||||
|
|
||||||
|
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" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Must be called before any navigator renders
|
// Must be called before any navigator renders
|
||||||
enableScreens();
|
enableScreens();
|
||||||
|
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator();
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
const LIGHT_TAB = {
|
const LIGHT_TAB = {
|
||||||
bg: "#FFFFFF",
|
bg: "#FFFFFF",
|
||||||
border: "#E8E2D5",
|
border: "#E8E2D5",
|
||||||
active: "#C4622D",
|
active: "#C4622D",
|
||||||
inactive: "#8A8278",
|
inactive: "#8A8278",
|
||||||
label: "#1A1714",
|
label: "#1A1714",
|
||||||
};
|
|
||||||
const DARK_TAB = {
|
|
||||||
bg: "#161513",
|
|
||||||
border: "#2C2A27",
|
|
||||||
active: "#E07B45",
|
|
||||||
inactive: "#706D67",
|
|
||||||
label: "#F0EDE8",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DARK_TAB = {
|
||||||
|
bg: "#161513",
|
||||||
|
border: "#2C2A27",
|
||||||
|
active: "#E07B45",
|
||||||
|
inactive: "#706D67",
|
||||||
|
label: "#F0EDE8",
|
||||||
|
};
|
||||||
|
|
||||||
function ProfileTab() {
|
function ProfileTab() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -56,13 +79,12 @@ export default function App() {
|
|||||||
const tab = dark ? DARK_TAB : LIGHT_TAB;
|
const tab = dark ? DARK_TAB : LIGHT_TAB;
|
||||||
|
|
||||||
const navTheme = dark
|
const navTheme = dark
|
||||||
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
|
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
|
||||||
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
||||||
|
|
||||||
const { updateInfo } = useUpdatecheck();
|
const { updateInfo } = useUpdatecheck();
|
||||||
const [updateDismissed, setUpdateDismissed] = useState(false);
|
const [updateDismissed, setUpdateDismissed] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
usePushNotifications();
|
usePushNotifications();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,19 +97,19 @@ export default function App() {
|
|||||||
|
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: tab.bg,
|
backgroundColor: tab.bg,
|
||||||
borderTopColor: tab.border,
|
borderTopColor: tab.border,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
height: 60,
|
height: 60,
|
||||||
paddingBottom: 8,
|
paddingBottom: 8,
|
||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
},
|
},
|
||||||
|
|
||||||
tabBarActiveTintColor: tab.active,
|
tabBarActiveTintColor: tab.active,
|
||||||
tabBarInactiveTintColor: tab.inactive,
|
tabBarInactiveTintColor: tab.inactive,
|
||||||
|
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
letterSpacing: 0.3,
|
letterSpacing: 0.3,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -96,9 +118,9 @@ export default function App() {
|
|||||||
string,
|
string,
|
||||||
{ active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }
|
{ active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }
|
||||||
> = {
|
> = {
|
||||||
"Subscribed": { active: "bookmark", inactive: "bookmark-outline" },
|
"Subscribed": { active: "bookmark", inactive: "bookmark-outline" },
|
||||||
"Discover": { active: "compass", inactive: "compass-outline" },
|
"Discover": { active: "compass", inactive: "compass-outline" },
|
||||||
"Profile": { active: "person-circle", inactive: "person-circle-outline" },
|
"Profile": { active: "person-circle", inactive: "person-circle-outline" },
|
||||||
};
|
};
|
||||||
const set = icons[route.name];
|
const set = icons[route.name];
|
||||||
|
|
||||||
@@ -130,7 +152,7 @@ export default function App() {
|
|||||||
</Tab.Navigator>
|
</Tab.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|
||||||
{/* Overlays the entire app, including the nav bar */}
|
{/* Overlays the entire app including the tab bar */}
|
||||||
{updateInfo && !updateDismissed && (
|
{updateInfo && !updateDismissed && (
|
||||||
<UpdatePrompt
|
<UpdatePrompt
|
||||||
updateInfo={updateInfo}
|
updateInfo={updateInfo}
|
||||||
@@ -141,4 +163,4 @@ export default function App() {
|
|||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Used by CI to stamp a mobile release build with its version number.
|
||||||
|
// Updates version.json (read by hooks/useUpdatecheck.tsx) and app.json's
|
||||||
|
// android.versionCode/versionName so the build, the in-app update check,
|
||||||
|
// and the Gitea release tag (mobile-vN) all agree on the same number.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const version = parseInt(process.argv[2], 10);
|
||||||
|
if (!Number.isInteger(version) || version <= 0) {
|
||||||
|
console.error('Usage: node set-mobile-version.js <positive integer>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'version.json'),
|
||||||
|
JSON.stringify({ version }, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const appJsonPath = path.join(root, 'app.json');
|
||||||
|
const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
|
||||||
|
appJson.expo.version = `1.0.${version}`;
|
||||||
|
appJson.expo.android = appJson.expo.android ?? {};
|
||||||
|
appJson.expo.android.versionCode = version;
|
||||||
|
fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
|
||||||
|
|
||||||
|
console.log(`Stamped mobile version ${version}`);
|
||||||
@@ -6,6 +6,14 @@ function authHeader(token?: string) {
|
|||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Called once from AuthProvider so api.tsx can trigger a logout/relogin
|
||||||
|
// when the backend rejects a request due to a missing/expired/invalid token.
|
||||||
|
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||||
|
onUnauthorized = handler;
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -30,6 +38,7 @@ async function request<T>(
|
|||||||
console.log('Failed URL:', res.url);
|
console.log('Failed URL:', res.url);
|
||||||
console.log('Status:', res.status);
|
console.log('Status:', res.status);
|
||||||
console.log('Response:', errorText);
|
console.log('Response:', errorText);
|
||||||
|
if (res.status === 401 && token) onUnauthorized?.();
|
||||||
throw new Error(errorText);
|
throw new Error(errorText);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
onTokenRefresh,
|
onTokenRefresh,
|
||||||
AuthorizationStatus,
|
AuthorizationStatus,
|
||||||
} from '@react-native-firebase/messaging';
|
} from '@react-native-firebase/messaging';
|
||||||
|
import notifee, { AndroidImportance } from '@notifee/react-native';
|
||||||
import { post } from '@/services/api';
|
import { post } from '@/services/api';
|
||||||
|
|
||||||
export async function registerDeviceToken(authToken: string): Promise<void> {
|
export async function registerDeviceToken(authToken: string): Promise<void> {
|
||||||
@@ -32,4 +33,23 @@ export function listenForTokenRefresh(authToken: string): () => void {
|
|||||||
return onTokenRefresh(getMessaging(), async (newToken) => {
|
return onTokenRefresh(getMessaging(), async (newToken) => {
|
||||||
await post('/device-tokens/register', { token: newToken }, authToken);
|
await post('/device-tokens/register', { token: newToken }, authToken);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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' },
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user