Compare commits

...
6 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 57f21e5cac fix(forward): harden comment retry + a11y after review
CI / Trigger Desktop Build (push) Successful in 9s
CI / Build & Quality Checks (push) Successful in 10m47s
Address findings from 2 review agents on the forward upgrades:

- Duplicate comment on retry (correctness): if a room's comment message
  sent but the forward then failed, retrying re-posted the comment. Track
  rooms whose comment already delivered (commentSentRef) and skip it on
  retry, sending only the missing forward. An already-commented room won't
  get the comment again even if the text is later edited (no-duplicate
  choice).

- a11y: give the message-preview box role="group" + aria-label
  ("Message to forward"), add aria-label to the comment and search inputs
  (placeholder is not a label), and match the RecentChip's RoomIcon
  fallback size (100) to the room-row convention for a size-200 avatar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:43:32 -04:00
jaredandClaude Opus 4.8 629db9724f feat(forward): message preview, optional comment, recent targets
The Forward dialog forwarded blind. Add three things (design-system
cleanup included):

- Preview: a compact read-only preview at the top of the dialog shows the
  sender + body, with a thumbnail for image/video (reuses ThumbnailContent
  and the getMemberName/getMemberAvatarMxc/trimReplyFromBody helpers). We
  already hold mEvent, so nothing is fetched.

- Comment: an optional "Add a comment" field sends a short m.text note to
  each target room, sequenced BEFORE the forwarded message per room so the
  note reads above the quoted content. The existing per-room failure /
  retry logic is preserved (a room fails if either send rejects).

- Recent targets: a "Recent" chip row (hidden while searching) offers
  one-tap selection of rooms you last forwarded to. Successful targets are
  recorded most-recent-first, deduped, capped at 8, in localStorage via the
  pure, unit-tested addRecentForwardTarget (state/recentForwardTargets.ts).
  Rooms you've since left are filtered out of the row.

Also replaces the hardcoded rgba(0,0,0,0.35) sending scrim with a
token-free opacity dim of the list (design-system rule: no hardcoded
colors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:37:49 -04:00
jaredandClaude Opus 4.8 a739c25f10 fix(status): stop presence heartbeats from clobbering status edits
Selecting an emoji (or any unsaved edit) in the profile Status Message
field could vanish because the presence-sync effect re-applied the remote
status_msg on every presence heartbeat (which fire every few seconds).
The dirty-edit guard alone left a window where a heartbeat carrying the
previous status overwrote the just-inserted emoji, so there was no way to
add emoji to a status.

Track the last remote status we synced and only react when the remote
value actually changes, instead of on every heartbeat. Repeated
heartbeats with an unchanged status are now ignored, so an unsaved local
edit is preserved regardless of the dirty flag's timing. Cross-device
status changes (a genuinely new remote value) and clears still sync, and
the pending-applied stale-echo guard is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:13:44 -04:00
jaredandClaude Opus 4.8 53ce2e40a4 fix(url-preview): thumbnail fallback + quiet timeline console spam
Two console-noise / glitch fixes surfaced while a room with link
previews was open:

- URL preview og:image thumbnails 400 when Synapse can't thumbnail a
  cached preview image (SVG/animated), leaving a broken image that the
  browser keeps re-requesting. GenericCard now falls back to the full
  image on error, then hides the image (and shows the link icon) if that
  also fails, so no broken image and no repeated failing requests.

- Extend the existing console.warn filter to drop matrix-js-sdk's
  high-volume, benign timeline bookkeeping warnings ("EventTimelineSet…"
  and "Decrypted event … is not in room …"), which fire constantly in
  E2EE rooms with threads. Real warnings still log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:47:19 -04:00
jaredandClaude Opus 4.8 398e743cdd fix(status): harden presets after review
Address findings from 2 review agents on the status-presets feature:

- Presence-echo race: a heartbeat can echo the previous status just after
  a new one is saved, reverting the input. Track the last-applied value
  and ignore non-matching echoes until our own echo lands or a 15s window
  elapses (bounded so a dropped echo can't block real cross-device
  updates). Applies to Save, preset apply, and Clear.

- Duplicate chips: a saved custom preset that matches a built-in is now
  hidden from "Your presets" (it already shows under Quick statuses).

- a11y: the two preset rows use aria-labelledby tied to their visible
  headings instead of mismatched hardcoded aria-labels.

- Visual grouping: a custom preset's chip and its delete X now sit with
  gap=0 as one unit while the row separates presets with gap=200, so a
  chip and its delete no longer read as two separate presets. Delete/Plus
  icons bumped to size=100 to match the folds chip-icon convention.

- Parity: addPreset/removePreset promises are now caught like the other
  account-data call sites.

Docs updated to the exact 11-preset built-in list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:37:05 -04:00
jaredandClaude Opus 4.8 d0614710b0 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>
2026-07-09 23:30:08 -04:00
10 changed files with 695 additions and 47 deletions
+5
View File
@@ -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 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
@@ -982,6 +983,10 @@ Hook: `src/app/hooks/useUserNotes.ts`
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
- **Message preview**: a compact preview at the top of the dialog shows the sender + body (and a thumbnail for image/video) so you can see what you're forwarding.
- **Optional comment**: an "Add a comment" field sends a short `m.text` note to each target room *before* the forwarded message (sequenced per room; a room counts as failed if either send fails).
- **Recent targets**: a "Recent" chip row (hidden while searching) surfaces the rooms you last forwarded to for one-tap selection. Successful targets are recorded most-recent-first, deduped, capped at 8, in localStorage (`cinny_recent_forward_targets_v1`) via the pure, unit-tested `addRecentForwardTarget` (`state/recentForwardTargets.ts`); rooms you've since left are dropped from the row.
### Live Bookmark Previews (P6-3)
`BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**.
@@ -1965,16 +1965,29 @@ function GenericCard({
const description = prev['og:description'] ?? '';
const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined;
// Synapse returns 400 from the thumbnail endpoint when it can't thumbnail a
// cached preview image (e.g. SVG/animated). Fall back to the full image, then
// hide entirely if that also fails — otherwise the card shows a broken image
// and the browser keeps re-requesting the failing thumbnail (console spam).
const [useFullImg, setUseFullImg] = useState(false);
const [imgFailed, setImgFailed] = useState(false);
const displayThumb = useFullImg ? imgUrl : thumbUrl;
const handleImgError = () => {
if (!useFullImg && imgUrl && imgUrl !== thumbUrl) setUseFullImg(true);
else setImgFailed(true);
};
return (
<>
{thumbUrl && (
{!imgFailed && displayThumb && (
<UrlPreviewImg
src={thumbUrl}
src={displayThumb}
alt={prev['og:title']}
title={prev['og:title']}
tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
onClick={onOpenViewer}
onError={handleImgError}
/>
)}
{imgUrl && (
@@ -1997,7 +2010,7 @@ function GenericCard({
size="T200"
priority="300"
>
{!thumbUrl && (
{(!displayThumb || imgFailed) && (
<Icon
src={Icons.Link}
size="50"
@@ -5,6 +5,7 @@ import {
Box,
Button,
Checkbox,
Chip,
color,
config,
Header,
@@ -21,9 +22,11 @@ import {
Scroll,
Spinner,
Text,
toRem,
} from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { useAtomValue, useAtom } from 'jotai';
import { IThumbnailContent } from '../../../../types/matrix/common';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { stopPropagation } from '../../../utils/keyboard';
import { useModalStyle } from '../../../hooks/useModalStyle';
@@ -31,6 +34,14 @@ import { mDirectAtom } from '../../../state/mDirectList';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { UserAvatar } from '../../../components/user-avatar';
import { ThumbnailContent } from '../../../components/message/content/ThumbnailContent';
import { getMemberAvatarMxc, getMemberName, trimReplyFromBody } from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import {
recentForwardTargetsAtom,
addRecentForwardTarget,
} from '../../../state/recentForwardTargets';
import { buildForwardContent } from './forwardContent';
type RoomRowProps = {
@@ -93,6 +104,141 @@ function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: R
);
}
// Compact, read-only preview of the message being forwarded — sender + body,
// plus a small thumbnail for image/video. We already hold mEvent, so no fetch.
function ForwardPreview({
mEvent,
useAuthentication,
}: {
mEvent: MatrixEvent;
useAuthentication: boolean;
}) {
const mx = useMatrixClient();
const room = mx.getRoom(mEvent.getRoomId() ?? '') ?? undefined;
const senderId = mEvent.getSender() ?? '';
const senderName = room ? getMemberName(room, senderId) : senderId.split(':')[0]?.slice(1) || senderId;
const senderMxc = room ? getMemberAvatarMxc(room, senderId) : undefined;
const senderAvatarUrl = senderMxc
? (mxcUrlToHttp(mx, senderMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
: undefined;
const content = mEvent.getContent();
const msgtype = content.msgtype;
const isMedia =
msgtype === MsgType.Image ||
msgtype === MsgType.Video ||
msgtype === MsgType.File ||
msgtype === MsgType.Audio;
const bodyStr = typeof content.body === 'string' ? content.body : '';
const label = isMedia
? ((content.filename as string | undefined) ?? bodyStr) || '(media)'
: trimReplyFromBody(bodyStr) || '(message)';
const info = content.info as IThumbnailContent | undefined;
const showThumb =
(msgtype === MsgType.Image || msgtype === MsgType.Video) &&
!!info &&
(!!info.thumbnail_url || !!info.thumbnail_file);
return (
<Box
shrink="No"
gap="200"
alignItems="Center"
role="group"
aria-label="Message to forward"
style={{
margin: `${config.space.S200} ${config.space.S400} 0`,
padding: config.space.S200,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
<Avatar size="200" radii="300">
<UserAvatar
userId={senderId}
src={senderAvatarUrl}
alt={senderName}
renderFallback={() => <Text size="H6">{nameInitials(senderName)}</Text>}
/>
</Avatar>
{showThumb && info && (
<ThumbnailContent
info={info}
renderImage={(src) => (
<img
src={src}
alt=""
style={{
width: toRem(40),
height: toRem(40),
borderRadius: config.radii.R300,
objectFit: 'cover',
flexShrink: 0,
}}
/>
)}
/>
)}
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
{senderName}
</Text>
<Text size="T200" priority="300" truncate>
{label}
</Text>
</Box>
</Box>
);
}
// A compact selectable chip for a recently-forwarded-to room.
function RecentChip({
room,
useAuthentication,
selected,
onToggle,
sending,
}: {
room: Room;
useAuthentication: boolean;
selected: boolean;
onToggle: () => void;
sending: boolean;
}) {
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
: undefined;
return (
<Chip
variant={selected ? 'Primary' : 'SurfaceVariant'}
fill="Soft"
radii="Pill"
disabled={sending}
onClick={onToggle}
aria-pressed={selected}
before={
<Avatar size="200" radii="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<RoomIcon roomType={room.getType()} size="100" joinRule={room.getJoinRule()} filled />
)}
/>
</Avatar>
}
>
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
{room.name}
</Text>
</Chip>
);
}
type Props = {
mEvent: MatrixEvent;
onClose: () => void;
@@ -105,9 +251,16 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const useAuthentication = useMediaAuthentication();
const searchInputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [comment, setComment] = useState('');
const [sending, setSending] = useState(false);
const [sentTo, setSentTo] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [recents, setRecents] = useAtom(recentForwardTargetsAtom);
// Rooms whose comment message already delivered this session — so a retry after
// a forward failure doesn't re-post the comment (only the missing forward). Kept
// for the dialog's lifetime: a room that already got the comment won't get it
// again even if the text is later edited, which is the safe (no-duplicate) choice.
const commentSentRef = useRef<Set<string>>(new Set());
// Selection persists across query changes: a room selected then filtered out
// of the rendered slice stays selected.
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
@@ -139,6 +292,17 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
}, [allRooms, query]);
// Resolve recent target ids to still-joined rooms (drop ones left/gone).
const recentRooms = useMemo(
() =>
recents
.map((id) => mx.getRoom(id))
.filter(
(r): r is Room => !!r && r.getMyMembership() === 'join' && !r.isSpaceRoom(),
),
[recents, mx],
);
const sendToSelected = useCallback(async () => {
if (sending || selectedRoomIds.size === 0) return;
const fwdContent = buildForwardContent(mx, mEvent);
@@ -150,20 +314,42 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
setError(null);
const ids = [...selectedRoomIds];
const commentBody = comment.trim();
const results = await Promise.allSettled(
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
ids.map((id) => {
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent);
// Send the optional comment first so it reads as a note above the
// forwarded content. The room counts as failed if either send rejects.
// Track rooms whose comment already landed so a retry (after the FORWARD
// failed) doesn't post the comment twice — only the missing forward.
const needsComment = commentBody && !commentSentRef.current.has(id);
const step = needsComment
? mx
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any)
.then(() => {
commentSentRef.current.add(id);
})
: Promise.resolve();
return step.then(sendForward);
}),
);
const failedIds: string[] = [];
const failedNames: string[] = [];
const succeededIds: string[] = [];
results.forEach((result, i) => {
if (result.status === 'rejected') {
failedIds.push(ids[i]);
failedNames.push(mx.getRoom(ids[i])?.name ?? ids[i]);
} else {
succeededIds.push(ids[i]);
}
});
// Remember successful targets (most-recent first) for the Recent row.
succeededIds.forEach((id) => setRecents((prev) => addRecentForwardTarget(prev, id)));
const total = ids.length;
const failed = failedNames.length;
@@ -184,7 +370,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
return;
}
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
}, [mx, mEvent, onClose, sending, selectedRoomIds]);
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -221,10 +407,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{!sentTo && <ForwardPreview mEvent={mEvent} useAuthentication={useAuthentication} />}
{!sentTo && (
<Box
shrink="No"
direction="Column"
gap="200"
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
>
<Input
@@ -233,15 +421,24 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
size="400"
radii="400"
outlined
aria-label="Search rooms"
placeholder="Search rooms…"
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/>
<Input
variant="Background"
size="400"
radii="400"
outlined
aria-label="Add a comment"
placeholder="Add a comment (optional)…"
value={comment}
disabled={sending}
onChange={(e: ChangeEvent<HTMLInputElement>) => setComment(e.target.value)}
/>
{error && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
@@ -262,7 +459,46 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<>
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
<Scroll size="300" hideTrack visibility="Hover">
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S200, opacity: sending ? 0.5 : 1 }}
>
{/* Recent targets — quick access, hidden while searching */}
{!query && recentRooms.length > 0 && (
<Box
direction="Column"
gap="100"
style={{ paddingBottom: config.space.S100 }}
>
<Text
size="L400"
priority="300"
style={{ padding: `0 ${config.space.S200}` }}
>
Recent
</Text>
<Box
gap="100"
wrap="Wrap"
role="group"
aria-label="Recent forward targets"
style={{ padding: `0 ${config.space.S200}` }}
>
{recentRooms.map((room) => (
<RecentChip
key={room.roomId}
room={room}
useAuthentication={useAuthentication}
selected={selectedRoomIds.has(room.roomId)}
onToggle={() => toggleRoom(room.roomId)}
sending={sending}
/>
))}
</Box>
<Line size="300" style={{ marginTop: config.space.S100 }} />
</Box>
)}
{filtered.slice(0, 60).map((room) => (
<RoomRow
key={room.roomId}
@@ -291,12 +527,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<Box
alignItems="Center"
justifyContent="Center"
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.35)',
borderRadius: config.radii.R500,
}}
style={{ position: 'absolute', inset: 0 }}
>
<Spinner variant="Secondary" size="400" />
</Box>
+169 -23
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,13 @@ 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,
normalizeLabel,
} from '../../../utils/statusPresets';
type ProfileProps = {
profile: UserProfile;
@@ -355,14 +363,26 @@ function ProfileStatus() {
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
const [statusMsg, setStatusMsg] = useState<string>(
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
);
const initialStatus = presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
const [statusMsg, setStatusMsg] = useState<string>(initialStatus);
// 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 last remote status we synced into the input. Presence heartbeats fire
// every few seconds carrying the SAME status; the sync effect must only react
// when the remote value actually changes, otherwise a repeated heartbeat can
// overwrite an unsaved local edit (e.g. an emoji just inserted) the instant
// the dirty flag is out of sync. Seeded with the value the input started on.
const lastSyncedRemoteRef = useRef<string>(initialStatus);
// 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<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
@@ -375,6 +395,20 @@ function ProfileStatus() {
// wipe the saved status on every invisible toggle.
if (presence.presence === Presence.Offline) return;
const remoteStatus = presence.status ?? '';
// Only act on an actual remote change. Repeated heartbeats carrying the same
// status are ignored so they can never clobber an unsaved local edit.
if (remoteStatus === lastSyncedRemoteRef.current) return;
lastSyncedRemoteRef.current = remoteStatus;
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);
@@ -412,32 +446,68 @@ 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();
// 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) {
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 }).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));
@@ -463,7 +533,83 @@ 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 id="status-quick-heading" size="T200" priority="300">
Quick statuses
</Text>
<Box gap="100" wrap="Wrap" role="group" aria-labelledby="status-quick-heading">
{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 id="status-custom-heading" size="T200" priority="300">
Your presets
</Text>
<Box gap="200" wrap="Wrap" role="group" aria-labelledby="status-custom-heading">
{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.
<Box key={preset.id} alignItems="Center" gap="0">
<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).catch(() => undefined)}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
))}
<Chip
type="button"
variant="Success"
fill="Soft"
radii="Pill"
outlined
disabled={saving || !statusMsg.trim()}
onClick={handleSaveCurrent}
before={<Icon size="100" 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
+43
View File
@@ -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,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// This module uses atomWithStorage(..., { getOnInit: true }), so it reads
// `localStorage` the moment it's evaluated. node has none, so install a no-op
// mock, then import dynamically (a static import would be hoisted above the
// mock assignment and evaluate the module too early).
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
const { addRecentForwardTarget } = await import('./recentForwardTargets');
test('addRecentForwardTarget prepends a new roomId', () => {
assert.deepEqual(addRecentForwardTarget(['!a', '!b'], '!c'), ['!c', '!a', '!b']);
});
test('addRecentForwardTarget dedupes and moves the roomId to the front', () => {
assert.deepEqual(addRecentForwardTarget(['!a', '!b', '!c'], '!b'), ['!b', '!a', '!c']);
});
test('addRecentForwardTarget ignores an empty roomId', () => {
assert.deepEqual(addRecentForwardTarget(['!a', '!b'], ''), ['!a', '!b']);
});
test('addRecentForwardTarget caps the list at 8 entries, dropping the oldest', () => {
const eight = Array.from({ length: 8 }, (_, i) => `!r${i}`);
const result = addRecentForwardTarget(eight, '!new');
assert.equal(result.length, 8);
assert.equal(result[0], '!new');
// the oldest entry (last) is dropped
assert.equal(result.includes('!r7'), false);
assert.deepEqual(result.slice(1), eight.slice(0, 7));
});
test('addRecentForwardTarget does not mutate its input', () => {
const input = ['!a', '!b'];
const before = [...input];
addRecentForwardTarget(input, '!c');
assert.deepEqual(input, before);
});
test('addRecentForwardTarget on an empty history returns a single-element list', () => {
assert.deepEqual(addRecentForwardTarget([], '!first'), ['!first']);
});
+41
View File
@@ -0,0 +1,41 @@
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
const STORAGE_KEY = 'cinny_recent_forward_targets_v1';
const MAX_RECENT_FORWARD_TARGETS = 8;
// Internal atom persists as a plain string[] of roomIds (JSON-serializable).
// getOnInit reads localStorage synchronously so the Recent row is present on the
// first render of the Forward dialog (no flash of the empty default).
const internalAtom = atomWithStorage<string[]>(
STORAGE_KEY,
[],
createJSONStorage(() => localStorage),
{ getOnInit: true },
);
/**
* Global atom: string[] of the most recent distinct roomIds forwarded to.
* Most-recent first, deduped, capped at MAX_RECENT_FORWARD_TARGETS.
* Backed by localStorage (device-local convenience — not synced across devices).
*/
export const recentForwardTargetsAtom = atom(
(get): string[] => get(internalAtom),
(_get, set, updater: string[] | ((prev: string[]) => string[])) => {
set(internalAtom, (prev) => {
const prevList = Array.isArray(prev) ? prev : [];
const next = typeof updater === 'function' ? updater(prevList) : updater;
return next;
});
},
);
/**
* Prepend a roomId: dedupes, drops empties, moves an existing id to the front,
* caps at MAX_RECENT_FORWARD_TARGETS.
*/
export const addRecentForwardTarget = (prev: string[], roomId: string): string[] => {
if (!roomId) return prev;
const withoutDupe = prev.filter((id) => id !== roomId);
return [roomId, ...withoutDupe].slice(0, MAX_RECENT_FORWARD_TARGETS);
};
+52
View File
@@ -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,
);
});
+58
View File
@@ -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);
}
+16 -4
View File
@@ -68,13 +68,25 @@ window.addEventListener('vite:preloadError', () => {
// Clear the reload flag after a successful load so future deploys can still trigger a reload
window.addEventListener('load', () => sessionStorage.removeItem('chunk-reload-attempted'));
// Synapse does not yet ship MSC3786/MSC3914 as server-default push rules.
// matrix-js-sdk patches them client-side on every login and logs a warn for each.
// Suppress the noise until Synapse implements these MSCs upstream.
// Filter out known-benign, high-volume matrix-js-sdk console warnings that we
// can't fix client-side and that would otherwise flood the console:
// - "Adding default global …": the SDK patches MSC3786/MSC3914 push rules on
// every login (one warn each) until Synapse ships them as server defaults.
// - "EventTimelineSet…": the SDK loudly warns whenever a decrypted event
// references a thread/room the current timeline set doesn't hold, then
// discards it harmlessly. This fires constantly in E2EE rooms with threads.
// - "Decrypted event … is not in room …": same family — a late decryption for
// an event the room's timeline no longer tracks; ignored by the SDK.
// These are informational SDK bookkeeping, not errors; real warnings still log.
{
const suppressedPrefixes = ['Adding default global ', 'EventTimelineSet'];
const _warn = console.warn.bind(console);
console.warn = (...args: unknown[]) => {
if (typeof args[0] === 'string' && args[0].startsWith('Adding default global ')) return;
const first = args[0];
if (typeof first === 'string') {
if (suppressedPrefixes.some((p) => first.startsWith(p))) return;
if (first.startsWith('Decrypted event ') && first.includes('is not in room')) return;
}
_warn(...args);
};
}