fix:APP-push点击文案显示home页

This commit is contained in:
吕新雨
2026-03-12 18:02:25 +08:00
parent d37262876b
commit 22443c82b6
12 changed files with 414 additions and 16 deletions

View File

@@ -15,6 +15,7 @@
},
"ios": {
"supportsTablet": true,
"requireFullScreen": true,
"bundleIdentifier": "com.damer.mindfulness"
},
"android": {

View File

@@ -5,6 +5,7 @@ import {
Text,
Pressable,
PanResponder,
AppState,
Animated as RNAnimated,
ImageBackground,
Platform,
@@ -29,6 +30,8 @@ import {
getUserProfile,
setReaction,
setThemeMode,
clearPendingHomePushMessage,
getPendingHomePushMessage,
getRecoFeedCache,
setRecoFeedCache,
getUserProfileScoring,
@@ -41,6 +44,7 @@ import {
} from '@/src/storage/appStorage';
import { fetchRecoFeed } from '@/src/services/recoApi';
import { subscribeHomePushMessage } from '@/src/services/pushNotificationRoute';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import ProfileModal from '@/components/home/ProfileModal';
@@ -109,6 +113,7 @@ export default function HomeScreen() {
const [busy, setBusy] = useState(false);
const [likeFilled, setLikeFilled] = useState(false);
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
const [pendingPushItem, setPendingPushItem] = useState<FeedItem | null>(null);
const [isFetching, setIsFetching] = useState(false);
const [cardWidth, setCardWidth] = useState<number | null>(null);
const [wrappedText, setWrappedText] = useState<string>('');
@@ -119,6 +124,24 @@ export default function HomeScreen() {
const likedIdsRef = useRef<Set<string>>(new Set());
const likeInFlightRef = useRef(false);
const applyPendingPushItem = useCallback(async (message: { notification_id: string; content_id?: number; text: string }) => {
const nextItem: FeedItem = {
content_id: message.content_id != null ? String(message.content_id) : `push:${message.notification_id}`,
text: message.text,
};
setPendingPushItem(nextItem);
indexRef.current = 0;
setIndex(0);
setLikeFilled(likedIdsRef.current.has(String(nextItem.content_id)));
await clearPendingHomePushMessage();
}, []);
const consumePendingPushItem = useCallback(async () => {
const pendingMessage = await getPendingHomePushMessage();
if (!pendingMessage?.text) return;
await applyPendingPushItem(pendingMessage);
}, [applyPendingPushItem]);
useEffect(() => {
busyRef.current = busy;
}, [busy]);
@@ -187,14 +210,25 @@ export default function HomeScreen() {
// 统一文案对象结构
const currentFeed = useMemo(() => {
if (feedItems.length > 0) {
return feedItems;
const baseFeed =
feedItems.length > 0
? feedItems
: MOCK_CONTENT.map(item => ({
content_id: item.id,
text: t(item.textKey)
}));
if (!pendingPushItem) {
return baseFeed;
}
return MOCK_CONTENT.map(item => ({
content_id: item.id,
text: t(item.textKey)
}));
}, [feedItems, t]);
return [
pendingPushItem,
...baseFeed.filter((entry) => (
String(entry.content_id) !== String(pendingPushItem.content_id) && entry.text !== pendingPushItem.text
)),
];
}, [feedItems, pendingPushItem, t]);
useEffect(() => {
currentFeedRef.current = currentFeed;
}, [currentFeed]);
@@ -381,13 +415,20 @@ export default function HomeScreen() {
useCallback(() => {
let cancelled = false;
(async () => {
const mode = await getThemeMode();
const profile = await getUserProfile();
const cache = await getRecoFeedCache();
const [mode, profile, cache, pendingMessage] = await Promise.all([
getThemeMode(),
getUserProfile(),
getRecoFeedCache(),
getPendingHomePushMessage(),
]);
if (cancelled) return;
setThemeModeState(mode);
setProfileName(profile.name);
if (pendingMessage?.text) {
await applyPendingPushItem(pendingMessage);
if (cancelled) return;
}
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
if (mode === 'suixin') {
@@ -415,9 +456,24 @@ export default function HomeScreen() {
return () => {
cancelled = true;
};
}, [fetchNewFeed, recoLang, ensureSuixinReady])
}, [applyPendingPushItem, fetchNewFeed, recoLang, ensureSuixinReady])
);
useEffect(() => {
const unsubscribe = subscribeHomePushMessage((message) => {
void applyPendingPushItem(message);
});
return unsubscribe;
}, [applyPendingPushItem]);
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
if (state !== 'active') return;
void consumePendingPushItem();
});
return () => sub.remove();
}, [consumePendingPushItem]);
const backgroundColor = useMemo(() => {
if (themeMode === 'suixin') {
return suixinBgColor;

View File

@@ -1,7 +1,7 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import { Stack, useRouter } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import * as Notifications from 'expo-notifications';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -11,7 +11,8 @@ import { Animated, AppState, Image, StyleSheet, View } from 'react-native';
import { useColorScheme } from '@/components/useColorScheme';
import { initI18n } from '@/src/i18n';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
import { getConsentAccepted, getOnboardingCompleted, getOrCreateClientUserId } from '@/src/storage/appStorage';
import { persistHomePushMessageFromResponse } from '@/src/services/pushNotificationRoute';
import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi';
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
@@ -149,6 +150,7 @@ export default function RootLayout() {
function RootLayoutNav() {
const colorScheme = useColorScheme();
const router = useRouter();
useEffect(() => {
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
@@ -165,6 +167,44 @@ function RootLayoutNav() {
return () => sub.remove();
}, []);
const handleNotificationResponse = useCallback(
async (response: Notifications.NotificationResponse) => {
const message = await persistHomePushMessageFromResponse(response);
if (!message) return;
const [consentAccepted, onboardingCompleted] = await Promise.all([
getConsentAccepted(),
getOnboardingCompleted(),
]);
if (consentAccepted && onboardingCompleted) {
router.replace('/(app)/home');
}
},
[router]
);
useEffect(() => {
let cancelled = false;
Notifications.getLastNotificationResponseAsync()
.then((response) => {
if (cancelled || !response) return;
return handleNotificationResponse(response);
})
.catch(() => {
// ignore通知冷启动读取失败不阻塞主流程
});
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
void handleNotificationResponse(response);
});
return () => {
cancelled = true;
sub.remove();
};
}, [handleNotificationResponse]);
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>

View File

@@ -60,7 +60,7 @@
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<true/>
<key>UIStatusBarHidden</key>
<false/>
<key>UIStatusBarStyle</key>

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../storage/appStorage', () => ({
getLastHandledNotificationId: vi.fn(async () => null),
setLastHandledNotificationId: vi.fn(async () => undefined),
setPendingHomePushMessage: vi.fn(async () => undefined),
}));
import { buildPendingHomePushMessageFromResponse } from '../pushNotificationRoute';
describe('pushNotificationRoute.buildPendingHomePushMessageFromResponse', () => {
it('能从每日推荐 push payload 提取 home 文案', () => {
const result = buildPendingHomePushMessageFromResponse({
notification: {
request: {
identifier: 'notif-1',
content: {
title: '每日推荐',
body: '先用通知正文兜底',
data: {
scene: 'push',
target_screen: 'home',
home_text: '点击推送后回到首页展示这句文案',
content_id: '42',
},
},
},
},
});
expect(result).toMatchObject({
notification_id: 'notif-1',
title: '每日推荐',
text: '点击推送后回到首页展示这句文案',
content_id: 42,
scene: 'push',
});
});
it('home_text 缺失时回退到通知正文', () => {
const result = buildPendingHomePushMessageFromResponse({
notification: {
request: {
identifier: 'notif-2',
content: {
body: '直接展示通知正文',
data: {
target_screen: 'home',
},
},
},
},
});
expect(result?.text).toBe('直接展示通知正文');
expect(result?.notification_id).toBe('notif-2');
});
it('非 home 目标且非 push 场景时忽略', () => {
const result = buildPendingHomePushMessageFromResponse({
notification: {
request: {
identifier: 'notif-3',
content: {
body: '这条不应该进入首页',
data: {
target_screen: 'profile',
scene: 'other',
},
},
},
},
});
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,100 @@
import {
getLastHandledNotificationId,
setLastHandledNotificationId,
setPendingHomePushMessage,
type PendingHomePushMessage,
} from '../storage/appStorage';
type NotificationContentLike = {
title?: string | null;
body?: string | null;
data?: Record<string, unknown> | null;
};
export type NotificationResponseLike = {
notification?: {
request?: {
identifier?: string;
content?: NotificationContentLike;
};
};
};
type HomePushListener = (message: PendingHomePushMessage) => void;
const homePushListeners = new Set<HomePushListener>();
function readString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readContentId(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return Math.trunc(parsed);
}
return undefined;
}
function notifyHomePushListeners(message: PendingHomePushMessage): void {
for (const listener of homePushListeners) {
listener(message);
}
}
export function subscribeHomePushMessage(listener: HomePushListener): () => void {
homePushListeners.add(listener);
return () => {
homePushListeners.delete(listener);
};
}
export function buildPendingHomePushMessageFromResponse(
response: NotificationResponseLike
): PendingHomePushMessage | null {
const request = response.notification?.request;
const content = request?.content;
const data =
content?.data && typeof content.data === 'object' && !Array.isArray(content.data)
? content.data
: {};
const targetScreen = readString(data.target_screen);
const scene = readString(data.scene);
const shouldOpenHome = targetScreen === 'home' || scene === 'push';
if (!shouldOpenHome) return null;
const text = readString(data.home_text) ?? readString(content?.body);
if (!text) return null;
return {
notification_id: readString(request?.identifier) ?? `push-${Date.now()}`,
received_at: new Date().toISOString(),
text,
title: readString(content?.title),
content_id: readContentId(data.content_id),
scene,
};
}
export async function persistHomePushMessageFromResponse(
response: NotificationResponseLike
): Promise<PendingHomePushMessage | null> {
const message = buildPendingHomePushMessageFromResponse(response);
if (!message) return null;
const lastHandledNotificationId = await getLastHandledNotificationId();
if (lastHandledNotificationId === message.notification_id) {
return null;
}
await setPendingHomePushMessage(message);
await setLastHandledNotificationId(message.notification_id);
notifyHomePushListeners(message);
return message;
}

View File

@@ -21,6 +21,8 @@ const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken'; // 旧:仅 token保留兼容读取
const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; // 旧:时间(保留兼容)
const KEY_PUSH_LAST_REGISTERED_PAYLOAD = 'push.lastRegisteredPayload'; // 新token+client_user_id+env+app_id
const KEY_PUSH_PENDING_HOME_MESSAGE = 'push.pendingHomeMessage';
const KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID = 'push.lastHandledNotificationId';
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
export type Reaction = 'like' | 'dislike';
@@ -112,6 +114,52 @@ export async function setLastRegisteredPushPayload(payload: Omit<LastRegisteredP
await setLastRegisteredPushToken(payload.pushToken);
}
export type PendingHomePushMessage = {
notification_id: string;
received_at: string; // ISO8601
text: string;
title?: string;
content_id?: number;
scene?: string;
};
export async function getPendingHomePushMessage(): Promise<PendingHomePushMessage | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_PENDING_HOME_MESSAGE);
if (!raw) return null;
try {
const obj = JSON.parse(raw) as Partial<PendingHomePushMessage>;
if (!obj || typeof obj !== 'object') return null;
if (!obj.notification_id || !obj.received_at || !obj.text) return null;
return {
notification_id: String(obj.notification_id),
received_at: String(obj.received_at),
text: String(obj.text),
title: obj.title ? String(obj.title) : undefined,
content_id: Number.isFinite(obj.content_id) ? Number(obj.content_id) : undefined,
scene: obj.scene ? String(obj.scene) : undefined,
};
} catch {
return null;
}
}
export async function setPendingHomePushMessage(message: PendingHomePushMessage): Promise<void> {
await AsyncStorage.setItem(KEY_PUSH_PENDING_HOME_MESSAGE, JSON.stringify(message));
}
export async function clearPendingHomePushMessage(): Promise<void> {
await AsyncStorage.removeItem(KEY_PUSH_PENDING_HOME_MESSAGE);
}
export async function getLastHandledNotificationId(): Promise<string | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID);
return raw ? String(raw) : null;
}
export async function setLastHandledNotificationId(notificationId: string): Promise<void> {
await AsyncStorage.setItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID, String(notificationId));
}
export type RecoFeedCacheItem = {
content_id: number;
text: string;