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
|
- 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: ... })`
|
- Status is broadcast via `mx.setPresence({ status_msg: ... })`
|
||||||
- Character counter appears at 56/64 characters remaining to warn of the limit
|
- 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
|
### Presence Badges
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
BUILT_IN_STATUS_PRESETS,
|
BUILT_IN_STATUS_PRESETS,
|
||||||
StatusPreset,
|
StatusPreset,
|
||||||
makePresetId,
|
makePresetId,
|
||||||
|
normalizeLabel,
|
||||||
} from '../../../utils/statusPresets';
|
} from '../../../utils/statusPresets';
|
||||||
|
|
||||||
type ProfileProps = {
|
type ProfileProps = {
|
||||||
@@ -368,6 +369,12 @@ function ProfileStatus() {
|
|||||||
// True while the user has unsaved local edits — prevents a server presence
|
// True while the user has unsaved local edits — prevents a server presence
|
||||||
// echo from overwriting what the user is currently typing/inserting.
|
// echo from overwriting what the user is currently typing/inserting.
|
||||||
const statusDirtyRef = useRef(false);
|
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 [clearAfter, setClearAfter] = useState('0');
|
||||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||||
const { presets, addPreset, removePreset } = useStatusPresets();
|
const { presets, addPreset, removePreset } = useStatusPresets();
|
||||||
@@ -383,6 +390,16 @@ function ProfileStatus() {
|
|||||||
// wipe the saved status on every invisible toggle.
|
// wipe the saved status on every invisible toggle.
|
||||||
if (presence.presence === Presence.Offline) return;
|
if (presence.presence === Presence.Offline) return;
|
||||||
const remoteStatus = presence.status ?? '';
|
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) {
|
if (remoteStatus) {
|
||||||
setStatusMsg(remoteStatus);
|
setStatusMsg(remoteStatus);
|
||||||
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
||||||
@@ -427,6 +444,8 @@ function ProfileStatus() {
|
|||||||
(rawMsg: string, clearAfterValue: string) => {
|
(rawMsg: string, clearAfterValue: string) => {
|
||||||
statusDirtyRef.current = false;
|
statusDirtyRef.current = false;
|
||||||
const msg = rawMsg.trim();
|
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);
|
saveStatus(msg).catch(() => undefined);
|
||||||
|
|
||||||
if (msg) {
|
if (msg) {
|
||||||
@@ -467,11 +486,19 @@ function ProfileStatus() {
|
|||||||
const handleSaveCurrent = useCallback(() => {
|
const handleSaveCurrent = useCallback(() => {
|
||||||
const label = statusMsg.trim();
|
const label = statusMsg.trim();
|
||||||
if (!label) return;
|
if (!label) return;
|
||||||
addPreset({ id: makePresetId(), label, clearAfter });
|
addPreset({ id: makePresetId(), label, clearAfter }).catch(() => undefined);
|
||||||
}, [statusMsg, clearAfter, addPreset]);
|
}, [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 = () => {
|
const handleClear = () => {
|
||||||
statusDirtyRef.current = false;
|
statusDirtyRef.current = false;
|
||||||
|
pendingAppliedRef.current = { value: '', ts: Date.now() };
|
||||||
setStatusMsg('');
|
setStatusMsg('');
|
||||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
@@ -500,10 +527,10 @@ function ProfileStatus() {
|
|||||||
<Box direction="Column" grow="Yes" gap="200">
|
<Box direction="Column" grow="Yes" gap="200">
|
||||||
{/* Quick statuses — built-in presets, one click applies message + timer */}
|
{/* Quick statuses — built-in presets, one click applies message + timer */}
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="T200" priority="300">
|
<Text id="status-quick-heading" size="T200" priority="300">
|
||||||
Quick statuses
|
Quick statuses
|
||||||
</Text>
|
</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) => (
|
{BUILT_IN_STATUS_PRESETS.map((preset) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
@@ -524,12 +551,14 @@ function ProfileStatus() {
|
|||||||
|
|
||||||
{/* Your presets — saved from the current status; synced via account data */}
|
{/* Your presets — saved from the current status; synced via account data */}
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="T200" priority="300">
|
<Text id="status-custom-heading" size="T200" priority="300">
|
||||||
Your presets
|
Your presets
|
||||||
</Text>
|
</Text>
|
||||||
<Box gap="100" wrap="Wrap" role="group" aria-label="Your saved status presets">
|
<Box gap="200" wrap="Wrap" role="group" aria-labelledby="status-custom-heading">
|
||||||
{presets.map((preset) => (
|
{customPresets.map((preset) => (
|
||||||
<Box key={preset.id} alignItems="Center" gap="100">
|
// 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
|
<Chip
|
||||||
type="button"
|
type="button"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
@@ -549,9 +578,9 @@ function ProfileStatus() {
|
|||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
fill="None"
|
fill="None"
|
||||||
aria-label={`Delete preset ${preset.label}`}
|
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>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -563,7 +592,7 @@ function ProfileStatus() {
|
|||||||
outlined
|
outlined
|
||||||
disabled={saving || !statusMsg.trim()}
|
disabled={saving || !statusMsg.trim()}
|
||||||
onClick={handleSaveCurrent}
|
onClick={handleSaveCurrent}
|
||||||
before={<Icon size="50" src={Icons.Plus} />}
|
before={<Icon size="100" src={Icons.Plus} />}
|
||||||
>
|
>
|
||||||
<Text as="span" size="B300">
|
<Text as="span" size="B300">
|
||||||
Save current
|
Save current
|
||||||
|
|||||||
Reference in New Issue
Block a user