Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 e50bd677d6 fix(presence): closing a tab no longer erases your custom status (#339)
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m20s
The pagehide keepalive write sent { presence: 'offline' } with no
status_msg, which Synapse treats as clearing it, so closing or reloading
any Lotus tab wiped the user's status for everyone (intermittently, as
the 10 s presence rate limit sometimes 429'd the write). It now sends the
current status along with the offline state.

Verified against the local Synapse: with a status set from another
device, two reloads and a tab close keep it (server shows
offline/"Gone fishing" after the close; before, the first reload wiped it).

Fixes #339

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:04:57 -04:00
6 changed files with 26 additions and 238 deletions
@@ -37,7 +37,6 @@ import {
} from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { formatAccuracy } from '../../utils/geoShare';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
@@ -733,8 +732,6 @@ export function MLocation({ content }: MLocationProps) {
const lon = parseFloat(location.longitude);
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
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},${
lat - 0.004
},${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">
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
{accuracyLabel && ` (${accuracyLabel})`}
</Text>
<Button
as="a"
+21 -40
View File
@@ -153,9 +153,6 @@ import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationS
import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
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 { createErrorToast, toastQueueAtom } from '../../state/toast';
import { getThreadDraftKey } from '../../state/room/thread';
@@ -359,7 +356,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
);
const displayRoomName = useLocalRoomName(room);
const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = useCallback(() => {
@@ -372,9 +368,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocating(false);
// [Gitea #340] Don't send straight away: confirm first (ShareLocationDialog).
const { latitude, longitude, accuracy } = pos.coords;
setPendingLocation({ latitude, longitude, accuracy });
const { latitude, longitude } = pos.coords;
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
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) => {
setLocating(false);
@@ -389,30 +397,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
},
{ timeout: 10000 },
);
}, []);
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],
);
}, [mx, roomId, threadRootId]);
const handleVoiceSend = useCallback(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -1553,7 +1538,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
style={touchTarget}
className={MobileTouchTarget}
>
{locating ? <Spinner variant="Secondary" size="400" /> : <Icon src={Icons.Pin} />}
{locating ? (
<Spinner variant="Secondary" size="400" />
) : (
<Icon src={Icons.SpaceGlobe} />
)}
</IconButton>
) : null;
@@ -1762,14 +1751,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onClose={() => setPollOpen(false)}
/>
)}
{pendingLocation && (
<ShareLocationDialog
location={pendingLocation}
roomName={displayRoomName}
onSend={handleSendLocation}
requestClose={() => setPendingLocation(null)}
/>
)}
{scheduleOpen && (
<ScheduleMessageModal
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>
);
}
+5 -1
View File
@@ -167,13 +167,17 @@ export function usePresenceUpdater() {
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the
// page and is the correct mechanism for an authed endpoint. (keepalive bodies are
// capped at 64KB, which this tiny payload is well under.)
// [Gitea #339] Synapse treats a missing status_msg as "clear it", so a bare
// { presence: 'offline' } wiped the user's custom status for everyone whenever
// any tab closed or reloaded. Send the current status along with it.
const status = readStatus();
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ presence: 'offline' }),
body: JSON.stringify({ presence: 'offline', ...(status ? { status_msg: status } : {}) }),
keepalive: true,
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
};
-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`;
};