Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 ddf9da1a55 fix(reminders): a reminder stays until you've seen it (#338)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m6s
ReminderMonitor pushed an ordinary toast (auto-dismissed after 4 s) and
deleted the reminder at once, with no OS notification — one that came due
while the app was in the background or the user was away was lost.

Now the reminder toast is sticky; clicking it opens the message and
dismissing it closes it, and only then is the reminder deleted (an unseen
one returns on the next start). When the window isn't focused it also
raises an OS notification (respecting "show notifications" and quiet
hours; no decrypted E2EE text, per N106).

Verified headless (notifications granted, window unfocused): due at
t+24 s, the toast stayed up for the remaining 51 s, one OS notification
"Reminder :: call the dentist before 5", the reminder stayed in
io.lotus.reminders until dismissed, then was removed.

Fixes #338

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:11:31 -04:00
6 changed files with 69 additions and 249 deletions
@@ -37,7 +37,6 @@ import {
} from '../../../types/matrix/common'; } from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common'; import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { formatAccuracy } from '../../utils/geoShare';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader'; import { FileHeader, FileDownloadButton } from './FileHeader';
@@ -733,8 +732,6 @@ export function MLocation({ content }: MLocationProps) {
const lon = parseFloat(location.longitude); const lon = parseFloat(location.longitude);
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />; if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
const description = typeof msc3488?.description === 'string' ? msc3488.description : undefined; const description = typeof msc3488?.description === 'string' ? msc3488.description : undefined;
// RFC 5870 `;u=` uncertainty (meters), sent by Lotus since Gitea #340.
const accuracyLabel = formatAccuracy(Number(/;u=([\d.]+)/.exec(geoUri)?.[1]));
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${ const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
lat - 0.004 lat - 0.004
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`; },${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
@@ -775,7 +772,6 @@ export function MLocation({ content }: MLocationProps) {
)} )}
<Text size="T300" priority="300"> <Text size="T300" priority="300">
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`} {`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
{accuracyLabel && ` (${accuracyLabel})`}
</Text> </Text>
<Button <Button
as="a" as="a"
+21 -40
View File
@@ -153,9 +153,6 @@ import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationS
import { ScheduleMessageModal } from './ScheduleMessageModal'; import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray'; import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator'; import { DraftIndicator } from './DraftIndicator';
import { PendingLocation, ShareLocationDialog } from './ShareLocationDialog';
import { buildGeoUri } from '../../utils/geoShare';
import { useLocalRoomName } from '../../hooks/useRoomMeta';
import { scheduledMessagesAtom } from '../../state/scheduledMessages'; import { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { createErrorToast, toastQueueAtom } from '../../state/toast'; import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { getThreadDraftKey } from '../../state/room/thread'; import { getThreadDraftKey } from '../../state/room/thread';
@@ -359,7 +356,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order), () => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order], [composerToolbarButtons?.order],
); );
const displayRoomName = useLocalRoomName(room);
const [locating, setLocating] = React.useState(false); const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null); const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = useCallback(() => { const handleShareLocation = useCallback(() => {
@@ -372,9 +368,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => { (pos) => {
setLocating(false); setLocating(false);
// [Gitea #340] Don't send straight away: confirm first (ShareLocationDialog). const { latitude, longitude } = pos.coords;
const { latitude, longitude, accuracy } = pos.coords; const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
setPendingLocation({ latitude, longitude, accuracy }); const ts = Date.now();
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
// m.location/m.asset/m.ts blocks so Element and other clients render a
// proper "shared location" pin instead of falling back to plain text.
sendRoomMessage(mx, roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Shared a location: ${geoUri}`,
geo_uri: geoUri,
'org.matrix.msc3488.location': { uri: geoUri },
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
});
}, },
(err) => { (err) => {
setLocating(false); setLocating(false);
@@ -389,30 +397,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, },
{ timeout: 10000 }, { timeout: 10000 },
); );
}, []); }, [mx, roomId, threadRootId]);
const [pendingLocation, setPendingLocation] = React.useState<PendingLocation | null>(null);
const handleSendLocation = useCallback(
(approximate: boolean) => {
if (!pendingLocation) return;
const { latitude, longitude, accuracy } = pendingLocation;
setPendingLocation(null);
const geoUri = buildGeoUri(latitude, longitude, accuracy, approximate);
const ts = Date.now();
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
// m.location/m.asset/m.ts blocks so Element and other clients render a
// proper "shared location" pin instead of falling back to plain text.
sendRoomMessage(mx, roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Shared ${approximate ? 'an approximate' : 'a'} location: ${geoUri}`,
geo_uri: geoUri,
'org.matrix.msc3488.location': { uri: geoUri },
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
});
},
[pendingLocation, mx, roomId, threadRootId],
);
const handleVoiceSend = useCallback( const handleVoiceSend = useCallback(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => { async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -1553,7 +1538,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
style={touchTarget} style={touchTarget}
className={MobileTouchTarget} className={MobileTouchTarget}
> >
{locating ? <Spinner variant="Secondary" size="400" /> : <Icon src={Icons.Pin} />} {locating ? (
<Spinner variant="Secondary" size="400" />
) : (
<Icon src={Icons.SpaceGlobe} />
)}
</IconButton> </IconButton>
) : null; ) : null;
@@ -1762,14 +1751,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onClose={() => setPollOpen(false)} onClose={() => setPollOpen(false)}
/> />
)} )}
{pendingLocation && (
<ShareLocationDialog
location={pendingLocation}
roomName={displayRoomName}
onSend={handleSendLocation}
requestClose={() => setPendingLocation(null)}
/>
)}
{scheduleOpen && ( {scheduleOpen && (
<ScheduleMessageModal <ScheduleMessageModal
roomId={roomId} roomId={roomId}
@@ -1,124 +0,0 @@
import React from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import { useModalStyle } from '../../hooks/useModalStyle';
import { stopPropagation } from '../../utils/keyboard';
import { formatAccuracy } from '../../utils/geoShare';
export type PendingLocation = { latitude: number; longitude: number; accuracy?: number };
type ShareLocationDialogProps = {
location: PendingLocation;
roomName: string;
onSend: (approximate: boolean) => void;
requestClose: () => void;
};
/**
* [Gitea #340] Confirm step before an m.location is posted: the toolbar button
* used to send the exact position from a single click. Shows what will be
* shared (coordinates + the fix's accuracy) and offers an approximate share.
*/
export function ShareLocationDialog({
location,
roomName,
onSend,
requestClose,
}: ShareLocationDialogProps) {
const modalStyle = useModalStyle(420);
const { latitude, longitude, accuracy } = location;
const accuracyLabel = formatAccuracy(accuracy);
const mapUrl = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: '#share-location-cancel',
onDeactivate: requestClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
role="dialog"
aria-modal="true"
aria-labelledby="share-location-title"
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text id="share-location-title" as="h2" size="H4">
Share your location?
</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text>
Everyone in <b>{roomName}</b> will see this position, and it can&apos;t be taken
back from their devices once sent.
</Text>
<Box direction="Column" gap="100">
<Text size="T400">
<b>
{latitude.toFixed(5)}, {longitude.toFixed(5)}
</b>
{accuracyLabel && ` (${accuracyLabel})`}
</Text>
<Text size="T200" priority="300">
{accuracyLabel
? 'The ± is how sure your device is of this position.'
: 'Your device did not say how accurate this position is.'}{' '}
<a href={mapUrl} target="_blank" rel="noreferrer noopener">
Check on OpenStreetMap
</a>
</Text>
</Box>
<Box gap="200" justifyContent="End" wrap="Wrap">
<Button
id="share-location-cancel"
variant="Secondary"
fill="Soft"
onClick={requestClose}
>
<Text size="B400">Cancel</Text>
</Button>
<Button variant="Secondary" fill="Soft" onClick={() => onSend(true)}>
<Text size="B400">Send approximate (~1 km)</Text>
</Button>
<Button variant="Primary" onClick={() => onSend(false)}>
<Text size="B400">Send</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+48 -12
View File
@@ -783,15 +783,20 @@ function ReminderMonitor() {
const { reminders, removeReminder } = useReminders(); const { reminders, removeReminder } = useReminders();
const setToast = useSetAtom(toastQueueAtom); const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const quietActive = useNotificationsQuiet();
const firedRef = useRef<Set<string>>(new Set()); const firedRef = useRef<Set<string>>(new Set());
const removingRef = useRef<Set<string>>(new Set()); const removingRef = useRef<Set<string>>(new Set());
// Read the latest reminders / DM map via refs so the poll interval below is // Read the latest reminders / DM map / notification prefs via refs so the poll
// created once — not torn down and restarted (which resets its 30s countdown // interval below is created once — not torn down and restarted (which resets
// and can indefinitely defer a near-due reminder) on every reminder sync (N115). // its 30s countdown and can indefinitely defer a near-due reminder) on every
// reminder sync (N115).
const remindersRef = useRef(reminders); const remindersRef = useRef(reminders);
remindersRef.current = reminders; remindersRef.current = reminders;
const mDirectsRef = useRef(mDirects); const mDirectsRef = useRef(mDirects);
mDirectsRef.current = mDirects; mDirectsRef.current = mDirects;
const osNotifyRef = useRef(false);
osNotifyRef.current = showNotifications && !quietActive;
useEffect(() => { useEffect(() => {
const check = () => { const check = () => {
@@ -815,6 +820,23 @@ function ReminderMonitor() {
const hashPath = mDirectsRef.current.has(r.roomId) const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId) ? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId); : getHomeRoomPath(r.roomId, r.eventId);
// [Gitea #338] A reminder is only done once the user has SEEN it: the
// toast stays until clicked or dismissed (it used to vanish after 4 s
// and the reminder was deleted at once, so one due while the user was
// away was lost). The removal is persisted on that click/dismiss; an
// unseen reminder comes back on the next start.
const done = () => {
if (removingRef.current.has(key)) return;
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
};
const open = () => {
done();
// window.location.hash setter auto-prepends '#' (as the toast does)
window.location.hash = hashPath;
};
setToast({ setToast({
id: `reminder-${key}`, id: `reminder-${key}`,
displayName: 'Reminder', displayName: 'Reminder',
@@ -822,16 +844,30 @@ function ReminderMonitor() {
roomName, roomName,
roomId: r.roomId, roomId: r.roomId,
hashPath, hashPath,
sticky: true,
onClick: open,
onDismiss: done,
}); });
} // Away from the window: also raise an OS notification (the reminder is
// Persist the removal, retrying on a later tick if it fails — without // an explicit request, but quiet hours / notifications-off win). Never
// re-showing the toast (N114). The server echo drops it from // put decrypted E2EE text in it (N106).
// `reminders` once the write lands. if (osNotifyRef.current && notificationPermission('granted') && !document.hasFocus()) {
if (!removingRef.current.has(key)) { const encrypted = !!room?.hasEncryptionStateEvent();
removingRef.current.add(key); showOsNotification(
removeReminder(r.eventId, r.timestamp).catch(() => { 'Reminder',
removingRef.current.delete(key); {
}); icon: LogoSVG,
badge: LogoSVG,
body: encrypted ? `A message in ${roomName}` : body,
tag: `reminder-${key}`,
data: { path: hashPath, roomId: r.roomId },
},
() => {
window.focus();
open();
},
).catch(() => undefined);
}
} }
}); });
}; };
-30
View File
@@ -1,30 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildGeoUri, formatAccuracy } from './geoShare';
import { parseGeoUri } from './common';
test('buildGeoUri: exact share keeps 6 decimals and adds the accuracy as u=', () => {
assert.equal(buildGeoUri(40.748817, -73.985428, 12.4), 'geo:40.748817,-73.985428;u=12');
assert.equal(buildGeoUri(40.748817, -73.985428), 'geo:40.748817,-73.985428');
assert.equal(buildGeoUri(40.748817, -73.985428, Number.NaN), 'geo:40.748817,-73.985428');
});
test('buildGeoUri: approximate share rounds to ~1 km and never claims better than 1 km', () => {
assert.equal(buildGeoUri(40.748817, -73.985428, 12, true), 'geo:40.75,-73.99;u=1000');
assert.equal(buildGeoUri(40.748817, -73.985428, 3500, true), 'geo:40.75,-73.99;u=3500');
});
test('buildGeoUri output still parses with the renderer', () => {
const loc = parseGeoUri(buildGeoUri(51.5, -0.12, 30));
assert.ok(loc);
assert.equal(parseFloat(loc.latitude), 51.5);
assert.equal(parseFloat(loc.longitude), -0.12);
});
test('formatAccuracy', () => {
assert.equal(formatAccuracy(12.4), '±12 m');
assert.equal(formatAccuracy(3500), '±3.5 km');
assert.equal(formatAccuracy(25000), '±25 km');
assert.equal(formatAccuracy(undefined), undefined);
assert.equal(formatAccuracy(0), undefined);
});
-39
View File
@@ -1,39 +0,0 @@
/**
* [Gitea #340] Helpers for the "Share location" confirm step: the geo URI to
* send (RFC 5870, with the fix's accuracy as `u=` so receivers can draw a
* radius) and a human accuracy label, so a ±3 km Wi-Fi/IP fix isn't presented
* — or sent — as if it were exact.
*/
/** Decimals kept for an "approximate" share: 2 → ~1.1 km of latitude. */
export const APPROXIMATE_DECIMALS = 2;
/** Uncertainty reported for an approximate share (half a 0.01° cell, rounded up). */
export const APPROXIMATE_UNCERTAINTY_M = 1000;
const validAccuracy = (accuracy?: number): number | undefined =>
typeof accuracy === 'number' && Number.isFinite(accuracy) && accuracy > 0
? Math.round(accuracy)
: undefined;
export const buildGeoUri = (
latitude: number,
longitude: number,
accuracy?: number,
approximate = false,
): string => {
const decimals = approximate ? APPROXIMATE_DECIMALS : 6;
const u = approximate
? Math.max(APPROXIMATE_UNCERTAINTY_M, validAccuracy(accuracy) ?? 0)
: validAccuracy(accuracy);
const base = `geo:${latitude.toFixed(decimals)},${longitude.toFixed(decimals)}`;
return u ? `${base};u=${u}` : base;
};
/** "±12 m", "±3.5 km", or undefined when the browser gave no accuracy. */
export const formatAccuracy = (accuracy?: number): string | undefined => {
const m = validAccuracy(accuracy);
if (m === undefined) return undefined;
if (m < 1000) return `±${m} m`;
const km = m / 1000;
return `±${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
};