Compare commits
6
Commits
39e75f4eea
...
57f21e5cac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57f21e5cac | ||
|
|
629db9724f | ||
|
|
a739c25f10 | ||
|
|
53ce2e40a4 | ||
|
|
398e743cdd | ||
|
|
d0614710b0 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
+16
-4
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user