Skip to content

Commit 448bbbc

Browse files
fix(mobile): handle expired snooze choices
1 parent feab8cd commit 448bbbc

3 files changed

Lines changed: 104 additions & 6 deletions

File tree

‎apps/mobile/src/features/threads/thread-list-v2-items.tsx‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state
66
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
77
import type { MenuAction } from "@react-native-menu/menu";
88
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
9-
import { Platform, Pressable, useColorScheme, useWindowDimensions, View } from "react-native";
9+
import {
10+
Alert,
11+
Platform,
12+
Pressable,
13+
useColorScheme,
14+
useWindowDimensions,
15+
View,
16+
} from "react-native";
1017
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
1118

1219
import { SymbolView } from "../../components/AppSymbol";
@@ -21,6 +28,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2128
import { useThreadPr } from "../../state/use-thread-pr";
2229
import { ThreadSwipeable } from "../home/thread-swipe-actions";
2330
import {
31+
resolveThreadListV2SnoozeMenuSelection,
2432
resolveThreadListV2SnoozeGateExpiryMs,
2533
resolveThreadListV2Status,
2634
resolveThreadListV2SwipeActions,
@@ -411,12 +419,26 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
411419
if (nativeEvent.event === "unsnooze") handleUnsnooze();
412420
if (nativeEvent.event === "archive") handleArchive();
413421
if (nativeEvent.event === "delete") handleDelete();
414-
const preset = resolveSnoozePresets(new Date()).find(
415-
(candidate) => nativeEvent.event === `snooze:${candidate.id}`,
416-
);
417-
if (preset) handleSnooze(preset.snoozedUntil);
422+
const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
423+
event: nativeEvent.event,
424+
displayedPresets: snoozePresets,
425+
now: new Date(),
426+
});
427+
if (snoozeSelection._tag === "selected") {
428+
handleSnooze(snoozeSelection.preset.snoozedUntil);
429+
} else if (snoozeSelection._tag === "expired") {
430+
Alert.alert("Could not snooze thread", "That snooze time has passed. Choose another time.");
431+
}
418432
},
419-
[handleArchive, handleDelete, handleSettle, handleSnooze, handleUnsettle, handleUnsnooze],
433+
[
434+
handleArchive,
435+
handleDelete,
436+
handleSettle,
437+
handleSnooze,
438+
handleUnsettle,
439+
handleUnsnooze,
440+
snoozePresets,
441+
],
420442
);
421443
const primaryAction = useMemo(() => {
422444
// Pre-settlement server: archive is the swipe action, as in v1. (Slim

‎apps/mobile/src/features/threads/threadListV2.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
22
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
3+
import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
34
import {
45
CommandId,
56
EnvironmentId,
@@ -16,6 +17,7 @@ import {
1617
buildThreadListV2Items,
1718
buildThreadListV2ListItems,
1819
resolveThreadListV2Enabled,
20+
resolveThreadListV2SnoozeMenuSelection,
1921
resolveThreadListV2SnoozeGateExpiryMs,
2022
resolveThreadListV2Status,
2123
resolveThreadListV2SwipeActions,
@@ -52,6 +54,54 @@ function makeThread(
5254

5355
const NOW = "2026-06-02T00:00:00.000Z";
5456

57+
describe("resolveThreadListV2SnoozeMenuSelection", () => {
58+
it("accepts a displayed evening preset while its wake time is still future", () => {
59+
const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30);
60+
const selectedAt = new Date(2026, 4, 8, 17, 0, 30);
61+
const displayedPresets = resolveSnoozePresets(menuOpenedAt);
62+
63+
const selection = resolveThreadListV2SnoozeMenuSelection({
64+
event: "snooze:evening",
65+
displayedPresets,
66+
now: selectedAt,
67+
});
68+
69+
expect(selection).toEqual({
70+
_tag: "selected",
71+
preset: displayedPresets.find((preset) => preset.id === "evening"),
72+
});
73+
});
74+
75+
it("expires a displayed preset once its wake time has passed", () => {
76+
const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 16, 59, 30));
77+
78+
expect(
79+
resolveThreadListV2SnoozeMenuSelection({
80+
event: "snooze:evening",
81+
displayedPresets,
82+
now: new Date(2026, 4, 8, 18, 0, 1),
83+
}),
84+
).toEqual({ _tag: "expired" });
85+
});
86+
87+
it("recomputes presets that remain available instead of using old timestamps", () => {
88+
const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10));
89+
const selectedAt = new Date(2026, 4, 8, 10, 30);
90+
const selection = resolveThreadListV2SnoozeMenuSelection({
91+
event: "snooze:hour",
92+
displayedPresets,
93+
now: selectedAt,
94+
});
95+
96+
expect(selection._tag).toBe("selected");
97+
if (selection._tag === "selected") {
98+
expect(selection.preset.snoozedUntil).toBe(
99+
new Date(selectedAt.getTime() + 60 * 60 * 1_000).toISOString(),
100+
);
101+
}
102+
});
103+
});
104+
55105
describe("resolveThreadListV2Enabled", () => {
56106
it("defaults on when the device has never chosen", () => {
57107
expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe(

‎apps/mobile/src/features/threads/threadListV2.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import {
33
effectiveSnoozed,
44
hasQueuedTurnStart,
55
QUEUED_TURN_START_GRACE_MS,
6+
resolveSnoozePresets,
67
snoozeWakeLabel,
78
} from "@t3tools/client-runtime/state/thread-settled";
9+
import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
810
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
911
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
1012
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
@@ -24,6 +26,30 @@ export { snoozeWakeLabel };
2426
export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
2527
export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
2628

29+
export function resolveThreadListV2SnoozeMenuSelection(input: {
30+
readonly event: string;
31+
readonly displayedPresets: ReadonlyArray<SnoozePreset>;
32+
readonly now: Date;
33+
}):
34+
| { readonly _tag: "selected"; readonly preset: SnoozePreset }
35+
| { readonly _tag: "expired" }
36+
| { readonly _tag: "not-snooze" } {
37+
if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" };
38+
39+
const currentPreset = resolveSnoozePresets(input.now).find(
40+
(candidate) => input.event === `snooze:${candidate.id}`,
41+
);
42+
if (currentPreset) return { _tag: "selected", preset: currentPreset };
43+
44+
const displayedPreset = input.displayedPresets.find(
45+
(candidate) => input.event === `snooze:${candidate.id}`,
46+
);
47+
if (displayedPreset && Date.parse(displayedPreset.snoozedUntil) > input.now.getTime()) {
48+
return { _tag: "selected", preset: displayedPreset };
49+
}
50+
return { _tag: "expired" };
51+
}
52+
2753
export function resolveThreadListV2SwipeActions(input: {
2854
readonly variant: "card" | "slim";
2955
readonly settlementSupported: boolean;

0 commit comments

Comments
 (0)