Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
IconChevronUp,
IconCircleCheck,
IconCircleXFilled,
IconClock,
IconCopy,
IconDeviceDesktop,
IconDots,
Expand Down Expand Up @@ -96,6 +97,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
camera: IconCamera,
checkmark: IconCheck,
"checkmark.circle": IconCircleCheck,
clock: IconClock,
"chevron.down": IconChevronDown,
"chevron.left": IconChevronLeft,
"chevron.left.forwardslash.chevron.right": IconCode,
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,14 @@ export function HomeRouteScreen() {
void checkForAppUpdateOnLaunch();
}, []);

const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
useThreadListActions();
const {
archiveThread,
confirmDeleteThread,
settleThread,
snoozeThread,
unsnoozeThread,
unsettleThread,
} = useThreadListActions();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(() => {
Expand Down Expand Up @@ -143,6 +149,8 @@ export function HomeRouteScreen() {
onArchiveThread={archiveThread}
onDeleteThread={confirmDeleteThread}
onSettleThread={settleThread}
onSnoozeThread={snoozeThread}
onUnsnoozeThread={unsnoozeThread}
onUnsettleThread={unsettleThread}
onEnvironmentChange={setSelectedEnvironmentId}
onProjectChange={setSelectedProjectKey}
Expand Down
92 changes: 65 additions & 27 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ import {
ThreadListRow,
ThreadListShowMoreRow,
} from "../threads/thread-list-items";
import { ThreadListV2PendingRow, ThreadListV2Row } from "../threads/thread-list-v2-items";
import {
ThreadListV2PendingRow,
ThreadListV2Row,
ThreadListV2SnoozedShelfHeader,
} from "../threads/thread-list-v2-items";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
Expand Down Expand Up @@ -100,6 +104,11 @@ interface HomeScreenProps {
readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
/** Resolves true iff the settle was dispatched and succeeded. */
readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onSnoozeThread: (
thread: EnvironmentThreadShell,
snoozedUntil: string,
) => Promise<boolean>;
readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
Expand Down Expand Up @@ -494,6 +503,18 @@ export function HomeScreen(props: HomeScreenProps) {
},
[props.onSettleThread],
);
const handleSnoozeThread = useCallback(
(thread: EnvironmentThreadShell, snoozedUntil: string) => {
void props.onSnoozeThread(thread, snoozedUntil);
},
[props.onSnoozeThread],
);
const handleUnsnoozeThread = useCallback(
(thread: EnvironmentThreadShell) => {
void props.onUnsnoozeThread(thread);
},
[props.onUnsnoozeThread],
);
const handleDeleteThread = props.onDeleteThread;
const handleUnsettleThread = props.onUnsettleThread;
// The settled tail renders in pages; expansion resets when the filter
Expand All @@ -511,6 +532,8 @@ export function HomeScreen(props: HomeScreenProps) {
() => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
[],
);
const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false);
const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []);
// now is quantized to the minute and ticks so the inactivity auto-settle
// boundary is actually crossed while the app stays open (mirrors web);
// without a clock dependency the partition memoizes a frozen "now".
Expand Down Expand Up @@ -551,7 +574,13 @@ export function HomeScreen(props: HomeScreenProps) {
}, [serverConfigs]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled)
return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null };
return {
items: [],
hiddenSettledCount: 0,
snoozedCount: 0,
snoozedShelfHeaderIndex: null,
nextSnoozeWakeAt: null,
};
// Settled threads are live shells; archived threads keep their original
// "hidden from lists" meaning.
return buildThreadListV2Items({
Expand All @@ -566,11 +595,14 @@ export function HomeScreen(props: HomeScreenProps) {
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
snoozedShelfExpanded,
selectedThreadKey: null,
});
}, [
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
settledVisibleCount,
settlementEnvironmentIds,
snoozeEnvironmentIds,
Expand Down Expand Up @@ -620,8 +652,12 @@ export function HomeScreen(props: HomeScreenProps) {
buildThreadListV2ListItems({
items: threadListV2Layout.items,
pendingTasks: v2PendingTasks,
snoozedCount: threadListV2Layout.snoozedCount,
snoozedShelfExpanded,
snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
snoozeLabelNow: `${nowMinute}:00.000Z`,
}),
[threadListV2Layout.items, v2PendingTasks],
[snoozedShelfExpanded, threadListV2Layout, v2PendingTasks],
);

const renderV2Item = useCallback(
Expand All @@ -648,12 +684,24 @@ export function HomeScreen(props: HomeScreenProps) {
/>
);
}
if (item.type === "v2-snoozed-shelf") {
return (
<ThreadListV2SnoozedShelfHeader
count={item.count}
expanded={item.expanded}
onToggle={toggleSnoozedShelf}
/>
);
}
const thread = item.item.thread;
return (
<ThreadListV2Row
thread={thread}
variant={item.item.variant}
showSettledDivider={item.item.showSettledDivider}
snoozed={item.item.snoozed}
snoozePresetMinute={nowMinute}
snoozeWakeLabelText={item.snoozeWakeLabelText}
project={
projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
}
Expand Down Expand Up @@ -686,6 +734,9 @@ export function HomeScreen(props: HomeScreenProps) {
onArchiveThread={props.onArchiveThread}
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
onSettleThread={handleSettleThread}
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
onSnoozeThread={handleSnoozeThread}
onUnsnoozeThread={handleUnsnoozeThread}
onUnsettleThread={handleUnsettleThread}
onChangeRequestState={handleChangeRequestState}
projectCwd={
Expand All @@ -700,6 +751,8 @@ export function HomeScreen(props: HomeScreenProps) {
handleChangeRequestState,
handleDeleteThread,
handleSettleThread,
handleSnoozeThread,
handleUnsnoozeThread,
handleSwipeableClose,
handleSwipeableWillOpen,
handleUnsettleThread,
Expand All @@ -712,9 +765,12 @@ export function HomeScreen(props: HomeScreenProps) {
props.savedConnectionsById,
serverConfigs,
settlementEnvironmentIds,
snoozeEnvironmentIds,
threadSearchMatchByKey,
toggleSnoozedShelf,
v2ProjectTitleByProjectKey,
props.searchQuery,
nowMinute,
],
);
const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
Expand All @@ -730,6 +786,7 @@ export function HomeScreen(props: HomeScreenProps) {
serverConfigs,
savedConnectionsById: props.savedConnectionsById,
searchQuery: props.searchQuery,
snoozePresetMinute: nowMinute,
threadSearchMatchByKey,
}),
[
Expand All @@ -738,6 +795,7 @@ export function HomeScreen(props: HomeScreenProps) {
props.searchQuery,
props.savedConnectionsById,
serverConfigs,
nowMinute,
threadSearchMatchByKey,
v2ProjectTitleByProjectKey,
],
Expand Down Expand Up @@ -953,31 +1011,11 @@ export function HomeScreen(props: HomeScreenProps) {
) : null;
// Self-contained: v1's listEmpty keys off projectGroups, which ignores the
// v2 project scope, so it can be null (results elsewhere) while this list
// is empty. Search outranks the scope — "No results" names the actionable
// fact when a query is active. Snoozed threads outrank the rest: "No
// threads yet" over an inbox that is merely all-snoozed reads as data
// loss.
const v2SnoozedCount = threadListV2Layout.snoozedCount;
// is empty. Snoozed threads need no special empty state: their shelf header
// is a list row even while collapsed.
const v2ListEmpty =
hasSearchQuery && threadSearch.isPending && v2SnoozedCount === 0 ? null : hasSearchQuery ? (
v2SnoozedCount > 0 ? (
// The snoozed threads already passed this search filter: "No
// results" would claim nothing matched when matches are merely
// parked.
<EmptyState
title={
v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed`
}
detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`}
/>
) : (
<EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
)
) : v2SnoozedCount > 0 ? (
<EmptyState
title={v2SnoozedCount === 1 ? "1 thread snoozed" : `${v2SnoozedCount} threads snoozed`}
detail="Snoozed threads return when their wake time passes."
/>
hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (
<EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
) : v2ScopedProjectGroup !== null ? (
<EmptyState
title={`No threads in ${v2ScopedProjectGroup.title}`}
Expand Down
Loading
Loading