Compare commits

..

2 Commits

Author SHA1 Message Date
jared b086be3def feat: auto-clear status after configurable duration
CI / Build & Quality Checks (push) Successful in 10m39s
Adds an 'Auto-clear after' dropdown to the Status Message settings tile
with options: Never / 30 min / 1 hr / 4 hr / 8 hr / Until midnight /
1 day / 7 days.

How it works:
- On save, stores the expiry timestamp in localStorage keyed by userId
  (lotus-status-expiry-<userId>) and sets expiryTs state
- A single useEffect on expiryTs drives the timer — re-saving cancels
  the previous timer cleanly via useEffect cleanup
- On mount, reads stored expiry from localStorage so auto-clear
  survives page reloads (fires immediately if already expired)
- Manual Clear Status also removes the stored expiry and cancels any
  active timer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:10:46 -04:00
jared 2707b59e20 fix: capture emoji button rect before state updater to avoid null currentTarget
React nullifies synthetic event's currentTarget before async state
updater callbacks run. Capture getBoundingClientRect() synchronously
in the onClick handler, then pass the already-computed rect into
setEmojiAnchor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:06:31 -04:00
+94 -11
View File
@@ -319,19 +319,68 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
);
}
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
const CLEAR_AFTER_OPTIONS = [
{ label: 'Never', value: '0' },
{ label: '30 minutes', value: String(30 * 60 * 1000) },
{ label: '1 hour', value: String(60 * 60 * 1000) },
{ label: '4 hours', value: String(4 * 60 * 60 * 1000) },
{ label: '8 hours', value: String(8 * 60 * 60 * 1000) },
{ label: 'Until midnight', value: 'today' },
{ label: '1 day', value: String(24 * 60 * 60 * 1000) },
{ label: '7 days', value: String(7 * 24 * 60 * 60 * 1000) },
];
function getMsFromOption(value: string): number {
if (value === '0') return 0;
if (value === 'today') {
const eod = new Date();
eod.setHours(23, 59, 59, 999);
return eod.getTime() - Date.now();
}
return parseInt(value, 10);
}
function ProfileStatus() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const presence = useUserPresence(userId);
const [statusMsg, setStatusMsg] = useState<string>(presence?.status ?? '');
const [clearAfter, setClearAfter] = useState('0');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const inputRef = useRef<HTMLInputElement>(null);
// Initialise expiry from localStorage so timer survives page reload
const [expiryTs, setExpiryTs] = useState<number>(() => {
const stored = localStorage.getItem(STATUS_EXPIRY_KEY(userId));
return stored ? parseInt(stored, 10) : 0;
});
// Sync input when another device changes the status
useEffect(() => {
setStatusMsg(presence?.status ?? '');
}, [presence?.status]);
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
useEffect(() => {
if (!expiryTs) return undefined;
const remaining = expiryTs - Date.now();
if (remaining <= 0) {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0);
mx.setPresence({ presence: 'online', status_msg: '' });
return undefined;
}
const timer = window.setTimeout(() => {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0);
mx.setPresence({ presence: 'online', status_msg: '' });
}, remaining);
return () => clearTimeout(timer);
}, [expiryTs, userId, mx]);
const [saveState, saveStatus] = useAsyncCallback(
useCallback(
(msg: string) =>
@@ -352,7 +401,6 @@ function ProfileStatus() {
const end = input.selectionEnd ?? statusMsg.length;
const next = statusMsg.slice(0, start) + unicode + statusMsg.slice(end);
setStatusMsg(next);
// restore cursor after emoji insertion
requestAnimationFrame(() => {
input.focus();
input.setSelectionRange(start + unicode.length, start + unicode.length);
@@ -372,15 +420,25 @@ function ProfileStatus() {
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (saving) return;
saveStatus(statusMsg.trim());
const msg = statusMsg.trim();
saveStatus(msg);
const delayMs = getMsFromOption(clearAfter);
if (msg && delayMs > 0) {
const ts = Date.now() + delayMs;
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
setExpiryTs(ts);
} else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0);
}
};
const handleClear = () => {
setStatusMsg('');
mx.setPresence({
presence: 'online',
status_msg: '',
});
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0);
mx.setPresence({ presence: 'online', status_msg: '' });
};
const hasChanges = statusMsg !== (presence?.status ?? '');
@@ -436,11 +494,10 @@ function ProfileStatus() {
aria-label="Insert emoji"
aria-expanded={!!emojiAnchor}
aria-haspopup="dialog"
onClick={(evt: React.MouseEvent<HTMLButtonElement>) =>
setEmojiAnchor((prev) =>
prev ? undefined : evt.currentTarget.getBoundingClientRect(),
)
}
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
const rect = evt.currentTarget.getBoundingClientRect();
setEmojiAnchor((prev) => (prev ? undefined : rect));
}}
>
<Icon src={Icons.Smile} size="100" />
</IconButton>
@@ -461,6 +518,32 @@ function ProfileStatus() {
<Text size="B400">Save</Text>
</Button>
</Box>
<Box alignItems="Center" gap="200">
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
Auto-clear after:
</Text>
<select
value={clearAfter}
onChange={(e) => setClearAfter(e.target.value)}
aria-label="Auto-clear status after"
style={{
background: 'var(--bg-surface-variant)',
border: `1px solid var(--border-surface-variant)`,
borderRadius: config.radii.R300,
color: 'inherit',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
cursor: 'pointer',
outline: 'none',
}}
>
{CLEAR_AFTER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Box>
{(presence?.status || statusMsg) && (
<Button
size="300"