Compare commits

...
3 Commits
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 1ab7514741 fix(a11y): open a message image from the keyboard (#164)
CI / Build & Quality Checks (push) Successful in 2m7s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 7m55s
Timeline images were focusable (tabIndex 0) but only opened on click, so a
keyboard user could Tab to one and do nothing with it, and screen readers
heard a plain image. They are now role="button" labelled "View image: <name>"
and open the viewer on Enter or Space. Focus already returns to the image when
the viewer closes (checked for mouse and keyboard).

Verified in Chromium: Tab → Enter opens the viewer, Escape returns focus to
"View image: red.png", Space opens it again; chromium e2e suite 19 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 23:39:43 -04:00
Lotus CIandClaude Opus 5.5 2b1f4ee5bc feat(threads): "View Thread (N replies)" on a thread's root message (#165)
CI / Build & Quality Checks (push) Successful in 1m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 12m7s
The SDK gives a thread root its own id as threadRootId, so the
`!isThreadedMessage` guard hid the thread action on exactly the message that
has a thread; the "N replies" chip was the only way in. Roots now get the
action in both the hover bar and the ⋮ menu, labelled "View thread" /
"View Thread (14 replies)" with the thread icon; other messages keep
"Reply in Thread". Same handler, which opens the thread panel.

Verified in Chromium: on a 14-reply root both entries show and open the panel;
an ordinary message still shows "Reply in thread".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:35:39 -04:00
Lotus CIandClaude Opus 5.5 b71f9c95c6 fix(emoji): flags render on Windows; "Twitter emoji" works again
Windows' emoji font has no flag glyphs, so 🇺🇸 (:flag_us:) showed as the
letters "US". Two fixes:

- A TwemojiFlags @font-face over the bundled Twemoji file, limited by
  unicode-range to the regional-indicator letters (U+1F1E6–1F1FF), is put in
  the font stack on Windows only. Every other emoji stays native, other
  platforms keep their own flags, and the file is fetched only when a flag is
  on screen.
- The Appearance font setting overwrote --font-secondary with a stack that
  had no emoji families at all, so neither this nor the existing "Twitter
  emoji" switch reached message text. The font map now keeps
  var(--font-flags), var(--font-emoji) before the generic family.

Verified in Chromium via CDP platform fonts: with a Windows user agent 🇺🇸 is
drawn by "Twemoji Mozilla" while 😀 stays on the system emoji font; with a
Linux user agent both stay native.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:33:23 -04:00
5 changed files with 68 additions and 11 deletions
@@ -47,6 +47,9 @@ type RenderImageProps = {
onError: () => void;
onClick: () => void;
tabIndex: number;
role: 'button';
'aria-label': string;
onKeyDown: (evt: React.KeyboardEvent<HTMLElement>) => void;
};
export type ImageContentProps = {
body: string;
@@ -188,6 +191,15 @@ export const ImageContent = as<'div', ImageContentProps>(
onError: handleError,
onClick: () => (onOpenViewer ? onOpenViewer() : setViewer(true)),
tabIndex: 0,
// [Gitea #164] Keyboard users could Tab to the image but not open it.
role: 'button',
'aria-label': `View image: ${body}`,
onKeyDown: (evt) => {
if (evt.key !== 'Enter' && evt.key !== ' ') return;
evt.preventDefault();
if (onOpenViewer) onOpenViewer();
else setViewer(true);
},
})}
</Box>
)}
+20 -6
View File
@@ -1176,6 +1176,18 @@ export const Message = React.memo(
};
const isThreadedMessage = mEvent.threadRootId !== undefined;
// [Gitea #165] A root that already has a thread: the same action opens it,
// so say "View Thread (N replies)" rather than "Reply in Thread".
// The SDK gives a thread root its own id as threadRootId, so without this
// a root (the one message that HAS a thread) got no thread action at all.
const isThreadRoot = !!mEvent.getId() && mEvent.threadRootId === mEvent.getId();
const showThreadAction = !isThreadedMessage || isThreadRoot;
const existingThread = mEvent.getId() ? room.getThread(mEvent.getId()!) : null;
const threadReplies = existingThread?.length ?? 0;
const threadActionLabel =
threadReplies > 0
? `View Thread (${threadReplies} ${threadReplies === 1 ? 'reply' : 'replies'})`
: 'Reply in Thread';
// The full action menu, shared by the desktop PopOut and the touch
// bottom sheet (#166).
@@ -1273,10 +1285,12 @@ export const Message = React.memo(
</Text>
</MenuItem>
)}
{!isThreadedMessage && (
{showThreadAction && (
<MenuItem
size="300"
after={<Icon src={Icons.ThreadPlus} size="100" />}
after={
<Icon src={threadReplies > 0 ? Icons.Thread : Icons.ThreadPlus} size="100" />
}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
@@ -1285,7 +1299,7 @@ export const Message = React.memo(
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Reply in Thread
{threadActionLabel}
</Text>
</MenuItem>
)}
@@ -1465,16 +1479,16 @@ export const Message = React.memo(
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{!isThreadedMessage && (
{showThreadAction && (
<IconButton
onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Reply in thread"
aria-label={threadReplies > 0 ? 'View thread' : 'Reply in thread'}
>
<Icon src={Icons.ThreadPlus} size="100" />
<Icon src={threadReplies > 0 ? Icons.Thread : Icons.ThreadPlus} size="100" />
</IconButton>
)}
{canEditEventOrCaption(mx, mEvent) && onEditId && (
+8 -4
View File
@@ -37,11 +37,15 @@ import { zIndices } from '../styles/zIndex';
import { OIDC_CALLBACK_PATH } from './paths';
import { OidcCallback } from './auth/oidc/OidcCallback';
// The emoji families (Twemoji when "Twitter emoji" is on, Twemoji flags on
// Windows — see SystemEmojiFeature) must sit before the generic family, or the
// chosen font's stack silently drops them.
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
const FONT_MAP: Record<string, string> = {
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
inter: "'InterVariable', sans-serif",
'jetbrains-mono': "'JetBrains Mono', monospace",
'fira-code': "'Fira Code', monospace",
system: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', ${EMOJI_FONTS}, sans-serif`,
inter: `'InterVariable', ${EMOJI_FONTS}, sans-serif`,
'jetbrains-mono': `'JetBrains Mono', ${EMOJI_FONTS}, monospace`,
'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`,
};
function AppearanceEffects() {
@@ -91,6 +91,13 @@ const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotu
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
const IS_WINDOWS =
typeof navigator !== 'undefined' &&
/Windows/i.test(
(navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ??
navigator.userAgent,
);
function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
@@ -99,6 +106,12 @@ function SystemEmojiFeature() {
} else {
document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
}
// Windows has no flag emoji glyphs (🇺🇸 shows as "US"): use Twemoji for the
// flag letters only, keeping every other emoji native.
document.documentElement.style.setProperty(
'--font-flags',
IS_WINDOWS ? 'TwemojiFlags' : 'TwemojiFlags_DISABLED',
);
return null;
}
+15 -1
View File
@@ -6,6 +6,19 @@
font-display: swap;
}
/* Country flags only (regional indicator letters U+1F1E6–1F1FF), from the same
Twemoji file. Windows' emoji font has no flag glyphs and draws 🇺🇸 as the
letters "US"; SystemEmojiFeature enables this family on Windows. The
unicode-range means the file is only fetched when a flag is on screen. */
@font-face {
font-family: TwemojiFlags;
src:
url('../public/font/Twemoji.Mozilla.v15.1.0.woff2'),
url('../public/font/Twemoji.Mozilla.v15.1.0.ttf');
unicode-range: U+1F1E6-1F1FF;
font-display: swap;
}
:root {
--tc-link: hsl(213deg 100% 45%);
@@ -28,7 +41,8 @@
--mx-uc-8: hsl(94, 100%, 35%);
--font-emoji: 'Twemoji_DISABLED';
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif;
--font-flags: 'TwemojiFlags_DISABLED';
--font-secondary: 'InterVariable', var(--font-flags), var(--font-emoji), sans-serif;
}
.dark-theme,