Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 78dcf9ee9d chore(deps): pin @lotusguild/element-call-embedded 0.25.0-lotus.27 (#265, #277)
CI / Build & Quality Checks (pull_request) Successful in 2m2s
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 16m19s
lotus.27 reports remote screenshare audio (element-call#57) and keeps
your own tile on phones with the camera off (element-call#58).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 17:24:42 -04:00
Lotus CIandClaude Opus 5.5 3418a8865d fix(call): one-row call bar on phones; screenshare-audio mute only when useful (#265, #277)
#265: in the compact layout (phones, narrow windows) the control bar
stacked into four rows (~330px, 40% of a portrait phone). It is now one
row: mic, deafen, camera, chat, soundboard, More and an icon-only End.
Screenshare, its audio mute and fullscreen move into More; Share Screen
is only offered where getDisplayMedia exists. (The self-view comes from
the fork, lotus.27.)

#277: "Mute Screenshare Audio" showed whenever screensharing was allowed,
as a speaker icon that did nothing. It now shows while the fork reports
remote screenshare audio (io.lotus.screenshare_audio_state), or while it
is muted, so a hidden mute cannot silence the next share.

Fixes #265
Fixes #277

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 17:24:38 -04:00
jared 5d2d7ca63c Merge pull request 'fix(a11y): dialog names, aria-pressed, labels, avatar alt, placeholder contrast (#278)' (#436) from fix/a11y-axe-findings into lotus
CI / Build & Quality Checks (push) Successful in 2m29s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 16m49s
2026-10-19 17:06:37 -04:00
jared 1a0e581f21 Merge pull request 'fix(copy): soundboard wording, deafen hint, pin typo; keep Reddit context= (#279, #280)' (#435) from fix/copy-and-reddit-context into lotus
CI / Build & Quality Checks (push) Canceled after 12s
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-19 16:54:30 -04:00
jared 2fff7a5388 Merge pull request 'feat(about): show the desktop version and the web build's commit and date (#276)' (#434) from feat/about-build-info 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-19 16:42:07 -04:00
jared 8ea03ab9b6 Merge pull request 'fix(schedule): limit to 24 hours ahead with a clear message (#275)' (#433) from fix/schedule-24h 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-19 16:28:34 -04:00
jared a1223ba5cf Merge pull request 'fix(sync): "Connecting…" only while catching up, not for 30 s after load (#274)' (#432) from fix/connecting-strip 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-19 16:15:39 -04:00
jared 400545c79a Merge pull request 'fix(ui): receipt pill, insights counts, settings dividers, decoration preview (#269-#273)' (#431) from fix/ui-polish-batch 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-19 15:59:47 -04:00
Lotus CIandClaude Opus 5.5 885ec7c82e fix(a11y): axe findings: dialog names, aria-pressed, labels, avatar alt, placeholder contrast (#278)
CI / Build & Quality Checks (pull_request) Successful in 1m47s
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 12m18s
- Modal500 takes a label: Room/Space Settings ("<name> settings") and
  Settings dialogs have accessible names.
- aria-selected on plain buttons (inbox filter chips, date/time picker
  chips, verification/usage/notification menu items) -> aria-pressed; the
  Permissions power chips open a menu -> aria-haspopup + aria-expanded.
- Labels tied to the room pack name input and the Export From/To dates.
- UserAvatar defaults to alt="" (decorative next to a name / in a
  labelled button) unless a caller passes one.
- Composer placeholder: Slate's inline opacity 0.333 overrode the #222
  AA-contrast class (2.66:1); it is dropped so the class (0.75) applies.
- e2e/a11y.spec.ts also audits Room Settings → Permissions, Emojis &
  Stickers and Export, checks the dialog name, and audits the inbox.

Fixes #278

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:35:59 -04:00
jared 997df415dd Merge pull request 'fix(outbox): two tabs no longer overwrite each other's queue (#268)' (#430) from fix/outbox-multi-tab into lotus
CI / Build & Quality Checks (push) Successful in 1m57s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 15:31:01 -04:00
Lotus CIandClaude Opus 5.5 8bfb8737d6 fix(copy): soundboard room/space wording, deafen Escape hint, pin typo; keep Reddit context= (#279, #280)
CI / Build & Quality Checks (pull_request) Successful in 1m50s
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 12m29s
#279: Room Settings → Soundboard said clips are "inherited by every room
under this space" for plain rooms too; the wording now follows
room.isSpaceRoom(). The Push to Deafen description only mentions Escape
while rebinding. "can pin a messages" → "can pin a message".

#280: the tracking stripper removed Reddit's context=, which is what
makes a comment permalink show its parent comments. Only share_id and
rdt are stripped now.

Fixes #279
Fixes #280

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:26:21 -04:00
Lotus CIandClaude Opus 5.5 72277399f0 feat(about): show the desktop version and the web build's commit and date (#276)
CI / Build & Quality Checks (pull_request) Successful in 1m46s
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 12m27s
Settings → About only showed cinny's package version, the same on web
and desktop and unchanged across many deploys a day. vite.config.js now
stamps the short commit (git, or LOTUS_BUILD_COMMIT; "unknown" without
git) and build time into the bundle, and About shows "Web build <commit>
· built <date>", linking the commit. On desktop it also shows "Desktop
<version>" from Tauri's built-in plugin:app|version (allowed by
core:default).

Fixes #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:24:37 -04:00
jared 7ad2a94e2e Merge pull request 'fix(security): widget consents and shared files cleared on logout; forged shares refused (#266, #267)' (#429) from fix/widget-share-hardening 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-19 15:18:57 -04:00
Lotus CIandClaude Opus 5.5 c6aac574ee fix(sync): "Connecting…" only while catching up, not for 30 s after load (#274)
CI / Build & Quality Checks (pull_request) Successful in 1m55s
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 13m48s
The strip showed for PREPARED and for the first SYNCING after it (or after
RECONNECTING), i.e. until the next /sync returned: ~30 s of long-poll on a
quiet account while the app already worked. A SYNCING means a sync just
succeeded, so the strip now shows only during CATCHUP; failures still show
as Reconnecting / Connection lost.

Fixes #274

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:14:38 -04:00
Lotus CIandClaude Opus 5.5 53ac87befe fix(ui): receipt pill, insights counts, settings dividers, decoration preview (#269-#273)
CI / Build & Quality Checks (pull_request) Successful in 2m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m45s
#269 read-receipt pill sits at the message row's right edge, not where
     the text ends (outside the shrink-to-fit content column; grows in
     Compact). Bubble layout unchanged.
#270 Most Active Members: the bar's % is of its own track, so every row
     shows its count.
#271 Noise Suppression description at T200 like the rest.
#272 decoration preview shows your own avatar under the decoration; the
     empty state is a visible dashed circle.
#273 grouped settings get a divider between tiles (SettingTileGroup, via
     globalStyle; SettingTile roots carry data-setting-tile), and on phones
     the Sync tile's buttons sit under its description.

Fixes #269
Fixes #270
Fixes #271
Fixes #272
Fixes #273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:06:20 -04:00
Lotus CIandClaude Opus 5.5 97d2d195c3 fix(outbox): two tabs no longer overwrite each other's queue (#268)
CI / Build & Quality Checks (pull_request) Successful in 1m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m4s
OutboxFeature loaded lotus_outbox_v1 once and then saved its own
in-memory list over the key on every change, so a second tab's save
dropped the first tab's unsent messages. persist() now re-reads the
stored list and applies the change (add/remove by txnId) to it.

Fixes #268

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:49:40 -04:00
Lotus CIandClaude Opus 5.5 14f05a3d5c fix(security): widget consents and shared files cleared on logout; forged shares refused (#266, #267)
CI / Build & Quality Checks (pull_request) Successful in 1m53s
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 12m51s
#266: remembered widget approvals (lotus.widgetConsent.*) survived logout,
so they applied silently to the next account on the device and listed the
previous account's rooms; clearPlaintextCaches now sweeps them. Space
structure, call membership and Lotus room-policy state join
PROTECTED_STATE_TYPES, so no widget can be granted them.

#267: any website could auto-submit a form to /share-target and land the
user in the share picker with attacker-chosen text and files. The worker
now refuses a share whose Origin is another site. (Sec-Fetch-Site, as the
issue suggested, never reaches a service worker: checked in Chromium.)
Origin null/absent, as the OS share sheet sends, stays accepted, and
/share now says the content came from another app or website. Both logout
paths await clearSharedPayload(); the server-forced one also clears the
soundboard clip cache, as #57 intended.

Fixes #266
Fixes #267

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:47:11 -04:00
48 changed files with 728 additions and 258 deletions
+19 -1
View File
@@ -118,8 +118,26 @@ test.describe('accessibility', () => {
test('room settings', async ({ page }) => {
await page.getByRole('button', { name: 'More Options' }).first().click();
await page.getByText('Room Settings', { exact: true }).click();
await expect(page.getByRole('dialog').first()).toBeVisible();
// [Gitea #278] The dialog has an accessible name.
await expect(page.getByRole('dialog', { name: 'A11y Room settings' })).toBeVisible();
await auditPage(page, 'room settings');
// [Gitea #278] aria-selected on the power chips, unlabelled pack name and
// export date inputs were only on these pages.
// One page at a time, in order.
for (const name of ['Permissions', 'Emojis & Stickers', 'Export']) {
// eslint-disable-next-line no-await-in-loop
await page.getByRole('dialog').getByText(name, { exact: true }).first().click();
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(1000);
// eslint-disable-next-line no-await-in-loop
await auditPage(page, `room settings: ${name}`);
}
});
test('inbox invites', async ({ page }) => {
await page.goto('/inbox/invites/');
await expect(page.getByRole('button', { name: /Known|Unknown|Spam/ }).first()).toBeVisible();
await auditPage(page, 'inbox invites');
});
});
});
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
"@lotusguild/element-call-embedded": "0.25.0-lotus.27",
"@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.26",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.26/element-call-embedded-0.25.0-lotus.26.tgz",
"integrity": "sha512-u0gFV/ne61MHVkl2JFY6TjC0C/vY2oYTz6dFaDtAmdLUBH6gU+6ar0QOr5xs9bhr4+HT8CSBibU8mLTR+ByjKA==",
"version": "0.25.0-lotus.27",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.27/element-call-embedded-0.25.0-lotus.27.tgz",
"integrity": "sha512-24ZAvGR/t0C6LWFygZkvtk7HalPUvjERDWWkxpJJf4AZCDOJGuhoOuBrjmO+tUGwIrgLOTN6EFAWSaPpTU9rlQ==",
"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.26",
"@lotusguild/element-call-embedded": "0.25.0-lotus.27",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+2 -2
View File
@@ -82,7 +82,7 @@ export function ManualVerificationMethodSwitcher({
<MenuItem
size="300"
variant="Surface"
aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
aria-pressed={value === ManualVerificationMethod.RecoveryPassphrase}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
>
@@ -93,7 +93,7 @@ export function ManualVerificationMethodSwitcher({
<MenuItem
size="300"
variant="Surface"
aria-selected={value === ManualVerificationMethod.RecoveryKey}
aria-pressed={value === ManualVerificationMethod.RecoveryKey}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
>
+4 -1
View File
@@ -7,8 +7,10 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
type Modal500Props = {
requestClose: () => void;
children: ReactNode;
/** [Gitea #278] The dialog's accessible name (role="dialog" needs one). */
label: string;
};
export function Modal500({ requestClose, children }: Modal500Props) {
export function Modal500({ requestClose, children, label }: Modal500Props) {
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -26,6 +28,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
variant="Background"
role="dialog"
aria-modal="true"
aria-label={label}
// On mobile expand to fill the viewport. On desktop fall back to the
// folds `size="500"` width (~50rem) — overriding maxWidth here would
// squish the two-pane settings layout.
+8 -1
View File
@@ -117,7 +117,14 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
<span
{...attributes}
// [Gitea #278] Slate puts an inline `opacity: 0.333` on the placeholder,
// which beat EditorPlaceholderContainer's AA-contrast opacity (#222):
// measured 2.66:1. Drop it so the class decides.
style={{ ...attributes.style, opacity: undefined }}
className={css.EditorPlaceholderContainer}
>
{/* Inner component to style the actual text position and appearance */}
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
{children}
@@ -39,7 +39,7 @@ export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
key={getUsageStr(usage)}
size="300"
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
aria-selected={isSelected(usage)}
aria-pressed={isSelected(usage)}
radii="300"
onClick={() => onChange(usage)}
>
@@ -34,7 +34,8 @@ export function SettingTile({ title, description, before, after, children }: Set
});
return (
<Box alignItems="Center" gap="300">
// data-setting-tile: lets a group card draw dividers between tiles only (#273).
<Box alignItems="Center" gap="300" data-setting-tile="">
{before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100">
{title && (
+3 -3
View File
@@ -69,7 +69,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedDay === day}
aria-pressed={selectedDay === day}
onClick={() => handleDay(day)}
disabled={
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
@@ -90,7 +90,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedMonth === month}
aria-pressed={selectedMonth === month}
onClick={() => handleMonth(month)}
disabled={
(selectedYear === minYear && month < minMonth) ||
@@ -115,7 +115,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedYear === year}
aria-pressed={selectedYear === year}
onClick={() => handleYear(year)}
>
<Text size="T300">{year}</Text>
+5 -5
View File
@@ -71,7 +71,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={hour === selectedHour ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-selected={hour === selectedHour}
aria-pressed={hour === selectedHour}
onClick={() => handleHour(hour)}
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
>
@@ -90,7 +90,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={hour === selectedHour ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-selected={hour === selectedHour}
aria-pressed={hour === selectedHour}
onClick={() => handleHour(hour)}
disabled={
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
@@ -109,7 +109,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={minute === selectedMinute ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-selected={minute === selectedMinute}
aria-pressed={minute === selectedMinute}
onClick={() => handleMinute(minute)}
disabled={
(minDay && hour24 === minHour24 && minute < minMinute) ||
@@ -127,7 +127,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={!selectedPM}
aria-pressed={!selectedPM}
onClick={() => handlePeriod(false)}
disabled={minDay && minPM}
>
@@ -138,7 +138,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedPM}
aria-pressed={selectedPM}
onClick={() => handlePeriod(true)}
disabled={maxDay && !maxPM}
>
@@ -33,7 +33,9 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
<AvatarImage
className={classNames(css.UserAvatar, className)}
src={src}
alt={alt}
// [Gitea #278] An avatar sits next to the person's name or inside a
// labelled button, so without an explicit alt it is decorative.
alt={alt ?? ''}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
+227 -134
View File
@@ -44,6 +44,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
import { SCREENSHARE_AUDIO_STATE } from '../../plugins/call/CallControl';
import { CallSoundboard } from './CallSoundboard';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
import { ScreenshareConfirm } from './ScreenshareConfirm';
@@ -90,6 +91,21 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
useCallControlState(callEmbed.control);
// [Gitea #277] Only offer "Mute Screenshare Audio" while a remote share's
// audio is playing, or while it is muted (so a hidden mute can't silently
// swallow the next share).
const [remoteScreenshareAudio, setRemoteScreenshareAudio] = useState(
callEmbed.control.remoteScreenshareAudio,
);
useEffect(() => {
const onState = () => setRemoteScreenshareAudio(callEmbed.control.remoteScreenshareAudio);
onState();
callEmbed.control.on(SCREENSHARE_AUDIO_STATE, onState);
return () => {
callEmbed.control.off(SCREENSHARE_AUDIO_STATE, onState);
};
}, [callEmbed]);
const showScreenshareAudio = remoteScreenshareAudio || screenshareAudioMuted;
const micLevel = useCallMicLevel(callEmbed);
const [cords, setCords] = useState<RectCords>();
@@ -122,6 +138,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
};
const showScreenshare = allowScreenshare || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
// [Gitea #265] Phones can't share their screen from a browser; don't offer it.
const canShareScreen = typeof navigator.mediaDevices?.getDisplayMedia === 'function';
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
@@ -166,6 +184,111 @@ export function CallControls({ callEmbed }: CallControlsProps) {
return () => clearTimeout(id);
}, [hangupState.status, callEmbed, setCallEmbed]);
const moreMenu = (
<PopOut
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" style={{ padding: config.space.S100 }}>
{compact && showScreenshare && screenshareMode !== 'hidden' && canShareScreen && (
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={() => {
setCords(undefined);
handleScreenshareToggle();
}}
>
<Text size="B300" truncate>
{screenshare ? 'Stop Screenshare' : 'Share Screen'}
</Text>
</MenuItem>
)}
{compact && showScreenshareAudio && (
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={() => {
setCords(undefined);
callEmbed.control.toggleScreenshareAudio();
}}
>
<Text size="B300" truncate>
{screenshareAudioMuted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
</Text>
</MenuItem>
)}
{compact && !!document.fullscreenEnabled && (
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={() => {
setCords(undefined);
handleFullscreen();
}}
>
<Text size="B300" truncate>
{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
</Text>
</MenuItem>
)}
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSpotlightClick}>
<Text size="B300" truncate>
{spotlight ? 'Grid View' : 'Spotlight View'}
</Text>
</MenuItem>
<MenuItem size="300" variant="Surface" radii="300" onClick={handleReactionsClick}>
<Text size="B300" truncate>
Reactions
</Text>
</MenuItem>
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSettingsClick}>
<Text size="B300" truncate>
Settings
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
variant="Surface"
fill="Soft"
radii="400"
size="400"
onClick={handleOpenMenu}
outlined
aria-label="More options"
aria-expanded={!!cords}
aria-haspopup="menu"
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
);
const hangupIcon = exiting ? (
<Spinner variant="Critical" fill="Solid" size="200" />
) : (
<Icon src={Icons.PhoneDown} size="200" filled />
);
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
return (
@@ -204,144 +327,114 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}}
onCancel={() => setShareConfirm(false)}
/>
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="400"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
wrap="Wrap"
>
<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}
level={micLevel}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
{!compact && showVideoGroup && <ControlDivider />}
{showVideoGroup && (
{compact ? (
// [Gitea #265] Phones / narrow windows: ONE row. Screenshare, its audio
// mute and fullscreen live in More (they rarely matter on a phone), and
// End is icon-only. The old stacked layout took four rows (~40% of a
// portrait phone).
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="200"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
wrap="NoWrap"
>
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
level={micLevel}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
<ChatButton />
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
{moreMenu}
<Button
variant="Critical"
fill="Solid"
radii="400"
size="400"
onClick={hangup}
disabled={exiting}
aria-label="End Call"
style={{ paddingInline: config.space.S300 }}
>
{hangupIcon}
</Button>
</SequenceCard>
) : (
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="400"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
wrap="Wrap"
>
<Box alignItems="Center" gap="Inherit" grow="Yes">
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
{/* Show a forbidden control while its track is still live so the
user can stop it; once stopped it hides and can't be restarted. */}
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && (
<>
{screenshareMode !== 'hidden' && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => handleScreenshareToggle()}
/>
)}
{/* Mute-screenshare-audio sits directly next to the screenshare
control since they're the same concern. */}
<ScreenshareAudioButton
muted={screenshareAudioMuted}
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
/>
</>
)}
{!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
level={micLevel}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
)}
</Box>
{!compact && <ControlDivider />}
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ChatButton />
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
<PopOut
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleSpotlightClick}
>
<Text size="B300" truncate>
{spotlight ? 'Grid View' : 'Spotlight View'}
</Text>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleReactionsClick}
>
<Text size="B300" truncate>
Reactions
</Text>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleSettingsClick}
>
<Text size="B300" truncate>
Settings
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
variant="Surface"
fill="Soft"
radii="400"
size="400"
onClick={handleOpenMenu}
outlined
aria-label="More options"
aria-expanded={!!cords}
aria-haspopup="menu"
{showVideoGroup && <ControlDivider />}
{showVideoGroup && (
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
{/* Show a forbidden control while its track is still live so the
user can stop it; once stopped it hides and can't be restarted. */}
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && (
<>
{screenshareMode !== 'hidden' && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => handleScreenshareToggle()}
/>
)}
{/* Mute-screenshare-audio sits directly next to the screenshare
control since they're the same concern. */}
{showScreenshareAudio && (
<ScreenshareAudioButton
muted={screenshareAudioMuted}
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
/>
)}
</>
)}
{!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
</Box>
)}
</Box>
<ControlDivider />
<Box alignItems="Center" gap="Inherit" grow="Yes">
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ChatButton />
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
{moreMenu}
</Box>
<Box shrink="No" direction="Column">
<Button
style={{ minWidth: toRem(88) }}
variant="Critical"
fill="Solid"
onClick={hangup}
before={hangupIcon}
disabled={exiting}
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
<Text size="B400">End</Text>
</Button>
</Box>
</Box>
<Box shrink="No" direction="Column">
<Button
style={{ minWidth: toRem(88) }}
variant="Critical"
fill="Solid"
onClick={hangup}
before={
exiting ? (
<Spinner variant="Critical" fill="Solid" size="200" />
) : (
<Icon src={Icons.PhoneDown} size="200" filled />
)
}
disabled={exiting}
>
<Text size="B400">End</Text>
</Button>
</Box>
</Box>
</SequenceCard>
</SequenceCard>
)}
</Box>
);
}
@@ -109,8 +109,11 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
alignItems="End"
>
<Box direction="Column" gap="100" grow="Yes">
<Text size="L400">Name</Text>
<Text as="label" htmlFor="room-pack-name" size="L400">
Name
</Text>
<Input
id="room-pack-name"
name="nameInput"
required
size="400"
@@ -136,7 +136,8 @@ export function PermissionGroups({
outlined={powerChanges}
fill="Soft"
radii="Pill"
aria-selected={opened}
aria-haspopup="menu"
aria-expanded={opened}
disabled={!canEdit || applyingChanges}
after={
powerChanges && (
@@ -200,7 +201,8 @@ export function PermissionGroups({
outlined={powerChanges}
fill="Soft"
radii="Pill"
aria-selected={opened}
aria-haspopup="menu"
aria-expanded={opened}
disabled={!canEdit || applyingChanges}
after={
powerChanges && (
@@ -38,10 +38,14 @@ export function Soundboard({ requestClose }: SoundboardProps) {
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="200">
<Text size="L400">This room / space (shared)</Text>
<Text size="L400">
{room?.isSpaceRoom() ? 'This space (shared)' : 'This room (shared)'}
</Text>
<Text size="T200" priority="300">
Clips here are shared with everyone, and inherited by every room under this space
— just like emoji/sticker packs. Only members with permission can edit.
{/* [Gitea #279] Only a space's clips are inherited by rooms under it. */}
{room?.isSpaceRoom()
? 'Clips here are shared with everyone, and inherited by every room under this space — just like emoji/sticker packs. Only members with permission can edit.'
: 'Clips here are shared with everyone in this room — just like emoji/sticker packs. Only members with permission can edit.'}
</Text>
{room && <RoomSoundboardPack room={room} stateKey="" />}
</Box>
+12 -7
View File
@@ -94,10 +94,15 @@ export function OutboxFeature() {
OFFLINE.has(mx.getSyncState()) ||
(typeof navigator !== 'undefined' && navigator.onLine === false);
const persist = (next: OutboxEntry[]) => {
if (next === entries) return;
// [Gitea #268] Read-modify-write: another tab may have queued or cleared
// entries since we last looked, and saving our own copy over the key
// would drop theirs. Entries are added/removed by txnId, so applying our
// change to the stored list merges cleanly.
const persist = (update: (list: OutboxEntry[]) => OutboxEntry[]) => {
const stored = loadOutbox(userId);
const next = update(stored);
entries = next;
saveOutbox(userId, entries);
if (next !== stored) saveOutbox(userId, next);
};
/**
@@ -123,8 +128,8 @@ export function OutboxFeature() {
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
live.set(txnId, { event, room });
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
persist(
withEntry(entries, {
persist((list) =>
withEntry(list, {
txnId,
roomId: room.roomId,
threadId: event.threadRootId ?? null,
@@ -138,7 +143,7 @@ export function OutboxFeature() {
// SENT, CANCELLED, or the remote echo replaced it (status null).
live.delete(txnId);
autoRetries.delete(txnId);
persist(withoutEntry(entries, txnId));
persist((list) => withoutEntry(list, txnId));
};
/** Re-send network failures, oldest first; a room stops at its first failure. */
@@ -191,7 +196,7 @@ export function OutboxFeature() {
return !!room && isDelivered(room, entry.txnId);
},
);
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
plan.drop.forEach((entry) => persist((list) => withoutEntry(list, entry.txnId)));
plan.restore.forEach((entry) => {
const room = mx.getRoom(entry.roomId);
if (!room || live.has(entry.txnId)) return;
@@ -353,8 +353,11 @@ ${msgRows}
>
<Box gap="400" wrap="Wrap">
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">From</Text>
<Text as="label" htmlFor="export-from-date" size="T300">
From
</Text>
<Input
id="export-from-date"
type="date"
variant="Secondary"
size="400"
@@ -364,8 +367,11 @@ ${msgRows}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">To</Text>
<Text as="label" htmlFor="export-to-date" size="T300">
To
</Text>
<Input
id="export-to-date"
type="date"
variant="Secondary"
size="400"
+13 -10
View File
@@ -401,16 +401,19 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
{displayName}
</Text>
<Box alignItems="Center" gap="200">
<div
style={{
height: 6,
width: barWidth,
background: color.Primary.Main,
borderRadius: config.radii.R300,
transition: 'width 0.3s ease',
flexShrink: 0,
}}
/>
{/* [Gitea #270] The bar's % width is of its own track,
not of the row, so the count always fits beside it. */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
height: 6,
width: barWidth,
background: color.Primary.Main,
borderRadius: config.radii.R300,
transition: 'width 0.3s ease',
}}
/>
</div>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{count}
</Text>
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
if (!room) return null;
return (
<Modal500 requestClose={closeSettings}>
<Modal500 requestClose={closeSettings} label={`${room.name} settings`}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<RoomSettings initialPage={page} requestClose={closeSettings} />
+24 -18
View File
@@ -1137,25 +1137,31 @@ export const Message = React.memo(
</AvatarBase>
);
// [Gitea #269] The content column shrinks to the message's width; the
// receipt pill / delivery status sit OUTSIDE it, in a full-width column,
// so their `margin-left: auto` reaches the row's right edge instead of
// wherever the text happens to end.
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{edit && onEditId ? (
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
) : (
children
)}
{reactions}
<Box direction="Column" style={{ maxWidth: '100%', flexGrow: 1, minWidth: 0 }}>
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{edit && onEditId ? (
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
) : (
children
)}
{reactions}
</Box>
{readReceiptUsers.length > 0 && (
<ReadReceiptAvatars
room={room}
@@ -531,7 +531,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
No Pinned Messages
</Text>
<Text size="T400" align="Center">
Users with sufficient power level can pin a messages from its context menu.
Users with sufficient power level can pin a message from its context menu.
</Text>
</Box>
</Box>
@@ -106,3 +106,19 @@ test('classifyWidgetCapabilities: reading protected state is fine, changing unpr
test('loadWidgetConsent tolerates missing storage', () => {
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
});
test('[#266] space structure, call membership and Lotus room policy can never be granted', () => {
const { auto, ask } = classifyWidgetCapabilities(
[
'm.space.child',
'm.space.parent',
'org.matrix.msc3401.call',
'org.matrix.msc3401.call.member',
'm.call.member',
'io.lotus.voice_limit',
'io.lotus.room_quality',
].map((t) => `org.matrix.msc2762.send.state_event:${t}`),
);
assert.equal(auto.size, 0);
assert.deepEqual(ask, []);
});
+14 -1
View File
@@ -67,6 +67,16 @@ export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
'm.room.third_party_invite',
'im.vector.modular.widgets',
'm.widget',
// [Gitea #266] Space structure, call membership and Lotus room policy: a
// widget could restructure a space, fake call membership or lift the
// voice-channel limit, so these can't be granted even with consent.
'm.space.child',
'm.space.parent',
'org.matrix.msc3401.call',
'org.matrix.msc3401.call.member',
'm.call.member',
'io.lotus.voice_limit',
'io.lotus.room_quality',
]);
export type WidgetPermissionRequest = {
@@ -137,8 +147,11 @@ export const classifyWidgetCapabilities = (
*/
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
// [Gitea #266] Swept on logout by clearPlaintextCaches, so one account's
// approvals never apply to the next account on the same device.
export const WIDGET_CONSENT_PREFIX = 'lotus.widgetConsent.';
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
`lotus.widgetConsent.${roomId}.${widgetId}`;
`${WIDGET_CONSENT_PREFIX}${roomId}.${widgetId}`;
export const loadWidgetConsent = (
roomId: string,
+47
View File
@@ -8,6 +8,7 @@ import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { tauriInvoke } from '../../../hooks/useTauri';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
@@ -78,6 +79,51 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
type AboutProps = {
requestClose: () => void;
};
/**
* [Gitea #276] Which build someone is on: the web deploy's commit and build
* date (stamped in by vite.config.js), plus the desktop app's own version from
* Tauri, the number a desktop or updater bug report needs.
*/
function BuildInfo() {
const [desktopVersion, setDesktopVersion] = useState<string>();
useEffect(() => {
tauriInvoke()?.('plugin:app|version')
.then((v) => {
if (typeof v === 'string') setDesktopVersion(v);
})
.catch(() => undefined);
}, []);
const commit = __LOTUS_BUILD_COMMIT__;
const built = new Date(__LOTUS_BUILD_DATE__);
const builtText = Number.isNaN(built.getTime())
? undefined
: built.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
return (
<Box direction="Column" gap="0">
{desktopVersion && (
<Text size="T200" priority="300">
Desktop {desktopVersion}
</Text>
)}
<Text size="T200" priority="300">
Web build{' '}
{commit !== 'unknown' ? (
<a
href={`https://code.lotusguild.org/LotusGuild/cinny/commit/${commit}`}
target="_blank"
rel="noreferrer noopener"
>
{commit}
</a>
) : (
commit
)}
{builtText && ` · built ${builtText}`}
</Text>
</Box>
);
}
export function About({ requestClose }: AboutProps) {
const mx = useMatrixClient();
const { support: serverSupport, loading: supportLoading } = useServerSupport();
@@ -117,6 +163,7 @@ export function About({ requestClose }: AboutProps) {
<Text size="T200">v{pkg.version}</Text>
</Box>
<Text>A Matrix client for Lotus Guild.</Text>
<BuildInfo />
</Box>
<Box gap="200" wrap="Wrap">
@@ -1,7 +1,12 @@
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
import { Avatar, Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { UserAvatar } from '../../../components/user-avatar';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SettingTile } from '../../../components/setting-tile';
import {
@@ -14,6 +19,8 @@ import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
const CELL_SIZE = 72;
// Matches AvatarDecoration's default inset, so the preview looks like the timeline.
const PREVIEW_INSET = 8;
function DecorationPreviewCell({
slug,
@@ -86,6 +93,12 @@ function categoryOf(slug: string | null): string {
export function ProfileDecoration() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const useAuthentication = useMediaAuthentication();
const profile = useUserProfile(userId);
const myName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const myAvatarUrl = profile.avatarUrl
? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
@@ -198,6 +211,10 @@ export function ProfileDecoration() {
<Box direction="Column" gap="300">
{/* Current selection preview */}
<Box alignItems="Center" gap="300">
{/* [Gitea #272] Your own avatar with the decoration overlaid, as the
timeline draws it (AvatarDecoration: the image is the avatar plus
an 8px margin on every side). With no decoration the circle gets
a visible dashed outline instead of vanishing into the card. */}
<div
style={{
position: 'relative',
@@ -205,10 +222,29 @@ export function ProfileDecoration() {
height: CELL_SIZE,
flexShrink: 0,
borderRadius: '50%',
background: color.SurfaceVariant.Container,
overflow: 'hidden',
boxSizing: 'border-box',
border: selected
? 'none'
: `2px dashed color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 45%, transparent)`,
}}
>
<Avatar
size="500"
radii="Pill"
style={{
position: 'absolute',
inset: PREVIEW_INSET,
width: CELL_SIZE - PREVIEW_INSET * 2,
height: CELL_SIZE - PREVIEW_INSET * 2,
}}
>
<UserAvatar
userId={userId}
src={myAvatarUrl}
alt=""
renderFallback={() => <Text size="H5">{nameInitials(myName)}</Text>}
/>
</Avatar>
{selected && (
<img
src={decorationUrl(selected)}
@@ -220,6 +256,7 @@ export function ProfileDecoration() {
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
zIndex: 1,
}}
/>
)}
+57 -35
View File
@@ -111,7 +111,8 @@ import {
translationUnavailableText,
} from '../../../utils/translation/langUtils';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css';
import { SequenceCardStyle, SettingTileGroup } from '../styles.css';
import { ScreenSize, useScreenSize } from '../../../hooks/useScreenSize';
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
import {
describeUpdateError,
@@ -1598,6 +1599,7 @@ function Privacy() {
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
// device-local (never synced) so switching it off here sticks.
function SettingsSyncSection() {
const isMobile = useScreenSize() === ScreenSize.Mobile;
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
const { pushNow, clearRemote } = useSettingsSyncActions();
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
@@ -1621,6 +1623,34 @@ function SettingsSyncSection() {
}
};
const syncButtons = (
<Box gap="200">
<Button
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={busy !== null}
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
onClick={() => run('push')}
>
<Text size="B300">Push now</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={busy !== null}
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
onClick={() => run('clear')}
>
<Text size="B300">Clear synced copy</Text>
</Button>
</Box>
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Sync</Text>
@@ -1636,34 +1666,12 @@ function SettingsSyncSection() {
<SettingTile
title="Use This Device's Settings Everywhere"
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
after={
<Box gap="200">
<Button
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={busy !== null}
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
onClick={() => run('push')}
>
<Text size="B300">Push now</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={busy !== null}
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
onClick={() => run('clear')}
>
<Text size="B300">Clear synced copy</Text>
</Button>
</Box>
}
/>
// [Gitea #273] On phones the two buttons squeezed the text into a
// narrow column; put them under the description there.
after={isMobile ? undefined : syncButtons}
>
{isMobile && <Box style={{ paddingTop: config.space.S100 }}>{syncButtons}</Box>}
</SettingTile>
{note && (
<Text
size="T200"
@@ -1800,7 +1808,8 @@ function Calls() {
title="Noise Suppression"
description={
<Box direction="Column" gap="200">
<Text>
{/* [Gitea #271] T200 like every other setting description. */}
<Text size="T200">
Filter background noise from your mic during calls. Browser-native uses the built-in
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
</Text>
@@ -2033,7 +2042,7 @@ function Calls() {
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
gap="400"
@@ -2089,7 +2098,12 @@ function Calls() {
description={
deafenBind.error ??
(deafenHotkey
? `Press ${keyLabel(deafenKey)} during a call to mute your speakers. Press Escape to cancel rebind.${
? `${
// [Gitea #279] The Escape hint only applies while rebinding.
deafenBind.listening
? 'Press a key to bind it, or Escape to cancel.'
: `Press ${keyLabel(deafenKey)} during a call to mute your speakers.`
}${
!isSafeGlobalToggleKey(deafenKey)
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
@@ -2130,7 +2144,7 @@ function Calls() {
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
gap="400"
@@ -2264,7 +2278,11 @@ function Calls() {
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SequenceCard
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile
title="Soundboard"
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
@@ -2751,7 +2769,11 @@ function Messages() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SequenceCard
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile
title="Translate Messages Into"
description={
@@ -98,7 +98,7 @@ export function NotificationModeSwitcher({ pushRule, onChange }: NotificationMod
key={mode}
size="300"
variant="Surface"
aria-selected={mode === selectedMode}
aria-pressed={mode === selectedMode}
radii="300"
onClick={() => handleSelect(mode)}
>
+14 -2
View File
@@ -1,6 +1,18 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config } from 'folds';
export const SequenceCardStyle = style({
padding: config.space.S300,
});
/**
* [Gitea #273] Several settings share one card; without a separator the next
* tile's title read like part of the previous description. A thin line goes
* between tiles only (not between a tile and its own slider or note).
*/
export const SettingTileGroup = style({});
// globalStyle: vanilla-extract's `selectors` may only target the element itself.
globalStyle(`${SettingTileGroup} > [data-setting-tile] ~ [data-setting-tile]`, {
borderTop: `1px solid color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 14%, transparent)`,
paddingTop: config.space.S400,
});
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
if (!room) return null;
return (
<Modal500 requestClose={closeSettings}>
<Modal500 requestClose={closeSettings} label={`${room.name} space settings`}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<SpaceSettings initialPage={page} requestClose={closeSettings} />
+6
View File
@@ -33,6 +33,8 @@ import {
} from '../../../client/initMatrix';
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
import { clearPlaintextCaches } from '../../state/plaintextCaches';
import { clearSoundboardClipCache } from '../../utils/soundboardClips';
import { clearSharedPayload } from '../../../swShare';
import { SplashScreen } from '../../components/splash-screen';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
@@ -172,6 +174,10 @@ const useLogoutListener = (mx?: MatrixClient) => {
// (translation, scheduled messages, recent searches/forwards/gifs/
// stickers, nav paths) — wipe them on server-forced logout too.
clearPlaintextCaches(mx?.getUserId() ?? undefined);
// [Gitea #57] As on manual logout: no decrypted clip bytes via blob: URLs.
clearSoundboardClipCache();
// [Gitea #267] As on manual logout: drop files stashed by the share target.
await clearSharedPayload().catch(() => undefined);
// Remove only the session credential keys — NOT settings, drafts, and
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
// window.localStorage.clear() is reserved for the explicit reset path.
+6 -6
View File
@@ -65,12 +65,12 @@ export function SyncStatus({ mx }: SyncStatusProps) {
);
}
if (
(stateData.current === SyncState.Prepared ||
stateData.current === SyncState.Syncing ||
stateData.current === SyncState.Catchup) &&
stateData.previous !== SyncState.Syncing
) {
// [Gitea #274] Only while catching up after a drop. Upstream also showed it
// for PREPARED and for the first SYNCING after PREPARED/RECONNECTING, i.e.
// until the NEXT /sync returned: on a quiet account that long-polls ~30 s
// while the app already works (a SYNCING means a sync just succeeded).
// A real failure still surfaces as Reconnecting / Connection lost.
if (stateData.current === SyncState.Catchup) {
return (
<Box direction="Column" shrink="No">
<Box
+3 -3
View File
@@ -369,7 +369,7 @@ function InviteFilters({
<Box gap="200">
<Chip
variant={isKnown ? 'Success' : 'Surface'}
aria-selected={isKnown}
aria-pressed={isKnown}
outlined={!isKnown}
onClick={() => onFilter(InviteFilter.Known)}
before={isKnown && <Icon size="100" src={Icons.Check} />}
@@ -385,7 +385,7 @@ function InviteFilters({
</Chip>
<Chip
variant={isUnknown ? 'Warning' : 'Surface'}
aria-selected={isUnknown}
aria-pressed={isUnknown}
outlined={!isUnknown}
onClick={() => onFilter(InviteFilter.Unknown)}
before={isUnknown && <Icon size="100" src={Icons.Check} />}
@@ -401,7 +401,7 @@ function InviteFilters({
</Chip>
<Chip
variant={isSpam ? 'Critical' : 'Surface'}
aria-selected={isSpam}
aria-pressed={isSpam}
outlined={!isSpam}
onClick={() => onFilter(InviteFilter.Spam)}
before={isSpam && <Icon size="100" src={Icons.Check} />}
+5
View File
@@ -145,6 +145,11 @@ export function Share() {
<Box direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text size="L400">Shared</Text>
{/* [Gitea #267] Not everything that arrives here came from the
user's share sheet; say so before they pick a room. */}
<Text size="T200" priority="300">
This came from another app or website. Check it before you send it.
</Text>
{shared.files.map((f) => (
<Box key={f.name} alignItems="Center" gap="200">
<Icon
+1 -1
View File
@@ -217,7 +217,7 @@ export function SettingsTab() {
</SidebarItemTooltip>
<PresencePicker />
{settingsOpen && (
<Modal500 requestClose={() => setSettingsOpen(false)}>
<Modal500 requestClose={() => setSettingsOpen(false)} label="Settings">
<Settings initialPage={initialPage} requestClose={() => setSettingsOpen(false)} />
</Modal500>
)}
@@ -82,7 +82,7 @@ function UnverifiedIndicator() {
</SidebarItem>
)}
{settings && (
<Modal500 requestClose={closeSettings}>
<Modal500 requestClose={closeSettings} label="Settings">
<Settings initialPage={SettingsPages.DevicesPage} requestClose={closeSettings} />
</Modal500>
)}
+25
View File
@@ -57,6 +57,15 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
export const CALL_ERROR_CHANGED = 'call_error_changed';
export const SCREENSHARE_AUDIO_STATE = 'screenshare_audio_state';
/** Pure: parse the fork's `io.lotus.screenshare_audio_state` (#277). */
export function parseScreenshareAudioState(data: unknown): boolean | undefined {
if (typeof data !== 'object' || data === null) return undefined;
const { present } = data as { present?: unknown };
return typeof present === 'boolean' ? present : undefined;
}
export type AudioOutputOption = { id: string; label: string };
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
@@ -281,6 +290,22 @@ export class CallControl extends EventEmitter implements CallControlState {
this.emit(AUDIO_OUTPUT_STATE);
}
// [Gitea #277] Whether a remote screenshare's audio is being received (the
// fork's `io.lotus.screenshare_audio_state`), so the call bar only offers
// "Mute Screenshare Audio" when there is something to mute.
private _remoteScreenshareAudio = false;
public get remoteScreenshareAudio(): boolean {
return this._remoteScreenshareAudio;
}
public onScreenshareAudioState(data: unknown) {
const present = parseScreenshareAudioState(data);
if (present === undefined || present === this._remoteScreenshareAudio) return;
this._remoteScreenshareAudio = present;
this.emit(SCREENSHARE_AUDIO_STATE);
}
private sendAudioOutput(deviceId: string): void {
if (!this.joined) return;
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
+6
View File
@@ -465,6 +465,12 @@ export class CallEmbed {
this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
// [Gitea #277] Remote screenshare audio being received, or not.
this.disposables.push(
this.listenAction('io.lotus.screenshare_audio_state', (evt) => {
this.control.onScreenshareAudioState((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
this.disposables.push(
this.listenAction('io.lotus.hotkey', (evt) => {
@@ -0,0 +1,12 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseScreenshareAudioState } from './CallControl';
test('parseScreenshareAudioState: reads present, ignores anything else (#277)', () => {
assert.equal(parseScreenshareAudioState({ present: true }), true);
assert.equal(parseScreenshareAudioState({ present: false }), false);
assert.equal(parseScreenshareAudioState({ present: 'yes' }), undefined);
assert.equal(parseScreenshareAudioState({}), undefined);
assert.equal(parseScreenshareAudioState(null), undefined);
assert.equal(parseScreenshareAudioState('present'), undefined);
});
+18 -1
View File
@@ -26,7 +26,10 @@ const api = {
: Object.getOwnPropertyDescriptor(target, key),
});
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
const { clearPlaintextCaches, plaintextCachesWiped, WIDGET_CONSENT_KEY_PREFIX } =
await import('./plaintextCaches');
const { WIDGET_CONSENT_PREFIX, widgetConsentKey } =
await import('../features/room/widgets/widgetUtils');
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
store.clear();
@@ -104,3 +107,17 @@ test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
});
test('[#266] clearPlaintextCaches sweeps remembered widget approvals', () => {
assert.equal(WIDGET_CONSENT_KEY_PREFIX, WIDGET_CONSENT_PREFIX, 'prefix kept in sync');
store.clear();
const consent = widgetConsentKey('!room:server', 'widget-1');
store.set(consent, '{"url":"https://pad.example","allowed":[],"denied":[]}');
store.set('lotus.widgetConsentNotAKey', 'kept');
removed.length = 0;
clearPlaintextCaches('@me:server');
assert.ok(removed.includes(consent), 'consent cleared');
assert.ok(store.has('lotus.widgetConsentNotAKey'), 'unrelated key kept');
});
+26
View File
@@ -139,6 +139,31 @@ const clearAccountScopedFlags = (userId?: string): void => {
});
};
/**
* [Gitea #266] Remembered widget approvals (`lotus.widgetConsent.<room>.<widget>`,
* widgetUtils.ts) would otherwise apply silently to the next account on this
* device, and their keys list the previous account's rooms. Kept as a string
* here so logout doesn't pull the widget code into the entry chunk; a test
* checks it matches WIDGET_CONSENT_PREFIX.
*/
export const WIDGET_CONSENT_KEY_PREFIX = 'lotus.widgetConsent.';
const clearWidgetConsents = (): void => {
let keys: string[];
try {
keys = Object.keys(localStorage);
} catch {
return;
}
keys.forEach((key) => {
if (!key.startsWith(WIDGET_CONSENT_KEY_PREFIX)) return;
try {
localStorage.removeItem(key);
} catch {
// best-effort
}
});
};
export const clearPlaintextCaches = (userId?: string): void => {
wiped = true;
clearTranslationCache();
@@ -153,5 +178,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearStatusMessage();
clearCallVolumesAndSyncMeta(userId);
clearAccountScopedFlags(userId);
clearWidgetConsents();
if (userId) clearNavToActivePathStore(userId);
};
+7
View File
@@ -109,3 +109,10 @@ describe('stripTrackingParamsInHtml', () => {
assert.equal(stripTrackingParamsInHtml(html), html);
});
});
it('[#280] Reddit keeps context= (it shows the parent comments) but drops share_id', () => {
assert.equal(
stripTrackingParams('https://www.reddit.com/r/x/comments/abc/def/xyz/?context=3&share_id=1'),
'https://www.reddit.com/r/x/comments/abc/def/xyz/?context=3',
);
});
+3 -1
View File
@@ -103,7 +103,9 @@ const HOST_PARAMS: Array<{ hosts: string[]; params: string[]; prefixes?: string[
},
{
hosts: ['reddit.com'],
params: ['share_id', 'rdt', 'context'],
// [Gitea #280] Not `context`: on a comment permalink, ?context=N shows the
// N parent comments, i.e. the conversation the sender meant to share.
params: ['share_id', 'rdt'],
},
{
hosts: ['tiktok.com'],
+4
View File
@@ -9,6 +9,7 @@ import { pushSessionToSW } from '../sw-session';
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
import { clearSharedPayload } from '../swShare';
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
@@ -146,6 +147,9 @@ export const logoutClient = async (mx: MatrixClient) => {
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
// clip bytes stay reachable via live blob: URLs until the reload otherwise.
clearSoundboardClipCache();
// [Gitea #267] Files stashed by the share target stay in Cache Storage until
// /share is opened; a shared photo must not outlive the logout.
await clearSharedPayload().catch(() => undefined);
// Remove only the session credential keys, preserving user preferences and
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
removeFallbackSession();
+4
View File
@@ -1,5 +1,9 @@
/// <reference types="vite/client" />
// [Gitea #276] Set by vite.config.js `define`.
declare const __LOTUS_BUILD_COMMIT__: string;
declare const __LOTUS_BUILD_DATE__: string;
declare module 'browser-encrypt-attachment' {
export interface EncryptedAttachmentInfo {
v: string;
+6 -1
View File
@@ -1,7 +1,7 @@
/// <reference lib="WebWorker" />
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare';
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
export type {};
@@ -249,6 +249,11 @@ self.addEventListener('fetch', (event: FetchEvent) => {
// share_target). Stash the form and bounce to the in-app /share page.
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length);
// [Gitea #267] A cross-site form posting here is not a share: drop it.
if (!isTrustedShareRequest(event.request.headers, self.location.origin)) {
event.respondWith(Response.redirect(`${base}/`, 303));
return;
}
event.respondWith(
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
);
+12 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shareDraftText } from './swShare';
import { isTrustedShareRequest, shareDraftText } from './swShare';
const base = { files: [], receivedAt: 0 };
@@ -25,3 +25,14 @@ test('shareDraftText drops a title/url the text already contains', () => {
assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test');
assert.equal(shareDraftText(base), '');
});
test('[#267] a share another website submitted is refused', () => {
const app = 'https://chat.lotusguild.org';
const h = (origin?: string) => new Headers(origin ? { Origin: origin } : {});
assert.equal(isTrustedShareRequest(h(), app), true, 'no Origin (OS share sheet)');
assert.equal(isTrustedShareRequest(h('null'), app), true, 'opaque origin (OS share sheet)');
assert.equal(isTrustedShareRequest(h(app), app), true, 'the app itself');
assert.equal(isTrustedShareRequest(h('https://evil.example'), app), false);
assert.equal(isTrustedShareRequest(h('https://chat.lotusguild.org.evil.example'), app), false);
assert.equal(isTrustedShareRequest(h('http://chat.lotusguild.org'), app), false, 'other scheme');
});
+21
View File
@@ -20,6 +20,27 @@ export type SharePayload = {
receivedAt: number;
};
/**
* [Gitea #267] Refuse a share that another website submitted: any site could
* otherwise auto-submit a cross-site form here and drop attacker-chosen text
* and files into the "Share to…" picker as if the user had shared them.
*
* The worker can't use `Sec-Fetch-Site`: fetch-metadata headers are added
* after the service worker and never appear on `event.request` (checked in
* Chromium). `Origin` does: a cross-site form carries the site's origin. The
* OS share sheet's POST has no web initiator, so its Origin is `null` or
* absent, and both stay accepted. A page can also produce `Origin: null`
* (sandboxed frame, no-referrer), which is why /share also says the content
* came from elsewhere.
*/
export const isTrustedShareRequest = (
headers: Pick<Headers, 'get'>,
selfOrigin: string,
): boolean => {
const origin = headers.get('Origin');
return origin === null || origin === 'null' || origin === selfOrigin;
};
/** Worker side: stash the POSTed form and answer with a redirect to /share. */
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
const form = await request.formData();
+19
View File
@@ -6,6 +6,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { VitePWA } from 'vite-plugin-pwa';
import fs from 'fs';
import { execSync } from 'child_process';
import path from 'path';
import buildConfig from './build.config';
@@ -272,8 +273,26 @@ const vendorChunks = (id) => {
if (id.includes('node_modules/chroma-js')) return 'chroma';
};
// [Gitea #276] Which build is this? The short commit and build time are shown
// in Settings → About (web deploys change many times a day while the package
// version stays the same). LOTUS_BUILD_COMMIT overrides; no git → "unknown".
const buildCommit = (() => {
if (process.env.LOTUS_BUILD_COMMIT) return process.env.LOTUS_BUILD_COMMIT.slice(0, 12);
try {
return execSync('git rev-parse --short=8 HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim();
} catch {
return 'unknown';
}
})();
export default defineConfig({
appType: 'spa',
define: {
__LOTUS_BUILD_COMMIT__: JSON.stringify(buildCommit),
__LOTUS_BUILD_DATE__: JSON.stringify(new Date().toISOString()),
},
publicDir: false,
base: buildConfig.base,
server: {