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 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 23:30:08 -04:00
co-authored by Claude Opus 4.8
parent 39e75f4eea
commit d0614710b0
5 changed files with 282 additions and 20 deletions
+128 -20
View File
@@ -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<RectCords>();
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<HTMLFormElement> = (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() {
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<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">
Quick statuses
</Text>
<Box gap="100" wrap="Wrap" role="group" aria-label="Built-in status presets">
{BUILT_IN_STATUS_PRESETS.map((preset) => (
<Chip
key={preset.id}
type="button"
variant="Secondary"
fill="Soft"
radii="Pill"
disabled={saving}
onClick={() => applyPreset(preset)}
>
<Text as="span" size="B300">
{preset.label}
</Text>
</Chip>
))}
</Box>
</Box>
{/* Your presets — saved from the current status; synced via account data */}
<Box direction="Column" gap="100">
<Text 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">
<Chip
type="button"
variant="Secondary"
fill="Soft"
radii="Pill"
disabled={saving}
onClick={() => applyPreset(preset)}
>
<Text as="span" size="B300">
{preset.label}
</Text>
</Chip>
<IconButton
type="button"
size="300"
radii="Pill"
variant="Secondary"
fill="None"
aria-label={`Delete preset ${preset.label}`}
onClick={() => removePreset(preset.id)}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
))}
<Chip
type="button"
variant="Success"
fill="Soft"
radii="Pill"
outlined
disabled={saving || !statusMsg.trim()}
onClick={handleSaveCurrent}
before={<Icon size="50" src={Icons.Plus} />}
>
<Text as="span" size="B300">
Save current
</Text>
</Chip>
</Box>
</Box>
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
<Box grow="Yes" direction="Column" gap="100">
<Input