diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 1ad4bac10..fffb573ed 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -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 diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index d4d2ca37b..78a0deece 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -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(); 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() { {/* Quick statuses โ€” built-in presets, one click applies message + timer */} - + Quick statuses - + {BUILT_IN_STATUS_PRESETS.map((preset) => ( - + Your presets - - {presets.map((preset) => ( - + + {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. + removePreset(preset.id)} + onClick={() => removePreset(preset.id).catch(() => undefined)} > - + ))} @@ -563,7 +592,7 @@ function ProfileStatus() { outlined disabled={saving || !statusMsg.trim()} onClick={handleSaveCurrent} - before={} + before={} > Save current