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:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user