Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 5130dfc9c8 fix(settings): "ENTER for Newline" stays per device (#348)
CI / Build & Quality Checks (pull_request) Successful in 3m6s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m37s
Settings sync mirrored enterForNewline to io.lotus.settings, so turning it
on for a phone keyboard switched the desktop to Ctrl+Enter-to-send (and
vice versa). It is now in DEVICE_LOCAL_KEYS.

Verified with one account on a phone and a desktop profile: toggling it
on the phone leaves the desktop at Enter-to-send, and it is no longer in
the synced account data.

Fixes #348

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:27:01 -04:00
jared e57af60466 Merge pull request 'fix(presence): leaving Do Not Disturb restores your status instead of "dnd" (#283)' (#355) from fix/dnd-restore-status into lotus
CI / Build & Quality Checks (push) Successful in 3m28s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 13m55s
2026-10-12 13:12:50 -04:00
jared 1f10866b60 Merge pull request 'fix(presence): Invisible, Hide Online Status, Idle and DND stick (#282)' (#354) from fix/presence-sticky into lotus
CI / Build & Quality Checks (push) Successful in 3m8s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Canceled after 14s
2026-10-12 12:53:55 -04:00
Lotus CIandClaude Opus 5.5 d9c797f3f5 fix(presence): leaving Do Not Disturb restores your status instead of "dnd" (#283)
CI / Build & Quality Checks (pull_request) Successful in 3m9s
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 12m36s
Lotus encodes DND as status_msg "dnd". After switching back to Auto, the
heartbeat read the server's status, adopted "dnd" as the user's own and
kept re-sending it, so the custom status was lost and other clients kept
showing "dnd"; the Settings status field showed "dnd" too.

pickHeartbeatStatus (unit-tested) never adopts the "dnd" sentinel and
falls back to the device's real status; Profile neither mirrors it into
the local status nor shows it in the field.

Verified on the local Synapse: "🎮 Gaming" → DND (unavailable/dnd) → Auto
→ online/"🎮 Gaming" (before: online/"dnd").

Still open in #283: while in DND other clients show the literal "dnd";
that needs MSC3026 busy presence on the homeserver (matrix#19).

Refs #283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 12:39:38 -04:00
Lotus CIandClaude Opus 5.5 9b2ec91dd6 fix(presence): Invisible, Hide Online Status, Idle and DND stick (#282)
CI / Build & Quality Checks (pull_request) Successful in 3m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m50s
usePresenceUpdater sent a one-off PUT /presence, but the SDK kept
long-polling /sync with the default set_presence=online and Synapse treats
every sync as "online", so Invisible / Hide Online Status / Idle / DND
flipped back within seconds — a privacy setting that silently didn't work.

The presence helpers now also call mx.setSyncPresence(): offline for
Invisible / Hide Online Status, unavailable for Idle / DND (and auto-mode
idle / hidden tab), online otherwise. The PUT stays for the immediate
change.

Verified on the local Synapse, another account polling every 5 s for 60 s
after a reload in each mode: invisible → offline ×12, hidePresence →
offline ×12, idle → unavailable ×12, dnd → unavailable ×12, online →
online (currently_active) ×12; /sync carries set_presence=offline /
unavailable / online accordingly (before: online* throughout).

Fixes #282

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 12:35:26 -04:00
jared 1618d71e53 Merge pull request 'fix(location): confirm before sharing, with accuracy and an approximate option (#340)' (#353) from fix/location-confirm into lotus
CI / Build & Quality Checks (push) Successful in 3m8s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 12m15s
2026-10-11 23:23:06 -04:00
jared 4736e23400 Merge pull request 'fix(reminders): a reminder stays until you've seen it (#338)' (#352) from fix/reminders-stick into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-11 23:22:56 -04:00
jared 06294f5269 Merge pull request 'fix(calls): "Channel Full" counts people, not devices (#342)' (#351) from fix/voice-full-distinct-users into lotus
CI / Build & Quality Checks (push) Canceled after 44s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-11 22:57:38 -04:00
jared fea9484a03 Merge pull request 'fix(presence): closing a tab no longer erases your custom status (#339)' (#350) from fix/presence-pagehide-status into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-11 22:57:25 -04:00
Lotus CIandClaude Opus 5.5 cf22fd8501 fix(location): confirm before sharing, with accuracy and an approximate option (#340)
CI / Build & Quality Checks (pull_request) Successful in 3m19s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m32s
The composer's location button sent the exact position (6 decimals) from a
single click once the browser permission had been granted, discarding the
fix's accuracy, so a ±3.5 km Wi-Fi/IP fix was shown as exact.

- ShareLocationDialog: "Share your location?" with the coordinates and
  ±accuracy, a "Check on OpenStreetMap" link, and Cancel / Send
  approximate (~1 km) / Send. Focus starts on Cancel; Esc cancels.
- utils/geoShare.ts (unit-tested): RFC 5870 geo URI with `;u=` accuracy;
  approximate shares round to 2 decimals and never claim better than 1 km.
- Location cards show the ± when the event carries `u=`.
- The toolbar icon is a pin instead of the globe.

Verified headless (fix 40.748817,-73.985428 ±3500 m): the click opens the
dialog and sends nothing; Cancel/Esc send nothing; Send approximate →
geo:40.75,-73.99;u=3500; Send → geo:40.748817,-73.985428;u=3500; the card
reads "40.74882, -73.98543 (±3.5 km)".

Fixes #340

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:16:05 -04:00
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
Lotus CIandClaude Opus 5.5 861293243c fix(calls): "Channel Full" counts people, not devices (#342)
CI / Build & Quality Checks (pull_request) Successful in 3m9s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m44s
useVoiceChannelFull compared the number of MatrixRTC memberships (one per
device) with io.lotus.voice_limit, so one person on desktop + phone, or a
crashed tab whose membership hadn't expired, filled two slots and locked
others out, while the server-side guard (which counts distinct users)
would have admitted them. utils/voiceChannelFull.ts (unit-tested) counts
distinct senders; the header/sidebar counter shows people too.

Verified on the local stack: limit 2, @aud_main in the call on two
devices; @aud_bob's prescreen now shows 1/2 with Join enabled (was
"Channel Full (2/2)"), matching the guard's 200.

Fixes #342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:07:18 -04:00
15 changed files with 416 additions and 42 deletions
@@ -37,6 +37,7 @@ 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';
@@ -732,6 +733,8 @@ 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}`;
@@ -772,6 +775,7 @@ export function MLocation({ content }: MLocationProps) {
)}
<Text size="T300" priority="300">
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
{accuracyLabel && ` (${accuracyLabel})`}
</Text>
<Button
as="a"
+40 -21
View File
@@ -153,6 +153,9 @@ 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';
@@ -356,6 +359,7 @@ 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(() => {
@@ -368,21 +372,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocating(false);
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,
});
// [Gitea #340] Don't send straight away: confirm first (ShareLocationDialog).
const { latitude, longitude, accuracy } = pos.coords;
setPendingLocation({ latitude, longitude, accuracy });
},
(err) => {
setLocating(false);
@@ -397,7 +389,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
},
{ 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(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -1538,11 +1553,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
style={touchTarget}
className={MobileTouchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="400" />
) : (
<Icon src={Icons.SpaceGlobe} />
)}
{locating ? <Spinner variant="Secondary" size="400" /> : <Icon src={Icons.Pin} />}
</IconButton>
) : null;
@@ -1751,6 +1762,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onClose={() => setPollOpen(false)}
/>
)}
{pendingLocation && (
<ShareLocationDialog
location={pendingLocation}
roomName={displayRoomName}
onSend={handleSendLocation}
requestClose={() => setPendingLocation(null)}
/>
)}
{scheduleOpen && (
<ScheduleMessageModal
roomId={roomId}
@@ -0,0 +1,124 @@
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>
);
}
@@ -57,7 +57,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
import { noteLocalStatusWrite } from '../../../utils/ownStatusSync';
import { DND_STATUS_SENTINEL, noteLocalStatusWrite } from '../../../utils/ownStatusSync';
import { ProfileDecoration } from './ProfileDecoration';
import { EmojiBoard } from '../../../components/emoji-board';
import { useStatusPresets } from '../../../hooks/useStatusPresets';
@@ -396,7 +396,8 @@ function ProfileStatus() {
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
const initialStatus = presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
const serverStatus = presence?.status === DND_STATUS_SENTINEL ? undefined : presence?.status;
const initialStatus = serverStatus ?? 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.
@@ -442,6 +443,9 @@ function ProfileStatus() {
pendingAppliedRef.current = null; // window elapsed — accept external changes
}
}
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
// typed: don't show it in the status field or keep it as their status.
if (remoteStatus === DND_STATUS_SENTINEL) return;
if (remoteStatus) {
setStatusMsg(remoteStatus);
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
+15 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { SetPresence } from 'matrix-js-sdk';
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
import { useMatrixClient } from './useMatrixClient';
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
@@ -90,11 +91,21 @@ export function usePresenceUpdater() {
return status;
};
// [Gitea #282] A one-off PUT /presence doesn't stick: the SDK long-polls
// /sync with set_presence=online by default and Synapse treats every sync
// as "online", so Invisible / Idle / DND flipped back within seconds. Tell
// the sync loop which presence to report too (as Element does).
const syncPresence = (presence: SetPresence) => {
mx.setSyncPresence(presence).catch(() => undefined);
};
const setOnline = async () => {
syncPresence(SetPresence.Online);
const status = await resolveStatus();
return send({ presence: 'online', ...(status ? { status_msg: status } : {}) }, 'online');
};
const setUnavailable = async (statusMsg?: string) => {
syncPresence(SetPresence.Unavailable);
const status = await resolveStatus();
return send(
{
@@ -104,7 +115,10 @@ export function usePresenceUpdater() {
'unavailable',
);
};
const setOffline = () => send({ presence: 'offline', status_msg: '' }, 'offline');
const setOffline = () => {
syncPresence(SetPresence.Offline);
return send({ presence: 'offline', status_msg: '' }, 'offline');
};
// Manual presence overrides — no activity tracking needed.
if (hidePresence || presenceStatus === 'invisible') {
+7 -4
View File
@@ -5,6 +5,7 @@ import { StateEvent } from '../../types/matrix/room';
import { VoiceLimitContent } from '../features/common-settings/general/RoomVoiceLimit';
import { useCallMembers, useCallSession } from './useCall';
import { useMatrixClient } from './useMatrixClient';
import { voiceChannelOccupancy } from '../utils/voiceChannelFull';
export type VoiceChannelFull = {
channelFull: boolean;
@@ -26,9 +27,11 @@ export const useVoiceChannelFull = (room: Room): VoiceChannelFull => {
return useMemo(() => {
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
// A user already counted in the session is rejoining and should not be blocked.
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
return { channelFull, current: callMembers.length, max: maxUsers };
// Distinct people, not per-device memberships (Gitea #342).
return voiceChannelOccupancy(
callMembers.map((m) => m.sender),
mx.getSafeUserId(),
maxUsers,
);
}, [limitEvent, callMembers, mx]);
};
+48 -12
View File
@@ -783,15 +783,20 @@ function ReminderMonitor() {
const { reminders, removeReminder } = useReminders();
const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom);
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const quietActive = useNotificationsQuiet();
const firedRef = 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
// created once — not torn down and restarted (which resets its 30s countdown
// and can indefinitely defer a near-due reminder) on every reminder sync (N115).
// Read the latest reminders / DM map / notification prefs via refs so the poll
// interval below is created once — not torn down and restarted (which resets
// its 30s countdown and can indefinitely defer a near-due reminder) on every
// reminder sync (N115).
const remindersRef = useRef(reminders);
remindersRef.current = reminders;
const mDirectsRef = useRef(mDirects);
mDirectsRef.current = mDirects;
const osNotifyRef = useRef(false);
osNotifyRef.current = showNotifications && !quietActive;
useEffect(() => {
const check = () => {
@@ -815,6 +820,23 @@ function ReminderMonitor() {
const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(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({
id: `reminder-${key}`,
displayName: 'Reminder',
@@ -822,16 +844,30 @@ function ReminderMonitor() {
roomName,
roomId: r.roomId,
hashPath,
sticky: true,
onClick: open,
onDismiss: done,
});
}
// Persist the removal, retrying on a later tick if it fails — without
// re-showing the toast (N114). The server echo drops it from
// `reminders` once the write lands.
if (!removingRef.current.has(key)) {
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
// Away from the window: also raise an OS notification (the reminder is
// an explicit request, but quiet hours / notifications-off win). Never
// put decrypted E2EE text in it (N106).
if (osNotifyRef.current && notificationPermission('granted') && !document.hasFocus()) {
const encrypted = !!room?.hasEncryptionStateEvent();
showOsNotification(
'Reminder',
{
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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,39 @@
/**
* [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`;
};
+31
View File
@@ -48,3 +48,34 @@ test("a fresh local save wins over a server read that hasn't caught up", () => {
'previous',
);
});
test('pickHeartbeatStatus: the "dnd" sentinel is never adopted as the user status (#283)', () => {
const base = { lastLocalWriteAt: 0, now: 100_000 };
// leaving DND: the server still says "dnd" → restore the real local status
assert.equal(
pickHeartbeatStatus({
...base,
local: '🎮 Gaming',
server: { presence: 'unavailable', status_msg: 'dnd' },
}),
'🎮 Gaming',
);
// a local copy that had absorbed the sentinel becomes empty, not "dnd"
assert.equal(
pickHeartbeatStatus({
...base,
local: 'dnd',
server: { presence: 'online', status_msg: 'dnd' },
}),
'',
);
// ordinary statuses are unaffected
assert.equal(
pickHeartbeatStatus({
...base,
local: 'old',
server: { presence: 'online', status_msg: 'new' },
}),
'new',
);
});
+10 -2
View File
@@ -9,6 +9,9 @@
* overridden by a server read that hasn't caught up yet.
*/
/** The status_msg Lotus sends for Do Not Disturb (see usePresenceUpdater). */
export const DND_STATUS_SENTINEL = 'dnd';
/** Trust this device's own copy for this long after it saved or cleared it. */
export const LOCAL_WRITE_GRACE_MS = 15_000;
@@ -37,7 +40,12 @@ export const pickHeartbeatStatus = ({
lastLocalWriteAt: number;
now: number;
}): string => {
if (!server || server.presence === 'offline') return local;
if (now - writtenAt < LOCAL_WRITE_GRACE_MS) return local;
// [Gitea #283] Lotus encodes Do Not Disturb as status_msg "dnd", so a device
// leaving DND must not adopt that sentinel as the user's status — keep (and
// so restore) the real one it holds locally.
const mine = local === DND_STATUS_SENTINEL ? '' : local;
if (!server || server.presence === 'offline') return mine;
if (now - writtenAt < LOCAL_WRITE_GRACE_MS) return mine;
if (server.status_msg === DND_STATUS_SENTINEL) return mine;
return server.status_msg ?? '';
};
+6
View File
@@ -104,3 +104,9 @@ describe('buildSyncedContent', () => {
assert.deepEqual(c.settings, pickSyncable(base));
});
});
describe('device-local keys', () => {
it('keeps enterForNewline on each device (#348)', () => {
assert.equal(DEVICE_LOCAL_KEYS.has('enterForNewline'), true);
});
});
+3
View File
@@ -49,6 +49,9 @@ export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Sett
'ringtoneVolume',
'soundboardVolume',
'cameraOnJoin',
// [Gitea #348] Enter = new line suits a phone keyboard, Enter = send a desktop one;
// syncing it switched the desktop to Ctrl+Enter when set on the phone.
'enterForNewline',
]);
/** The subset of `settings` that is synced (deep-copied so callers can't alias). */
+28
View File
@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { voiceChannelOccupancy } from './voiceChannelFull';
test('voiceChannelOccupancy: one user on two devices fills one slot, not two', () => {
assert.deepEqual(voiceChannelOccupancy(['@a:x', '@a:x'], '@b:x', 2), {
channelFull: false,
current: 1,
max: 2,
});
});
test('voiceChannelOccupancy: full when distinct users reach the limit', () => {
assert.deepEqual(voiceChannelOccupancy(['@a:x', '@c:x', '@a:x'], '@b:x', 2), {
channelFull: true,
current: 2,
max: 2,
});
});
test('voiceChannelOccupancy: someone already in the call can always join another device', () => {
assert.equal(voiceChannelOccupancy(['@a:x', '@b:x'], '@b:x', 2).channelFull, false);
});
test('voiceChannelOccupancy: no limit (0) is never full; unknown senders are ignored', () => {
assert.equal(voiceChannelOccupancy(['@a:x', '@c:x'], '@b:x', 0).channelFull, false);
assert.equal(voiceChannelOccupancy([undefined, '@a:x'], '@b:x', 2).current, 1);
});
+25
View File
@@ -0,0 +1,25 @@
/**
* [Gitea #342] Whether a voice channel with an `io.lotus.voice_limit` is full.
* Counts distinct PEOPLE, not call memberships: a MatrixRTC membership is per
* device, so one user on desktop + phone (or with a crashed tab's membership
* not yet expired) used to fill two slots. This matches the server-side guard
* (matrix `voice-limit-guard.py`, which reduces LiveKit identities to users).
*/
export type VoiceChannelOccupancy = {
channelFull: boolean;
/** Distinct users in the call. */
current: number;
max: number;
};
export const voiceChannelOccupancy = (
memberSenders: Array<string | undefined>,
myUserId: string,
maxUsers: number,
): VoiceChannelOccupancy => {
const users = new Set(memberSenders.filter((s): s is string => !!s));
// A user already in the call (another device, or rejoining) is never blocked.
const alreadyMember = users.has(myUserId);
const channelFull = maxUsers > 0 && !alreadyMember && users.size >= maxUsers;
return { channelFull, current: users.size, max: maxUsers };
};