Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 70f1d640ed fix(messages): collapsed long messages work with the keyboard and on any background (#343)
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 16m15s
- Tab into a link in the hidden part used to scroll the overflow:hidden box
  itself (top cut off, and it stayed that way); focus inside now expands
  the message, and collapsing resets the scroll.
- "Read more" / "Show less" is announced as a toggle (aria-expanded,
  aria-controls); the arrows are aria-hidden.
- The fade is a mask on the content instead of a gradient in a fixed
  surface colour, so there's no band in bubble layout, on highlights or
  over chat backgrounds.
- The cap is 20rem instead of 320px, so a larger font shows as many lines.

Verified headless on a 41-line message with a link on the last line:
aria-expanded false → focusing the link expands it (aria-expanded true,
no clipping); bubble layout fades into the bubble with no band.

Fixes #343

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:31:50 -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
4 changed files with 79 additions and 23 deletions
+32 -19
View File
@@ -1,4 +1,4 @@
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
import React, { CSSProperties, ReactNode, useEffect, useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
import { IContent, MatrixEvent } from 'matrix-js-sdk';
@@ -41,7 +41,10 @@ import { formatAccuracy } from '../../utils/geoShare';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
const COLLAPSE_MAX_HEIGHT = 320; // px ≈ 20 lines
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
const COLLAPSE_MAX_HEIGHT_REM = 20;
const collapseMaxHeightPx = (): number =>
COLLAPSE_MAX_HEIGHT_REM * (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16);
type CollapsibleBodyProps = {
eventId?: string;
@@ -79,7 +82,7 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
const el = bodyRef.current;
if (!el) return undefined;
const observer = new ResizeObserver(() => {
setNeedsCollapse(el.scrollHeight > COLLAPSE_MAX_HEIGHT);
setNeedsCollapse(el.scrollHeight > collapseMaxHeightPx());
});
observer.observe(el);
return () => observer.disconnect();
@@ -89,16 +92,36 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
// (Gitea #85); useReducedMotion subscribes to the change event instead.
const prefersReducedMotion = useReducedMotion();
const bodyId = useId();
const isClipped = needsCollapse && collapsed;
// [Gitea #343] The clipped part stays in the DOM, so Tab can reach a link in
// it; the browser would then scroll the overflow:hidden box itself (top cut
// off, and it stayed that way). Expand instead when focus lands inside.
const handleFocus = () => {
if (isClipped) setCollapsed(false);
};
useEffect(() => {
if (isClipped && bodyRef.current) bodyRef.current.scrollTop = 0;
}, [isClipped]);
// Fade the content itself rather than painting a fixed-colour gradient over
// it, so it blends on any background (bubbles, highlights, chat backgrounds).
const fadeMask = 'linear-gradient(#000 calc(100% - 3rem), transparent)';
return (
<div>
<div
ref={bodyRef}
id={bodyId}
onFocus={handleFocus}
style={{
position: 'relative',
...(needsCollapse && collapsed
...(isClipped
? {
maxHeight: `${COLLAPSE_MAX_HEIGHT}px`,
maxHeight: `${COLLAPSE_MAX_HEIGHT_REM}rem`,
overflow: 'hidden',
maskImage: fadeMask,
WebkitMaskImage: fadeMask,
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
}
: {
@@ -107,23 +130,12 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
}}
>
{children}
{needsCollapse && collapsed && (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '3rem',
background: `linear-gradient(transparent, ${color.Surface.Container})`,
pointerEvents: 'none',
}}
/>
)}
</div>
{needsCollapse && (
<button
type="button"
aria-expanded={!collapsed}
aria-controls={bodyId}
onClick={() => setCollapsed((c) => !c)}
style={{
cursor: 'pointer',
@@ -134,7 +146,8 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
}}
>
<Text as="span" size="T200" style={{ color: color.Primary.Main }}>
{collapsed ? 'Read more ↓' : 'Show less ↑'}
{collapsed ? 'Read more' : 'Show less'}
<span aria-hidden="true">{collapsed ? ' ↓' : ' ↑'}</span>
</Text>
</button>
)}
@@ -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);
+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 ?? '';
};