From d0614710b0998b50b33fdbfc83e83913683930e8 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 9 Jul 2026 23:30:08 -0400 Subject: [PATCH] feat(status): built-in and custom status presets The Status Message field required typing every status from scratch. Add a one-click preset row: - Built-in "Quick statuses" spanning gaming, social, life and work (Gaming, In a party, Ranked grind, AFK, Watching, In a meeting, Working remotely, Lunch, On vacation, Out sick...), each carrying a suggested auto-clear so a click sets the message and the timer at once. - Custom presets: save the current status as a reusable preset, stored in io.lotus.status_presets account data (synced across devices via the shared account-data list store), de-duped by normalized label, capped at 20, deletable inline. The existing save path is factored into a shared applyStatus() used by the Save button and by preset apply, so server writes, the status localStorage keys, and the auto-clear expiry bookkeeping stay identical. Ordering/de-dupe logic is pure in utils/statusPresets.ts (upsertPreset, normalizeLabel) with unit tests; no change to the presence wire format, expiry monitor, or presence-mode selector. Co-Authored-By: Claude Opus 4.8 --- LOTUS_FEATURES.md | 1 + src/app/features/settings/account/Profile.tsx | 148 +++++++++++++++--- src/app/hooks/useStatusPresets.ts | 43 +++++ src/app/utils/statusPresets.test.ts | 52 ++++++ src/app/utils/statusPresets.ts | 58 +++++++ 5 files changed, 282 insertions(+), 20 deletions(-) create mode 100644 src/app/hooks/useStatusPresets.ts create mode 100644 src/app/utils/statusPresets.test.ts create mode 100644 src/app/utils/statusPresets.ts diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 35442c273..1ad4bac10 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -910,6 +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`. ### Presence Badges diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 6a480243a..d4d2ca37b 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -27,6 +27,7 @@ import { Spinner, PopOut, RectCords, + Chip, } from 'folds'; import { Method } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; @@ -56,6 +57,12 @@ import { useCapabilities } from '../../../hooks/useCapabilities'; import { Presence, useUserPresence } from '../../../hooks/useUserPresence'; import { ProfileDecoration } from './ProfileDecoration'; import { EmojiBoard } from '../../../components/emoji-board'; +import { useStatusPresets } from '../../../hooks/useStatusPresets'; +import { + BUILT_IN_STATUS_PRESETS, + StatusPreset, + makePresetId, +} from '../../../utils/statusPresets'; type ProfileProps = { profile: UserProfile; @@ -363,6 +370,7 @@ function ProfileStatus() { const statusDirtyRef = useRef(false); const [clearAfter, setClearAfter] = useState('0'); const [emojiAnchor, setEmojiAnchor] = useState(); + const { presets, addPreset, removePreset } = useStatusPresets(); // Sync input when another device changes the status. // Skipped while the user has unsaved local edits to avoid clobbering @@ -412,30 +420,56 @@ function ProfileStatus() { setStatusMsg(evt.currentTarget.value); }; + // Save a status message + auto-clear timer. Shared by the Save button and the + // one-click presets so all three go through exactly the same server write and + // localStorage bookkeeping. + const applyStatus = useCallback( + (rawMsg: string, clearAfterValue: string) => { + statusDirtyRef.current = false; + const msg = rawMsg.trim(); + saveStatus(msg).catch(() => undefined); + + if (msg) { + localStorage.setItem(STATUS_MSG_KEY(userId), msg); + } else { + localStorage.removeItem(STATUS_MSG_KEY(userId)); + } + + const delayMs = getMsFromOption(clearAfterValue); + if (msg && delayMs > 0) { + // Persist the expiry timestamp; the always-mounted StatusExpiryMonitor + // (ClientNonUIFeatures) fires the auto-clear even when Settings is closed. + localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs)); + } else { + localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); + } + }, + [saveStatus, userId], + ); + const handleSubmit: FormEventHandler = (evt) => { evt.preventDefault(); if (saving) return; - statusDirtyRef.current = false; - const msg = statusMsg.trim(); - saveStatus(msg).catch(() => undefined); - - if (msg) { - localStorage.setItem(STATUS_MSG_KEY(userId), msg); - } else { - localStorage.removeItem(STATUS_MSG_KEY(userId)); - } - - const delayMs = getMsFromOption(clearAfter); - if (msg && delayMs > 0) { - // Persist the expiry timestamp; the always-mounted StatusExpiryMonitor - // (ClientNonUIFeatures) fires the auto-clear even when Settings is closed. - const ts = Date.now() + delayMs; - localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts)); - } else { - localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); - } + applyStatus(statusMsg, clearAfter); }; + // Preset click = one-click apply: reflect it in the inputs and save immediately. + const applyPreset = useCallback( + (preset: StatusPreset) => { + if (saving) return; + setStatusMsg(preset.label); + setClearAfter(preset.clearAfter); + applyStatus(preset.label, preset.clearAfter); + }, + [saving, applyStatus], + ); + + const handleSaveCurrent = useCallback(() => { + const label = statusMsg.trim(); + if (!label) return; + addPreset({ id: makePresetId(), label, clearAfter }); + }, [statusMsg, clearAfter, addPreset]); + const handleClear = () => { statusDirtyRef.current = false; setStatusMsg(''); @@ -463,7 +497,81 @@ function ProfileStatus() { } > - + + {/* Quick statuses โ€” built-in presets, one click applies message + timer */} + + + Quick statuses + + + {BUILT_IN_STATUS_PRESETS.map((preset) => ( + applyPreset(preset)} + > + + {preset.label} + + + ))} + + + + {/* Your presets โ€” saved from the current status; synced via account data */} + + + Your presets + + + {presets.map((preset) => ( + + applyPreset(preset)} + > + + {preset.label} + + + removePreset(preset.id)} + > + + + + ))} + } + > + + Save current + + + + + ({ + eventType: STATUS_PRESETS_KEY, + read: (content) => content?.presets ?? [], + write: (presets) => ({ presets }), +}); + +export function useStatusPresets(): { + presets: StatusPreset[]; + addPreset: (preset: StatusPreset) => Promise; + removePreset: (id: string) => Promise; +} { + const mx = useMatrixClient(); + const presets = statusPresetsStore.useValue(mx); + + const addPreset = useCallback( + (preset: StatusPreset) => + statusPresetsStore.enqueueWrite(mx, (current) => upsertPreset(current, preset, MAX_PRESETS)), + [mx], + ); + + const removePreset = useCallback( + (id: string) => + statusPresetsStore.enqueueWrite(mx, (current) => current.filter((p) => p.id !== id)), + [mx], + ); + + return { presets, addPreset, removePreset }; +} diff --git a/src/app/utils/statusPresets.test.ts b/src/app/utils/statusPresets.test.ts new file mode 100644 index 000000000..6ed6291a6 --- /dev/null +++ b/src/app/utils/statusPresets.test.ts @@ -0,0 +1,52 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { upsertPreset, normalizeLabel, StatusPreset } from './statusPresets'; + +const p = (id: string, label: string, clearAfter = '0'): StatusPreset => ({ id, label, clearAfter }); + +test('normalizeLabel trims and lowercases', () => { + assert.equal(normalizeLabel(' ๐ŸŽฎ Gaming '), '๐ŸŽฎ gaming'); + assert.equal(normalizeLabel('AFK'), 'afk'); +}); + +test('upsertPreset prepends a new preset', () => { + const list = [p('1', 'a'), p('2', 'b')]; + const out = upsertPreset(list, p('3', 'c')); + assert.deepEqual( + out.map((x) => x.id), + ['3', '1', '2'], + ); +}); + +test('upsertPreset de-dupes by normalized label, moving the entry to the front', () => { + const list = [p('1', 'Gaming'), p('2', 'b'), p('3', 'c')]; + // Same label (different case/whitespace) โ†’ old entry removed, new one at front. + const out = upsertPreset(list, p('9', ' gaming ')); + assert.deepEqual( + out.map((x) => x.id), + ['9', '2', '3'], + ); + assert.equal(out.filter((x) => normalizeLabel(x.label) === 'gaming').length, 1); +}); + +test('upsertPreset enforces the cap, dropping the oldest', () => { + const list = Array.from({ length: 20 }, (_, i) => p(String(i), `label-${i}`)); + const out = upsertPreset(list, p('new', 'fresh'), 20); + assert.equal(out.length, 20); + assert.equal(out[0].id, 'new'); + // The last (oldest) entry, id '19', is dropped. + assert.equal( + out.some((x) => x.id === '19'), + false, + ); +}); + +test('upsertPreset does not mutate its input', () => { + const list = [p('1', 'a'), p('2', 'b')]; + const before = list.map((x) => x.id); + upsertPreset(list, p('3', 'c')); + assert.deepEqual( + list.map((x) => x.id), + before, + ); +}); diff --git a/src/app/utils/statusPresets.ts b/src/app/utils/statusPresets.ts new file mode 100644 index 000000000..b51358b4b --- /dev/null +++ b/src/app/utils/statusPresets.ts @@ -0,0 +1,58 @@ +// Status presets โ€” quick-pick statuses for the profile Status Message field. +// +// A preset's `label` is the full status string (it may include a leading emoji); +// `clearAfter` is one of the CLEAR_AFTER_OPTIONS values used by ProfileStatus +// ('0' = never, 'today' = until midnight, or a milliseconds string), so applying +// a preset feeds the existing getMsFromOption path unchanged. + +export type StatusPreset = { + id: string; + label: string; + clearAfter: string; +}; + +const HOUR = String(60 * 60 * 1000); +const MIN30 = String(30 * 60 * 1000); +const HOUR4 = String(4 * 60 * 60 * 1000); +const DAY7 = String(7 * 24 * 60 * 60 * 1000); + +// Built-in presets span gaming, social, and life โ€” not just work โ€” since Lotus is +// used mostly for gaming but for all use cases. Order groups related ones together. +export const BUILT_IN_STATUS_PRESETS: StatusPreset[] = [ + { id: 'builtin-gaming', label: '๐ŸŽฎ Gaming', clearAfter: HOUR4 }, + { id: 'builtin-party', label: '๐ŸŽง In a party', clearAfter: HOUR4 }, + { id: 'builtin-ranked', label: '๐Ÿ† Ranked grind', clearAfter: HOUR4 }, + { id: 'builtin-afk', label: '๐Ÿ˜ด AFK', clearAfter: MIN30 }, + { id: 'builtin-watching', label: '๐Ÿฟ Watching', clearAfter: HOUR4 }, + { id: 'builtin-lunch', label: '๐Ÿฝ๏ธ Lunch', clearAfter: MIN30 }, + { id: 'builtin-meeting', label: '๐Ÿ—“๏ธ In a meeting', clearAfter: HOUR }, + { id: 'builtin-remote', label: '๐Ÿ  Working remotely', clearAfter: 'today' }, + { id: 'builtin-focusing', label: '๐ŸŽฏ Focusing', clearAfter: HOUR }, + { id: 'builtin-vacation', label: '๐ŸŒด On vacation', clearAfter: DAY7 }, + { id: 'builtin-sick', label: '๐Ÿค’ Out sick', clearAfter: 'today' }, +]; + +/** Normalize a label for de-dupe: trim + lowercase (emoji preserved). */ +export function normalizeLabel(label: string): string { + return label.trim().toLowerCase(); +} + +/** Stable-enough unique id for a custom preset (used as a React key). */ +export function makePresetId(): string { + return `preset-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Insert a preset at the front of the list, de-duped by normalized label so + * re-saving the same status moves it to the front instead of duplicating, and + * capped at `max`. Pure โ€” returns a new array and never mutates the input. + */ +export function upsertPreset( + list: StatusPreset[], + preset: StatusPreset, + max = 20, +): StatusPreset[] { + const key = normalizeLabel(preset.label); + const withoutDup = list.filter((p) => normalizeLabel(p.label) !== key); + return [preset, ...withoutDup].slice(0, max); +}