diff --git a/back/controllers/reward.controller.js b/back/controllers/reward.controller.js index 84a9f38..8a60f65 100644 --- a/back/controllers/reward.controller.js +++ b/back/controllers/reward.controller.js @@ -130,6 +130,20 @@ const ACHIEVEMENT_CATALOG = { category: 'social', hidden: false, }, + MULTI_SESSIONS_5: { + id: 'MULTI_SESSIONS_5', + name: 'Entraînement en Duo', + description: "Vous avez terminé 5 séances en mode Multi.", + category: 'social', + hidden: false, + }, + MULTI_SESSIONS_30: { + id: 'MULTI_SESSIONS_30', + name: "Frères d'Armes", + description: "Vous avez terminé 30 séances en mode Multi.", + category: 'social', + hidden: false, + }, }; // Nombre total de trophées dans le catalogue (utile pour les stats) @@ -256,6 +270,18 @@ async function checkAndUnlockAchievements(userId) { } } + // Trophées gradués sur le cumul de séances Multi terminées + // (totalMultiSessions, incrémenté dans workoutLobby.controller.js → finishLobby). + const MULTI_SESSION_ACHIEVEMENTS = [ + [5, 'MULTI_SESSIONS_5'], + [30, 'MULTI_SESSIONS_30'], + ]; + for (const [threshold, achievementId] of MULTI_SESSION_ACHIEVEMENTS) { + if (!unlockedIds.has(achievementId) && user.totalMultiSessions >= threshold) { + tryUnlock(achievementId); + } + } + if (newlyUnlocked.length > 0) { user.markModified('achievements'); await user.save(); diff --git a/back/controllers/workoutLobby.controller.js b/back/controllers/workoutLobby.controller.js index 5b35836..215f455 100644 --- a/back/controllers/workoutLobby.controller.js +++ b/back/controllers/workoutLobby.controller.js @@ -317,8 +317,9 @@ exports.unreadyLobby = async (req, res, next) => { /** * Passe le statut du membre appelant à 'finished'. Dès que TOUS les membres * ont fini, le lobby passe 'completed', le bonus XP Multi est figé - * (`xpBonusPercent`), et les trophées sociaux Multi sont vérifiés pour - * chaque membre (FIRST_MULTI_SESSION, MULTI_SQUAD_FULL à 5 joueurs). + * (`xpBonusPercent`), le compteur totalMultiSessions de chaque membre est + * incrémenté, et les trophées sociaux Multi sont vérifiés (FIRST_MULTI_SESSION, + * MULTI_SQUAD_FULL à 5 joueurs, MULTI_SESSIONS_5, MULTI_SESSIONS_30). */ exports.finishLobby = async (req, res, next) => { try { @@ -359,6 +360,14 @@ exports.finishLobby = async (req, res, next) => { if (allFinished) { // Best-effort : les trophées ne doivent jamais faire échouer la clôture. try { + // Incrémente le compteur AVANT de vérifier les trophées gradués + // (MULTI_SESSIONS_5 / MULTI_SESSIONS_30), sinon le seuil serait + // évalué sur l'ancienne valeur. + await User.updateMany( + { _id: { $in: lobby.members.map((m) => m.user) } }, + { $inc: { totalMultiSessions: 1 } }, + ); + const results = await Promise.all( lobby.members.map((m) => checkAndUnlockAchievements(m.user.toString())), ); diff --git a/back/models/User.js b/back/models/User.js index dba79ee..33d5f17 100644 --- a/back/models/User.js +++ b/back/models/User.js @@ -149,6 +149,11 @@ const UserSchema = new mongoose.Schema( // cosmétique Rouge Sang Unique à 100 coffres). totalChestsOpened: { type: Number, default: 0, min: 0 }, + // Cumul du nombre de séances Multi terminées (lobby passé 'completed', + // voir workoutLobby.controller.js → finishLobby) — condition des trophées + // gradués MULTI_SESSIONS_5 / MULTI_SESSIONS_30 (reward.controller.js). + totalMultiSessions: { type: Number, default: 0, min: 0 }, + // Cosmétiques Uniques définitivement débloqués (réclamés depuis // l'inventaire — voir inventory.controller.js → claimUniqueItem). // Clés libres du catalogue front (BorderPicker.js / profileThemes.js) : diff --git a/back/tests/workoutLobby.test.js b/back/tests/workoutLobby.test.js index 9652abc..7f8acc5 100644 --- a/back/tests/workoutLobby.test.js +++ b/back/tests/workoutLobby.test.js @@ -374,6 +374,46 @@ describe('Lobby Multi — Section VII', () => { expect(res.body.newlyUnlocked).not.toContain('MULTI_SQUAD_FULL'); }); + it('✅ Incrémente totalMultiSessions pour chaque membre à la clôture', async () => { + const lobbyId = await createActiveLobby([alice, bob]); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`); + + const aliceUser = await User.findById(alice.userId); + const bobUser = await User.findById(bob.userId); + expect(aliceUser.totalMultiSessions).toBe(1); + expect(bobUser.totalMultiSessions).toBe(1); + }); + + it('✅ Débloque MULTI_SESSIONS_5 à la 5e séance Multi terminée', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { totalMultiSessions: 4 } }); + + const lobbyId = await createActiveLobby([alice, bob]); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`); + const res = await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`); + + const aliceUser = await User.findById(alice.userId); + const bobUser = await User.findById(bob.userId); + expect(aliceUser.totalMultiSessions).toBe(5); + expect(aliceUser.achievements.some((a) => a.achievementId === 'MULTI_SESSIONS_5')).toBe(true); + // Bob n'a fait qu'1 séance Multi : pas encore débloqué pour lui + expect(bobUser.totalMultiSessions).toBe(1); + expect(bobUser.achievements.some((a) => a.achievementId === 'MULTI_SESSIONS_5')).toBe(false); + expect(res.body.newlyUnlocked).not.toContain('MULTI_SESSIONS_5'); + }); + + it('✅ Débloque MULTI_SESSIONS_30 à la 30e séance Multi terminée', async () => { + await User.updateOne({ _id: alice.userId }, { $set: { totalMultiSessions: 29 } }); + + const lobbyId = await createActiveLobby([alice, bob]); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${alice.token}`); + await request(app).post(`/api/lobby/${lobbyId}/finish`).set('Authorization', `Bearer ${bob.token}`); + + const aliceUser = await User.findById(alice.userId); + expect(aliceUser.totalMultiSessions).toBe(30); + expect(aliceUser.achievements.some((a) => a.achievementId === 'MULTI_SESSIONS_30')).toBe(true); + }); + it("❌ 422 si le lobby n'est pas encore actif (waiting)", async () => { const res = await request(app).post('/api/lobby/create').set('Authorization', `Bearer ${alice.token}`); const lobbyId = res.body.lobby._id; diff --git a/front/.env.example b/front/.env.example index 014189b..65d82c1 100644 --- a/front/.env.example +++ b/front/.env.example @@ -19,3 +19,22 @@ TOKEN_KEY=athly_token # ================================ # development | production APP_ENV=development + + +# ================================ +# GOOGLE OAUTH (Section VIII) +# ================================ +# Client IDs OAuth 2.0 créés dans Google Cloud Console (console.cloud.google.com +# → APIs & Services → Identifiants). Un ID par plateforme car chacun a un +# type d'application différent : +# - GOOGLE_EXPO_CLIENT_ID : type "Web", utilisé pour le proxy Expo Go en dev +# - GOOGLE_IOS_CLIENT_ID : type "iOS", bundle ID com.clementin.athly +# - GOOGLE_ANDROID_CLIENT_ID: type "Android", package com.clementin.athly +# - GOOGLE_WEB_CLIENT_ID : type "Web", utilisé pour la version PWA/web +# Tant qu'ils ne sont pas renseignés, le bouton Google reste désactivé côté +# front (voir useGoogleAuth.js) — le backend répond 501 de toute façon sans +# GOOGLE_CLIENT_ID configuré côté serveur (voir back/.env). +GOOGLE_EXPO_CLIENT_ID= +GOOGLE_IOS_CLIENT_ID= +GOOGLE_ANDROID_CLIENT_ID= +GOOGLE_WEB_CLIENT_ID= diff --git a/front/app.json b/front/app.json index 7485a26..bf1268e 100644 --- a/front/app.json +++ b/front/app.json @@ -3,6 +3,7 @@ "name": "Athly", "slug": "athly-app", "version": "1.0.0", + "scheme": "athly", "orientation": "portrait", "icon": "./assets/favicon.png", "userInterfaceStyle": "light", diff --git a/front/package-lock.json b/front/package-lock.json index 71a2e72..c0a28ff 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -17,11 +17,14 @@ "@react-navigation/stack": "^7.8.7", "axios": "^1.16.0", "expo": "~54.0.27", + "expo-auth-session": "~7.0.11", + "expo-crypto": "~15.0.9", "expo-haptics": "~15.0.8", "expo-linear-gradient": "~15.0.8", "expo-notifications": "^0.32.17", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "expo-web-browser": "~15.0.11", "lottie-react-native": "~7.3.1", "qs": "^6.15.1", "react": "19.1.0", @@ -5787,6 +5790,24 @@ "react-native": "*" } }, + "node_modules/expo-auth-session": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.11.tgz", + "integrity": "sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==", + "license": "MIT", + "dependencies": { + "expo-application": "~7.0.8", + "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.9", + "expo-linking": "~8.0.12", + "expo-web-browser": "~15.0.11", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -5801,6 +5822,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "15.0.9", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz", + "integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.23", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz", @@ -5855,6 +5888,20 @@ "react-native": "*" } }, + "node_modules/expo-linking": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", + "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==", + "license": "MIT", + "dependencies": { + "expo-constants": "~18.0.13", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.26", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz", @@ -5935,6 +5982,16 @@ "react-native": "*" } }, + "node_modules/expo-web-browser": { + "version": "15.0.11", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz", + "integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", diff --git a/front/package.json b/front/package.json index d63c2a8..69cf2ef 100644 --- a/front/package.json +++ b/front/package.json @@ -20,23 +20,26 @@ "@react-navigation/stack": "^7.8.7", "axios": "^1.16.0", "expo": "~54.0.27", + "expo-auth-session": "~7.0.11", + "expo-crypto": "~15.0.9", "expo-haptics": "~15.0.8", "expo-linear-gradient": "~15.0.8", "expo-notifications": "^0.32.17", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "expo-web-browser": "~15.0.11", "lottie-react-native": "~7.3.1", "qs": "^6.15.1", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", - "react-native-web": "~0.19.13", "react-native-chart-kit": "^6.12.2", "react-native-dotenv": "^3.4.11", "react-native-gesture-handler": "~2.28.0", "react-native-safe-area-context": "^5.6.2", "react-native-screens": "~4.16.0", - "react-native-svg": "15.12.1" + "react-native-svg": "15.12.1", + "react-native-web": "~0.19.13" }, "private": true, "devDependencies": { diff --git a/front/src/data/profileThemes.js b/front/src/data/profileThemes.js index 4b1a492..8b02fa1 100644 --- a/front/src/data/profileThemes.js +++ b/front/src/data/profileThemes.js @@ -2,6 +2,8 @@ // Each theme overrides accentColor, glow, shimmer and background variant. // unlockLevel: minimum level required to pick this theme (God Mode bypasses). +import { Colors } from '../constants/theme'; + export const PROFILE_THEMES = [ { id: 'auto', @@ -135,8 +137,8 @@ export const PROFILE_THEMES = [ unlockLevel: 0, requiresCosmetic: 'THEME_BLOODSANG', requiresChests: 100, // pour le texte de progression avant réclamation - accentColor: '#FF2E4D', - glowColor: 'rgba(163,0,0,0.75)', + accentColor: Colors.uniqueBloodBright, + glowColor: Colors.uniqueBloodGlow, shimmer: true, hasGlow: true, bgVariant: 'blood', diff --git a/front/src/hooks/useGoogleAuth.js b/front/src/hooks/useGoogleAuth.js new file mode 100644 index 0000000..54794e4 --- /dev/null +++ b/front/src/hooks/useGoogleAuth.js @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import * as Google from 'expo-auth-session/providers/google'; +import * as WebBrowser from 'expo-web-browser'; +import { + GOOGLE_EXPO_CLIENT_ID, + GOOGLE_IOS_CLIENT_ID, + GOOGLE_ANDROID_CLIENT_ID, + GOOGLE_WEB_CLIENT_ID, +} from '@env'; + +// Ferme proprement l'onglet du navigateur système ouvert par promptAsync() +// une fois l'auth terminée — sans ça l'onglet reste bloqué en attente sur +// certaines plateformes (voir la doc expo-auth-session). +WebBrowser.maybeCompleteAuthSession(); + +// ─── useGoogleAuth ──────────────────────────────────────────────────────────── +// Connexion Google en un clic (Section VIII) : demande un idToken via le SDK +// Expo Auth Session, à envoyer ensuite à POST /api/auth/google (vérifié côté +// serveur — voir back/services/auth.service.js → googleLogin). +// +// Les Client IDs viennent de .env (voir .env.example pour la marche à suivre +// dans Google Cloud Console). Tant qu'ils ne sont pas renseignés, `isConfigured` +// vaut false et le bouton doit rester désactivé côté écran appelant. + +export function useGoogleAuth() { + const isConfigured = Boolean( + GOOGLE_EXPO_CLIENT_ID || GOOGLE_IOS_CLIENT_ID || GOOGLE_ANDROID_CLIENT_ID || GOOGLE_WEB_CLIENT_ID, + ); + + const [request, response, promptAsync] = Google.useIdTokenAuthRequest({ + clientId: GOOGLE_EXPO_CLIENT_ID || undefined, + iosClientId: GOOGLE_IOS_CLIENT_ID || undefined, + androidClientId: GOOGLE_ANDROID_CLIENT_ID || undefined, + webClientId: GOOGLE_WEB_CLIENT_ID || undefined, + }); + + const idToken = useMemo(() => { + if (!response || response.type !== 'success') return null; + return response.authentication?.idToken || response.params?.id_token || null; + }, [response]); + + return { + isConfigured, + request, + response, + idToken, + promptAsync, + }; +} diff --git a/front/src/screens/Auth/LoginScreen.js b/front/src/screens/Auth/LoginScreen.js index 4aecc0d..0ae90bd 100644 --- a/front/src/screens/Auth/LoginScreen.js +++ b/front/src/screens/Auth/LoginScreen.js @@ -10,8 +10,9 @@ import { Ionicons } from '@expo/vector-icons'; import { Colors } from '../../constants/theme'; import AuthInput from '../../components/inputs/AuthInput'; import NotificationBanner from '../../components/common/NotificationBanner'; -import { login } from '../../services/auth.service'; +import { login, googleLogin } from '../../services/auth.service'; import { useAuth } from '../../context/AuthContext'; +import { useGoogleAuth } from '../../hooks/useGoogleAuth'; const LOGO_ORANGE = require('../../../assets/logo-orange.png'); const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -30,16 +31,53 @@ function FadeLoader() { export default function LoginScreen({ navigation }) { const { signIn } = useAuth(); + const { isConfigured: googleConfigured, idToken, promptAsync } = useGoogleAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); + const [googleLoading, setGoogleLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); const [rememberMe, setRememberMe] = useState(true); const [emailErr, setEmailErr] = useState(''); const [globalErr, setGlobalErr] = useState(''); const [errType, setErrType] = useState('error'); + // Dès que promptAsync() résout avec succès, useGoogleAuth expose l'idToken — + // on le transmet immédiatement au backend pour vérification et connexion. + useEffect(() => { + if (!idToken) return; + (async () => { + try { + setGoogleLoading(true); + setGlobalErr(''); + const res = await googleLogin(idToken); + if (res?.token) { + await signIn(res.token, rememberMe); + } + } catch (error) { + const status = error?.status; + if (status >= 500) { + setErrType('info'); + setGlobalErr('Une erreur est survenue, notre équipe est sur le coup.'); + } else { + setErrType('error'); + setGlobalErr('Connexion Google impossible. Réessaie.'); + } + } finally { + setGoogleLoading(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idToken]); + + const handleGoogleLogin = async () => { + try { await promptAsync(); } catch (_) { + setErrType('error'); + setGlobalErr('Connexion Google impossible. Réessaie.'); + } + }; + const validateEmail = (val = email) => { if (!val) { setEmailErr('Email requis'); return false; } if (!EMAIL_RE.test(val)) { setEmailErr('Email invalide'); return false; } @@ -163,6 +201,24 @@ export default function LoginScreen({ navigation }) { + {googleConfigured && ( + + {googleLoading + ? + : ( + <> + + Continuer avec Google + + )} + + )} + Pas encore de compte ? navigation.navigate('Register')}> @@ -225,6 +281,15 @@ const s = StyleSheet.create({ dividerLine: { flex: 1, height: 1, backgroundColor: Colors.separator }, dividerText: { color: Colors.textMuted, marginHorizontal: 12, fontSize: 12 }, + googleBtn: { + flexDirection: 'row', height: 56, borderRadius: 14, + justifyContent: 'center', alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, borderColor: 'rgba(255,255,255,0.14)', + marginBottom: 20, + }, + googleBtnText: { color: Colors.textPrimary, fontSize: 15, fontWeight: '700' }, + switchRow: { flexDirection: 'row', justifyContent: 'center' }, switchLabel: { color: Colors.textMuted, fontSize: 14 }, linkBold: { color: Colors.primary, fontWeight: '700', fontSize: 14 }, diff --git a/front/src/screens/Profile/SettingsScreen.js b/front/src/screens/Profile/SettingsScreen.js index 3a2c468..b5a1c9e 100644 --- a/front/src/screens/Profile/SettingsScreen.js +++ b/front/src/screens/Profile/SettingsScreen.js @@ -925,7 +925,7 @@ export default function SettingsScreen({ navigation }) { Crée un groupe avec 3 coéquipiers factices, un par statut de Météo des - séances testable (🔥 Prêt / ⚡ Actif / ✅ Validé - le 4e, 💤 En sommeil, + séances testable (Prêt / Actif / Validé - le 4e, En sommeil, s'obtient en ne touchant à aucun des trois). Rejouable sans doublons. diff --git a/front/src/services/auth.service.js b/front/src/services/auth.service.js index a49dbab..2b2cc61 100644 --- a/front/src/services/auth.service.js +++ b/front/src/services/auth.service.js @@ -13,6 +13,14 @@ export async function register(data) { return res.data; } +// Connexion en un clic via Google — idToken obtenu côté client (expo-auth-session), +// vérifié côté serveur (voir back/services/auth.service.js → googleLogin). +export async function googleLogin(idToken) { + const res = await API.post('/auth/google', { idToken }); + if (res.data?.token) await saveToken(res.data.token); + return res.data; +} + export async function logout() { await removeToken(); } diff --git a/front/src/services/notificationService.js b/front/src/services/notificationService.js index b7055ce..df28eb6 100644 --- a/front/src/services/notificationService.js +++ b/front/src/services/notificationService.js @@ -3,7 +3,12 @@ import { Platform } from 'react-native'; import Constants from 'expo-constants'; import AsyncStorage from '@react-native-async-storage/async-storage'; -const DAILY_NOTIF_IDS_KEY = 'athly:notif:daily_ids:v2'; +const DAILY_NOTIF_IDS_KEY = 'athly:notif:daily_ids:v2'; +// Titre du DERNIER rappel quotidien généré (persisté en cache local) — permet +// à pickDailyOccurrence d'éviter une répétition même quand la planification +// est régénérée (ensureDailyRemindersScheduled), sans quoi le jour 1 d'un +// nouveau lot pourrait reproduire le titre du dernier jour du lot précédent. +const LAST_NOTIF_TITLE_KEY = 'athly:notif:last_title:v1'; const CHANNEL_ORANGE_ID = 'streak-orange'; const CHANNEL_VIOLET_ID = 'streak-purple'; const CHANNEL_BIRTHDAY_ID = 'athly-birthday'; @@ -161,9 +166,14 @@ export async function scheduleDailyReminder(hour = 18, minute = 0, count = DAILY // une nouvelle, pour éviter l'accumulation de rappels dupliqués en arrière-plan. await cancelDailyReminder(); + // Anti-répétition inter-lots : reprend le dernier titre généré (même après + // régénération du stock) pour ne jamais enchaîner deux jours identiques. + let lastTitle = null; + try { lastTitle = await AsyncStorage.getItem(LAST_NOTIF_TITLE_KEY); } catch (_) {} + const now = new Date(); const ids = []; - let previous = null; + let previous = lastTitle ? { msg: { title: lastTitle } } : null; for (let dayOffset = 0; dayOffset < count; dayOffset++) { const date = new Date(now); @@ -195,6 +205,9 @@ export async function scheduleDailyReminder(hour = 18, minute = 0, count = DAILY try { await AsyncStorage.setItem(DAILY_NOTIF_IDS_KEY, JSON.stringify({ hour, minute, ids })); + if (previous?.msg?.title) { + await AsyncStorage.setItem(LAST_NOTIF_TITLE_KEY, previous.msg.title); + } } catch (_) {} return ids;