fix(status): harden presets after review

Address findings from 2 review agents on the status-presets feature:

- Presence-echo race: a heartbeat can echo the previous status just after
  a new one is saved, reverting the input. Track the last-applied value
  and ignore non-matching echoes until our own echo lands or a 15s window
  elapses (bounded so a dropped echo can't block real cross-device
  updates). Applies to Save, preset apply, and Clear.

- Duplicate chips: a saved custom preset that matches a built-in is now
  hidden from "Your presets" (it already shows under Quick statuses).

- a11y: the two preset rows use aria-labelledby tied to their visible
  headings instead of mismatched hardcoded aria-labels.

- Visual grouping: a custom preset's chip and its delete X now sit with
  gap=0 as one unit while the row separates presets with gap=200, so a
  chip and its delete no longer read as two separate presets. Delete/Plus
  icons bumped to size=100 to match the folds chip-icon convention.

- Parity: addPreset/removePreset promises are now caught like the other
  account-data call sites.

Docs updated to the exact 11-preset built-in list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 23:37:05 -04:00
co-authored by Claude Opus 4.8
parent d0614710b0
commit 398e743cdd
2 changed files with 40 additions and 11 deletions
+1 -1
View File
@@ -910,7 +910,7 @@ A presence status selector in the user panel offering five modes:
- Optional auto-clear timer with presets: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days
- Status is broadcast via `mx.setPresence({ status_msg: ... })`
- Character counter appears at 56/64 characters remaining to warn of the limit
- **Status presets**: a "Quick statuses" row of built-in presets spanning gaming/social/life/work (🎮 Gaming, 🎧 In a party, 🏆 Ranked grind, 😴 AFK, 🍿 Watching, 🗓️ In a meeting, 🏠 Working remotely, 🍽️ Lunch, 🌴 On vacation, 🤒 Out sick). Clicking a preset applies its message + suggested auto-clear in one click. Users can also save the current status as a reusable custom preset (stored in `io.lotus.status_presets` account data, synced across devices, de-duped by label, capped at 20) and delete presets inline. Built-in list + pure `upsertPreset` de-dupe/cap logic live in `src/app/utils/statusPresets.ts` (unit-tested); persistence in `src/app/hooks/useStatusPresets.ts`.
- **Status presets**: a "Quick statuses" row of 11 built-in presets spanning gaming/social/life/work 🎮 Gaming, 🎧 In a party, 🏆 Ranked grind, 😴 AFK, 🍿 Watching, 🍽️ Lunch, 🗓️ In a meeting, 🏠 Working remotely, 🎯 Focusing, 🌴 On vacation, 🤒 Out sick (see `BUILT_IN_STATUS_PRESETS`). Clicking a preset applies its message + suggested auto-clear in one click. Users can also save the current status as a reusable custom preset (stored in `io.lotus.status_presets` account data, synced across devices, de-duped by label, capped at 20; a saved preset matching a built-in is hidden to avoid a duplicate chip) and delete presets inline. Built-in list + pure `upsertPreset` de-dupe/cap logic live in `src/app/utils/statusPresets.ts` (unit-tested); persistence in `src/app/hooks/useStatusPresets.ts`.
### Presence Badges
+39 -10
View File
@@ -62,6 +62,7 @@ import {
BUILT_IN_STATUS_PRESETS,
StatusPreset,
makePresetId,
normalizeLabel,
} from '../../../utils/statusPresets';
type ProfileProps = {
@@ -368,6 +369,12 @@ function ProfileStatus() {
// True while the user has unsaved local edits — prevents a server presence
// echo from overwriting what the user is currently typing/inserting.
const statusDirtyRef = useRef(false);
// The value we most recently applied (with the apply time). A presence
// heartbeat can echo the PREVIOUS status just after we save the new one; that
// stale echo would otherwise revert the input. We ignore non-matching echoes
// until our own echo lands or a short window elapses (bounded so a genuinely
// dropped echo can't block real cross-device updates forever).
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
const [clearAfter, setClearAfter] = useState('0');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const { presets, addPreset, removePreset } = useStatusPresets();
@@ -383,6 +390,16 @@ function ProfileStatus() {
// wipe the saved status on every invisible toggle.
if (presence.presence === Presence.Offline) return;
const remoteStatus = presence.status ?? '';
const pending = pendingAppliedRef.current;
if (pending) {
if (remoteStatus === pending.value) {
pendingAppliedRef.current = null; // our own echo landed — accept it
} else if (Date.now() - pending.ts < 15_000) {
return; // stale echo of the previous status; ignore within the window
} else {
pendingAppliedRef.current = null; // window elapsed — accept external changes
}
}
if (remoteStatus) {
setStatusMsg(remoteStatus);
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
@@ -427,6 +444,8 @@ function ProfileStatus() {
(rawMsg: string, clearAfterValue: string) => {
statusDirtyRef.current = false;
const msg = rawMsg.trim();
// Guard against a stale presence echo reverting this value (see the sync effect).
pendingAppliedRef.current = { value: msg, ts: Date.now() };
saveStatus(msg).catch(() => undefined);
if (msg) {
@@ -467,11 +486,19 @@ function ProfileStatus() {
const handleSaveCurrent = useCallback(() => {
const label = statusMsg.trim();
if (!label) return;
addPreset({ id: makePresetId(), label, clearAfter });
addPreset({ id: makePresetId(), label, clearAfter }).catch(() => undefined);
}, [statusMsg, clearAfter, addPreset]);
// Hide any saved preset that duplicates a built-in (it already shows under
// "Quick statuses"), so the same status can't appear as two chips.
const customPresets = useMemo(() => {
const builtInLabels = new Set(BUILT_IN_STATUS_PRESETS.map((p) => normalizeLabel(p.label)));
return presets.filter((p) => !builtInLabels.has(normalizeLabel(p.label)));
}, [presets]);
const handleClear = () => {
statusDirtyRef.current = false;
pendingAppliedRef.current = { value: '', ts: Date.now() };
setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
@@ -500,10 +527,10 @@ function ProfileStatus() {
<Box direction="Column" grow="Yes" gap="200">
{/* Quick statuses — built-in presets, one click applies message + timer */}
<Box direction="Column" gap="100">
<Text size="T200" priority="300">
<Text id="status-quick-heading" size="T200" priority="300">
Quick statuses
</Text>
<Box gap="100" wrap="Wrap" role="group" aria-label="Built-in status presets">
<Box gap="100" wrap="Wrap" role="group" aria-labelledby="status-quick-heading">
{BUILT_IN_STATUS_PRESETS.map((preset) => (
<Chip
key={preset.id}
@@ -524,12 +551,14 @@ function ProfileStatus() {
{/* Your presets — saved from the current status; synced via account data */}
<Box direction="Column" gap="100">
<Text size="T200" priority="300">
<Text id="status-custom-heading" size="T200" priority="300">
Your presets
</Text>
<Box gap="100" wrap="Wrap" role="group" aria-label="Your saved status presets">
{presets.map((preset) => (
<Box key={preset.id} alignItems="Center" gap="100">
<Box gap="200" wrap="Wrap" role="group" aria-labelledby="status-custom-heading">
{customPresets.map((preset) => (
// gap="0" keeps the chip and its delete X reading as one unit; the
// parent row's larger gap="200" separates one preset from the next.
<Box key={preset.id} alignItems="Center" gap="0">
<Chip
type="button"
variant="Secondary"
@@ -549,9 +578,9 @@ function ProfileStatus() {
variant="Secondary"
fill="None"
aria-label={`Delete preset ${preset.label}`}
onClick={() => removePreset(preset.id)}
onClick={() => removePreset(preset.id).catch(() => undefined)}
>
<Icon size="50" src={Icons.Cross} />
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
))}
@@ -563,7 +592,7 @@ function ProfileStatus() {
outlined
disabled={saving || !statusMsg.trim()}
onClick={handleSaveCurrent}
before={<Icon size="50" src={Icons.Plus} />}
before={<Icon size="100" src={Icons.Plus} />}
>
<Text as="span" size="B300">
Save current