-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathLiveActivitySettingsDialog.tsx
More file actions
296 lines (270 loc) · 12 KB
/
Copy pathLiveActivitySettingsDialog.tsx
File metadata and controls
296 lines (270 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
/**
* Live Activity Settings Dialog
*
* Poll interval, dwell window, tile cap, and a page-specific monitor ignore
* list. The ignore list only stops a monitor pulling focus on this page; it
* stays visible everywhere else. That is separate from the profile-wide
* monitor exclusion (Settings > hidden monitors), which hides a monitor
* everywhere.
*/
import { useMemo, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
import { Switch } from '../ui/switch';
import { useSettingsStore, mergeProfileSettings } from '../../stores/settings';
import { LIVE_ACTIVITY } from '../../lib/zmninja-ng-constants';
import type { MonitorData } from '../../api/types';
export interface LiveActivitySettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
profileId: string;
monitors: MonitorData[];
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
/**
* Local text draft for a store-backed numeric field, committed on blur (or
* Enter) instead of on every keystroke.
*
* Committing on every `onChange` has two problems. Binding the input straight
* to the committed store number makes it impossible to clear: `Number('')` is
* 0, so an empty or partial value would clamp to the minimum and redraw into
* the input mid-edit. Committing a clamped value on every keystroke is also
* self-defeating even with a local draft: the commit changes `storedValue`,
* which resyncs the draft to the clamped string, so the NEXT keystroke
* appends to that clamped string instead of what the user actually typed
* (typing "12" one digit at a time: "1" commits and clamps to the minimum
* "2", the draft resyncs to "2", and the next keystroke produces "22").
*
* Committing only on blur/Enter removes the loop entirely: `onChange` only
* ever touches local state, so `storedValue` cannot change mid-edit and the
* resync below cannot fire while the user is still typing.
*
* Deliberate policy for a genuine conflict, an external write landing while
* the field has focus: the user's in-progress edit wins. `lastStoredValue`
* (the last external value actually applied to the draft) is only advanced
* together with applying it, never on its own, so a `storedValue` change
* that arrives while focused leaves `lastStoredValue` stale rather than
* marking that value as seen without ever showing it. On blur, `commit()`
* writes whatever the user typed, superseding the value that arrived
* mid-edit; the render right after that commit then sees its own new
* `storedValue` differ from the still-stale `lastStoredValue` and applies
* it (a no-op here, since it already matches the just-typed draft). Because
* the two pieces of state always advance together, a field can never end up
* permanently desynced: any external write that arrived mid-edit and was
* superseded, or one that lands after the field is unfocused, is picked up
* the next time this runs unfocused.
*
* The resync itself runs during render rather than in a `useEffect`, per
* React's documented pattern for "adjusting state when a prop changes"
* (comparing against the last-seen prop value in state and calling
* `setState` inline): it updates the draft before the browser paints the
* stale value, where an effect-based resync would paint once with the old
* draft and then again with the corrected one. Focus tracking uses state
* rather than a ref for the same reason: the render-time guard below needs
* to read it, and reading a ref during render is unsafe.
*/
function useClampedNumberField(
storedValue: number,
min: number,
max: number,
onCommit: (clamped: number) => void
) {
const [draft, setDraft] = useState(() => String(storedValue));
const [lastStoredValue, setLastStoredValue] = useState(storedValue);
const [isFocused, setIsFocused] = useState(false);
// Both pieces of state advance together, and only while unfocused: never
// mark a value as seen (lastStoredValue) without also applying it (draft).
if (storedValue !== lastStoredValue && !isFocused) {
setLastStoredValue(storedValue);
setDraft(String(storedValue));
}
const onChange = (raw: string) => {
setDraft(raw);
};
const onFocus = () => {
setIsFocused(true);
};
const commit = () => {
const trimmed = draft.trim();
const parsed = Number(trimmed);
const clamped =
trimmed === '' || !Number.isFinite(parsed) ? storedValue : clamp(parsed, min, max);
setDraft(String(clamped));
if (clamped !== storedValue) onCommit(clamped);
};
const onBlur = () => {
setIsFocused(false);
commit();
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') commit();
};
return { draft, onChange, onFocus, onBlur, onKeyDown };
}
export function LiveActivitySettingsDialog({
open,
onOpenChange,
profileId,
monitors,
}: LiveActivitySettingsDialogProps) {
const { t } = useTranslation();
const rawSettings = useSettingsStore(
useShallow((state) => state.profileSettings?.[profileId])
);
const settings = useMemo(() => mergeProfileSettings(rawSettings), [rawSettings]);
const ignoredSet = useMemo(
() => new Set(settings.liveActivityIgnoredMonitorIds),
[settings.liveActivityIgnoredMonitorIds]
);
const pollField = useClampedNumberField(
settings.liveActivityPollSeconds,
LIVE_ACTIVITY.minPollSeconds,
LIVE_ACTIVITY.maxPollSeconds,
(clamped) =>
useSettingsStore.getState().updateProfileSettings(profileId, { liveActivityPollSeconds: clamped })
);
const dwellField = useClampedNumberField(
settings.liveActivityDwellSeconds,
LIVE_ACTIVITY.minDwellSeconds,
LIVE_ACTIVITY.maxDwellSeconds,
(clamped) =>
useSettingsStore.getState().updateProfileSettings(profileId, { liveActivityDwellSeconds: clamped })
);
const tilesField = useClampedNumberField(
settings.liveActivityMaxTiles,
LIVE_ACTIVITY.minTiles,
LIVE_ACTIVITY.maxTiles,
(clamped) =>
useSettingsStore.getState().updateProfileSettings(profileId, { liveActivityMaxTiles: clamped })
);
const handleIgnoreToggle = (monitorId: string, watched: boolean) => {
const current = settings.liveActivityIgnoredMonitorIds;
const next = watched
? current.filter((id) => id !== monitorId)
: current.includes(monitorId)
? current
: [...current, monitorId];
useSettingsStore.getState().updateProfileSettings(profileId, { liveActivityIgnoredMonitorIds: next });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="live-activity-settings-dialog">
<DialogHeader>
<DialogTitle>{t('live_activity.settings_title')}</DialogTitle>
<DialogDescription>{t('live_activity.settings_desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="live-activity-poll">{t('live_activity.poll_interval_label')}</Label>
{/* The unit rides beside the box because a number input cannot
hold text. aria-hidden: the field's own description already
says the value is in seconds, so announcing it again here
would only repeat it mid-value. */}
<div className="flex items-center gap-1.5">
<Input
id="live-activity-poll"
type="number"
min={LIVE_ACTIVITY.minPollSeconds}
max={LIVE_ACTIVITY.maxPollSeconds}
value={pollField.draft}
onChange={(e) => pollField.onChange(e.target.value)}
onFocus={pollField.onFocus}
onBlur={pollField.onBlur}
onKeyDown={pollField.onKeyDown}
className="w-20"
data-testid="live-activity-poll-input"
/>
<span className="text-sm text-muted-foreground" aria-hidden="true">
{t('live_activity.unit_seconds')}
</span>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('live_activity.poll_interval_desc')}</p>
<p className="text-xs text-muted-foreground">{t('live_activity.poll_bandwidth_note')}</p>
</div>
<Separator />
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="live-activity-dwell">{t('live_activity.dwell_label')}</Label>
<div className="flex items-center gap-1.5">
<Input
id="live-activity-dwell"
type="number"
min={LIVE_ACTIVITY.minDwellSeconds}
max={LIVE_ACTIVITY.maxDwellSeconds}
value={dwellField.draft}
onChange={(e) => dwellField.onChange(e.target.value)}
onFocus={dwellField.onFocus}
onBlur={dwellField.onBlur}
onKeyDown={dwellField.onKeyDown}
className="w-20"
data-testid="live-activity-dwell-input"
/>
<span className="text-sm text-muted-foreground" aria-hidden="true">
{t('live_activity.unit_seconds')}
</span>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('live_activity.dwell_desc')}</p>
</div>
<Separator />
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="live-activity-tiles">{t('live_activity.max_tiles_label')}</Label>
<Input
id="live-activity-tiles"
type="number"
min={LIVE_ACTIVITY.minTiles}
max={LIVE_ACTIVITY.maxTiles}
value={tilesField.draft}
onChange={(e) => tilesField.onChange(e.target.value)}
onFocus={tilesField.onFocus}
onBlur={tilesField.onBlur}
onKeyDown={tilesField.onKeyDown}
className="w-20"
data-testid="live-activity-tiles-input"
/>
</div>
<p className="text-xs text-muted-foreground">{t('live_activity.max_tiles_desc')}</p>
</div>
<Separator />
<div className="space-y-2">
<Label>{t('live_activity.ignore_list_label')}</Label>
<p className="text-xs text-muted-foreground">{t('live_activity.ignore_list_desc')}</p>
{monitors.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('live_activity.ignore_list_empty')}</p>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{monitors.map(({ Monitor }) => (
<div key={Monitor.Id} className="flex items-center justify-between gap-2">
<Label
htmlFor={`live-activity-ignore-${Monitor.Id}`}
className="text-sm font-normal truncate min-w-0"
title={Monitor.Name}
>
{Monitor.Name}
</Label>
<Switch
id={`live-activity-ignore-${Monitor.Id}`}
checked={!ignoredSet.has(Monitor.Id)}
onCheckedChange={(checked) => handleIgnoreToggle(Monitor.Id, checked)}
data-testid={`live-activity-ignore-${Monitor.Id}`}
/>
</div>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}