fix:重复点击
This commit is contained in:
@@ -113,6 +113,18 @@ export default function HomeScreen() {
|
||||
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||||
const [wrappedText, setWrappedText] = useState<string>('');
|
||||
const wrapLogRef = useRef<{ key: string } | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
const indexRef = useRef(0);
|
||||
const currentFeedRef = useRef<FeedItem[]>([]);
|
||||
const likedIdsRef = useRef<Set<string>>(new Set());
|
||||
const likeInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
busyRef.current = busy;
|
||||
}, [busy]);
|
||||
useEffect(() => {
|
||||
indexRef.current = index;
|
||||
}, [index]);
|
||||
|
||||
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||
@@ -183,6 +195,9 @@ export default function HomeScreen() {
|
||||
text: t(item.textKey)
|
||||
}));
|
||||
}, [feedItems, t]);
|
||||
useEffect(() => {
|
||||
currentFeedRef.current = currentFeed;
|
||||
}, [currentFeed]);
|
||||
|
||||
const item = useMemo(() => {
|
||||
const data = currentFeed[index % currentFeed.length];
|
||||
@@ -430,24 +445,60 @@ export default function HomeScreen() {
|
||||
transform: [{ scale: likeScale.value }],
|
||||
}));
|
||||
|
||||
const setBusySafe = useCallback((next: boolean) => {
|
||||
busyRef.current = next;
|
||||
setBusy(next);
|
||||
}, []);
|
||||
|
||||
const setLikeInFlight = useCallback((next: boolean) => {
|
||||
likeInFlightRef.current = next;
|
||||
}, []);
|
||||
|
||||
const syncLikeFilledByIndex = useCallback((nextIndex: number) => {
|
||||
const list = currentFeedRef.current;
|
||||
const len = list.length;
|
||||
if (!len) {
|
||||
setLikeFilled(false);
|
||||
return;
|
||||
}
|
||||
const safe = ((nextIndex % len) + len) % len;
|
||||
const nextId = String(list[safe]?.content_id);
|
||||
setLikeFilled(likedIdsRef.current.has(nextId));
|
||||
}, []);
|
||||
|
||||
const applyIndexChange = useCallback((nextIndex: number) => {
|
||||
indexRef.current = nextIndex;
|
||||
setIndex(nextIndex);
|
||||
syncLikeFilledByIndex(nextIndex);
|
||||
}, [syncLikeFilledByIndex]);
|
||||
|
||||
const maybeFetchNewFeedIfNeeded = useCallback((nextIndex: number) => {
|
||||
const len = currentFeedRef.current.length;
|
||||
if (!len) return;
|
||||
// 当接近当前列表末尾时(例如还剩 5 条)提前拉取
|
||||
if (nextIndex + 5 >= len && !isFetchingRef.current) {
|
||||
fetchNewFeed();
|
||||
}
|
||||
}, [fetchNewFeed]);
|
||||
|
||||
// 切换到下一条文案的统一动画逻辑
|
||||
const triggerNextContent = useCallback(() => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
if (busyRef.current) return;
|
||||
setBusySafe(true);
|
||||
|
||||
// 注意:不要在 Reanimated worklet 回调里读取 React ref(例如 indexRef/currentFeedRef),会导致值不更新或异常
|
||||
const nextIndex = indexRef.current + 1;
|
||||
|
||||
// 1. 当前文案向上移动并消失
|
||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
// 2. 切换数据索引
|
||||
runOnJS(setIndex)(index + 1);
|
||||
runOnJS(setLikeFilled)(false);
|
||||
runOnJS(applyIndexChange)(nextIndex);
|
||||
runOnJS(advanceSuixinOnNextContent)();
|
||||
|
||||
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
||||
if (index + 5 >= currentFeed.length && !isFetching) {
|
||||
runOnJS(fetchNewFeed)();
|
||||
}
|
||||
// 检查是否需要拉取新文案(注意:不要把匿名函数塞进 runOnJS,可能导致原生崩溃)
|
||||
runOnJS(maybeFetchNewFeedIfNeeded)(nextIndex);
|
||||
|
||||
// 3. 准备下一条文案:先瞬移到下方 40pt
|
||||
translateY.value = 40;
|
||||
@@ -456,26 +507,29 @@ export default function HomeScreen() {
|
||||
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(setBusy)(false);
|
||||
runOnJS(setBusySafe)(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
|
||||
}, [applyIndexChange, setBusySafe, translateY, opacity, advanceSuixinOnNextContent, maybeFetchNewFeedIfNeeded]);
|
||||
|
||||
// 切换到上一条文案的统一动画逻辑(下滑触发)
|
||||
const triggerPrevContent = useCallback(() => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
if (busyRef.current) return;
|
||||
setBusySafe(true);
|
||||
|
||||
// 注意:同上,不要在 worklet 里读取 React ref
|
||||
const len = currentFeedRef.current.length;
|
||||
const raw = indexRef.current - 1;
|
||||
const nextIndex = len ? ((raw % len) + len) % len : Math.max(0, raw);
|
||||
|
||||
// 1. 当前文案向下移动并消失
|
||||
translateY.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
// 2. 切换数据索引(循环回退)
|
||||
const nextIndex = index - 1 < 0 ? Math.max(0, currentFeed.length - 1) : index - 1;
|
||||
runOnJS(setIndex)(nextIndex);
|
||||
runOnJS(setLikeFilled)(false);
|
||||
runOnJS(applyIndexChange)(nextIndex);
|
||||
|
||||
// 3. 准备上一条文案:先瞬移到上方 40pt
|
||||
translateY.value = -40;
|
||||
@@ -484,12 +538,12 @@ export default function HomeScreen() {
|
||||
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(setBusy)(false);
|
||||
runOnJS(setBusySafe)(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [busy, index, currentFeed.length, translateY, opacity]);
|
||||
}, [applyIndexChange, setBusySafe, translateY, opacity]);
|
||||
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
@@ -533,7 +587,21 @@ export default function HomeScreen() {
|
||||
).current;
|
||||
|
||||
async function onPressLike() {
|
||||
if (busy) return;
|
||||
if (busyRef.current) return;
|
||||
if (likeInFlightRef.current) return;
|
||||
|
||||
// 已经喜欢过:不重复写入收藏,直接当作“下一条”
|
||||
if (likeFilled || likedIdsRef.current.has(item.id)) {
|
||||
triggerNextContent();
|
||||
return;
|
||||
}
|
||||
|
||||
likeInFlightRef.current = true;
|
||||
|
||||
const likedItemId = item.id;
|
||||
const likedItemText = item.text;
|
||||
// 先记下“已喜欢”,保证回退时能恢复点亮状态(即便异步保存稍后才完成)
|
||||
likedIdsRef.current.add(likedItemId);
|
||||
setLikeFilled(true);
|
||||
|
||||
// 1. 获取当前日期
|
||||
@@ -543,24 +611,33 @@ export default function HomeScreen() {
|
||||
// 2. 保存到收藏夹,包含当前背景信息
|
||||
const favItem = {
|
||||
favId: String(Date.now()), // 生成唯一 ID
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
id: likedItemId,
|
||||
text: likedItemText,
|
||||
date: dateStr,
|
||||
themeMode: themeMode,
|
||||
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||||
};
|
||||
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
|
||||
await addFavorite(favItem);
|
||||
try {
|
||||
await addFavorite(favItem);
|
||||
} catch (error) {
|
||||
console.error('Home: addFavorite 失败', error);
|
||||
}
|
||||
|
||||
// 3. 记录到后端 Reaction(喜欢)
|
||||
console.log('Home: Triggering setReaction', item.id);
|
||||
await setReaction(item.id, 'like');
|
||||
try {
|
||||
await setReaction(item.id, 'like');
|
||||
} catch (error) {
|
||||
console.error('Home: setReaction 失败', error);
|
||||
}
|
||||
|
||||
// 4. 爱心缩放动画
|
||||
likeScale.value = withSequence(
|
||||
withTiming(0.8, { duration: 100 }),
|
||||
withTiming(1.2, { duration: 150 }),
|
||||
withTiming(1, { duration: 100 }, (finished) => {
|
||||
runOnJS(setLikeInFlight)(false);
|
||||
if (finished) {
|
||||
console.log('Home: Like animation finished, triggering next content');
|
||||
runOnJS(triggerNextContent)();
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function OnboardingScreen() {
|
||||
const [name, setName] = useState('');
|
||||
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||
const [reminderTimes, setReminderTimes] = useState(3);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
|
||||
const currentStep = STEPS[stepIndex];
|
||||
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
|
||||
@@ -56,6 +57,9 @@ export default function OnboardingScreen() {
|
||||
}, [t, currentStep]);
|
||||
|
||||
async function onFinish() {
|
||||
if (finishing) return;
|
||||
setFinishing(true);
|
||||
try {
|
||||
// 用户选择每日次数 > 0:在此页直接触发系统通知权限(已移除单独的 push 引导页)。
|
||||
const wantsPush = reminderTimes > 0;
|
||||
|
||||
@@ -159,9 +163,17 @@ export default function OnboardingScreen() {
|
||||
} finally {
|
||||
router.replace('/(app)/home');
|
||||
}
|
||||
} catch (e) {
|
||||
// 极端情况下(例如本地存储/初始化异常)避免卡死在 loading:提示并允许用户重试
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('[OnboardingFinish] 异常:', msg);
|
||||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||
setFinishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const onNext = () => {
|
||||
if (finishing) return;
|
||||
if (stepIndex < STEPS.length - 1) {
|
||||
setStepIndex(stepIndex + 1);
|
||||
} else {
|
||||
@@ -170,6 +182,7 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const onBack = () => {
|
||||
if (finishing) return;
|
||||
if (stepIndex > 0) {
|
||||
setStepIndex(stepIndex - 1);
|
||||
}
|
||||
@@ -177,6 +190,7 @@ export default function OnboardingScreen() {
|
||||
|
||||
/** 只跳過當前這一步(不填/不選當前題,進入下一步) */
|
||||
const handleSkipCurrentStep = () => {
|
||||
if (finishing) return;
|
||||
if (currentStep.type === 'name') {
|
||||
onNext();
|
||||
} else if (currentStep.type === 'selection') {
|
||||
@@ -200,6 +214,7 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const handleSkipStep = () => {
|
||||
if (finishing) return;
|
||||
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||
onNext();
|
||||
};
|
||||
@@ -236,6 +251,7 @@ export default function OnboardingScreen() {
|
||||
value={Math.max(1, reminderTimes)}
|
||||
onChange={setReminderTimes}
|
||||
onFinish={onFinish}
|
||||
loading={finishing}
|
||||
onSkip={() => {
|
||||
// 跳过每日提醒:视为 0 次(关闭)
|
||||
setReminderTimes(0);
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi';
|
||||
|
||||
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -75,6 +76,17 @@ export default function RootLayout() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 只要系统通知权限已经 granted,就主动上报 Push Token(不依赖用户在“每日提醒”里点确认)
|
||||
ensurePushTokenRegisteredIfPermitted()
|
||||
.then((res) => {
|
||||
if (__DEV__) console.log('[push_token_sync]', res);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (__DEV__) console.warn('[push_token_sync] 失败(不阻塞启动)', e);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”)
|
||||
if (loaded && i18nReady) setAppReady(true);
|
||||
@@ -113,7 +125,7 @@ export default function RootLayout() {
|
||||
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
|
||||
<View style={styles.splashOverlay}>
|
||||
<Image
|
||||
source={require('../assets/images/splashScreen.png')}
|
||||
source={require('../assets/images/Screen_page.png')}
|
||||
style={styles.splashImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
@@ -41,7 +41,7 @@ import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg'
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { changeLanguage } from '@/src/i18n';
|
||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||||
import { ensurePushTokenRegisteredIfPermitted, getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
@@ -479,6 +479,13 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled };
|
||||
await setDailyReminderSettings(next);
|
||||
|
||||
// 若系统权限已授予且用户意愿为开启:兜底同步一次 token(避免“没点开关/没触发 toggle 导致后端无 token”)
|
||||
if (nextEnabled) {
|
||||
ensurePushTokenRegisteredIfPermitted().catch(() => {
|
||||
// ignore:不阻塞保存
|
||||
});
|
||||
}
|
||||
|
||||
// 同步后端偏好(幂等;失败不阻塞)
|
||||
try {
|
||||
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, ActivityIndicator } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||
@@ -12,9 +13,11 @@ interface ReminderStepProps {
|
||||
onChange: (value: number) => void;
|
||||
onFinish: () => void;
|
||||
onSkip?: () => void;
|
||||
/** 完成后请求通知权限时的加载态 */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
export function ReminderStep({ value, onChange, onFinish, loading = false }: ReminderStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -30,7 +33,7 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.counterContainer}>
|
||||
<TouchableOpacity onPress={handleReduce} activeOpacity={0.7}>
|
||||
<TouchableOpacity onPress={handleReduce} disabled={loading} activeOpacity={0.7}>
|
||||
<ReduceIcon width={47} height={47} />
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -41,14 +44,27 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
|
||||
<TouchableOpacity onPress={handleAdd} disabled={loading} activeOpacity={0.7}>
|
||||
<AddIcon width={47} height={47} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
|
||||
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
||||
<BtnClicked width={87} height={57} />
|
||||
<TouchableOpacity onPress={onFinish} disabled={loading} activeOpacity={0.8}>
|
||||
<View style={styles.finishWrap}>
|
||||
{loading ? (
|
||||
<LinearGradient
|
||||
colors={['#F69F7B', '#F99CC0']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[styles.loadingPill, styles.finishDisabled]}
|
||||
>
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<BtnClicked width={87} height={57} />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -92,4 +108,20 @@ const styles = StyleSheet.create({
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
},
|
||||
finishWrap: {
|
||||
width: 87,
|
||||
height: 57,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
finishDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
loadingPill: {
|
||||
width: 87,
|
||||
height: 57,
|
||||
borderRadius: 28.5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
|
||||
* 默认回退到 prod(避免误打到 localhost 导致真机“无法发起网络请求”)。
|
||||
*/
|
||||
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
|
||||
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'prod';
|
||||
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'dev';
|
||||
|
||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@ import { Platform } from 'react-native';
|
||||
|
||||
import { httpJson } from '../utils/http';
|
||||
import { APP_ENV } from '../constants/env';
|
||||
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
|
||||
import {
|
||||
getDailyReminderSettings,
|
||||
getLastRegisteredPushToken,
|
||||
getOrCreateClientUserId,
|
||||
getUserProfileScoring,
|
||||
setLastRegisteredPushToken,
|
||||
} from '../storage/appStorage';
|
||||
import type { UserProfileScoring } from '../storage/appStorage';
|
||||
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
|
||||
|
||||
@@ -154,6 +160,22 @@ export async function registerPushToken(args: { pushToken: string }): Promise<vo
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensurePushTokenRegisteredIfPermitted(): Promise<{ ok: boolean; reason: string }> {
|
||||
// 只要系统权限已授权,就应尽早把 token 写入后端(不依赖用户在“每日提醒”里点确认)
|
||||
const settings = await Notifications.getPermissionsAsync();
|
||||
if (settings.status !== 'granted') return { ok: false, reason: 'permission_not_granted' };
|
||||
|
||||
const token = await getExpoPushTokenOrThrow();
|
||||
|
||||
// 简单去重:token 未变化则不重复上报(减少网络与日志噪音)
|
||||
const last = await getLastRegisteredPushToken().catch(() => null);
|
||||
if (last && String(last) === String(token)) return { ok: true, reason: 'already_registered' };
|
||||
|
||||
await registerPushToken({ pushToken: token });
|
||||
await setLastRegisteredPushToken(token);
|
||||
return { ok: true, reason: 'registered' };
|
||||
}
|
||||
|
||||
export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise<PushPreferencesResponse> {
|
||||
const clientUserId = await getOrCreateClientUserId();
|
||||
const tz = pickTimezone();
|
||||
|
||||
@@ -18,6 +18,8 @@ const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
|
||||
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
||||
const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state';
|
||||
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||
const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken';
|
||||
const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt';
|
||||
|
||||
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
||||
export type Reaction = 'like' | 'dislike';
|
||||
@@ -69,6 +71,16 @@ export type DailyReminderSettings = {
|
||||
pushEnabled: boolean;
|
||||
};
|
||||
|
||||
export async function getLastRegisteredPushToken(): Promise<string | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_TOKEN);
|
||||
return raw ? String(raw) : null;
|
||||
}
|
||||
|
||||
export async function setLastRegisteredPushToken(token: string): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_TOKEN, String(token));
|
||||
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_AT, new Date().toISOString());
|
||||
}
|
||||
|
||||
export type RecoFeedCacheItem = {
|
||||
content_id: number;
|
||||
text: string;
|
||||
@@ -188,7 +200,7 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
|
||||
}
|
||||
|
||||
export type FavoriteItem = {
|
||||
favId: string; // 唯一标识,支持重复点赞同一文案
|
||||
favId: string; // 唯一标识
|
||||
id: string;
|
||||
/**
|
||||
* 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案)
|
||||
@@ -200,13 +212,28 @@ export type FavoriteItem = {
|
||||
};
|
||||
|
||||
export async function getFavorites(): Promise<FavoriteItem[]> {
|
||||
return await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
|
||||
const raw = await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
|
||||
// 去重:同一条文案只保留最新的一条,防止重复点击喜欢导致弹窗重复展示
|
||||
const seen = new Set<string>();
|
||||
const deduped: FavoriteItem[] = [];
|
||||
for (const item of raw) {
|
||||
const key = String(item.id);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(item);
|
||||
}
|
||||
// 若发现历史数据有重复,顺便写回清理后的版本
|
||||
if (deduped.length !== raw.length) {
|
||||
await setJson(KEY_FAVORITES_ITEMS, deduped);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export async function addFavorite(item: FavoriteItem): Promise<void> {
|
||||
const list = await getFavorites();
|
||||
// 允许重复点赞,不再根据 id 去重
|
||||
const newList = [item, ...list];
|
||||
// 幂等写入:同一条文案只保留一条(最新),避免重复点击喜欢产生重复项
|
||||
const filtered = list.filter(x => String(x.id) !== String(item.id));
|
||||
const newList = [item, ...filtered];
|
||||
console.log('Adding to favorites:', JSON.stringify(item));
|
||||
await setJson(KEY_FAVORITES_ITEMS, newList);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user