Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 1c62fb4722 fix(reactions): the reactions row no longer flickers or loses its spacing (#305)
CI / Build & Quality Checks (pull_request) Successful in 3m3s
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 12m57s
Two bugs in the one-row "+N" collapse:
- measure() used the border box, which grows by 4 px while collapsed
  (margin -2 / padding 2), so a row within 4 px of fitting flipped
  collapsed↔expanded every frame (flicker, layout shifts, ~11 % of a core
  per looping message). It now measures the content box.
- The collapsed style used the `margin` shorthand, which overrode the
  timeline's marginTop and, when removed, cleared it; the row then stuck
  to the message text. Longhand margins keep marginTop (minus 2 px).

Verified headless (12 reactions, viewport sweep 1300–1400 px): before,
90 style flips in 1.5 s at 1344 px and margin-top 0px after a cycle;
now 0 flips at every width and margin-top stays 8px.

Fixes #305

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:26:16 -04:00
3 changed files with 27 additions and 45 deletions
+24 -4
View File
@@ -69,8 +69,15 @@ export const Reactions = as<'div', ReactionsProps>(
const measure = useCallback(() => { const measure = useCallback(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
const rowWidth = container.getBoundingClientRect().width; // [Gitea #305] The width the chips actually get (content box). The border
const gap = parseFloat(getComputedStyle(container).columnGap) || 8; // box grows by 4 px while collapsed (margin -2 / padding 2), and measuring
// it made a row within 4 px of fitting flip collapsed↔expanded every frame.
const cs = getComputedStyle(container);
const rowWidth =
container.clientWidth -
(parseFloat(cs.paddingLeft) || 0) -
(parseFloat(cs.paddingRight) || 0);
const gap = parseFloat(cs.columnGap) || 8;
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : []; const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0); const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48; const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
@@ -110,8 +117,21 @@ export const Reactions = as<'div', ReactionsProps>(
style={{ style={{
...props.style, ...props.style,
...(collapsed && rowHeight ...(collapsed && rowHeight
? // 2px breathing room so row-1 focus outlines are not clipped. ? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 } // margins so the timeline's marginTop survives (Gitea #305: the
// `margin` shorthand overrode it and, once removed, cleared it).
{
maxHeight: rowHeight + 4,
overflow: 'hidden',
padding: 2,
marginLeft: -2,
marginRight: -2,
marginBottom: -2,
marginTop:
props.style?.marginTop !== undefined
? `calc(${typeof props.style.marginTop === 'number' ? `${props.style.marginTop}px` : props.style.marginTop} - 2px)`
: -2,
}
: {}), : {}),
}} }}
ref={(el) => { ref={(el) => {
-25
View File
@@ -1,7 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
plain,
DEFAULT_GROUPS, DEFAULT_GROUPS,
kumaBeatTime, kumaBeatTime,
kumaUrls, kumaUrls,
@@ -261,27 +260,3 @@ test('a dismissed calls-down banner lets the next one through, never hides serve
'server-down', 'server-down',
); );
}); });
test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)', () => {
assert.equal(
plain('join #lotus-general or #announcements:lotusguild.org'),
'join #lotus-general or #announcements:lotusguild.org',
);
assert.equal(
plain('ping @some_user about snake_case and C#'),
'ping @some_user about snake_case and C#',
);
assert.equal(
plain('calls -> fixed, > 5 people, 5 * 3 = 15'),
'calls -> fixed, > 5 people, 5 * 3 = 15',
);
assert.equal(
plain('# Heading\n* item one\n- item two\n> quoted'),
'Heading\nitem one\nitem two\nquoted',
);
assert.equal(plain('**bold**, *em*, _em_, `/sfu/get`'), 'bold, em, em, /sfu/get');
assert.equal(
plain('Read the <b>guide</b> <img src=x onerror=alert(1)> [docs](https://x.y)'),
'Read the guide docs',
);
});
+3 -16
View File
@@ -240,25 +240,12 @@ const incidentTone = (style: string): BannerTone => {
}; };
/** Markdown-ish incident text as plain text for the Details line. */ /** Markdown-ish incident text as plain text for the Details line. */
/** const plain = (s: string): string =>
* [Gitea #308] Markdown → plain text for the Details line. Strips only the
* syntax: removing every `*_\`#>` also broke "#lotus-general", "snake_case",
* "->" and "5 * 3". HTML tags are dropped (React escapes the text anyway).
*/
export const plain = (s: string): string =>
s s
.replace(/!\[[^\]]*\]\([^)]*\)/g, '') .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/<\/?[a-z][^>]*>/gi, '') .replace(/[*_`#>]/g, '')
.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '') .replace(/\s+\n/g, '\n')
.replace(/^[ \t]*>[ \t]?/gm, '')
.replace(/^[ \t]*[*+-][ \t]+/gm, '')
.replace(/`([^`\n]+)`/g, '$1')
.replace(/\*\*(?=\S)([^*\n]*?\S)\*\*/g, '$1')
.replace(/__(?=\S)([^_\n]*?\S)__/g, '$1')
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, '$1$2')
.replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1$2')
.replace(/[ \t]+\n/g, '\n')
.trim(); .trim();
/** /**