Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 a4a163df3d fix(rooms): "Rename for me" names in Ctrl+K, forward/share pickers and notifications (#336)
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 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m29s
The personal room name (io.lotus.room_names) was only used by the
sidebar, header, intro and call bar; the quick switcher couldn't find a
room by it ("No match found") and showed the server name, as did the
forward/share pickers, toasts, OS notifications and reminders.

useRoomMeta gains useLocalRoomNames() / getLocalRoomName(); the quick
switcher matches both names and shows the personal one, the forward and
share pickers show and filter by it, and message/reminder notifications
use it for the room title.

Verified headless with general renamed to "Zebra Lounge": Ctrl+K "Zebra"
and "general" both list "Zebra Lounge"; the forward picker filtered by
"zebra" lists "Zebra Lounge".

Not covered here (follow-up): message-search result headers, the inbox
notifications page and the threads list still use the server name.

Refs #336

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:40:56 -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 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
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
17 changed files with 415 additions and 45 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>
);
}
@@ -48,6 +48,7 @@ import {
addRecentForwardTarget,
} from '../../../state/recentForwardTargets';
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type RoomRowProps = {
room: Room;
@@ -58,6 +59,7 @@ type RoomRowProps = {
sending: boolean;
};
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
@@ -97,7 +99,7 @@ function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: R
>
<Box direction="Column">
<Text size="T300" truncate>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
{dm && (
<Text size="T200" priority="300" truncate>
@@ -212,6 +214,7 @@ function RecentChip({
onToggle: () => void;
sending: boolean;
}) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
@@ -240,7 +243,7 @@ function RecentChip({
}
>
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</Chip>
);
@@ -252,6 +255,7 @@ type Props = {
};
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const modalStyle = useModalStyle(400);
const directs = useAtomValue(mDirectAtom);
@@ -296,8 +300,10 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const filtered = useMemo(() => {
if (!query) return allRooms;
const q = query.toLowerCase();
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
}, [allRooms, query]);
return allRooms.filter((r) =>
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
);
}, [allRooms, query, localNames]);
// Resolve recent target ids to still-joined rooms (drop ones left/gone).
const recentRooms = useMemo(
+11 -5
View File
@@ -37,6 +37,7 @@ import {
useAsyncSearch,
UseAsyncSearchOptions,
} from '../../hooks/useAsyncSearch';
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import {
@@ -157,17 +158,20 @@ export function Search({ requestClose }: SearchProps) {
const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces);
const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces);
const localNames = useLocalRoomNames();
const getTargetStr: SearchItemStrGetter<string> = useCallback(
(roomId: string) => {
const roomName = getRoom(roomId)?.name ?? roomId;
// [Gitea #336] Match the user's own "Rename for me" name as well.
const names = localNames[roomId] ? [localNames[roomId], roomName] : [roomName];
if (mDirects.has(roomId)) {
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
if (targetUsername) return [roomName, targetUsername];
if (targetUsername) return [...names, targetUsername];
}
return roomName;
return names.length === 1 ? names[0] : names;
},
[getRoom, mDirects, mx],
[getRoom, mDirects, mx, localNames],
);
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
@@ -388,8 +392,10 @@ export function Search({ requestClose }: SearchProps) {
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="T400" truncate>
{queryHighlighRegex
? highlightText(queryHighlighRegex, [room.name])
: room.name}
? highlightText(queryHighlighRegex, [
localNames[room.roomId] ?? room.name,
])
: (localNames[room.roomId] ?? room.name)}
</Text>
{dmUsername && (
<Text as="span" size="T200" priority="300" truncate>
@@ -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);
+20 -2
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') {
@@ -167,13 +181,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));
};
+16
View File
@@ -102,6 +102,22 @@ export const useLocalRoomName = (room: Room): string => {
return localNames[room.roomId] ?? name;
};
/**
* [Gitea #336] The whole "Rename for me" map, for lists that show many rooms
* (quick switcher, forward/share pickers) so they use the name the user sees in
* the sidebar, not the server name.
*/
export const useLocalRoomNames = (): Readonly<Record<string, string>> => {
const mx = useMatrixClient();
return roomNamesStore.useValue(mx);
};
/** Non-hook read for notification paths (toast / OS notification titles). */
export const getLocalRoomName = (
mx: ReturnType<typeof useMatrixClient>,
roomId: string,
): string | undefined => roomNamesStore.getLatest(mx)[roomId];
export const useHasLocalRoomName = (roomId: string): boolean => {
const mx = useMatrixClient();
const localNames = roomNamesStore.useValue(mx);
+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]);
};
+4 -2
View File
@@ -14,6 +14,7 @@ import {
ThreadEvent,
} from 'matrix-js-sdk';
import { useNotificationsQuiet } from '../../hooks/useNotificationsQuiet';
import { getLocalRoomName } from '../../hooks/useRoomMeta';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
@@ -639,7 +640,8 @@ function MessageNotifications() {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
notify({
roomName: room.name ?? 'Unknown',
// [Gitea #336] the user's own "Rename for me" name when they set one
roomName: getLocalRoomName(mx, room.roomId) ?? room.name ?? 'Unknown',
roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined,
@@ -808,7 +810,7 @@ function ReminderMonitor() {
if (!firedRef.current.has(key)) {
firedRef.current.add(key);
const room = mx.getRoom(r.roomId);
const roomName = room?.name ?? 'Unknown Room';
const roomName = getLocalRoomName(mx, r.roomId) ?? room?.name ?? 'Unknown Room';
// Reminders for E2EE rooms carry no text (account data is server-
// readable), so resolve the body from the local timeline at fire time.
const localBody = room?.findEventById(r.eventId)?.getContent()?.body;
+10 -3
View File
@@ -38,6 +38,7 @@ import {
import { BlockType } from '../../../components/editor';
import { bytesToSize } from '../../../utils/common';
import { getHomePath } from '../../pathUtils';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type Shared = { payload: SharePayload; files: File[] };
@@ -47,6 +48,7 @@ type Shared = { payload: SharePayload; files: File[] };
* as the draft) — the user still presses Send.
*/
export function Share() {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const navigate = useNavigate();
const { navigateRoom } = useRoomNavigate();
@@ -69,8 +71,12 @@ export function Share() {
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
const q = query.trim().toLowerCase();
return q ? all.filter((r) => r.name.toLowerCase().includes(q)) : all;
}, [mx, query]);
return q
? all.filter((r) =>
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
)
: all;
}, [mx, query, localNames]);
// Written straight into the room's draft atoms so its composer shows the
// upload board + text as soon as it mounts.
@@ -210,6 +216,7 @@ function ShareRoomRow({
disabled: boolean;
onPick: (room: Room) => void;
}) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
@@ -237,7 +244,7 @@ function ShareRoomRow({
>
<Box direction="Column">
<Text size="T300" truncate>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
{dm && (
<Text size="T200" priority="300" truncate>
+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 ?? '';
};
+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 };
};