added push notifications

This commit is contained in:
2026-06-03 19:13:56 +02:00
parent a9278b8269
commit 6559a9cd4b
24 changed files with 1598 additions and 557 deletions
+2
View File
@@ -52,6 +52,8 @@ nbdist/
frontend/node_modules/ frontend/node_modules/
frontend/google-services.json
frontend/.expo/ frontend/.expo/
frontend/dist/ frontend/dist/
frontend/web-build/ frontend/web-build/
+2 -1
View File
@@ -39,7 +39,8 @@ dependencies {
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
implementation("io.jsonwebtoken:jjwt-api:0.13.0") implementation("io.jsonwebtoken:jjwt-api:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
implementation("org.projectlombok:lombok:1.18.42")
annotationProcessor 'org.projectlombok:lombok:1.18.42'
implementation 'com.google.firebase:firebase-admin:9.9.0' implementation 'com.google.firebase:firebase-admin:9.9.0'
} }
+1 -1
View File
@@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS public.subjects
CONSTRAINT subjects_pkey PRIMARY KEY (id) CONSTRAINT subjects_pkey PRIMARY KEY (id)
); );
CREATE TABLE IF NOT EXISTS public."user-subject" CREATE TABLE IF NOT EXISTS public."user_subject"
( (
user_id uuid NOT NULL, user_id uuid NOT NULL,
subject_id uuid NOT NULL, subject_id uuid NOT NULL,
@@ -1,45 +1,42 @@
package dev.ksan.etfoglasiserver.controller; package dev.ksan.etfoglasiserver.controller;
import dev.ksan.etfoglasiserver.model.DeviceToken; import dev.ksan.etfoglasiserver.model.DeviceToken;
import dev.ksan.etfoglasiserver.model.RegisterTokenRequest;
import dev.ksan.etfoglasiserver.model.User; import dev.ksan.etfoglasiserver.model.User;
import dev.ksan.etfoglasiserver.repository.DeviceTokenRepo; import dev.ksan.etfoglasiserver.repository.DeviceTokenRepo;
import dev.ksan.etfoglasiserver.repository.UserRepo; import dev.ksan.etfoglasiserver.repository.UserRepo;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/device-tokens") @RequestMapping("/api/device-tokens")
@RequiredArgsConstructor
public class DeviceTokenController { public class DeviceTokenController {
@Autowired private final DeviceTokenRepo deviceTokenRepo;
private DeviceTokenRepo deviceTokenRepo; private final UserRepo userRepo;
@Autowired
private UserRepo userRepo;
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<?> register( public ResponseEntity<?> register(
@RequestParam UUID userId, @RequestBody RegisterTokenRequest request,
@RequestParam String token @AuthenticationPrincipal UserDetails principal) {
) {
User user = userRepo.findById(userId) User user = userRepo.findByEmail(principal.getUsername())
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
DeviceToken deviceToken = deviceTokenRepo DeviceToken deviceToken = deviceTokenRepo
.findByFcmToken(token) .findByFcmToken(request.getToken())
.orElse(new DeviceToken()); .orElse(new DeviceToken());
deviceToken.setUser(user); deviceToken.setUser(user);
deviceToken.setFcmToken(token); deviceToken.setFcmToken(request.getToken());
deviceTokenRepo.save(deviceToken); deviceTokenRepo.save(deviceToken);
return ResponseEntity.ok("Token saved"); return ResponseEntity.noContent().build();
} }
} }
@@ -4,8 +4,11 @@ import dev.ksan.etfoglasiserver.dto.JwtResponseDTO;
import dev.ksan.etfoglasiserver.dto.UserCreationDTO; import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
import dev.ksan.etfoglasiserver.dto.UserDTO; import dev.ksan.etfoglasiserver.dto.UserDTO;
import dev.ksan.etfoglasiserver.dto.UserLoginDTO; import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.User; import dev.ksan.etfoglasiserver.model.User;
import dev.ksan.etfoglasiserver.service.UserService; import dev.ksan.etfoglasiserver.service.UserService;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -22,13 +25,11 @@ public class UserController {
@GetMapping("users/me") @GetMapping("users/me")
public ResponseEntity<UserDTO> getUserById(Authentication authentication) { public ResponseEntity<UserDTO> getUserById(Authentication authentication) {
String email = authentication.getName(); String email = authentication.getName();
User user = service.getAccountByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
return ResponseEntity.ok(new UserDTO(user));
User user = service.getAccountByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
} }
@PutMapping("/users/me") @PutMapping("/users/me")
@@ -43,6 +44,20 @@ public class UserController {
return ResponseEntity.ok(userUpdated); return ResponseEntity.ok(userUpdated);
} }
@PostMapping("users/me/notification-type")
public ResponseEntity<?> updateNotificationType(
@RequestBody Map<String, String> request,
Authentication authentication) {
String email = authentication.getName();
NotificationMethod type = NotificationMethod.valueOf(request.get("notificationType"));
service.updateNotificationType(email, type);
return ResponseEntity.ok(Map.of("status", "updated"));
}
@PutMapping("/users/me/subjects/{subjectId}") @PutMapping("/users/me/subjects/{subjectId}")
public ResponseEntity<UserDTO> addSubject( public ResponseEntity<UserDTO> addSubject(
@PathVariable UUID subjectId, @PathVariable UUID subjectId,
@@ -83,6 +98,8 @@ public class UserController {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST); return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
} }
@DeleteMapping("/users/me") @DeleteMapping("/users/me")
public ResponseEntity<Void> deleteUser(Authentication authentication) { public ResponseEntity<Void> deleteUser(Authentication authentication) {
@@ -3,14 +3,25 @@ package dev.ksan.etfoglasiserver.dto;
import dev.ksan.etfoglasiserver.model.NotificationMethod; import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.Subject; import dev.ksan.etfoglasiserver.model.Subject;
import dev.ksan.etfoglasiserver.model.User; import dev.ksan.etfoglasiserver.model.User;
import lombok.Getter;
import lombok.Setter;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
public class UserDTO { public class UserDTO {
@Setter
@Getter
private UUID id; private UUID id;
@Setter
@Getter
private String email; private String email;
@Setter
@Getter
private Set<Subject> subjectSet; private Set<Subject> subjectSet;
@Getter
@Setter
private NotificationMethod notificationType;
public UserDTO() {} public UserDTO() {}
@@ -19,38 +30,17 @@ public class UserDTO {
this.id = id; this.id = id;
this.email = email; this.email = email;
this.subjectSet = subjectSet; this.subjectSet = subjectSet;
this.notificationType = notificationMethod2;
} }
public UserDTO(User user) { public UserDTO(User user) {
this.id = user.getId(); this.id = user.getId();
this.email = user.getEmail(); this.email = user.getEmail();
this.subjectSet = user.getSubjectSet(); this.subjectSet = user.getSubjectSet();
this.notificationType = user.getNotificationMethod();
} }
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Set<Subject> getSubjectSet() {
return subjectSet;
}
public void setSubjectSet(Set<Subject> subjectSet) {
this.subjectSet = subjectSet;
}
} }
@@ -7,6 +7,7 @@ import lombok.Setter;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
@Getter // ← add class-level getter
@Entity @Entity
@Table( @Table(
name = "device_tokens", name = "device_tokens",
@@ -25,7 +26,6 @@ public class DeviceToken {
@JoinColumn(name = "user_id", nullable = false) @JoinColumn(name = "user_id", nullable = false)
private User user; private User user;
@Getter
@Setter @Setter
@Column(name = "fcm_token", nullable = false) @Column(name = "fcm_token", nullable = false)
private String fcmToken; private String fcmToken;
@@ -38,5 +38,4 @@ public class DeviceToken {
public void touch() { public void touch() {
this.updatedAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now();
} }
} }
@@ -3,6 +3,5 @@ package dev.ksan.etfoglasiserver.model;
public enum NotificationMethod { public enum NotificationMethod {
NO_NOTIFICATION, NO_NOTIFICATION,
EMAIL,
PUSH_NOTIFICATION PUSH_NOTIFICATION
} }
@@ -0,0 +1,8 @@
package dev.ksan.etfoglasiserver.model;
import lombok.Data;
@Data
public class RegisterTokenRequest {
private String token;
}
@@ -1,6 +1,8 @@
package dev.ksan.etfoglasiserver.model; package dev.ksan.etfoglasiserver.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -9,13 +11,18 @@ import java.util.UUID;
@Table(name = "subjects") @Table(name = "subjects")
public class Subject { public class Subject {
@Getter
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
@Column(columnDefinition = "uuid") @Column(columnDefinition = "uuid")
private UUID id; private UUID id;
@Getter
@Setter
@Column private long code; @Column private long code;
@Getter
@Setter
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@@ -26,23 +33,4 @@ public class Subject {
public Subject(){ public Subject(){
} }
public UUID getId() {
return id;
}
public long getCode() {
return code;
}
public void setCode(long code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
} }
@@ -9,6 +9,8 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Setter;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@@ -18,9 +20,11 @@ public class SubjectAlt {
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
private UUID id; private UUID id;
@Setter
@Column(nullable = false) @Column(nullable = false)
String name; String name;
@Setter
@ManyToOne(fetch = FetchType.EAGER) @ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(nullable = false) @JoinColumn(nullable = false)
private Subject subject; private Subject subject;
@@ -32,22 +36,15 @@ public class SubjectAlt {
} }
public UUID getId() { public UUID getId() {
return id; return this.id;
} }
public String getName() { public String getName() {
return name; return this.name;
}
public void setName(String name) {
this.name = name;
} }
public Subject getSubject() { public Subject getSubject() {
return subject; return this.subject;
} }
public void setSubject(Subject subject) {
this.subject = subject;
}
} }
@@ -43,6 +43,8 @@ public class EntryService {
Entry newEntry = new Entry(entry); Entry newEntry = new Entry(entry);
newEntry.setSubject(subject); newEntry.setSubject(subject);
entryRepo.save(newEntry); entryRepo.save(newEntry);
subjectService.notifyAsync(newEntry);
} }
public void addEntry(Entry entry) { public void addEntry(Entry entry) {
@@ -4,6 +4,7 @@ import dev.ksan.etfoglasiserver.dto.JwtResponseDTO;
import dev.ksan.etfoglasiserver.dto.UserCreationDTO; import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
import dev.ksan.etfoglasiserver.dto.UserDTO; import dev.ksan.etfoglasiserver.dto.UserDTO;
import dev.ksan.etfoglasiserver.dto.UserLoginDTO; import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.Subject; import dev.ksan.etfoglasiserver.model.Subject;
import dev.ksan.etfoglasiserver.model.User; import dev.ksan.etfoglasiserver.model.User;
import dev.ksan.etfoglasiserver.repository.SubjectRepo; import dev.ksan.etfoglasiserver.repository.SubjectRepo;
@@ -204,4 +205,11 @@ public class UserService {
return getUserDTO(user); return getUserDTO(user);
} }
public void updateNotificationType(String email, NotificationMethod notificationType) {
User user = userRepo.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
user.setNotificationMethod(notificationType);
userRepo.save(user);
}
} }
+9 -6
View File
@@ -1,17 +1,18 @@
{ {
"expo": { "expo": {
"name": "etfoglasi-frontend", "name": "etfoglasi",
"slug": "etfoglasi-frontend", "slug": "etfoglasi",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "etfoglasifrontend", "scheme": "etfoglasi",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true
}, },
"android": { "android": {
"googleServicesFile": "./google-services.json",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png", "foregroundImage": "./assets/images/android-icon-foreground.png",
@@ -19,7 +20,8 @@
"monochromeImage": "./assets/images/android-icon-monochrome.png" "monochromeImage": "./assets/images/android-icon-monochrome.png"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"package": "dev.ksan.etfoglasi"
}, },
"web": { "web": {
"output": "static", "output": "static",
@@ -39,7 +41,9 @@
"backgroundColor": "#000000" "backgroundColor": "#000000"
} }
} }
] ],
"@react-native-firebase/app",
"@react-native-firebase/messaging"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
@@ -47,4 +51,3 @@
} }
} }
} }
+52 -3
View File
@@ -1,13 +1,62 @@
import "../globals.css"; import "../globals.css";
import { Stack } from "expo-router"; import { useEffect } from 'react';
import {AuthProvider} from "@/context/AuthContext"; import { Stack } from 'expo-router';
import {SafeAreaProvider} from "react-native-safe-area-context"; import { SafeAreaProvider } from 'react-native-safe-area-context';
import {
getMessaging,
onMessage,
onNotificationOpenedApp,
getInitialNotification,
} from '@react-native-firebase/messaging';
import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications';
import { AuthProvider, useAuth } from "@/context/AuthContext";
import Toast from 'react-native-toast-message';
function NotificationSetup() {
const { user, loading } = useAuth();
useEffect(() => {
if (loading || !user) return;
registerDeviceToken(user.token);
const messaging = getMessaging();
const unsubRefresh = listenForTokenRefresh(user.token);
const unsubForeground = onMessage(messaging, async (msg) => {
console.log('Foreground notification:', msg);
Toast.show({
type: 'info',
text1: msg.notification?.title ?? 'New Notification',
position: 'top',
visibilityTime: 4000,
});
});
onNotificationOpenedApp(messaging, (msg) => {
console.log('Opened from background:', msg);
});
getInitialNotification(messaging).then((msg) => {
if (msg) console.log('Opened from quit state:', msg);
});
return () => {
unsubRefresh();
unsubForeground();
};
}, [user, loading]);
return null;
}
export default function RootLayout() { export default function RootLayout() {
return ( return (
<AuthProvider> <AuthProvider>
<SafeAreaProvider> <SafeAreaProvider>
<NotificationSetup />
<Stack screenOptions={{ headerShown: false }} /> <Stack screenOptions={{ headerShown: false }} />
<Toast />
</SafeAreaProvider> </SafeAreaProvider>
</AuthProvider> </AuthProvider>
); );
+17 -7
View File
@@ -3,7 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { import {
authApi, authApi,
LoginPayload, RegisterPayload, subscriptionsApi, UserUpdateDTO, LoginPayload, RegisterPayload, subscriptionsApi, userApi, UserUpdateDTO,
} from "@/services/api"; } from "@/services/api";
type User = { type User = {
@@ -11,6 +11,7 @@ type User = {
email: string; email: string;
token: string; token: string;
subscribedSubjectIds: string[]; subscribedSubjectIds: string[];
notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
}; };
type AuthContextValue = { type AuthContextValue = {
@@ -23,6 +24,7 @@ type AuthContextValue = {
unsubscribe: (subjectId: string) => Promise<void>; unsubscribe: (subjectId: string) => Promise<void>;
isSubscribed: (subjectId: string) => boolean; isSubscribed: (subjectId: string) => boolean;
updateUser: (newEmail?: string, newPassword?: string) => Promise<void>; updateUser: (newEmail?: string, newPassword?: string) => Promise<void>;
updateNotificationType: (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => Promise<void>;
}; };
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);
@@ -43,17 +45,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(u); setUser(u);
}; };
const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => {
if (!user) return;
await userApi.updateNotificationType(type, user.token);
await persist({ ...user, notificationType: type });
};
const login = async (payload: LoginPayload) => { const login = async (payload: LoginPayload) => {
const res = await authApi.login(payload); const res = await authApi.login(payload);
// Gracefully fetch subscriptions — don't block login if this fails
let subscribedSubjectIds: string[] = []; let subscribedSubjectIds: string[] = [];
let notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION' = 'PUSH_NOTIFICATION';
if (res.userId) { if (res.userId) {
try { try {
const dto = await authApi.getUser( res.token); const dto = await authApi.getUser(res.token);
subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? []; subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? [];
} catch { notificationType = dto.notificationType ?? 'PUSH_NOTIFICATION';
// aaaa } catch (e) {
console.error('Failed to load subscriptions on login:', e);
} }
} }
@@ -62,6 +71,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
email: res.email, email: res.email,
token: res.token, token: res.token,
subscribedSubjectIds, subscribedSubjectIds,
notificationType,
}); });
}; };
@@ -116,7 +126,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
user?.subscribedSubjectIds.includes(subjectId) ?? false; user?.subscribedSubjectIds.includes(subjectId) ?? false;
return ( return (
<AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser }}> <AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser, updateNotificationType }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+3 -2
View File
@@ -15,8 +15,9 @@ import {enableScreens} from "react-native-screens";
import {SafeAreaProvider} from "react-native-safe-area-context"; 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 {getMessaging} from "@firebase/messaging";
messaging().setBackgroundMessageHandler(async () => {});
// Must be called before any navigator renders // Must be called before any navigator renders
enableScreens(); enableScreens();
+1325 -432
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,18 +1,20 @@
{ {
"name": "etfoglasi-frontend", "name": "etfoglasi",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint" "lint": "expo lint"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@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-native-firebase/app": "^24.0.0",
"@react-native-firebase/messaging": "^24.0.0",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
@@ -37,6 +39,7 @@
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-tailwindcss": "^1.1.11", "react-native-tailwindcss": "^1.1.11",
"react-native-toast-message": "^2.3.3",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.18" "tailwindcss": "^3.4.18"
+8 -1
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, {useCallback, useState} from "react";
import { import {
View, Text, ScrollView, TouchableOpacity, View, Text, ScrollView, TouchableOpacity,
ActivityIndicator, useColorScheme, ActivityIndicator, useColorScheme,
@@ -7,6 +7,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import SearchBar from "../components/SearchBar"; import SearchBar from "../components/SearchBar";
import CollapsibleCategory from "../components/CollapsibleCategory"; import CollapsibleCategory from "../components/CollapsibleCategory";
import {useFocusEffect} from "expo-router";
const GROUPS = [ const GROUPS = [
@@ -40,6 +41,12 @@ export default function AllFeed() {
setTimeout(() => setRefreshing(false), 800); setTimeout(() => setRefreshing(false), 800);
}; };
useFocusEffect(
useCallback(() => {
handleRefresh();
}, [])
);
return ( return (
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}> <SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
{/* Header */} {/* Header */}
+30 -19
View File
@@ -23,6 +23,8 @@ type SettingRowProps = {
accent?: string; accent?: string;
}; };
function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) { function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) {
const dark = useColorScheme() === "dark"; const dark = useColorScheme() === "dark";
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278"); const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
@@ -149,11 +151,25 @@ function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) { function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
const [showSubs, setShowSubs] = useState(false); const [showSubs, setShowSubs] = useState(false);
const dark = useColorScheme() === "dark"; const dark = useColorScheme() === "dark";
const { user } = useAuth(); const { user, updateNotificationType } = useAuth();
const [notifications, setNotifications] = React.useState(true); const [notifications, setNotifications] = useState(
user?.notificationType === 'PUSH_NOTIFICATION'
);
const [digest, setDigest] = React.useState(false); const [digest, setDigest] = React.useState(false);
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const handleNotificationToggle = async (value: boolean) => {
setNotifications(value);
try {
await updateNotificationType(
value ? 'PUSH_NOTIFICATION' : 'NO_NOTIFICATION'
);
} catch {
setNotifications(!value); // revert on failure
}
};
return ( return (
<> <>
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
@@ -197,13 +213,19 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
> >
<SectionLabel title="Notifications" /> <SectionLabel title="Notifications" />
<SettingRow <SettingRow
icon="notifications-outline" label="Push notifications (TODO)" icon="notifications-outline"
toggle toggleValue={notifications} onToggle={setNotifications} label="Push notifications"
toggle
toggleValue={notifications}
onToggle={handleNotificationToggle}
accent={dark ? "#E07B45" : "#C4622D"} accent={dark ? "#E07B45" : "#C4622D"}
/> />
<SettingRow <SettingRow
icon="mail-outline" label="Daily digest email (TODO)" icon="mail-outline"
toggle toggleValue={digest} onToggle={setDigest} label="Weekly Digest email (TODO)"
toggle
toggleValue={digest}
onToggle={setDigest}
accent={dark ? "#5E9EF4" : "#3B7DD8"} accent={dark ? "#5E9EF4" : "#3B7DD8"}
/> />
@@ -231,32 +253,21 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
<Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text> <Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>
<Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}> <Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}>
<EditProfile onClose={() => setShowEdit(false)} /> <EditProfile onClose={() => setShowEdit(false)} />
</Modal> </Modal>
<Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}> <Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}>
<HelpSupport onClose={() => setShowHelp(false)} /> <HelpSupport onClose={() => setShowHelp(false)} />
</Modal> </Modal>
<Modal <Modal visible={showSubs} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowSubs(false)}>
visible={showSubs}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowSubs(false)}
>
<ManageSubscriptions onClose={() => setShowSubs(false)} /> <ManageSubscriptions onClose={() => setShowSubs(false)} />
</Modal> </Modal>
</> </>
); );
} }
export default function Profile() { export default function Profile() {
const dark = useColorScheme() === "dark"; const dark = useColorScheme() === "dark";
const { user, logout } = useAuth(); const { user, logout } = useAuth();
+9 -3
View File
@@ -9,6 +9,7 @@ import SearchBar from "../components/SearchBar";
import ExpandableItem from "../components/ExpandableItem"; import ExpandableItem from "../components/ExpandableItem";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Entry, entriesApi } from "../services/api"; import { Entry, entriesApi } from "../services/api";
import {useFocusEffect} from "expo-router";
type SortMode = "time" | "subject"; type SortMode = "time" | "subject";
@@ -124,9 +125,14 @@ export default function SubscribedFeed() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [user?.subscribedSubjectIds]); }, [user?.subscribedSubjectIds?.join(',')]);
useEffect(() => { fetchFeed(); }, [fetchFeed]); // Always re-fetch when tab gains focus
useFocusEffect(
useCallback(() => {
fetchFeed();
}, [fetchFeed])
);
// Filter then group // Filter then group
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -249,7 +255,7 @@ export default function SubscribedFeed() {
<View className="items-center mt-16 px-8"> <View className="items-center mt-16 px-8">
<Text className="text-4xl mb-3">🔍</Text> <Text className="text-4xl mb-3">🔍</Text>
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}> <Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
No results for "{query}" No results for &#34;{query}&#34;
</Text> </Text>
</View> </View>
) : ( ) : (
+17 -2
View File
@@ -1,7 +1,7 @@
const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this
export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
function authHeader(token?: string) { function authHeader(token?: string) {
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
@@ -25,7 +25,13 @@ async function request<T>(
headers: { "Content-Type": "application/json", ...authHeader(token) }, headers: { "Content-Type": "application/json", ...authHeader(token) },
...(body ? { body: JSON.stringify(body) } : {}), ...(body ? { body: JSON.stringify(body) } : {}),
}); });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`)); if (!res.ok) {
const errorText = await res.text().catch(() => `HTTP ${res.status}`);
console.log('Failed URL:', res.url);
console.log('Status:', res.status);
console.log('Response:', errorText);
throw new Error(errorText);
}
return res.json(); return res.json();
} }
@@ -65,6 +71,7 @@ export type UserDTO = {
id: string; id: string;
email: string; email: string;
subjectSet: Subject[]; subjectSet: Subject[];
notificationType: NotificationType;
}; };
@@ -115,6 +122,12 @@ export const authApi = {
}; };
export const userApi = {
updateNotificationType: (
type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION',
token: string
) => post<void>('/users/me/notification-type', { notificationType: type }, token),
};
export const subjectsApi = { export const subjectsApi = {
getAll: (token?: string) => get<Subject[]>("/subjects", token), getAll: (token?: string) => get<Subject[]>("/subjects", token),
@@ -135,6 +148,8 @@ export const subscriptionsApi = {
), ),
}; };
export const entriesApi = { export const entriesApi = {
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) => getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
get<SpringPage<Entry>>("/entries", undefined, params), get<SpringPage<Entry>>("/entries", undefined, params),
+35
View File
@@ -0,0 +1,35 @@
import {
getMessaging,
requestPermission,
getToken,
onTokenRefresh,
AuthorizationStatus,
} from '@react-native-firebase/messaging';
import { post } from '@/services/api';
export async function registerDeviceToken(authToken: string): Promise<void> {
const messaging = getMessaging();
const status = await requestPermission(messaging);
const granted =
status === AuthorizationStatus.AUTHORIZED ||
status === AuthorizationStatus.PROVISIONAL;
if (!granted) return;
const fcmToken = await getToken(messaging);
await fetch(`${process.env.EXPO_PUBLIC_API_URL}/device-tokens/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ token: fcmToken }),
});
}
export function listenForTokenRefresh(authToken: string): () => void {
return onTokenRefresh(getMessaging(), async (newToken) => {
await post('/device-tokens/register', { token: newToken }, authToken);
});
}