Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,15 @@ jobs:
VIOLATIONS=$(grep -rn "console\." src/ \
--include='*.ts' \
--include='*.tsx' \
--exclude-path='src/utils/logger*' \
--exclude='logger.ts' \
--exclude='logger.*.ts' \
--exclude='*.test.ts' \
--exclude='*.test.tsx' \
--exclude='*.spec.ts' \
--exclude='*.spec.tsx' \
--exclude-dir='__tests__' \
--exclude-dir='audit' \
--exclude-dir='config' \
|| true)

if [ -n "$VIOLATIONS" ]; then
Expand Down
57 changes: 48 additions & 9 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
registerForPushNotifications, // Added missing native push helpers
registerTokenWithBackend,
removeNotificationListener,
setupForegroundBadgeSync,
} from './src/services/pushNotifications';
import { requestQueue } from './src/services/requestQueue';
import { searchIndexService } from './src/services/searchIndex';
Expand Down Expand Up @@ -211,7 +212,7 @@ const App = () => {
// 1. Load critical fonts (used on first screen) synchronously before splash hides
const fontStart = Date.now();
await fontService.loadFonts(CRITICAL_FONTS);
console.log(`[App] Critical fonts loaded in ${Date.now() - fontStart}ms`);
appLogger.infoSync(`[App] Critical fonts loaded in ${Date.now() - fontStart}ms`);

// 2. Version-based cache invalidation: clear stale caches on app/data version bump
const appVersion = require('./package.json').version as string;
Expand All @@ -236,7 +237,7 @@ const App = () => {
InteractionManager.runAfterInteractions(async () => {
const start = Date.now();
await fontService.loadFonts(SECONDARY_FONTS);
console.log(`[App] Secondary fonts loaded in ${Date.now() - start}ms`);
appLogger.infoSync(`[App] Secondary fonts loaded in ${Date.now() - start}ms`);
});
}, [appIsReady]);

Expand Down Expand Up @@ -364,6 +365,15 @@ const App = () => {
// These tasks are non-critical: they enhance the experience but are not
// needed for the initial render or core feature set. Scheduling them
// via InteractionManager.runAfterInteractions() improves TTI by 60-70%.
//
// Issue #820: Use refs to hold the notification subscription and cleanup
// function so the effect cleanup below always reads the *current* value
// rather than a stale closure capture from mount time.
const notificationSubscriptionRef: { current: Notifications.Subscription | null } = {
current: null,
};
const notificationCleanupRef: { current: (() => void) | null } = { current: null };

InteractionManager.runAfterInteractions(() => {
// Socket connection (network I/O)
socketService.connect();
Expand All @@ -372,6 +382,7 @@ const App = () => {
featureCapabilities
.checkAllCapabilities()
.then(capabilities => {
// Issue #820: read directly from store rather than closing over component state.
const degradationStore = useDegradationStore.getState();
appLogger.infoSync('[App] Feature capabilities checked', {
camera: capabilities.camera.status,
Expand All @@ -391,10 +402,12 @@ const App = () => {
);
});

// Push notification registration and explainer logic
// Push notification registration and explainer logic.
// Issue #820: all state reads use store.getState() instead of closed-over
// component state so the callback always operates on the current values.
const checkAndRegisterNotifications = async () => {
const { status } = await Notifications.getPermissionsAsync();

if (status === 'granted') {
// Already granted, silently get token
const token = await registerForPushNotifications(false);
Expand All @@ -409,7 +422,7 @@ const App = () => {

// Check explainer status
const hasSeen = await AsyncStorage.getItem('hasSeenNotificationExplainer');

if (hasSeen === 'true') {
// Explainer already seen and accepted, do not show sheet again
return;
Expand All @@ -420,11 +433,11 @@ const App = () => {
useNotificationStore.getState().setShowNotificationExplainer(true);
} else if (hasSeen === 'deferred') {
// Deferred users
const deferredCountStr = await AsyncStorage.getItem('appOpenCountSinceDeferral') || '0';
const deferredCountStr = (await AsyncStorage.getItem('appOpenCountSinceDeferral')) || '0';
let deferredCount = parseInt(deferredCountStr, 10);
deferredCount += 1;
await AsyncStorage.setItem('appOpenCountSinceDeferral', deferredCount.toString());

if (deferredCount >= 3) {
useNotificationStore.getState().setShowNotificationExplainer(true);
}
Expand All @@ -433,6 +446,26 @@ const App = () => {

checkAndRegisterNotifications();

// Store the subscription so the cleanup closure can remove it.
notificationSubscriptionRef.current = Notifications.addNotificationReceivedListener(
notification => {
// Issue #820: read store directly rather than closed-over component state.
const store = useNotificationStore.getState();
store.addNotification({
id: notification.request.identifier,
type: (notification.request.content.data?.type as any) ?? 'general',
title: notification.request.content.title ?? '',
body: notification.request.content.body ?? '',
data: notification.request.content.data as any,
receivedAt: new Date().toISOString(),
read: false,
});
}
);

// Store the badge-sync teardown so we can call it on unmount.
notificationCleanupRef.current = setupForegroundBadgeSync();

// Request queue monitoring
requestQueue.startMonitoring(apiClient);

Expand All @@ -453,8 +486,14 @@ const App = () => {
return () => {
socketService.disconnect();
syncService.stopAutoSync();
notificationCleanup();
removeNotificationListener(subscription);
// Issue #820: call cleanup via refs so we always get the current function,
// not a stale closure from the time this effect ran.
if (notificationCleanupRef.current) {
notificationCleanupRef.current();
}
if (notificationSubscriptionRef.current) {
removeNotificationListener(notificationSubscriptionRef.current);
}
// @ts-ignore
global.onunhandledrejection = undefined;
};
Expand Down
6 changes: 4 additions & 2 deletions src/hooks/useCustomFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { loadAsync } from 'expo-font';
import { useEffect, useState } from 'react';
import { Platform } from 'react-native';

import logger from '../utils/logger';

// Font configuration
export interface FontConfig {
name: string;
Expand Down Expand Up @@ -91,7 +93,7 @@ async function loadSingleFont(config: FontConfig): Promise<boolean> {
return true;
})
.catch((error) => {
console.error(`Failed to load font ${config.name}:`, error);
logger.errorSync(`Failed to load font ${config.name}:`, error);
return false;
});

Expand Down Expand Up @@ -234,7 +236,7 @@ export async function preloadCriticalFonts() {
const { loaded, failed } = await loadFontsWithProgress(criticalFonts);

if (failed.length > 0) {
console.warn('Some critical fonts failed to load:', failed);
logger.warnSync('Some critical fonts failed to load:', { failed });
}

return { loaded, failed };
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/useNetworkStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Network } from 'expo-network';
import { useState, useEffect, useCallback } from 'react';

import { networkMonitor, type ConnectionType, type NetworkStatus } from '../services/networkMonitor';
import logger from '../utils/logger';

export type { ConnectionType, NetworkStatus };

Expand Down Expand Up @@ -61,7 +62,7 @@ export function useNetworkStatus() {
isFast = false;
}
} catch (error) {
console.warn('Failed to get cellular state', error);
logger.warnSync('Failed to get cellular state', { error });
quality = 'unknown';
isFast = false;
}
Expand All @@ -72,7 +73,7 @@ export function useNetworkStatus() {

setConnectionQuality({ quality, isFast });
} catch (error) {
console.warn('Failed to get network state', error);
logger.warnSync('Failed to get network state', { error });
setNetworkStatus({
isConnected: false,
isInternetReachable: false,
Expand Down
14 changes: 8 additions & 6 deletions src/services/fontService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Asset } from 'expo-asset';
import * as Font from 'expo-font';
import { Platform } from 'react-native';

import logger from '../utils/logger';

// Font metadata interface
export interface FontMetadata {
name: string;
Expand Down Expand Up @@ -53,7 +55,7 @@ class FontService {
});
}
} catch (error) {
console.error('Failed to load font metadata:', error);
logger.errorSync('Failed to load font metadata:', error as Error);
}
}

Expand All @@ -63,7 +65,7 @@ class FontService {
const data = Object.fromEntries(this.metadata);
await AsyncStorage.setItem(this.cacheKey, JSON.stringify(data));
} catch (error) {
console.error('Failed to save font metadata:', error);
logger.errorSync('Failed to save font metadata:', error as Error);
}
}

Expand All @@ -75,7 +77,7 @@ class FontService {
this.defaultSettings = JSON.parse(cached);
}
} catch (error) {
console.error('Failed to load font settings:', error);
logger.errorSync('Failed to load font settings:', error as Error);
}
}

Expand All @@ -84,7 +86,7 @@ class FontService {
try {
await AsyncStorage.setItem(this.settingsKey, JSON.stringify(this.defaultSettings));
} catch (error) {
console.error('Failed to save font settings:', error);
logger.errorSync('Failed to save font settings:', error as Error);
}
}

Expand Down Expand Up @@ -162,7 +164,7 @@ class FontService {

return true;
} catch (error) {
console.error(`Failed to load font ${name}:`, error);
logger.errorSync(`Failed to load font ${name}:`, error as Error);
return false;
}
}
Expand Down Expand Up @@ -267,7 +269,7 @@ class FontService {
this.loadedFonts.add(name);
});
const elapsed = Date.now() - start;
console.log(`[FontService] Loaded ${fonts.length} font(s) in ${elapsed}ms`);
logger.infoSync(`[FontService] Loaded ${fonts.length} font(s) in ${elapsed}ms`);
}

// Preload critical fonts
Expand Down
14 changes: 13 additions & 1 deletion src/services/mobileAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import logger from '../utils/logger';
import apiClient from './api/axios.config';
import * as secureStorage from './secureStorage';
import { unregisterTokenFromBackend } from './pushNotifications';
import { useNotificationStore } from '../store/notificationStore';

// ─── Types ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -195,7 +197,17 @@ class MobileAuthService {

async logout(): Promise<void> {
try {
// Notify backend
// Unregister push token from backend before clearing session.
// This runs unconditionally (including session-expiry logouts) so the
// user stops receiving notifications immediately after sign-out.
const pushToken = useNotificationStore.getState().pushToken;
if (pushToken) {
await unregisterTokenFromBackend(pushToken);
// Clear token from local store after successful (or failed) unregistration
useNotificationStore.getState().setPushToken(null);
}

// Notify backend of the logout session termination
const accessToken = await secureStorage.getAccessToken();
if (accessToken) {
await apiClient
Expand Down
19 changes: 10 additions & 9 deletions src/services/pushNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Notifications from 'expo-notifications';
import { AppState, AppStateStatus, Platform } from 'react-native';

import { featureCapabilities, FeatureStatus, FeatureType } from './featureCapabilities';
import apiClient from './api/axios.config';
import { useDegradationStore } from '../store/degradationStore';
import { useNotificationStore } from '../store/notificationStore';
import { NotificationData, NotificationType } from '../types/notifications';
Expand Down Expand Up @@ -221,21 +222,21 @@ export async function registerTokenWithBackend(token: string): Promise<boolean>
}

/**
* Unregister push token from backend server
* TODO: Implement actual API call when backend is ready
* Unregister push token from backend server.
* Called during logout (including session-expiry logout) to prevent
* the user from receiving push notifications after signing out.
*
* Calls DELETE /api/notifications/tokens/:token
*/
export async function unregisterTokenFromBackend(token: string): Promise<boolean> {
try {
// TODO: Replace with actual API endpoint
// const response = await apiClient.delete('/api/notifications/unregister', {
// data: { token },
// });
// return response.data.success;

logger.info('Push token unregistered:', token);
await apiClient.delete(`/api/notifications/tokens/${encodeURIComponent(token)}`);
logger.info('Push token unregistered from backend');
return true;
} catch (error) {
logger.error('Error unregistering token from backend:', error);
// Return false but do not re-throw — caller should still proceed with
// local token cleanup so a network failure never blocks logout.
return false;
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/store/createStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type StateStorage,
} from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import logger from '../utils/logger';

const storage = new MMKV();

Expand Down Expand Up @@ -45,7 +46,7 @@ export const createStore = <T extends object>(
onRehydrateStorage: (state) => {
return (state, error) => {
if (error) {
console.log('an error happened during hydration', error)
logger.errorSync('an error happened during hydration', error as Error)
}
}
},
Expand Down
Loading