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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { createAccountDataListStore } from './createAccountDataListStore';
|
||||
import { StatusPreset, upsertPreset } from '../utils/statusPresets';
|
||||
|
||||
const STATUS_PRESETS_KEY = 'io.lotus.status_presets';
|
||||
const MAX_PRESETS = 20;
|
||||
|
||||
type StatusPresetsContent = {
|
||||
presets: StatusPreset[];
|
||||
};
|
||||
|
||||
// Shared, concurrency-safe store. See createAccountDataListStore for why the
|
||||
// snapshot + write queue must be module-scoped (writes are serialized to avoid
|
||||
// lost updates, since setAccountData replaces the whole content with no merge).
|
||||
const statusPresetsStore = createAccountDataListStore<StatusPreset[], StatusPresetsContent>({
|
||||
eventType: STATUS_PRESETS_KEY,
|
||||
read: (content) => content?.presets ?? [],
|
||||
write: (presets) => ({ presets }),
|
||||
});
|
||||
|
||||
export function useStatusPresets(): {
|
||||
presets: StatusPreset[];
|
||||
addPreset: (preset: StatusPreset) => Promise<void>;
|
||||
removePreset: (id: string) => Promise<void>;
|
||||
} {
|
||||
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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user