feat: Add Time-Tracking Toggle and Inactivity Timeout (#1890)
* feat: implement on-off switch and inactivity timeout for time-tracking * chore: remove stray console.log
This commit is contained in:
@@ -1,3 +1,19 @@
|
||||
window.onUserInteraction = (() => {
|
||||
let groupStart = 0;
|
||||
// Group multiple interactions within a short time frame to avoid excessive calls
|
||||
const groupTime = 3000;
|
||||
return () => {
|
||||
let now = Date.now();
|
||||
if (now - groupStart < groupTime) {
|
||||
// We're inside the group
|
||||
} else {
|
||||
// We're the first in a while
|
||||
groupStart = now;
|
||||
reader.post({ type: 'interaction' });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
/* eslint-disable no-console */
|
||||
window.reader = new (function () {
|
||||
const {
|
||||
@@ -100,6 +116,7 @@ window.reader = new (function () {
|
||||
});
|
||||
|
||||
document.onscrollend = () => {
|
||||
onUserInteraction();
|
||||
if (!this.generalSettings.val.pageReader) {
|
||||
this.post({
|
||||
type: 'save',
|
||||
@@ -111,6 +128,10 @@ window.reader = new (function () {
|
||||
}
|
||||
};
|
||||
|
||||
document.onpointerdown = () => onUserInteraction();
|
||||
document.onpointermove = () => onUserInteraction();
|
||||
document.onpointerup = () => onUserInteraction();
|
||||
|
||||
if (DEBUG) {
|
||||
// eslint-disable-next-line no-global-assign, no-new-object
|
||||
console = new Object();
|
||||
@@ -505,6 +526,7 @@ window.pageReader = new (function () {
|
||||
};
|
||||
|
||||
this.movePage = destPage => {
|
||||
onUserInteraction();
|
||||
if (this.chapterEndingVisible.val) {
|
||||
if (destPage < 0) {
|
||||
this.showChapterEnding(false);
|
||||
|
||||
@@ -80,6 +80,14 @@ export interface AppSettings {
|
||||
|
||||
hideBackdrop: boolean;
|
||||
defaultChapterSort: ChapterOrderKey;
|
||||
|
||||
/**
|
||||
* Time-tracking settings
|
||||
*/
|
||||
/** Whether to actually enable time tracking */
|
||||
timeTrackingEnabled: boolean;
|
||||
/** The timeout after which to consider the user inactive and not track time */
|
||||
inactivityTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface BrowseSettings {
|
||||
@@ -188,6 +196,12 @@ const initialAppSettings: AppSettings = {
|
||||
|
||||
hideBackdrop: false,
|
||||
defaultChapterSort: 'positionAsc',
|
||||
|
||||
/**
|
||||
* Time-tracking settings
|
||||
*/
|
||||
timeTrackingEnabled: true,
|
||||
inactivityTimeoutMs: 5 * 60 * 1000, // 5 minutes
|
||||
};
|
||||
|
||||
const initialBrowseSettings: BrowseSettings = {
|
||||
|
||||
@@ -65,7 +65,7 @@ export const ChapterContent = ({
|
||||
openDrawer,
|
||||
}: ChapterContentProps) => {
|
||||
const { left, right } = useSafeAreaInsets();
|
||||
const { novel, chapter } = useChapterContext();
|
||||
const { novel, chapter, onUserInteraction } = useChapterContext();
|
||||
const readerSheetRef = useRef<BottomSheetModalMethods>(null);
|
||||
const theme = useTheme();
|
||||
const { pageReader = false, keepScreenOn } = useChapterGeneralSettings();
|
||||
@@ -135,7 +135,8 @@ export const ChapterContent = ({
|
||||
`);
|
||||
}, [hidden, searchVisible, webViewRef]);
|
||||
|
||||
const scrollToStart = () =>
|
||||
const scrollToStart = () => {
|
||||
onUserInteraction();
|
||||
requestAnimationFrame(() => {
|
||||
webViewRef?.current?.injectJavaScript(
|
||||
!pageReader
|
||||
@@ -147,7 +148,8 @@ export const ChapterContent = ({
|
||||
document.querySelector("chapter").style.transform = 'translate(0%)';
|
||||
})()`,
|
||||
);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
const openDrawerI = useCallback(() => {
|
||||
openDrawer();
|
||||
@@ -161,6 +163,7 @@ export const ChapterContent = ({
|
||||
}, [searchVisible]);
|
||||
|
||||
const handleReaderPress = useCallback(() => {
|
||||
onUserInteraction();
|
||||
if (searchVisible) {
|
||||
setSearchVisible(false);
|
||||
return;
|
||||
|
||||
@@ -95,6 +95,8 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
nextChapter,
|
||||
prevChapter,
|
||||
webViewRef,
|
||||
onUserInteraction,
|
||||
isTTSReadingRef
|
||||
} = useChapterContext();
|
||||
const theme = useTheme();
|
||||
const initialReaderSettings = useMemo(
|
||||
@@ -121,7 +123,6 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
|
||||
const nextChapterScreenVisible = useRef<boolean>(false);
|
||||
const autoStartTTSRef = useRef<boolean>(false);
|
||||
const isTTSReadingRef = useRef<boolean>(false);
|
||||
const appStateRef = useRef(AppState.currentState);
|
||||
const ttsQueueRef = useRef<string[]>([]);
|
||||
const ttsQueueIndexRef = useRef<number>(0);
|
||||
@@ -210,7 +211,7 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
isPlaying: isTTSReadingRef.current,
|
||||
});
|
||||
}
|
||||
}, [novel?.name, novel?.cover, chapter.name]);
|
||||
}, [novel?.name, novel?.cover, chapter.name, isTTSReadingRef]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -301,11 +302,12 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [webViewRef]);
|
||||
}, [webViewRef, isTTSReadingRef]);
|
||||
|
||||
const speakText = (text: string) => {
|
||||
Speech.speak(text, {
|
||||
onDone() {
|
||||
onUserInteraction();
|
||||
const isBackground =
|
||||
appStateRef.current === 'background' ||
|
||||
appStateRef.current === 'inactive';
|
||||
@@ -505,10 +507,14 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
if (event.data && typeof event.data === 'object') {
|
||||
const data = event.data as { isReading?: boolean };
|
||||
const isReading = data.isReading === true;
|
||||
onUserInteraction();
|
||||
isTTSReadingRef.current = isReading;
|
||||
updateTTSPlaybackState(isReading);
|
||||
}
|
||||
break;
|
||||
case 'interaction':
|
||||
onUserInteraction();
|
||||
break;
|
||||
}
|
||||
}}
|
||||
source={{
|
||||
|
||||
@@ -5,6 +5,7 @@ import NativeFile from '@modules/native-file'
|
||||
const mockUseNovelActions = jest.fn();
|
||||
const mockUseChapterGeneralSettings = jest.fn();
|
||||
const mockUseLibrarySettings = jest.fn();
|
||||
const mockUseAppSettings = jest.fn();
|
||||
const mockUseTracker = jest.fn();
|
||||
const mockUseTrackedNovel = jest.fn();
|
||||
const mockUseFullscreenMode = jest.fn();
|
||||
@@ -30,6 +31,7 @@ jest.mock('@screens/novel/NovelContext', () => ({
|
||||
jest.mock('@hooks/persisted', () => ({
|
||||
useChapterGeneralSettings: () => mockUseChapterGeneralSettings(),
|
||||
useLibrarySettings: () => mockUseLibrarySettings(),
|
||||
useAppSettings: () => mockUseAppSettings(),
|
||||
useTracker: () => mockUseTracker(),
|
||||
useTrackedNovel: (...args: unknown[]) => mockUseTrackedNovel(...args),
|
||||
}));
|
||||
@@ -154,6 +156,7 @@ describe('useChapter', () => {
|
||||
volumeButtonsOffset: 100,
|
||||
});
|
||||
mockUseLibrarySettings.mockReturnValue({ incognitoMode: false });
|
||||
mockUseAppSettings.mockReturnValue({ timeTrackingEnabled: true, inactivityTimeoutMs: 60000 });
|
||||
mockUseTracker.mockReturnValue({ tracker: { id: 'tracker' } });
|
||||
mockUseTrackedNovel.mockReturnValue({
|
||||
trackedNovel: { progress: 1 },
|
||||
|
||||
@@ -24,50 +24,37 @@ describe('useTimeTracking', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('flushes elapsed time on the 60s interval', () => {
|
||||
renderHook(() => useTimeTracking(1, false, increaseTimeSpent));
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenCalledWith(1, expect.any(Number));
|
||||
const elapsed = increaseTimeSpent.mock.calls[0][1];
|
||||
expect(elapsed).toBeGreaterThanOrEqual(60000);
|
||||
});
|
||||
|
||||
it('flushes when the app goes to the background', () => {
|
||||
renderHook(() => useTimeTracking(1, false, increaseTimeSpent));
|
||||
renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent));
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
appStateHandler('background');
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenCalledWith(1, expect.any(Number));
|
||||
expect(increaseTimeSpent).toHaveBeenCalledWith(1, 5000);
|
||||
});
|
||||
|
||||
it('does not double count after backgrounding and foregrounding', () => {
|
||||
renderHook(() => useTimeTracking(1, false, increaseTimeSpent));
|
||||
renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent));
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
appStateHandler('background'); // flush 5s
|
||||
appStateHandler('background');
|
||||
jest.advanceTimersByTime(20000);
|
||||
appStateHandler('active');
|
||||
jest.advanceTimersByTime(3000);
|
||||
appStateHandler('background'); // flush 3s
|
||||
});
|
||||
|
||||
const firstElapsed = increaseTimeSpent.mock.calls[0][1];
|
||||
expect(firstElapsed).toBeLessThan(6000); // 5s flush
|
||||
const secondElapsed = increaseTimeSpent.mock.calls[1][1];
|
||||
expect(secondElapsed).toBeLessThan(4000); // 3s flush
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 5000);
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(2, 1, 3000);
|
||||
expect(increaseTimeSpent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('flushes remaining time on unmount', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useTimeTracking(1, false, increaseTimeSpent),
|
||||
useTimeTracking(1, false, 60000, true, increaseTimeSpent),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -76,13 +63,13 @@ describe('useTimeTracking', () => {
|
||||
|
||||
unmount();
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenLastCalledWith(1, expect.any(Number));
|
||||
expect(increaseTimeSpent).toHaveBeenLastCalledWith(1, 10000);
|
||||
});
|
||||
|
||||
it('flushes for the previous chapter id when chapterId changes', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ chapterId }: { chapterId: number }) =>
|
||||
useTimeTracking(chapterId, false, increaseTimeSpent),
|
||||
useTimeTracking(chapterId, false, 60000, true, increaseTimeSpent),
|
||||
{ initialProps: { chapterId: 1 } },
|
||||
);
|
||||
|
||||
@@ -92,17 +79,175 @@ describe('useTimeTracking', () => {
|
||||
|
||||
rerender({ chapterId: 2 });
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenCalledWith(1, expect.any(Number));
|
||||
expect(increaseTimeSpent).toHaveBeenCalledWith(1, 10000);
|
||||
expect(increaseTimeSpent).not.toHaveBeenCalledWith(2, expect.any(Number));
|
||||
});
|
||||
|
||||
it('does not track time in incognito mode', () => {
|
||||
renderHook(() => useTimeTracking(1, true, increaseTimeSpent));
|
||||
it('flushes after user interaction', () => {
|
||||
const onUserInteraction = renderHook(() =>
|
||||
useTimeTracking(1, false, 60000, true, increaseTimeSpent),
|
||||
).result.current.onUserInteraction;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
jest.advanceTimersByTime(10000);
|
||||
onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenCalledWith(1, 10000);
|
||||
});
|
||||
|
||||
it('does not track time when time tracking is turned off', () => {
|
||||
const onUserInteraction = renderHook(() => useTimeTracking(1, false, 60000, false, increaseTimeSpent)).result.current.onUserInteraction;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(6000);
|
||||
onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not track time when the user is inactive for too long', () => {
|
||||
const onUserInteraction = renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent)).result.current.onUserInteraction;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(70000);
|
||||
onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets inactivity timeout correctly', () => {
|
||||
const onUserInteraction = renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent)).result.current.onUserInteraction;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(30000);
|
||||
onUserInteraction();
|
||||
jest.advanceTimersByTime(30000);
|
||||
onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 30000);
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(2, 1, 30000);
|
||||
expect(increaseTimeSpent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('tracks time accurately when the user interacts multiple times in quick succession', () => {
|
||||
const onUserInteraction = renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent)).result.current.onUserInteraction;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(30000);
|
||||
onUserInteraction();
|
||||
onUserInteraction(); // clock didn't advance, so shouldn't call increaseTimeSpent again
|
||||
jest.advanceTimersByTime(2);
|
||||
onUserInteraction();
|
||||
jest.advanceTimersByTime(20000);
|
||||
onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 30000);
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(2, 1, 2);
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(3, 1, 20000);
|
||||
expect(increaseTimeSpent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('tracks time accurately even when the user goes inactive for a while', () => {
|
||||
const onUserInteraction = renderHook(() => useTimeTracking(1, false, 60000, true, increaseTimeSpent)).result.current.onUserInteraction;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(30000);
|
||||
onUserInteraction();
|
||||
jest.advanceTimersByTime(10000);
|
||||
onUserInteraction();
|
||||
jest.advanceTimersByTime(50000);
|
||||
onUserInteraction();
|
||||
|
||||
// Inactivity
|
||||
jest.advanceTimersByTime(100000);
|
||||
onUserInteraction(); // shouldn't do anything
|
||||
jest.advanceTimersByTime(30000);
|
||||
onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 30000);
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(2, 1, 10000);
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(3, 1, 50000);
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(4, 1, 30000);
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('does not track time in incognito mode', () => {
|
||||
const onUserInteraction = renderHook(() => useTimeTracking(1, true, 60000, true, increaseTimeSpent)).result.current.onUserInteraction;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(6000);
|
||||
onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).not.toHaveBeenCalled();
|
||||
});
|
||||
it('tracks time when TTS is reading even if the user is inactive for too long', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTimeTracking(1, false, 60000, true, increaseTimeSpent),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.isTTSReadingRef.current = true;
|
||||
jest.advanceTimersByTime(70000);
|
||||
result.current.onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenCalledWith(1, 70000);
|
||||
});
|
||||
|
||||
it('tracks time when TTS is reading even if the app goes to the background', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTimeTracking(1, false, 60000, true, increaseTimeSpent),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.isTTSReadingRef.current = true;
|
||||
jest.advanceTimersByTime(70000);
|
||||
appStateHandler('background');
|
||||
jest.advanceTimersByTime(10000);
|
||||
result.current.onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 70000); // on background
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(2, 1, 10000); // on user interaction after background
|
||||
});
|
||||
it('does not double-count when onUserInteraction is called back-to-back during TTS', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTimeTracking(1, false, 60000, true, increaseTimeSpent),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.isTTSReadingRef.current = true;
|
||||
jest.advanceTimersByTime(5000);
|
||||
result.current.onUserInteraction();
|
||||
result.current.onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 5000);
|
||||
expect(increaseTimeSpent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('resumes normal inactivity gating once TTS stops', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTimeTracking(1, false, 60000, true, increaseTimeSpent),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.isTTSReadingRef.current = true;
|
||||
jest.advanceTimersByTime(5000);
|
||||
result.current.onUserInteraction();
|
||||
result.current.isTTSReadingRef.current = false;
|
||||
jest.advanceTimersByTime(70000);
|
||||
result.current.onUserInteraction();
|
||||
});
|
||||
|
||||
expect(increaseTimeSpent).toHaveBeenNthCalledWith(1, 1, 5000);
|
||||
expect(increaseTimeSpent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { insertHistory } from '@database/queries/HistoryQueries';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import {
|
||||
useAppSettings,
|
||||
useChapterGeneralSettings,
|
||||
useLibrarySettings,
|
||||
useTrackedNovel,
|
||||
@@ -70,12 +71,13 @@ export default function useChapter(
|
||||
volumeButtonsOffset,
|
||||
} = useChapterGeneralSettings();
|
||||
const { incognitoMode } = useLibrarySettings();
|
||||
const { timeTrackingEnabled, inactivityTimeoutMs } = useAppSettings();
|
||||
const [error, setError] = useState<string>();
|
||||
const { tracker } = useTracker();
|
||||
const { trackedNovel, updateAllTrackedNovels } = useTrackedNovel(novel.id);
|
||||
const { setImmersiveMode, showStatusAndNavBar } = useFullscreenMode();
|
||||
|
||||
useTimeTracking(chapter.id, incognitoMode || false, increaseTimeSpent);
|
||||
const { onUserInteraction, isTTSReadingRef } = useTimeTracking(chapter.id, incognitoMode || false, inactivityTimeoutMs, timeTrackingEnabled, increaseTimeSpent);
|
||||
|
||||
const connectVolumeButton = useCallback(() => {
|
||||
const offset = defaultTo(
|
||||
@@ -411,6 +413,8 @@ export default function useChapter(
|
||||
setChapter,
|
||||
setLoading,
|
||||
getChapter,
|
||||
onUserInteraction,
|
||||
isTTSReadingRef,
|
||||
}),
|
||||
[
|
||||
hidden,
|
||||
@@ -431,6 +435,8 @@ export default function useChapter(
|
||||
setChapter,
|
||||
setLoading,
|
||||
getChapter,
|
||||
onUserInteraction,
|
||||
isTTSReadingRef,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,33 +3,33 @@ import { AppState, AppStateStatus } from 'react-native';
|
||||
|
||||
|
||||
/**
|
||||
* Starts tracking time spent on the chapter, and flushes the data to the database:
|
||||
* - periodically every minute
|
||||
* - when the app goes in the background
|
||||
* - when the chapter changes
|
||||
* Starts tracking time spent on the chapter and returns a callback to be called on user interaction (to prevent the inactivity timeout from being triggered).
|
||||
*/
|
||||
export default function useTimeTracking(
|
||||
chapterId: number,
|
||||
incognitoMode: boolean,
|
||||
inactivityTimeoutMs: number | undefined,
|
||||
turnedOn: boolean,
|
||||
increaseTimeSpent: (chapterId: number, elapsed: number) => void,
|
||||
) {
|
||||
const startTimestampRef = useRef(Date.now());
|
||||
const lastUserInteractionRef = useRef(Date.now());
|
||||
const isTTSReadingRef = useRef(false);
|
||||
|
||||
/** Increases the time spent on the current chapter since the last time this function was called */
|
||||
/** Increases the time spent on the current chapter since the last user interaction, as long as it was not too long ago */
|
||||
const flushElapsedTime = useCallback(() => {
|
||||
if (!incognitoMode) {
|
||||
const elapsed = Date.now() - startTimestampRef.current;
|
||||
const enabled = turnedOn && !incognitoMode;
|
||||
const isInactive = inactivityTimeoutMs !== undefined && Date.now() - lastUserInteractionRef.current > inactivityTimeoutMs;
|
||||
if (enabled && (!isInactive || isTTSReadingRef.current)) {
|
||||
const elapsed = Date.now() - lastUserInteractionRef.current;
|
||||
if (elapsed > 0) {
|
||||
increaseTimeSpent(chapterId, elapsed);
|
||||
}
|
||||
}
|
||||
startTimestampRef.current = Date.now();
|
||||
}, [chapterId, incognitoMode, increaseTimeSpent]);
|
||||
}, [chapterId, incognitoMode, inactivityTimeoutMs, turnedOn, increaseTimeSpent]);
|
||||
|
||||
// Flush the elapsed time every minute
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(flushElapsedTime, 60000);
|
||||
return () => clearInterval(intervalId);
|
||||
const onUserInteraction = useCallback(() => {
|
||||
flushElapsedTime();
|
||||
lastUserInteractionRef.current = Date.now();
|
||||
}, [flushElapsedTime]);
|
||||
|
||||
// Flush the elapsed time when the app goes in the background
|
||||
@@ -39,9 +39,16 @@ export default function useTimeTracking(
|
||||
if (appState === 'active' && nextState.match(/inactive|background/)) {
|
||||
// Save the elapsed time when the app goes in the background
|
||||
flushElapsedTime();
|
||||
lastUserInteractionRef.current = Date.now();
|
||||
} else if (appState.match(/inactive|background/) && nextState === 'active') {
|
||||
// Reset the start timestamp when the app comes back
|
||||
startTimestampRef.current = Date.now();
|
||||
// Reset the start timestamp when the app comes back and the TTS isn't on
|
||||
if (!isTTSReadingRef.current) {
|
||||
lastUserInteractionRef.current = Date.now();
|
||||
} else {
|
||||
// If TTS is on, we want to count the time spent in the background as well
|
||||
flushElapsedTime();
|
||||
lastUserInteractionRef.current = Date.now();
|
||||
}
|
||||
}
|
||||
appState = nextState;
|
||||
};
|
||||
@@ -51,9 +58,10 @@ export default function useTimeTracking(
|
||||
|
||||
// Flush the elapsed time when the chapter changes
|
||||
useEffect(() => {
|
||||
startTimestampRef.current = Date.now();
|
||||
lastUserInteractionRef.current = Date.now();
|
||||
return () => {
|
||||
flushElapsedTime();
|
||||
};
|
||||
}, [chapterId, flushElapsedTime]);
|
||||
return { onUserInteraction, isTTSReadingRef };
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import NovelBadgesModal from './modals/NovelBadgesModal';
|
||||
import { NavigationState } from '@react-navigation/native';
|
||||
import { getString } from '@strings/translations';
|
||||
import SettingSwitch from '../components/SettingSwitch';
|
||||
import InactivityTimeoutModal from './modals/InactivityTimeoutModal';
|
||||
|
||||
interface GenralSettingsProps {
|
||||
navigation: NavigationState;
|
||||
@@ -60,6 +61,8 @@ const GenralSettings: React.FC<GenralSettingsProps> = ({ navigation }) => {
|
||||
disableHapticFeedback,
|
||||
useLibraryFAB,
|
||||
chapterDownloadCooldownMs,
|
||||
timeTrackingEnabled,
|
||||
inactivityTimeoutMs,
|
||||
setAppSettings,
|
||||
} = useAppSettings();
|
||||
|
||||
@@ -106,6 +109,10 @@ const GenralSettings: React.FC<GenralSettingsProps> = ({ navigation }) => {
|
||||
* Download Cooldown Modal
|
||||
*/
|
||||
const downloadCooldownModalRef = useBoolean();
|
||||
/**
|
||||
* Inactivity Timeout Modal
|
||||
*/
|
||||
const inactivityTimeoutModalRef = useBoolean();
|
||||
return (
|
||||
<SafeAreaView excludeTop>
|
||||
<Appbar
|
||||
@@ -228,6 +235,27 @@ const GenralSettings: React.FC<GenralSettingsProps> = ({ navigation }) => {
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Divider theme={theme} />
|
||||
<List.SubHeader theme={theme}>
|
||||
{getString('generalSettingsScreen.timeTracking')}
|
||||
</List.SubHeader>
|
||||
<SettingSwitch
|
||||
label={getString('generalSettingsScreen.enableTimeTracking')}
|
||||
value={timeTrackingEnabled}
|
||||
description={getString('generalSettingsScreen.enableTimeTrackingDesc')}
|
||||
onPress={() =>
|
||||
setAppSettings({ timeTrackingEnabled: !timeTrackingEnabled })
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title={getString('generalSettingsScreen.inactivityTimeout')}
|
||||
description={inactivityTimeoutMs === undefined
|
||||
? getString('generalSettingsScreen.inactivityTimeoutNever')
|
||||
: getString('time.minutes', { count: inactivityTimeoutMs / 60000 })}
|
||||
onPress={inactivityTimeoutModalRef.setTrue}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Divider theme={theme} />
|
||||
<List.SubHeader theme={theme}>
|
||||
{getString('generalSettings')}
|
||||
</List.SubHeader>
|
||||
@@ -299,6 +327,12 @@ const GenralSettings: React.FC<GenralSettingsProps> = ({ navigation }) => {
|
||||
hideModal={downloadCooldownModalRef.setFalse}
|
||||
theme={theme}
|
||||
/>
|
||||
<InactivityTimeoutModal
|
||||
inactivityTimeoutMs={inactivityTimeoutMs}
|
||||
modalVisible={inactivityTimeoutModalRef.value}
|
||||
hideModal={inactivityTimeoutModalRef.setFalse}
|
||||
theme={theme}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Text, StyleSheet } from 'react-native';
|
||||
|
||||
import { Portal } from 'react-native-paper';
|
||||
|
||||
import { RadioButton } from '@components/RadioButton/RadioButton';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { useAppSettings } from '@hooks/persisted';
|
||||
import { getString } from '@strings/translations';
|
||||
import { Modal } from '@components';
|
||||
|
||||
interface InactivityTimeoutModalProps {
|
||||
inactivityTimeoutMs: number | undefined;
|
||||
modalVisible: boolean;
|
||||
hideModal: () => void;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const InactivityTimeoutModal: React.FC<InactivityTimeoutModalProps> = ({
|
||||
theme,
|
||||
inactivityTimeoutMs,
|
||||
hideModal,
|
||||
modalVisible,
|
||||
}) => {
|
||||
const { setAppSettings } = useAppSettings();
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal visible={modalVisible} onDismiss={hideModal}>
|
||||
<Text style={[styles.modalHeader, { color: theme.onSurface }]}>
|
||||
{getString('generalSettingsScreen.inactivityTimeout')}
|
||||
</Text>
|
||||
<Text style={{ color: theme.onSurface }}>
|
||||
{getString('generalSettingsScreen.inactivityTimeoutDesc')}
|
||||
</Text>
|
||||
<RadioButton
|
||||
key={0}
|
||||
status={inactivityTimeoutMs === undefined}
|
||||
label={getString('generalSettingsScreen.inactivityTimeoutNever')}
|
||||
onPress={() => setAppSettings({ inactivityTimeoutMs: undefined })}
|
||||
theme={theme}
|
||||
/>
|
||||
{[1, 3, 5, 10, 15, 30].map(time => (
|
||||
<RadioButton
|
||||
key={time}
|
||||
status={inactivityTimeoutMs === time * 60 * 1000}
|
||||
onPress={() => setAppSettings({ inactivityTimeoutMs: time * 60 * 1000 })}
|
||||
label={`${getString('time.minutes', { count: time })}`}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default InactivityTimeoutModal;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalHeader: {
|
||||
fontSize: 24,
|
||||
marginBottom: 10,
|
||||
},
|
||||
});
|
||||
@@ -305,7 +305,13 @@
|
||||
"updateLibraryDesc": "Not recommended for low devices",
|
||||
"updateOngoing": "Only update ongoing novels",
|
||||
"updateTime": "Show last update time",
|
||||
"useFAB": "Use FAB in Library"
|
||||
"useFAB": "Use FAB in Library",
|
||||
"timeTracking": "Time Tracking",
|
||||
"enableTimeTracking": "Enable time tracking",
|
||||
"enableTimeTrackingDesc": "Tracks time spent reading and displays it in the statistics screen",
|
||||
"inactivityTimeout": "Inactivity Timeout",
|
||||
"inactivityTimeoutNever": "Never",
|
||||
"inactivityTimeoutDesc": "Automatically pause time tracking after a period of inactivity"
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
@@ -543,8 +549,8 @@
|
||||
"topNovelsByTimeSpent": "Top novels by time spent",
|
||||
"topCategoriesByTimeSpent": "Top categories by time spent",
|
||||
"totalTimeSpent": "Total time spent",
|
||||
"showNovels": "Group by novel",
|
||||
"showCategories": "Group by category"
|
||||
"showNovels": "Group by novel",
|
||||
"showCategories": "Group by category"
|
||||
},
|
||||
"tracking": "Tracking",
|
||||
"trackingScreen": {
|
||||
|
||||
@@ -269,6 +269,12 @@ export interface StringMap {
|
||||
'generalSettingsScreen.updateOngoing': 'string';
|
||||
'generalSettingsScreen.updateTime': 'string';
|
||||
'generalSettingsScreen.useFAB': 'string';
|
||||
'generalSettingsScreen.timeTracking': 'string';
|
||||
'generalSettingsScreen.enableTimeTracking': 'string';
|
||||
'generalSettingsScreen.enableTimeTrackingDesc': 'string';
|
||||
'generalSettingsScreen.inactivityTimeout': 'string';
|
||||
'generalSettingsScreen.inactivityTimeoutNever': 'string';
|
||||
'generalSettingsScreen.inactivityTimeoutDesc': 'string';
|
||||
'globalSearch.allSources': 'string';
|
||||
'globalSearch.searchIn': 'string';
|
||||
'history': 'string';
|
||||
|
||||
Reference in New Issue
Block a user