Compare commits

...
3 Commits
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 06bb896a00 chore(deps): element-call 0.25.0-lotus.16 (#146)
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 9m38s
io.lotus.mic_level for the mute-button level meter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 12:14:29 -04:00
Lotus CIandClaude Opus 5.5 3449338b3e feat(call): mic level meter on the mute button (#146)
While unmuted, the microphone button (call view and the bottom call bar)
shows three small green bars in the icon's corner driven by the fork's
io.lotus.mic_level (element-call v0.25.0-lotus.16), so you can see the mic is
actually picking you up. Nothing is drawn while muted or silent; the button's
label is unchanged (the bars are decorative).

Your own name no longer appears in the "… is speaking" line: that is now for
everyone else, and your voice is on the meter. Avatar speaking rings still
include you, and the "You're muted" talking-while-muted notice is unchanged.

Verified in a local call with a fake-tone mic: data-mic-level cycles 3/2/1
with the tone on both buttons, disappears on mute and returns on unmute; the
status line reads "bob is speaking..." only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 12:09:34 -04:00
Lotus CIandClaude Opus 5.5 75d55e861b feat(composer): offer to format pasted code as a code block (#107)
CI / Build & Quality Checks (push) Successful in 1m52s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 10m45s
When a multi-line paste looks like code, a chip above the composer asks
"That looks like code. Format it as a code block (js)?" with Format as code /
No thanks. It never converts on its own; typing, No, or 8 s dismiss it, and
Settings → Editor → "Offer to Format Pasted Code" turns it off.

- utils/looksLikeCode.ts (pure, tested): ≥ 3 lines and ≥ 2 of indentation
  with depth changes, statement terminators, operators + brackets, keywords,
  monospace/<pre> clipboard HTML, or SQL clause lines. Prose guards: quoted
  replies, URL lists, long sentence-punctuated lines, plain-word lines,
  markdown lists. Language guess only when fairly sure (js/ts/python/sql/
  rust/c/php).
- Accept rebuilds the pasted paragraphs as one code block from their plain
  text (code lines hold text only) and leaves the caret after it.
- Code blocks carry an optional lang → <code class="language-js"> (whitelisted
  identifier only).

Verified in Chromium: pasting a JS function shows the chip; Format as code →
sent formatted_body is <pre><code class="language-js"> with indentation intact;
a three-line prose paste shows no chip; typing after a paste dismisses it.
Unit tests: 14 detector fixtures + 2 output tests; chromium e2e 19 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 11:05:49 -04:00
17 changed files with 519 additions and 20 deletions
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.15",
"@lotusguild/element-call-embedded": "0.25.0-lotus.16",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.15",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.15/element-call-embedded-0.25.0-lotus.15.tgz",
"integrity": "sha512-hPqUEVJEhs5yXxErHUk86rawuatNw/CYPPAw+gEfB8IFgw5pLLgqIl9gdTkXuiJm2+A2FGKR2ws2lj7lNpxWPA==",
"version": "0.25.0-lotus.16",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.16/element-call-embedded-0.25.0-lotus.16.tgz",
"integrity": "sha512-X/MzZ2Ip7IpZD3B+SkBoBzgMfURyZuYD6OaGL9r7sw2nl7Y/0JzgpT8RWV12lu4DlOfWbbeO+ka1l+pTYVewdQ==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.15",
"@lotusguild/element-call-embedded": "0.25.0-lotus.16",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+19
View File
@@ -89,3 +89,22 @@ test('markdown: no math conversion inside a backtick span, math outside still co
assert.ok(/<code[^>]*>\$x\$<\/code>/.test(out), 'backtick span stays literal');
assert.ok(out.includes('data-mx-maths="y"'));
});
test('[#107] a code block language becomes class="language-…"', () => {
const block = {
...(el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('let a = 1;')])]) as object),
lang: 'js',
} as unknown as Descendant;
assert.equal(
toMatrixCustomHTML(block, OPTS),
'<pre><code class="language-js">let a = 1;\n</code></pre>',
);
});
test('[#107] an unsafe language value is dropped', () => {
const block = {
...(el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('x')])]) as object),
lang: 'js" onclick="x',
} as unknown as Descendant;
assert.equal(toMatrixCustomHTML(block, OPTS), '<pre><code>x\n</code></pre>');
});
+4 -1
View File
@@ -86,7 +86,10 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
case BlockType.CodeLine:
return `${children}\n`;
case BlockType.CodeBlock:
return `<pre><code>${children}</code></pre>`;
// [Gitea #107] Only a plain identifier is ever put in the class.
return node.lang && /^[a-z0-9+#-]{1,20}$/.test(node.lang)
? `<pre><code class="language-${node.lang}">${children}</code></pre>`
: `<pre><code>${children}</code></pre>`;
case BlockType.QuoteLine:
return `${children}<br/>`;
case BlockType.BlockQuote:
+2
View File
@@ -64,6 +64,8 @@ export type CodeLineElement = {
};
export type CodeBlockElement = {
type: BlockType.CodeBlock;
/** [Gitea #107] Optional language → `<code class="language-…">`. */
lang?: string;
children: CodeLineElement[];
};
export type QuoteLineElement = {
+11 -3
View File
@@ -1,8 +1,9 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback, useState } from 'react';
import { useSetAtom } from 'jotai';
import { MicLevelBars } from '../call/MicLevelBars';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
import { MobileTouchTarget } from '../../styles/mobile.css';
@@ -13,9 +14,11 @@ import { AudioOutputButton, audioOutputSelectable } from './AudioOutputButton';
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
/** [Gitea #146] Your input level, 0–3 bars; shown only while unmuted. */
level?: number;
disabled?: boolean;
};
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
function MicrophoneButton({ enabled, onToggle, disabled, level = 0 }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
@@ -41,7 +44,10 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
disabled={disabled || loading}
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
>
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
<span style={{ position: 'relative', display: 'inline-flex' }}>
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
{enabled && <MicLevelBars level={level} />}
</span>
</IconButton>
)}
</TooltipProvider>
@@ -177,6 +183,7 @@ export function CallControl({
callJoined: boolean;
}) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const micLevel = useCallMicLevel(callEmbed);
const setCallEmbed = useSetAtom(callEmbedAtom);
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
@@ -224,6 +231,7 @@ export function CallControl({
enabled={microphone}
onToggle={handleMicrophoneToggle}
disabled={!callJoined}
level={micLevel}
/>
<SoundButton
enabled={sound}
+12 -3
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Box, Spinner, Text, color } from 'folds';
import { useAtomValue } from 'jotai';
import classNames from 'classnames';
@@ -14,6 +14,7 @@ import { StatusDivider } from './components';
import { CallEmbed } from '../../plugins/call/CallEmbed';
import { useCallJoined } from '../../hooks/useCallEmbed';
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MemberSpeaking } from './MemberSpeaking';
import { clockSkewAtom } from '../../state/clockSkew';
import { describeSkewVsServer } from '../../utils/clockSkew';
@@ -29,6 +30,14 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
const screenSize = useScreenSize();
const callJoined = useCallJoined(callEmbed);
const speakers = useCallSpeakers(callEmbed);
// [Gitea #146] Your own voice is on the mic button's level meter; the
// "… is speaking" line is for everyone else. (Avatar rings still include you.)
const mx = useMatrixClient();
const myUserId = mx.getUserId();
const otherSpeakers = useMemo(
() => new Set([...speakers].filter((id) => id !== myUserId)),
[speakers, myUserId],
);
// [Gitea #158] Same warning as the top banner, where the user is looking during a call.
const clockSkew = useAtomValue(clockSkewAtom);
@@ -69,11 +78,11 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
</Text>
</>
)}
{speakers.size > 0 && (
{otherSpeakers.size > 0 && (
<>
<StatusDivider />
<span data-spacing-node />
<MemberSpeaking room={room} speakers={speakers} />
<MemberSpeaking room={room} speakers={otherSpeakers} />
</>
)}
</>
+7 -2
View File
@@ -29,7 +29,7 @@ import {
SoundButton,
VideoButton,
} from './Controls';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { CallEmbed, useCallControlState, useCallMicLevel } from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed';
@@ -85,6 +85,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
useCallControlState(callEmbed.control);
const micLevel = useCallMicLevel(callEmbed);
const [cords, setCords] = useState<RectCords>();
const [shareConfirm, setShareConfirm] = useState(false);
@@ -200,7 +201,11 @@ export function CallControls({ callEmbed }: CallControlsProps) {
>
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
level={micLevel}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
{!compact && showVideoGroup && <ControlDivider />}
+8 -2
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
import { useAtom } from 'jotai';
import { MicLevelBars } from './MicLevelBars';
import * as css from './styles.css';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { callChatAtom } from '../../state/callEmbed';
@@ -15,8 +16,10 @@ export function ControlDivider() {
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
/** [Gitea #146] Your input level, 0–3 bars; shown only while unmuted. */
level?: number;
};
export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
export function MicrophoneButton({ enabled, onToggle, level = 0 }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
@@ -43,7 +46,10 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
outlined
disabled={loading}
>
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
<span style={{ position: 'relative', display: 'inline-flex' }}>
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
{enabled && <MicLevelBars level={level} />}
</span>
</IconButton>
)}
</TooltipProvider>
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { color } from 'folds';
type MicLevelBarsProps = {
/** 0–3; nothing is drawn at 0. */
level: number;
};
const HEIGHTS = [3, 5, 7];
/**
* [Gitea #146] Three small bars in the corner of the mic button showing your
* own input level while unmuted, so you can see the mic is actually picking
* you up. Decorative: the button's label already says whether it's on.
*/
export function MicLevelBars({ level }: MicLevelBarsProps) {
if (level <= 0) return null;
return (
<span
aria-hidden
data-mic-level={level}
style={{
position: 'absolute',
right: 1,
bottom: 1,
display: 'flex',
alignItems: 'flex-end',
gap: 1,
pointerEvents: 'none',
}}
>
{HEIGHTS.map((h, i) => (
<span
key={h}
style={{
width: 2,
height: h,
borderRadius: 1,
background: i < level ? color.Success.Main : 'transparent',
}}
/>
))}
</span>
);
}
+113 -3
View File
@@ -13,8 +13,9 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate';
import { Transforms, Editor, Element as SlateElement, Node as SlateNode, Range } from 'slate';
import {
Button,
Box,
Dialog,
Icon,
@@ -58,6 +59,7 @@ import {
getBeginCommand,
trimCommand,
getMentions,
BlockType,
} from '../../components/editor';
import { EmojiBoardTab } from '../../components/emoji-board/types';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -99,6 +101,7 @@ import { filesToUploadItems } from '../../utils/uploadItems';
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { looksLikeCode } from '../../utils/looksLikeCode';
import { useAlive } from '../../hooks/useAlive';
import {
ComposerToolbarButtonKey,
@@ -297,6 +300,48 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
// [Gitea #107] "Paste as code block?" offer for the top-level blocks a
// paste just filled. Dismissed by typing, by No, or after 8 s.
const [offerCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
const [codeOffer, setCodeOffer] = useState<{ start: number; end: number; lang?: string }>();
useEffect(() => {
if (!codeOffer) return undefined;
const t = window.setTimeout(() => setCodeOffer(undefined), 8000);
return () => window.clearTimeout(t);
}, [codeOffer]);
const acceptCodeOffer = useCallback(() => {
if (!codeOffer) return;
const { start, end, lang } = codeOffer;
setCodeOffer(undefined);
if (end >= editor.children.length || start > end) return;
const blocks = editor.children.slice(start, end + 1);
// Only plain paragraphs (what a paste produces); anything else, leave it.
if (blocks.some((b) => !SlateElement.isElement(b) || b.type !== BlockType.Paragraph)) return;
// Rebuild from plain text: code lines hold text only, and a paste may
// have produced links/mentions.
const lines = blocks.map((b) => SlateNode.string(b));
Editor.withoutNormalizing(editor, () => {
for (let i = end; i >= start; i -= 1) Transforms.removeNodes(editor, { at: [i] });
Transforms.insertNodes(
editor,
{
type: BlockType.CodeBlock,
lang,
children: lines.map((text) => ({ type: BlockType.CodeLine, children: [{ text }] })),
},
{ at: [start] },
);
if (start + 1 >= editor.children.length) {
Transforms.insertNodes(
editor,
{ type: BlockType.Paragraph, children: [{ text: '' }] },
{ at: [start + 1] },
);
}
});
Transforms.select(editor, Editor.start(editor, [start + 1]));
ReactEditor.focus(editor);
}, [codeOffer, editor]);
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
const showLocation = composerToolbarButtons?.showLocation ?? true;
@@ -411,7 +456,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handlePaste = useCallback<React.ClipboardEventHandler>(
(evt) => {
handleFilePaste(evt);
if (evt.defaultPrevented || !stripTracking) return;
if (evt.defaultPrevented) return;
if (offerCodePaste && editor.selection) {
const pasted = evt.clipboardData?.getData('text/plain') ?? '';
const startBlock = Range.start(editor.selection).path[0];
const startNode = editor.children[startBlock];
const inCode =
SlateElement.isElement(startNode) && startNode.type === BlockType.CodeBlock;
const guess =
!inCode && pasted
? looksLikeCode(pasted, evt.clipboardData?.getData('text/html'))
: undefined;
if (guess?.isCode) {
// Slate inserts after this handler returns; read where it ended.
window.setTimeout(() => {
const endBlock = editor.selection ? Range.end(editor.selection).path[0] : startBlock;
setCodeOffer({ start: startBlock, end: endBlock, lang: guess.lang });
}, 0);
}
}
if (!stripTracking) return;
const text = evt.clipboardData?.getData('text/plain');
if (!text) return;
const cleaned = stripTrackingParamsInText(text);
@@ -424,7 +488,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
dt.setData('text/plain', cleaned);
ReactEditor.insertData(editor, dt);
},
[handleFilePaste, stripTracking, editor],
[handleFilePaste, stripTracking, editor, offerCodePaste],
);
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const { gifApiKey } = useClientConfig();
@@ -853,6 +917,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
// Typing (not Tab/arrows/modifiers) dismisses the paste-as-code offer.
if (
evt.key.length === 1 ||
evt.key === 'Backspace' ||
evt.key === 'Delete' ||
evt.key === 'Enter'
) {
setCodeOffer(undefined);
}
if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!isComposing(evt)
@@ -1149,6 +1222,43 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Text>
</Box>
)}
{codeOffer && (
<Box
role="group"
aria-label="Paste as code block"
alignItems="Center"
gap="200"
wrap="Wrap"
style={{
margin: `0 ${config.space.S300} ${config.space.S100}`,
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
<Icon size="100" src={Icons.BlockCode} style={{ flexShrink: 0 }} />
<Text size="T200" style={{ flexGrow: 1 }}>
That looks like code. Format it as a code block
{codeOffer.lang ? ` (${codeOffer.lang})` : ''}?
</Text>
<Button size="300" variant="Primary" radii="300" onClick={acceptCodeOffer}>
<Text size="B300">Format as code</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="None"
radii="300"
onClick={() => {
setCodeOffer(undefined);
ReactEditor.focus(editor);
}}
>
<Text size="B300">No thanks</Text>
</Button>
</Box>
)}
<ScheduledMessagesTray roomId={roomId} />
<CustomEditor
editableName={editableName}
@@ -1405,6 +1405,7 @@ function ComposerToolbarReorder({
function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [offerCodePaste, setOfferCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
settingsAtom,
@@ -1455,6 +1456,13 @@ function Editor() {
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Offer to Format Pasted Code"
description="When something you paste looks like code, offer to turn it into a code block. Nothing changes unless you accept."
after={<Switch variant="Primary" value={offerCodePaste} onChange={setOfferCodePaste} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Formatting Toolbar"
+27
View File
@@ -85,6 +85,11 @@ export class CallEmbed {
private lotusCallStateListeners = new Set<() => void>();
// [Gitea #146] Local mic level (0–3 bars) from io.lotus.mic_level.
private micLevel = 0;
private micLevelListeners = new Set<() => void>();
// [Gitea #17] Listeners notified when the fork sends io.lotus.request_state.
private forkStateRequestListeners = new Set<() => void>();
@@ -417,6 +422,15 @@ export class CallEmbed {
}
}),
);
this.disposables.push(
this.listenAction('io.lotus.mic_level', (evt) => {
const bars = (evt.detail as { data?: { bars?: unknown } } | undefined)?.data?.bars;
const level = typeof bars === 'number' ? Math.max(0, Math.min(3, Math.round(bars))) : 0;
if (level === this.micLevel) return;
this.micLevel = level;
this.micLevelListeners.forEach((l) => l());
}),
);
// [Gitea #43] Screenshare + layout state from the fork; also switches the
// call bar's layout / settings / reactions buttons to widget actions.
this.disposables.push(
@@ -766,6 +780,19 @@ export class CallEmbed {
};
}
/** [Gitea #146] Latest local mic level, 0–3 bars (0 while muted). */
public getMicLevel(): number {
return this.micLevel;
}
/** [Gitea #146] Subscribe to mic level changes. Returns an unsubscribe. */
public onMicLevel(cb: () => void): () => void {
this.micLevelListeners.add(cb);
return () => {
this.micLevelListeners.delete(cb);
};
}
public onLotusCallState(cb: () => void): () => void {
this.lotusCallStateListeners.add(cb);
return () => {
+13 -1
View File
@@ -3,9 +3,10 @@ import {
IWidgetApiAcknowledgeResponseData,
IWidgetApiRequestData,
} from 'matrix-widget-api';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
import { CallControl, CallControlEvent } from './CallControl';
import { CallControlState } from './CallControlState';
import type { CallEmbed } from './CallEmbed';
export const useClientWidgetApiEvent = <T>(
api: ClientWidgetApi | undefined,
@@ -61,3 +62,14 @@ export const useCallControlState = (control: CallControl | undefined): CallContr
if (entry.control !== control) return control?.getState() ?? DEFAULT_CONTROL_STATE;
return entry.state;
};
const noSubscribe = () => () => undefined;
/** [Gitea #146] The local mic level (0–3 bars) reported by the call, or 0. */
export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => {
const subscribe = useCallback(
(cb: () => void) => (callEmbed ? callEmbed.onMicLevel(cb) : noSubscribe()),
[callEmbed],
);
return useSyncExternalStore(subscribe, () => callEmbed?.getMicLevel() ?? 0);
};
+4
View File
@@ -268,6 +268,9 @@ export interface Settings {
// [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or
// send, and from links rendered in the timeline. Local only.
stripTrackingParams: boolean;
// [Gitea #107] After a paste that looks like code, offer to make it a code
// block (a chip above the composer; never converts on its own).
offerCodePaste: boolean;
// [Gitea #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP
// uploads without re-encoding. Default on.
stripImageMetadata: boolean;
@@ -397,6 +400,7 @@ const defaultSettings: Settings = {
warnOnUnverifiedDevices: false,
stripTrackingParams: true,
offerCodePaste: true,
stripImageMetadata: true,
callRejoinAfterRestart: 'ask',
hapticFeedback: true,
+130
View File
@@ -0,0 +1,130 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { looksLikeCode } from './looksLikeCode';
const code = (text: string, html?: string) => looksLikeCode(text, html);
describe('looksLikeCode: code', () => {
it('JavaScript', () => {
const r = code(`function add(a, b) {
const sum = a + b;
if (sum > 10) {
return sum;
}
return 0;
}`);
assert.equal(r.isCode, true);
assert.equal(r.lang, 'js');
});
it('TypeScript', () => {
const r = code(`interface User {
id: string;
name: string;
}
const u: User = { id: '1', name: 'a' };`);
assert.deepEqual(r, { isCode: true, lang: 'ts' });
});
it('Python', () => {
const r = code(`def greet(name):
if not name:
return "hi"
return f"hi {name}"
print(greet("bob"))`);
assert.deepEqual(r, { isCode: true, lang: 'python' });
});
it('SQL', () => {
const r = code(`SELECT id, name
FROM users
WHERE active = 1
ORDER BY name;`);
assert.deepEqual(r, { isCode: true, lang: 'sql' });
});
it('Rust', () => {
const r = code(`pub fn main() {
let mut total = 0;
for i in 0..10 {
total += i;
}
}`);
assert.deepEqual(r, { isCode: true, lang: 'rust' });
});
it('config-ish JSON without a language guess', () => {
const r = code(`{
"name": "lotus",
"version": "1.0.0",
"private": true
}`);
assert.equal(r.isCode, true);
});
it('an editor paste carrying monospace HTML counts as a signal', () => {
const text = `server {
listen 443 ssl;
server_name chat.example.org;
}`;
assert.equal(code(text, '<div style="font-family: Menlo, monospace">x</div>').isCode, true);
});
});
describe('looksLikeCode: not code', () => {
it('fewer than three lines', () => {
assert.equal(code('const a = 1;\nconst b = 2;').isCode, false);
});
it('prose with line breaks', () => {
assert.equal(
code(`Hey, are we still on for tonight?
I can bring snacks if someone else handles drinks.
Let me know what time works, I'm free after six.`).isCode,
false,
);
});
it('a long paragraph pasted as lines', () => {
const line =
'This is a long sentence about the plan. It keeps going for a while. And then some more words follow here.';
assert.equal(code([line, line, line].join('\n')).isCode, false);
});
it('a markdown list', () => {
assert.equal(
code(`- milk (2 litres)
- eggs: a dozen
- bread, the good kind
- coffee!`).isCode,
false,
);
});
it('a quoted reply', () => {
assert.equal(
code(`> const a = 1;
> if (a) { run(); }
> return a;`).isCode,
false,
);
});
it('a list of links', () => {
assert.equal(
code(`https://example.org/a?b=1
https://example.org/c
https://example.org/d;e`).isCode,
false,
);
});
it('a log excerpt', () => {
assert.equal(
code(`2026-09-25 10:00:01 INFO server started
2026-09-25 10:00:02 INFO listening on 8008
2026-09-25 10:00:05 WARN slow request 1200ms`).isCode,
false,
);
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* [Gitea #107] Decide whether a multi-line paste looks like source code, so the
* composer can OFFER to turn it into a code block (never converts on its own).
* Pure and unit-tested; tuned to keep prose, lists, quotes and URL lists out.
*/
export type CodeGuess = {
/** Offer "Paste as code block?" */
isCode: boolean;
/** Language for `class="language-…"`, only when fairly sure. */
lang?: string;
};
const KEYWORDS = [
/\bfunction\b/,
/\bconst\b/,
/\blet\b/,
/\bvar\b/,
/\breturn\b/,
/\bimport\b/,
/\bexport\b/,
/\bclass\b/,
/\bdef\b/,
/\bfn\b/,
/\bpub\b/,
/\bstruct\b/,
/\bif \(/,
/\bfor \(/,
/\bwhile \(/,
/#include\b/,
/\bSELECT\b/i,
/\bFROM\b/i,
/<\?php/,
/<\//,
];
const MONO_HTML = /font-family:[^;"]*(mono|courier|consolas|menlo)|<pre[\s>]|<code[\s>]/i;
const indentOf = (line: string): number => {
const m = /^[ \t]*/.exec(line);
return m ? m[0].replace(/\t/g, ' ').length : 0;
};
export function guessLanguage(text: string): string | undefined {
if (
/^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE)\b/im.test(text) &&
/\bFROM\b|\bINTO\b|\bSET\b|\(/i.test(text)
)
return 'sql';
if (/\bpub fn\b|\bfn \w+\(.*\)\s*(->|\{)|\blet mut\b/.test(text)) return 'rust';
if (/^\s*#include\s*[<"]/m.test(text)) return 'c';
if (
/^\s*def \w+\(.*\):\s*$/m.test(text) ||
(/^\s*(from \w+ )?import \w+\s*$/m.test(text) && /:\s*$/m.test(text))
)
return 'python';
if (/:\s*(string|number|boolean)\b|\binterface \w+|\btype \w+ =/.test(text) && /[{;]/.test(text))
return 'ts';
if (/\b(const|let|var)\b|=>/.test(text) && /[{;]/.test(text)) return 'js';
if (/^\s*<\?php/m.test(text)) return 'php';
return undefined;
}
export function looksLikeCode(text: string, html?: string): CodeGuess {
const lines = text.replace(/\r\n?/g, '\n').split('\n');
const nonEmpty = lines.filter((l) => l.trim() !== '');
if (nonEmpty.length < 3) return { isCode: false };
const n = nonEmpty.length;
const share = (pred: (l: string) => boolean) => nonEmpty.filter(pred).length / n;
// ── Prose guards ──
if (share((l) => /^\s*>/.test(l)) > 0.5) return { isCode: false }; // quoted reply
if (nonEmpty.every((l) => /^\s*(<)?https?:\/\/\S+(>)?\s*$/.test(l))) return { isCode: false };
const avgLen = nonEmpty.reduce((sum, l) => sum + l.length, 0) / n;
if (avgLen > 90 && share((l) => (l.match(/[.!?] /g) ?? []).length >= 2) > 0.5) {
return { isCode: false };
}
if (share((l) => /^[\p{L}\p{N}\s'’",.!?-]*$/u.test(l)) > 0.6) return { isCode: false };
if (share((l) => /^\s*([-*+]|\d+[.)])\s+\S/.test(l)) > 0.6) return { isCode: false }; // list
// ── Signals: need two ──
let signals = 0;
const indented = share((l) => /^( {2,}|\t)/.test(l));
let depthChanges = 0;
for (let i = 1; i < nonEmpty.length; i += 1) {
if (indentOf(nonEmpty[i]) !== indentOf(nonEmpty[i - 1])) depthChanges += 1;
}
if (indented >= 0.4 && depthChanges >= 2) signals += 1;
if (share((l) => /[;{}):,]\s*$/.test(l)) >= 0.3) signals += 1;
if (
nonEmpty.filter((l) => /=>|->|::|\(\)|[^=!<>]=[^=]/.test(l)).length >= 2 &&
/[{}[\]]/.test(text)
) {
signals += 1;
}
if (KEYWORDS.filter((k) => k.test(text)).length >= 2) signals += 1;
if (html && MONO_HTML.test(html)) signals += 1;
// SQL has few symbols: lines that open with a clause keyword are its shape.
if (
share((l) =>
/^\s*(SELECT|FROM|WHERE|(LEFT |RIGHT |INNER )?JOIN|ORDER BY|GROUP BY|HAVING|INSERT|VALUES|UPDATE|SET|DELETE|LIMIT|AND|OR)\b/.test(
l,
),
) >= 0.5
) {
signals += 1;
}
if (signals < 2) return { isCode: false };
return { isCode: true, lang: guessLanguage(text) };
}