Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a24b168cb | ||
|
|
71b7619082 | ||
|
|
e4591f669b | ||
|
|
185970bc5a | ||
|
|
d5b0b40b17 | ||
|
|
8cdf192b56 | ||
|
|
222927b558 | ||
|
|
02bd356a89 | ||
|
|
22d5aea5e6 |
@@ -415,6 +415,64 @@ test.describe('local homeserver regression', () => {
|
||||
.toBe(status);
|
||||
});
|
||||
|
||||
test('status: applies at once, quick changes fold into one write (slow-save report)', async ({
|
||||
page,
|
||||
}) => {
|
||||
let lastOk = 0;
|
||||
const sent: string[] = [];
|
||||
await page.route(/\/presence\/[^/]+\/status/, (route) => {
|
||||
if (route.request().method() !== 'PUT') return route.continue();
|
||||
const now = Date.now();
|
||||
if (now - lastOk < 10_000) {
|
||||
return route.fulfill({
|
||||
status: 429,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
errcode: 'M_LIMIT_EXCEEDED',
|
||||
retry_after_ms: 10_000 - (now - lastOk),
|
||||
}),
|
||||
});
|
||||
}
|
||||
lastOk = now;
|
||||
sent.push(JSON.parse(route.request().postData() ?? '{}').status_msg ?? '');
|
||||
return route.continue();
|
||||
});
|
||||
await loginUI(page, alice);
|
||||
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||
await page.getByText('Account', { exact: true }).first().click();
|
||||
const input = page.getByLabel('Status message');
|
||||
const save = input.locator('xpath=ancestor::form[1]').getByRole('button', { name: 'Save' });
|
||||
|
||||
// Use up the 10 s budget, then pick three quick statuses in a row.
|
||||
await input.fill(uniq('first '));
|
||||
await save.click();
|
||||
await expect(save).toBeDisabled(); // applied: nothing left to save, no spinner wait
|
||||
const quick = page.getByRole('group', { name: 'Quick statuses' });
|
||||
await quick.getByRole('button', { name: /Lunch/ }).click();
|
||||
await quick.getByRole('button', { name: /Watching/ }).click();
|
||||
await quick.getByRole('button', { name: /AFK/ }).click();
|
||||
await expect(input).toHaveValue('😴 AFK');
|
||||
await expect(save).toBeDisabled();
|
||||
await expect(page.getByText(/Status set\. Sharing it with others/)).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await api<{ status_msg?: string }>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/presence/${enc(alice.userId)}/status`,
|
||||
alice.token,
|
||||
)
|
||||
).status_msg,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe('😴 AFK');
|
||||
// The middle choices were superseded while waiting, never sent.
|
||||
expect(sent.filter((m) => /Lunch|Watching/.test(m))).toEqual([]);
|
||||
await expect(page.getByText(/Status set\. Sharing it with others/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ ...devices['Pixel 7'] });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||
|
||||
// [Gitea #463] Search across all settings.
|
||||
test.describe('settings search (#463)', () => {
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||
});
|
||||
|
||||
test('finds a setting, opens its page and shows it', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const user = await ensureUser(uniq('ss'));
|
||||
await loginUI(page, user);
|
||||
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||
const dialog = page.getByRole('dialog').first();
|
||||
const box = dialog.getByRole('textbox', { name: 'Search settings' });
|
||||
|
||||
// Ctrl+F focuses the box while settings are open (after the dialog has
|
||||
// placed its own initial focus).
|
||||
await expect(box).toBeVisible();
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('ControlOrMeta+f');
|
||||
await expect(box).toBeFocused();
|
||||
|
||||
await box.fill('deafen');
|
||||
const results = dialog.getByRole('list', { name: 'Matching settings' });
|
||||
const first = results.getByRole('listitem').first();
|
||||
await expect(first).toContainText('Push to deafen');
|
||||
await expect(first).toContainText('Calls & audio');
|
||||
await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeHidden();
|
||||
await first.click();
|
||||
await expect(dialog.getByText('Calls & audio', { exact: true }).last()).toBeVisible();
|
||||
await expect(dialog.getByText('Push to deafen', { exact: true })).toBeInViewport();
|
||||
await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeVisible();
|
||||
|
||||
// A description word finds it too; Enter picks the top result.
|
||||
await box.fill('utm');
|
||||
await expect(results.getByRole('listitem').first()).toContainText(
|
||||
'Strip tracking parameters from links',
|
||||
);
|
||||
await box.press('Enter');
|
||||
await expect(dialog.getByText('Strip tracking parameters from links')).toBeInViewport();
|
||||
|
||||
// No match, then Esc clears.
|
||||
await box.fill('zzzz-nothing');
|
||||
await expect(dialog.getByText(/No settings match/)).toBeVisible();
|
||||
await box.press('Escape');
|
||||
await expect(box).toHaveValue('');
|
||||
await expect(dialog).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||
import { whenMediaAuthReady } from '../../../../sw-session';
|
||||
|
||||
type RenderViewerProps = {
|
||||
src: string;
|
||||
@@ -115,13 +116,24 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
const handleLoad = () => {
|
||||
setLoad(true);
|
||||
};
|
||||
// An image requested before the service worker controls the page goes out
|
||||
// without the access token and fails (first visit, just after an update).
|
||||
// Retry once by itself when that's ready; only then offer "Retry".
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const autoRetried = useRef(false);
|
||||
const handleError = () => {
|
||||
setLoad(false);
|
||||
if (!autoRetried.current && !encInfo) {
|
||||
autoRetried.current = true;
|
||||
whenMediaAuthReady().then(() => setAttempt((n) => n + 1));
|
||||
return;
|
||||
}
|
||||
setError(true);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setError(false);
|
||||
setAttempt((n) => n + 1);
|
||||
loadSrc();
|
||||
};
|
||||
|
||||
@@ -182,7 +194,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
<Box key={attempt} className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
{renderImage({
|
||||
alt: body,
|
||||
title: body,
|
||||
|
||||
@@ -9,46 +9,14 @@ import { keyframes } from '@vanilla-extract/css';
|
||||
* component; this module is motion only.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Lantern bob — a hung lantern rises a touch and sinks again on a long, lazy
|
||||
* cycle, as if buoyed by warm air. translateY + a whisper of scale only; the
|
||||
* per-lantern duration/delay desynchronise the swarm.
|
||||
*/
|
||||
export const animLanternBob = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||
'50%': { transform: 'translate3d(0, -2.2vh, 0) scale(1.015)' },
|
||||
'100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Lantern pendulum — a gentle rotational sway about the top mount, so each
|
||||
* lantern rocks like it hangs from a string. Pairs with the bob on a different
|
||||
* period to read as organic drift rather than a metronome.
|
||||
*/
|
||||
export const animLanternSway = keyframes({
|
||||
'0%': { transform: 'rotate(-2.4deg)' },
|
||||
'50%': { transform: 'rotate(2.4deg)' },
|
||||
'100%': { transform: 'rotate(-2.4deg)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Tassel sway — the silk tassel under a lantern trails its parent's motion with
|
||||
* a wider, slightly lagging swing. transformOrigin is the top of the tassel.
|
||||
*/
|
||||
export const animTasselSway = keyframes({
|
||||
'0%': { transform: 'rotate(5deg)' },
|
||||
'50%': { transform: 'rotate(-5deg)' },
|
||||
'100%': { transform: 'rotate(5deg)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Lantern inner glow — the warm light inside each lantern swells and dims, like
|
||||
* a candle breathing. Opacity + scale only.
|
||||
*/
|
||||
export const animGlowBreathe = keyframes({
|
||||
'0%': { transform: 'scale(0.94)', opacity: '0.55' },
|
||||
'50%': { transform: 'scale(1.06)', opacity: '0.9' },
|
||||
'100%': { transform: 'scale(0.94)', opacity: '0.55' },
|
||||
'0%': { opacity: '0.7' },
|
||||
'50%': { opacity: '1' },
|
||||
'100%': { opacity: '0.7' },
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -77,16 +45,6 @@ export const animPetalSway = keyframes({
|
||||
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Dragon drift — the gold dragon silhouette breathes and undulates almost
|
||||
* imperceptibly across the scene. translate + scale + opacity only, very slow.
|
||||
*/
|
||||
export const animDragonDrift = keyframes({
|
||||
'0%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
|
||||
'50%': { transform: 'translate3d(2%, -1%, 0) scale(1.04)', opacity: '0.6' },
|
||||
'100%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Lacquer-tint breathing — a barely-there pulse of the warm red ambient wash so
|
||||
* the static base feels alive without distracting motion.
|
||||
@@ -107,3 +65,13 @@ export const animEmberRise = keyframes({
|
||||
'80%': { opacity: '0.5' },
|
||||
'100%': { transform: 'translate3d(0.6vw, -26vh, 0) scale(1)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Lantern garland sway — the lantern set shifts a pixel or two side to side, as
|
||||
* if in a breeze. Transform only.
|
||||
*/
|
||||
export const animGarlandSway = keyframes({
|
||||
'0%': { transform: 'translate3d(-1.5px, 0, 0)' },
|
||||
'50%': { transform: 'translate3d(1.5px, 0, 0)' },
|
||||
'100%': { transform: 'translate3d(-1.5px, 0, 0)' },
|
||||
});
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animLanternBob,
|
||||
animLanternSway,
|
||||
animTasselSway,
|
||||
animGarlandSway,
|
||||
animGlowBreathe,
|
||||
animPetalFall,
|
||||
animPetalSway,
|
||||
animDragonDrift,
|
||||
animLacquerPulse,
|
||||
animEmberRise,
|
||||
} from './LunarNewYear.css';
|
||||
@@ -31,46 +28,6 @@ const GOLD_HI = 'oklch(0.92 0.10 92)';
|
||||
// data-URI so it costs a single GPU-composited layer (no DOM weight). The curve
|
||||
// is intentionally abstract and very subtle — a calligraphic ribbon-body with a
|
||||
// suggestion of a head, mane and tail arcing across the upper scene.
|
||||
const dragonUri = ((): string => {
|
||||
const svg =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='760' height='320' viewBox='0 0 760 320'>` +
|
||||
`<defs>` +
|
||||
`<linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>` +
|
||||
`<stop offset='0' stop-color='oklch(0.86 0.13 88)' stop-opacity='0.85'/>` +
|
||||
`<stop offset='0.55' stop-color='oklch(0.82 0.14 85)' stop-opacity='0.7'/>` +
|
||||
`<stop offset='1' stop-color='oklch(0.78 0.13 80)' stop-opacity='0.45'/>` +
|
||||
`</linearGradient>` +
|
||||
`</defs>` +
|
||||
`<g fill='none' stroke='url(%23g)' stroke-linecap='round' stroke-linejoin='round'>` +
|
||||
// Sinuous body — a thick tapering serpentine ribbon.
|
||||
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
|
||||
`stroke-width='26' opacity='0.5'/>` +
|
||||
// Inner highlight running along the body for a calligraphic sheen.
|
||||
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
|
||||
`stroke-width='7' opacity='0.7'/>` +
|
||||
// Head + horn flourish at the leading end.
|
||||
`<path d='M30 180 C10 160 8 130 26 120 M26 120 C36 112 50 116 52 130' ` +
|
||||
`stroke-width='9' opacity='0.6'/>` +
|
||||
// Mane / whisker strokes flaring back from the head.
|
||||
`<path d='M44 134 C70 120 96 132 110 152 M40 150 C66 148 92 160 104 180' ` +
|
||||
`stroke-width='5' opacity='0.45'/>` +
|
||||
// Tail wisps.
|
||||
`<path d='M740 150 C754 138 758 160 748 172 M726 158 C742 168 744 186 732 196' ` +
|
||||
`stroke-width='5' opacity='0.45'/>` +
|
||||
`</g></svg>`;
|
||||
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
|
||||
})();
|
||||
|
||||
type Lantern = {
|
||||
left: number;
|
||||
top: number;
|
||||
scale: number;
|
||||
bobDuration: number;
|
||||
swayDuration: number;
|
||||
delay: number;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
type Petal = {
|
||||
left: number;
|
||||
size: number;
|
||||
@@ -90,8 +47,6 @@ type Ember = {
|
||||
delay: number;
|
||||
};
|
||||
|
||||
// A single five-petal plum blossom (gold), inline SVG so each petal sliver is
|
||||
// one cheap element. Returned as a data-URI background painted on a square.
|
||||
const blossomUri = ((): string => {
|
||||
const petals = Array.from({ length: 5 }, (_, i) => {
|
||||
const a = (i * 72 * Math.PI) / 180;
|
||||
@@ -107,28 +62,56 @@ const blossomUri = ((): string => {
|
||||
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
|
||||
})();
|
||||
|
||||
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
// Paper lanterns strung across the upper third, gently staggered in depth.
|
||||
const lanterns = useMemo<Lantern[]>(() => {
|
||||
const slots = [
|
||||
{ left: 9, top: 7, scale: 1.0 },
|
||||
{ left: 27, top: 13, scale: 0.82 },
|
||||
{ left: 46, top: 6, scale: 1.12 },
|
||||
{ left: 64, top: 15, scale: 0.78 },
|
||||
{ left: 82, top: 9, scale: 0.95 },
|
||||
{ left: 92, top: 20, scale: 0.7 },
|
||||
];
|
||||
return slots.map((s, i) => ({
|
||||
left: s.left,
|
||||
top: s.top,
|
||||
scale: s.scale,
|
||||
bobDuration: 7 + rand(i + 1) * 4,
|
||||
swayDuration: 5.5 + rand(i + 4) * 3,
|
||||
delay: -rand(i + 7) * 6,
|
||||
opacity: 0.78 + rand(i + 2) * 0.18,
|
||||
}));
|
||||
}, []);
|
||||
// [Gitea #231] Lantern garland: one 480px SVG tile (four scallops) repeats
|
||||
// across the width; small lanterns hang at the scallop low points, all within
|
||||
// the top ~20px.
|
||||
const TILE_W = 480;
|
||||
const TILE_H = 20;
|
||||
const SCALLOP = 120;
|
||||
const tile = (body: string) =>
|
||||
`url("data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='${TILE_W}' height='${TILE_H}' viewBox='0 0 ${TILE_W} ${TILE_H}'>${body}</svg>`,
|
||||
)}")`;
|
||||
const CORD = tile(
|
||||
`<path d='M0 1 Q${SCALLOP / 2} 9 ${SCALLOP} 1 ${Array.from(
|
||||
{ length: TILE_W / SCALLOP - 1 },
|
||||
(_, i) => `T${(i + 2) * SCALLOP} 1`,
|
||||
).join(' ')}' fill='none' stroke='${GOLD}' stroke-width='1.1' stroke-opacity='0.8'/>`,
|
||||
);
|
||||
const lanternSvg = (which: 0 | 1) =>
|
||||
Array.from({ length: TILE_W / SCALLOP }, (_, k) => {
|
||||
if (k % 2 !== which) return '';
|
||||
const x = SCALLOP / 2 + k * SCALLOP;
|
||||
return (
|
||||
`<ellipse cx='${x}' cy='11.5' rx='9' ry='7' fill='${VERMILION}' opacity='0.28'/>` +
|
||||
`<rect x='${x - 3}' y='5' width='6' height='1.6' rx='0.6' fill='${GOLD}'/>` +
|
||||
`<ellipse cx='${x}' cy='11' rx='5.6' ry='4.4' fill='${CRIMSON}' stroke='${GOLD}' stroke-width='0.6'/>` +
|
||||
`<path d='M${x - 2.4} 7.2 Q${x - 3.4} 11 ${x - 2.4} 14.8 M${x} 6.6 V15.4 M${x + 2.4} 7.2 Q${x + 3.4} 11 ${x + 2.4} 14.8' fill='none' stroke='${GOLD}' stroke-width='0.4' stroke-opacity='0.7'/>` +
|
||||
`<ellipse cx='${x - 1.6}' cy='9.6' rx='1.6' ry='1.2' fill='${GOLD_HI}' opacity='0.45'/>` +
|
||||
`<rect x='${x - 2.4}' y='15.2' width='4.8' height='1.4' rx='0.5' fill='${GOLD}'/>` +
|
||||
`<path d='M${x} 16.6 V19.4' stroke='${GOLD}' stroke-width='0.9' stroke-linecap='round'/>`
|
||||
);
|
||||
}).join('');
|
||||
const LANTERNS_A = tile(lanternSvg(0));
|
||||
const LANTERNS_B = tile(lanternSvg(1));
|
||||
|
||||
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
const garlandLayer = (image: string, animated: boolean, delay = 0): React.CSSProperties => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `${TILE_H}px`,
|
||||
backgroundImage: image,
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundSize: `${TILE_W}px ${TILE_H}px`,
|
||||
backgroundPosition: '-20px 0',
|
||||
animation:
|
||||
reduced || !animated
|
||||
? 'none'
|
||||
: `${animGarlandSway} 5.5s ease-in-out ${delay}s infinite, ${animGlowBreathe} 3.8s ease-in-out ${delay}s infinite`,
|
||||
willChange: reduced || !animated ? undefined : 'transform, opacity',
|
||||
});
|
||||
// Drifting gold plum-blossom petals — two parallax bands (far small/dim/slow,
|
||||
// near large/bright/fast) for depth.
|
||||
const petals = useMemo<Petal[]>(() => {
|
||||
@@ -185,216 +168,33 @@ export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
`radial-gradient(120% 80% at 50% -8%, ${CRIMSON.replace(')', ' / 0.16)')} 0%, transparent 56%)`,
|
||||
`radial-gradient(90% 70% at 50% 112%, oklch(0.42 0.17 28 / 0.1) 0%, transparent 60%)`,
|
||||
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.07) 0%, transparent 26%, transparent 82%, oklch(0.40 0.16 28 / 0.08) 100%)`,
|
||||
`radial-gradient(120% 70% at 50% -8%, ${CRIMSON.replace(')', ' / 0.09)')} 0%, transparent 52%)`,
|
||||
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.035) 0%, transparent 22%)`,
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Imperial-gold dragon silhouette arcing across the upper scene — a
|
||||
single composited SVG layer, blurred and screen-blended so it reads as
|
||||
an ethereal gilt apparition, never a hard graphic. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
left: '-6%',
|
||||
right: '-6%',
|
||||
height: '46%',
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: dragonUri,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'contain',
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'blur(1.1px)',
|
||||
opacity: reduced ? 0.52 : undefined,
|
||||
animation: reduced ? 'none' : `${animDragonDrift} 30s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Warm vignette frame — crimson edges, clear center, with a faint cheap
|
||||
backdrop-filter for a silken haze around the rim. */}
|
||||
{/* Warm vignette frame — a faint crimson rim, clear center. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||
backgroundImage:
|
||||
'radial-gradient(135% 120% at 50% 40%, transparent 54%, oklch(0.55 0.16 28 / 0.06) 76%, oklch(0.40 0.16 28 / 0.16) 100%)',
|
||||
'radial-gradient(135% 120% at 50% 40%, transparent 60%, oklch(0.55 0.16 28 / 0.05) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* The garland string the lanterns hang from — a faint warm line. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '6%',
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: `radial-gradient(140% 80% at 50% -40%, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.14)',
|
||||
)} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Paper lanterns. Each is a hung group: a sway wrapper rotating about its
|
||||
mount, an inner bob, then the lantern body (glow + ribs + caps) and a
|
||||
trailing tassel. */}
|
||||
{lanterns.map((l, i) => {
|
||||
const W = 30 * l.scale;
|
||||
const H = 38 * l.scale;
|
||||
const cap = Math.max(8, W * 0.5);
|
||||
return (
|
||||
<div
|
||||
key={`lantern-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${l.left}%`,
|
||||
top: `${l.top}%`,
|
||||
marginLeft: `${-W / 2}px`,
|
||||
transformOrigin: 'top center',
|
||||
opacity: l.opacity,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animLanternSway} ${l.swayDuration}s ease-in-out ${l.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animLanternBob} ${l.bobDuration}s ease-in-out ${l.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
>
|
||||
{/* short cord from the string to the top cap */}
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: `${10 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
background: `linear-gradient(${GOLD}, ${GOLD.replace(')', ' / 0.3)')})`,
|
||||
}}
|
||||
/>
|
||||
{/* top gold cap */}
|
||||
<div
|
||||
style={{
|
||||
width: `${cap}px`,
|
||||
height: `${4 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
borderRadius: `${2 * l.scale}px`,
|
||||
background: `linear-gradient(90deg, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.6)',
|
||||
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
|
||||
boxShadow: `0 0 ${5 * l.scale}px ${GOLD.replace(')', ' / 0.55)')}`,
|
||||
}}
|
||||
/>
|
||||
{/* lantern body */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: `${W}px`,
|
||||
height: `${H}px`,
|
||||
margin: `${1 * l.scale}px auto`,
|
||||
borderRadius: '50% / 42%',
|
||||
background: `radial-gradient(circle at 38% 32%, ${VERMILION.replace(
|
||||
')',
|
||||
' / 0.95)',
|
||||
)} 0%, ${CRIMSON} 58%, oklch(0.40 0.18 26 / 0.95) 100%)`,
|
||||
border: `${1.2 * l.scale}px solid ${GOLD.replace(')', ' / 0.8)')}`,
|
||||
boxShadow: `0 0 ${16 * l.scale}px ${CRIMSON.replace(
|
||||
')',
|
||||
' / 0.5)',
|
||||
)}, inset 0 0 ${10 * l.scale}px oklch(0.78 0.16 60 / 0.35)`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* breathing inner candle glow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '52%',
|
||||
width: `${W * 0.6}px`,
|
||||
height: `${H * 0.55}px`,
|
||||
marginLeft: `${-W * 0.3}px`,
|
||||
marginTop: `${-H * 0.275}px`,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${GOLD_HI.replace(
|
||||
')',
|
||||
' / 0.9)',
|
||||
)} 0%, oklch(0.80 0.16 65 / 0.5) 45%, transparent 75%)`,
|
||||
filter: 'blur(1px)',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animGlowBreathe} ${l.bobDuration * 0.7}s ease-in-out ${
|
||||
l.delay
|
||||
}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
{/* vertical paper ribs */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: `repeating-linear-gradient(90deg, transparent 0, transparent ${
|
||||
W / 6 - 0.6
|
||||
}px, ${GOLD.replace(')', ' / 0.18)')} ${W / 6 - 0.6}px, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.18)',
|
||||
)} ${W / 6}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* bottom gold cap */}
|
||||
<div
|
||||
style={{
|
||||
width: `${cap}px`,
|
||||
height: `${4 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
borderRadius: `${2 * l.scale}px`,
|
||||
background: `linear-gradient(90deg, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.6)',
|
||||
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
|
||||
}}
|
||||
/>
|
||||
{/* swaying silk tassel */}
|
||||
<div
|
||||
style={{
|
||||
width: `${2 * l.scale}px`,
|
||||
height: `${16 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
transformOrigin: 'top center',
|
||||
background: `linear-gradient(${CRIMSON}, ${GOLD.replace(')', ' / 0.8)')})`,
|
||||
borderRadius: '1px',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animTasselSway} ${l.swayDuration * 0.8}s ease-in-out ${l.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* [Gitea #231] A garland of small paper lanterns on a scalloped cord, all
|
||||
within the top ~20px so it stays above header icons and text (the old
|
||||
lanterns hung 7-20% down, over Create Room, search boxes and messages).
|
||||
Two lantern sets glow and sway a little, alternately. */}
|
||||
<div aria-hidden="true" style={garlandLayer(CORD, false)} />
|
||||
<div aria-hidden="true" style={garlandLayer(LANTERNS_A, true, 0)} />
|
||||
<div aria-hidden="true" style={garlandLayer(LANTERNS_B, true, -2.2)} />
|
||||
|
||||
{/* Drifting gold plum-blossom petals (motion only). Static settled
|
||||
blossoms render below for the reduced/preview scene. */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
color,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
@@ -43,6 +44,8 @@ import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||
import { SettingsPages } from './pages';
|
||||
import { SettingsSearch } from './search/SettingsSearch';
|
||||
import type { SettingsEntry } from './search/settingsIndex';
|
||||
|
||||
export { SettingsPages };
|
||||
|
||||
@@ -94,6 +97,49 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
|
||||
});
|
||||
const menuItems = useSettingsMenuItems();
|
||||
// [Gitea #463] Settings search: results replace the page list while typing;
|
||||
// picking one opens its page and briefly highlights the setting.
|
||||
const pageNames = useMemo(
|
||||
() => new Map(menuItems.map((item) => [item.page, item.name])),
|
||||
[menuItems],
|
||||
);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [highlight, setHighlight] = useState<string>();
|
||||
const pickResult = useCallback((entry: SettingsEntry) => {
|
||||
setActivePage(entry.page);
|
||||
setHighlight(entry.title);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!highlight) return undefined;
|
||||
let tries = 0;
|
||||
let flashTimer: number | undefined;
|
||||
const find = () => {
|
||||
const match = [...document.querySelectorAll<HTMLElement>('p, span, label, h1, h2, h3')].find(
|
||||
(el) =>
|
||||
!el.closest('nav') && el.textContent?.trim() === highlight && el.offsetParent !== null,
|
||||
);
|
||||
if (!match) {
|
||||
tries += 1;
|
||||
if (tries < 20) poll = window.setTimeout(find, 100);
|
||||
else setHighlight(undefined);
|
||||
return;
|
||||
}
|
||||
const target = (match.closest('[class*="SequenceCard"]') as HTMLElement | null) ?? match;
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
const prev = target.style.boxShadow;
|
||||
target.style.boxShadow = `0 0 0 2px ${color.Primary.Main}`;
|
||||
target.style.transition = 'box-shadow 0.3s';
|
||||
flashTimer = window.setTimeout(() => {
|
||||
target.style.boxShadow = prev;
|
||||
setHighlight(undefined);
|
||||
}, 1800);
|
||||
};
|
||||
let poll = window.setTimeout(find, 50);
|
||||
return () => {
|
||||
window.clearTimeout(poll);
|
||||
window.clearTimeout(flashTimer);
|
||||
};
|
||||
}, [highlight, activePage]);
|
||||
|
||||
const handlePageRequestClose = () => {
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
@@ -134,8 +180,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<SettingsSearch
|
||||
pageNames={pageNames}
|
||||
onActiveChange={setSearching}
|
||||
onPick={pickResult}
|
||||
/>
|
||||
<PageNavContent>
|
||||
<nav aria-label="Settings sections" style={{ flexGrow: 1 }}>
|
||||
<nav aria-label="Settings sections" style={{ flexGrow: 1 }} hidden={searching}>
|
||||
{menuItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.name}
|
||||
|
||||
@@ -38,11 +38,7 @@ import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
||||
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||
import {
|
||||
describePresenceError,
|
||||
setPresenceWithRetry,
|
||||
writePresence,
|
||||
} from '../../../utils/presenceWrite';
|
||||
import { describePresenceError, queuePresenceWrite } from '../../../utils/presenceWrite';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
@@ -419,6 +415,10 @@ function ProfileStatus() {
|
||||
// until our own echo lands or a short window elapses (bounded so a genuinely
|
||||
// dropped echo can't block real cross-device updates forever).
|
||||
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
|
||||
// The status as this device last applied or synced it. "Save" compares with
|
||||
// this, not with the server echo, so it switches off the moment a status is
|
||||
// applied instead of staying lit until the (rate-limited) write lands.
|
||||
const [savedMsg, setSavedMsg] = useState<string>(initialStatus);
|
||||
const [clearAfter, setClearAfter] = useState('0');
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
const { presets, addPreset, removePreset } = useStatusPresets();
|
||||
@@ -442,7 +442,7 @@ function ProfileStatus() {
|
||||
if (pending) {
|
||||
if (remoteStatus === pending.value) {
|
||||
pendingAppliedRef.current = null; // our own echo landed — accept it
|
||||
} else if (Date.now() - pending.ts < 15_000) {
|
||||
} else if (Date.now() - pending.ts < 30_000) {
|
||||
return; // stale echo of the previous status; ignore within the window
|
||||
} else {
|
||||
pendingAppliedRef.current = null; // window elapsed — accept external changes
|
||||
@@ -451,6 +451,7 @@ function ProfileStatus() {
|
||||
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
|
||||
// typed: don't show it in the status field or keep it as their status.
|
||||
if (remoteStatus === DND_STATUS_SENTINEL) return;
|
||||
setSavedMsg(remoteStatus);
|
||||
if (remoteStatus) {
|
||||
setStatusMsg(remoteStatus);
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
||||
@@ -467,9 +468,10 @@ function ProfileStatus() {
|
||||
useCallback(
|
||||
(msg: string) =>
|
||||
// Synapse allows ONE presence write per 10 s per user (shared by all
|
||||
// devices) and our heartbeat spends that budget too — wait out a 429
|
||||
// instead of failing the user's save.
|
||||
setPresenceWithRetry(mx, {
|
||||
// devices) and our heartbeat spends that budget too. The shared queue
|
||||
// waits out a 429 and folds quick successive changes into one write;
|
||||
// the UI doesn't wait on it (the status is applied locally at once).
|
||||
queuePresenceWrite(mx, {
|
||||
// Derive presence from the user's chosen setting so writing a status
|
||||
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
@@ -498,6 +500,7 @@ function ProfileStatus() {
|
||||
(rawMsg: string, clearAfterValue: string) => {
|
||||
statusDirtyRef.current = false;
|
||||
const msg = rawMsg.trim();
|
||||
setSavedMsg(msg);
|
||||
// Guard against a stale presence echo reverting this value (see the sync effect).
|
||||
pendingAppliedRef.current = { value: msg, ts: Date.now() };
|
||||
noteLocalStatusWrite();
|
||||
@@ -527,19 +530,17 @@ function ProfileStatus() {
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
applyStatus(statusMsg, clearAfter);
|
||||
};
|
||||
|
||||
// Preset click = one-click apply: reflect it in the inputs and save immediately.
|
||||
const applyPreset = useCallback(
|
||||
(preset: StatusPreset) => {
|
||||
if (saving) return;
|
||||
setStatusMsg(preset.label);
|
||||
setClearAfter(preset.clearAfter);
|
||||
applyStatus(preset.label, preset.clearAfter);
|
||||
},
|
||||
[saving, applyStatus],
|
||||
[applyStatus],
|
||||
);
|
||||
|
||||
const handleSaveCurrent = useCallback(() => {
|
||||
@@ -560,17 +561,18 @@ function ProfileStatus() {
|
||||
pendingAppliedRef.current = { value: '', ts: Date.now() };
|
||||
noteLocalStatusWrite();
|
||||
setStatusMsg('');
|
||||
setSavedMsg('');
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
// Preserve the user's chosen presence when clearing the status message.
|
||||
writePresence(mx, {
|
||||
queuePresenceWrite(mx, {
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
status_msg: '',
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const hasChanges = statusMsg !== (presence?.status ?? '');
|
||||
const hasChanges = statusMsg.trim() !== savedMsg;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
@@ -599,7 +601,6 @@ function ProfileStatus() {
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={saving}
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -625,7 +626,6 @@ function ProfileStatus() {
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={saving}
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -651,7 +651,7 @@ function ProfileStatus() {
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
outlined
|
||||
disabled={saving || !statusMsg.trim()}
|
||||
disabled={!statusMsg.trim()}
|
||||
onClick={handleSaveCurrent}
|
||||
before={<Icon size="100" src={Icons.Plus} />}
|
||||
>
|
||||
@@ -662,7 +662,7 @@ function ProfileStatus() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Input
|
||||
name="statusMsgInput"
|
||||
@@ -672,7 +672,6 @@ function ProfileStatus() {
|
||||
placeholder="What's on your mind?"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
readOnly={saving}
|
||||
maxLength={64}
|
||||
/>
|
||||
<Text
|
||||
@@ -733,13 +732,17 @@ function ProfileStatus() {
|
||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || saving}
|
||||
disabled={!hasChanges}
|
||||
type="submit"
|
||||
>
|
||||
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{saving && (
|
||||
<Text size="T200" priority="300" role="status">
|
||||
Status set. Sharing it with others, this can take a few seconds.
|
||||
</Text>
|
||||
)}
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{describePresenceError(saveState.error)}
|
||||
@@ -764,7 +767,6 @@ function ProfileStatus() {
|
||||
radii="300"
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text size="B300">Clear Status</Text>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Input, MenuItem, Text, config } from 'folds';
|
||||
import { SettingsPages } from '../pages';
|
||||
import { searchSettings, SettingsEntry } from './settingsIndex';
|
||||
import { loadSettingsIndex } from './loadSettingsIndex';
|
||||
import { isTauri } from '../../../hooks/useTauri';
|
||||
|
||||
type SettingsSearchProps = {
|
||||
pageNames: Map<SettingsPages, string>;
|
||||
/** Whether a query is active (the page list is hidden meanwhile). */
|
||||
onActiveChange: (active: boolean) => void;
|
||||
onPick: (entry: SettingsEntry) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #463] Search box at the top of the settings sidebar. Ctrl/Cmd+F
|
||||
* focuses it while settings are open; Esc clears it.
|
||||
*/
|
||||
export function SettingsSearch({ pageNames, onActiveChange, onPick }: SettingsSearchProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [entries, setEntries] = useState<SettingsEntry[]>();
|
||||
const active = query.trim().length > 0;
|
||||
|
||||
useEffect(() => onActiveChange(active), [active, onActiveChange]);
|
||||
|
||||
const ensureIndex = () => {
|
||||
if (!entries) loadSettingsIndex().then(setEntries, () => setEntries([]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
|
||||
// Capture phase + stop: settings is on top, so this beats the app's
|
||||
// own Ctrl/Cmd+F (message search) underneath.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, []);
|
||||
|
||||
// Only desktop-app options in the desktop app; only pages that exist here.
|
||||
const results = entries
|
||||
? searchSettings(entries, query, { desktop: isTauri() }).filter((e) => pageNames.has(e.page))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200" style={{ padding: `0 ${config.space.S200}` }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="400"
|
||||
variant="Background"
|
||||
radii="400"
|
||||
placeholder="Search settings"
|
||||
aria-label="Search settings"
|
||||
value={query}
|
||||
onFocus={ensureIndex}
|
||||
onChange={(e) => {
|
||||
ensureIndex();
|
||||
setQuery(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && query) {
|
||||
e.stopPropagation();
|
||||
setQuery('');
|
||||
}
|
||||
if (e.key === 'Enter' && results[0]) {
|
||||
onPick(results[0]);
|
||||
setQuery('');
|
||||
}
|
||||
}}
|
||||
before={<Icon size="100" src={Icons.Search} />}
|
||||
after={
|
||||
query ? (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Background"
|
||||
aria-label="Clear search"
|
||||
onClick={() => setQuery('')}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{active && (
|
||||
<Box direction="Column" role="list" aria-label="Matching settings">
|
||||
{entries === undefined && (
|
||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||
Searching…
|
||||
</Text>
|
||||
)}
|
||||
{entries !== undefined && results.length === 0 && (
|
||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||
No settings match “{query.trim()}”.
|
||||
</Text>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<MenuItem
|
||||
key={`${r.page}-${r.title}`}
|
||||
role="listitem"
|
||||
variant="Background"
|
||||
radii="400"
|
||||
onClick={() => {
|
||||
onPick(r);
|
||||
setQuery('');
|
||||
}}
|
||||
>
|
||||
<Box direction="Column" style={{ padding: `${config.space.S100} 0`, minWidth: 0 }}>
|
||||
<Text size="T300" truncate>
|
||||
{r.title}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{pageNames.get(r.page)}
|
||||
{r.section && r.section !== r.title ? ` · ${r.section}` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { parseSettingsSource, SettingsEntry } from './settingsIndex';
|
||||
|
||||
// [Gitea #463] The settings source, as text, in its own lazily loaded chunk:
|
||||
// fetched the first time someone searches, never at startup.
|
||||
const sources = import.meta.glob(['../**/*.tsx', '!../**/*.test.tsx'], {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
}) as Record<string, () => Promise<string>>;
|
||||
|
||||
let index: Promise<SettingsEntry[]> | undefined;
|
||||
|
||||
export const loadSettingsIndex = (): Promise<SettingsEntry[]> => {
|
||||
index ??= Promise.all(
|
||||
Object.entries(sources).map(async ([path, load]) =>
|
||||
// '../general/General.tsx' -> '/general/General.tsx' (what the parser matches)
|
||||
parseSettingsSource(path.replace(/^\.\./, ''), await load()),
|
||||
),
|
||||
).then((all) => all.flat());
|
||||
return index;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { parseSettingsSource, searchSettings } from './settingsIndex';
|
||||
import { SettingsPages } from '../pages';
|
||||
|
||||
const general = fs.readFileSync('src/app/features/settings/general/General.tsx', 'utf8');
|
||||
const devices = fs.readFileSync('src/app/features/settings/devices/Devices.tsx', 'utf8');
|
||||
const sys = fs.readFileSync(
|
||||
'src/app/features/settings/notifications/SystemNotification.tsx',
|
||||
'utf8',
|
||||
);
|
||||
const entries = [
|
||||
...parseSettingsSource('src/app/features/settings/general/General.tsx', general),
|
||||
...parseSettingsSource('src/app/features/settings/devices/Devices.tsx', devices),
|
||||
...parseSettingsSource('src/app/features/settings/notifications/SystemNotification.tsx', sys),
|
||||
];
|
||||
const find = (t: string) => entries.find((e) => e.title === t);
|
||||
|
||||
test('[#463] indexes the real settings source with the right pages', () => {
|
||||
assert.equal(find('Push to deafen')?.page, SettingsPages.CallsPage);
|
||||
assert.equal(find('Strip tracking parameters from links')?.page, SettingsPages.DevicesPage);
|
||||
assert.equal(find('Page zoom')?.page, SettingsPages.AppearancePage);
|
||||
assert.equal(find('Enter for a new line')?.page, SettingsPages.MessagesPage);
|
||||
assert.equal(find('Show hidden events')?.page, SettingsPages.DeveloperToolsPage);
|
||||
assert.equal(find('Device verification')?.page, SettingsPages.DevicesPage);
|
||||
assert.equal(find('Quiet hours')?.page, SettingsPages.NotificationPage);
|
||||
assert.equal(find('Launch on login')?.desktopOnly, true);
|
||||
assert.match(find('Strip tracking parameters from links')?.description ?? '', /utm_/);
|
||||
assert.ok(entries.length > 70, `indexed ${entries.length}`);
|
||||
});
|
||||
|
||||
test('[#463] search: words anywhere, title first, desktop-only hidden in a browser', () => {
|
||||
assert.equal(searchSettings(entries, 'deafen')[0]?.title, 'Push to deafen');
|
||||
assert.equal(
|
||||
searchSettings(entries, 'tracking')[0]?.title,
|
||||
'Strip tracking parameters from links',
|
||||
);
|
||||
assert.equal(searchSettings(entries, 'zoom')[0]?.title, 'Page zoom');
|
||||
assert.ok(searchSettings(entries, 'utm').some((e) => e.title.startsWith('Strip tracking')));
|
||||
assert.equal(searchSettings(entries, 'launch on login').length, 0);
|
||||
assert.equal(
|
||||
searchSettings(entries, 'launch on login', { desktop: true })[0]?.title,
|
||||
'Launch on login',
|
||||
);
|
||||
assert.deepEqual(searchSettings(entries, ' '), []);
|
||||
});
|
||||
|
||||
test('[#463] parser handles the attribute forms and skips dynamic titles', () => {
|
||||
const src = `
|
||||
<Text size="L400">Things</Text>
|
||||
<SettingTile title="Plain" description="A & B" after={<Switch value={x} />} />
|
||||
<SettingTile title={'Quoted'} description={\`tick\`} />
|
||||
<SettingTile title={\`Dynamic \${name}\`} />
|
||||
<SettingTile title={someVar} />`;
|
||||
const got = parseSettingsSource('src/app/features/settings/about/About.tsx', src);
|
||||
assert.deepEqual(
|
||||
got.map((e) => [e.title, e.description, e.section]),
|
||||
[
|
||||
['Plain', 'A & B', 'Things'],
|
||||
['Quoted', 'tick', 'Things'],
|
||||
['Things', '', undefined],
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { SettingsPages } from '../pages';
|
||||
|
||||
/**
|
||||
* [Gitea #463] Search across all settings. The index is built from the
|
||||
* settings source itself (each <SettingTile title=… description=…> and each
|
||||
* section header), so options added later are found without a hand-kept list.
|
||||
* Pure: path + source text in, entries out.
|
||||
*/
|
||||
export type SettingsEntry = {
|
||||
title: string;
|
||||
description: string;
|
||||
page: SettingsPages;
|
||||
/** Section header the setting sits under, when known. */
|
||||
section?: string;
|
||||
/** Desktop-only (hidden in a browser). */
|
||||
desktopOnly?: boolean;
|
||||
};
|
||||
|
||||
// General.tsx: which page each section (or helper used by a section) is on.
|
||||
const GENERAL_SECTIONS: Record<string, SettingsPages> = {
|
||||
Appearance: SettingsPages.AppearancePage,
|
||||
SystemThemePreferences: SettingsPages.AppearancePage,
|
||||
SelectTheme: SettingsPages.AppearancePage,
|
||||
PageZoomInput: SettingsPages.AppearancePage,
|
||||
CollapsiblePicker: SettingsPages.AppearancePage,
|
||||
SeasonalBgGrid: SettingsPages.AppearancePage,
|
||||
ChatBgGrid: SettingsPages.AppearancePage,
|
||||
Messages: SettingsPages.MessagesPage,
|
||||
SelectMessageLayout: SettingsPages.MessagesPage,
|
||||
SelectMessageSpacing: SettingsPages.MessagesPage,
|
||||
Editor: SettingsPages.MessagesPage,
|
||||
ComposerToolbarReorder: SettingsPages.MessagesPage,
|
||||
ComposerToolbarButtonRow: SettingsPages.MessagesPage,
|
||||
DateAndTime: SettingsPages.MessagesPage,
|
||||
SelectDateFormat: SettingsPages.MessagesPage,
|
||||
DateHint: SettingsPages.MessagesPage,
|
||||
CustomDateFormat: SettingsPages.MessagesPage,
|
||||
PresetDateFormat: SettingsPages.MessagesPage,
|
||||
Calls: SettingsPages.CallsPage,
|
||||
Privacy: SettingsPages.DevicesPage,
|
||||
SettingsSyncSection: SettingsPages.DataStoragePage,
|
||||
AdvancedDisplayOptions: SettingsPages.DeveloperToolsPage,
|
||||
DesktopChromeSetting: SettingsPages.DesktopPage,
|
||||
AutostartSetting: SettingsPages.DesktopPage,
|
||||
KeychainMirrorSetting: SettingsPages.DesktopPage,
|
||||
DesktopSettings: SettingsPages.DesktopPage,
|
||||
PackageUpdate: SettingsPages.DesktopPage,
|
||||
AppUpdates: SettingsPages.DesktopPage,
|
||||
};
|
||||
|
||||
const FOLDER_PAGES: [RegExp, SettingsPages][] = [
|
||||
[/\/account\//, SettingsPages.AccountPage],
|
||||
[/\/notifications\//, SettingsPages.NotificationPage],
|
||||
[/\/devices\//, SettingsPages.DevicesPage],
|
||||
[/\/developer(-tools)?\//, SettingsPages.DeveloperToolsPage],
|
||||
[/\/emojis-stickers\//, SettingsPages.EmojisStickersPage],
|
||||
[/\/about\//, SettingsPages.AboutPage],
|
||||
[/\/general\/(StorageUsage|YourData)\.tsx$/, SettingsPages.DataStoragePage],
|
||||
];
|
||||
|
||||
const decode = (s: string): string =>
|
||||
s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** A JSX attribute's static text: "…", {'…'}, {"…"} or {`…`} without ${}. */
|
||||
const attr = (tag: string, name: string): string | undefined => {
|
||||
const m = new RegExp(
|
||||
`\\b${name}=(?:"([^"]*)"|\\{\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*\\}|\\{\\s*"([^"]*)"\\s*\\}|\\{\\s*\`([^\`$]*)\`\\s*\\})`,
|
||||
).exec(tag);
|
||||
const v = m && (m[1] ?? m[2] ?? m[3] ?? m[4]);
|
||||
return v ? decode(v) : undefined;
|
||||
};
|
||||
|
||||
/** The opening tag starting at `start` (up to the matching '>' outside braces/strings). */
|
||||
const openingTag = (src: string, start: number): string => {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = start; i < src.length; i += 1) {
|
||||
const c = src[i];
|
||||
if (quote) {
|
||||
if (c === quote && src[i - 1] !== '\\') quote = null;
|
||||
} else if (c === '"' || c === "'" || c === '`') quote = c;
|
||||
else if (c === '{') depth += 1;
|
||||
else if (c === '}') depth -= 1;
|
||||
else if (c === '>' && depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
return src.slice(start);
|
||||
};
|
||||
|
||||
export const parseSettingsSource = (path: string, src: string): SettingsEntry[] => {
|
||||
const isGeneral = /\/general\/General\.tsx$/.test(path);
|
||||
const folderPage = FOLDER_PAGES.find(([re]) => re.test(path))?.[1];
|
||||
if (!isGeneral && folderPage === undefined) return [];
|
||||
|
||||
// For General.tsx: positions of each top-level function, to know the section.
|
||||
const fns = isGeneral
|
||||
? [...src.matchAll(/\n(?:export )?function (\w+)\(/g)].map((m) => ({
|
||||
at: m.index ?? 0,
|
||||
name: m[1],
|
||||
}))
|
||||
: [];
|
||||
const pageAt = (pos: number): SettingsPages | undefined => {
|
||||
if (!isGeneral) return folderPage;
|
||||
let name: string | undefined;
|
||||
fns.forEach((f) => {
|
||||
if (f.at < pos) name = f.name;
|
||||
});
|
||||
return name ? GENERAL_SECTIONS[name] : undefined;
|
||||
};
|
||||
|
||||
const out: SettingsEntry[] = [];
|
||||
const headers = [...src.matchAll(/<Text size="L400">([^<{]+)<\/Text>/g)].map((m) => ({
|
||||
at: m.index ?? 0,
|
||||
text: decode(m[1]),
|
||||
}));
|
||||
const sectionAt = (pos: number): string | undefined => {
|
||||
let s: string | undefined;
|
||||
headers.forEach((h) => {
|
||||
if (h.at < pos) s = h.text;
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
for (const m of src.matchAll(/<SettingTile\b/g)) {
|
||||
const at = m.index ?? 0;
|
||||
const tag = openingTag(src, at);
|
||||
const title = attr(tag, 'title');
|
||||
const page = pageAt(at);
|
||||
if (title && page !== undefined) {
|
||||
out.push({
|
||||
title,
|
||||
description: attr(tag, 'description') ?? '',
|
||||
page,
|
||||
section: sectionAt(at),
|
||||
desktopOnly: page === SettingsPages.DesktopPage || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
headers.forEach((h) => {
|
||||
const page = pageAt(h.at);
|
||||
if (page !== undefined && !out.some((e) => e.title === h.text && e.page === page)) {
|
||||
out.push({
|
||||
title: h.text,
|
||||
description: '',
|
||||
page,
|
||||
desktopOnly: page === SettingsPages.DesktopPage || undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const norm = (s: string): string => s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
||||
|
||||
/**
|
||||
* Pure: rank entries for a query. Every word must appear in the title,
|
||||
* description or section; title matches rank first.
|
||||
*/
|
||||
export const searchSettings = (
|
||||
entries: SettingsEntry[],
|
||||
query: string,
|
||||
{ desktop = false, limit = 30 }: { desktop?: boolean; limit?: number } = {},
|
||||
): SettingsEntry[] => {
|
||||
const words = norm(query).split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return [];
|
||||
const scored: { e: SettingsEntry; score: number }[] = [];
|
||||
entries.forEach((e) => {
|
||||
if (e.desktopOnly && !desktop) return;
|
||||
const title = norm(e.title);
|
||||
const rest = norm(`${e.description} ${e.section ?? ''}`);
|
||||
let score = 0;
|
||||
const all = words.every((w) => {
|
||||
if (title.startsWith(w)) score += 3;
|
||||
else if (title.includes(w)) score += 2;
|
||||
else if (rest.includes(w)) score += 1;
|
||||
else return false;
|
||||
return true;
|
||||
});
|
||||
if (all) scored.push({ e, score });
|
||||
});
|
||||
return scored
|
||||
.sort((a, b) => b.score - a.score || a.e.title.localeCompare(b.e.title))
|
||||
.slice(0, limit)
|
||||
.map((s) => s.e);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { SetPresence } from 'matrix-js-sdk';
|
||||
import { PresenceDeduper, PresenceWrite, writePresence } from '../utils/presenceWrite';
|
||||
import { PresenceDeduper, PresenceWrite, queuePresenceWrite } from '../utils/presenceWrite';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
@@ -70,9 +70,25 @@ export function usePresenceUpdater() {
|
||||
// by all devices) and /sync already keeps us online — only send when the
|
||||
// state or status actually changes, so a manual status save isn't 429'd
|
||||
// by a heartbeat that changed nothing.
|
||||
// What the server reported in the last resolveStatus() (consumed by send).
|
||||
let serverNow: { presence: string; status_msg?: string } | undefined;
|
||||
const send = (write: PresenceWrite, label: string) => {
|
||||
const server = serverNow;
|
||||
serverNow = undefined;
|
||||
if (!deduper.shouldSend(write)) return Promise.resolve();
|
||||
return writePresence(mx, write)
|
||||
// The server already shows exactly this (e.g. /sync keeps us online and
|
||||
// the status is unchanged): writing it again would only spend the
|
||||
// 10-second budget a status save may need next.
|
||||
if (
|
||||
server &&
|
||||
server.presence === write.presence &&
|
||||
(server.status_msg ?? '') === (write.status_msg ?? '')
|
||||
) {
|
||||
deduper.sent(write);
|
||||
return Promise.resolve();
|
||||
}
|
||||
// Shared queue with the status save: waits out a 429, newest write wins.
|
||||
return queuePresenceWrite(mx, write)
|
||||
.then(() => deduper.sent(write))
|
||||
.catch((err) => warnPresenceFailure(label, err));
|
||||
};
|
||||
@@ -84,6 +100,7 @@ export function usePresenceUpdater() {
|
||||
const local = readStatus();
|
||||
if (!userId) return local;
|
||||
const server = await mx.getPresence(userId).catch(() => undefined);
|
||||
serverNow = server;
|
||||
const status = pickHeartbeatStatus({
|
||||
local,
|
||||
server,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { PresenceDeduper, describePresenceError, setPresenceWithRetry } from './presenceWrite';
|
||||
import { IPresenceOpts, MatrixError } from 'matrix-js-sdk';
|
||||
import {
|
||||
PresenceDeduper,
|
||||
describePresenceError,
|
||||
isPresenceWritePending,
|
||||
queuePresenceWrite,
|
||||
setPresenceWithRetry,
|
||||
} from './presenceWrite';
|
||||
|
||||
const limited = (retryMs = 3000) =>
|
||||
new MatrixError(
|
||||
@@ -79,3 +85,43 @@ test('error wording', () => {
|
||||
);
|
||||
assert.match(describePresenceError(new TypeError('Failed to fetch')), /couldn't reach/);
|
||||
});
|
||||
|
||||
test('[status save] queuePresenceWrite: newest wins, waits out 429 once, all callers settle', async () => {
|
||||
const sent: string[] = [];
|
||||
let calls = 0;
|
||||
const sleeps: number[] = [];
|
||||
const mx = {
|
||||
setPresence: async (w: IPresenceOpts) => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
sent.push(`429:${w.status_msg}`);
|
||||
throw new MatrixError({ errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 7000 }, 429);
|
||||
}
|
||||
sent.push(`ok:${w.status_msg}`);
|
||||
},
|
||||
};
|
||||
const sleepFn = async (ms: number) => {
|
||||
sleeps.push(ms);
|
||||
};
|
||||
// 'a' goes out at once; 'b' and 'c' arrive while it waits out the limit.
|
||||
const first = queuePresenceWrite(mx, { presence: 'online', status_msg: 'a' }, { sleepFn });
|
||||
const second = queuePresenceWrite(mx, { presence: 'online', status_msg: 'b' }, { sleepFn });
|
||||
const third = queuePresenceWrite(mx, { presence: 'online', status_msg: 'c' }, { sleepFn });
|
||||
assert.equal(isPresenceWritePending(mx), true);
|
||||
await Promise.all([first, second, third]);
|
||||
// 'a' was 429'd; after the wait only the newest ('c') goes out — 'a' isn't
|
||||
// retried and 'b' is never sent.
|
||||
assert.deepEqual(sent, ['429:a', 'ok:c']);
|
||||
assert.deepEqual(sleeps, [7000]);
|
||||
assert.equal(isPresenceWritePending(mx), false);
|
||||
});
|
||||
|
||||
test('[status save] queuePresenceWrite: a failure rejects the callers of that write', async () => {
|
||||
const mx = {
|
||||
setPresence: async () => {
|
||||
throw new MatrixError({ errcode: 'M_FORBIDDEN' }, 403);
|
||||
},
|
||||
};
|
||||
await assert.rejects(queuePresenceWrite(mx, { presence: 'online', status_msg: 'x' }));
|
||||
assert.equal(isPresenceWritePending(mx), false);
|
||||
});
|
||||
|
||||
@@ -94,3 +94,78 @@ export const describePresenceError = (e: unknown): string => {
|
||||
}
|
||||
return "Failed to save status — couldn't reach the server. Try again.";
|
||||
};
|
||||
|
||||
type PresenceQueue = {
|
||||
pending?: PresenceWrite;
|
||||
waiters: { resolve: () => void; reject: (e: unknown) => void }[];
|
||||
running: boolean;
|
||||
};
|
||||
const queues = new WeakMap<object, PresenceQueue>();
|
||||
|
||||
/**
|
||||
* One presence writer per client, newest write wins. The status save and the
|
||||
* heartbeat share Synapse's one-write-per-10-s budget, so instead of each
|
||||
* waiting out 429s on its own (and racing), writes queue here: anything still
|
||||
* waiting is replaced by the newer write, which goes out as soon as the server
|
||||
* allows. Each caller's promise settles with the outcome of the write that
|
||||
* finally carried (or superseded) theirs.
|
||||
*/
|
||||
export function queuePresenceWrite(
|
||||
mx: Pick<MatrixClient, 'setPresence'>,
|
||||
write: PresenceWrite,
|
||||
opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise<void> } = {},
|
||||
): Promise<void> {
|
||||
let q = queues.get(mx);
|
||||
if (!q) {
|
||||
q = { waiters: [], running: false };
|
||||
queues.set(mx, q);
|
||||
}
|
||||
const queue = q;
|
||||
queue.pending = write;
|
||||
const settled = new Promise<void>((resolve, reject) => {
|
||||
queue.waiters.push({ resolve, reject });
|
||||
});
|
||||
if (!queue.running) {
|
||||
queue.running = true;
|
||||
const maxWaitMs = opts.maxWaitMs ?? 25_000;
|
||||
const sleepFn = opts.sleepFn ?? sleep;
|
||||
(async () => {
|
||||
let waited = 0;
|
||||
while (queue.pending) {
|
||||
const next = queue.pending;
|
||||
queue.pending = undefined;
|
||||
const waiters = queue.waiters.splice(0);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await writePresence(mx, next);
|
||||
waited = 0;
|
||||
if (queue.pending) queue.waiters.unshift(...waiters);
|
||||
else waiters.forEach((w) => w.resolve());
|
||||
} catch (e) {
|
||||
const wait =
|
||||
e instanceof MatrixError && e.httpStatus === 429
|
||||
? Math.min(e.getRetryAfterMs() ?? 10_500, 15_000)
|
||||
: undefined;
|
||||
if (wait !== undefined && waited + wait <= maxWaitMs) {
|
||||
waited += wait;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await sleepFn(wait);
|
||||
// After the wait, send whatever is newest: a change made meanwhile
|
||||
// replaces this one rather than queueing behind it.
|
||||
queue.pending ??= next;
|
||||
queue.waiters.unshift(...waiters);
|
||||
} else if (queue.pending) {
|
||||
queue.waiters.unshift(...waiters);
|
||||
} else {
|
||||
waiters.forEach((w) => w.reject(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.running = false;
|
||||
})();
|
||||
}
|
||||
return settled;
|
||||
}
|
||||
|
||||
/** Whether a presence write is queued or in flight for this client. */
|
||||
export const isPresenceWritePending = (mx: object): boolean => queues.get(mx)?.running ?? false;
|
||||
|
||||
@@ -9,3 +9,25 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?:
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once media requests can carry the session: immediately-ish when a
|
||||
* service worker already controls the page, otherwise when one takes control
|
||||
* (first visit / just updated) or after `timeoutMs`. Used to retry a media
|
||||
* load that raced the worker's activation and went out without a token.
|
||||
*/
|
||||
export function whenMediaAuthReady(timeoutMs = 3000): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const sw = 'serviceWorker' in navigator ? navigator.serviceWorker : undefined;
|
||||
if (!sw || sw.controller) {
|
||||
window.setTimeout(resolve, 500);
|
||||
return;
|
||||
}
|
||||
const done = () => {
|
||||
sw.removeEventListener('controllerchange', done);
|
||||
resolve();
|
||||
};
|
||||
sw.addEventListener('controllerchange', done);
|
||||
window.setTimeout(done, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
+11
-1
@@ -189,7 +189,17 @@ function lotusDenoise() {
|
||||
'Run `npm ci` to install @workadventure/noise-suppression.',
|
||||
);
|
||||
}
|
||||
fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), { recursive: true });
|
||||
// [Gitea #210] Ship only what can load. The threaded LiteRT build is used
|
||||
// only on a cross-origin-isolated page (COOP + COEP), and neither the web
|
||||
// call page nor the desktop one is, so its ~9 MB never loads; source maps
|
||||
// and type declarations aren't needed at runtime either. If the call page
|
||||
// ever turns on COOP/COEP, drop the `threaded` exclusion.
|
||||
const dtlnSkip = (src) =>
|
||||
/litert_wasm_threaded_internal\./.test(src) || /\.(map|d\.ts)$/.test(src);
|
||||
fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), {
|
||||
recursive: true,
|
||||
filter: (src) => !dtlnSkip(src),
|
||||
});
|
||||
|
||||
const dfnMissing = dfn.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
|
||||
if (dfnMissing.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user