Compare commits

...
4 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 27c6dacc60 Merge pull request 'Seasonal: Autumn redesign (#237), needs human approval' (#241) from seasonal/autumn into lotus
CI / Build & Quality Checks (push) Successful in 1m48s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Canceled after 2m20s
Merge seasonal/autumn: Autumn redesign (#237), approved by Jared

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 14:29:08 -04:00
Lotus CIandClaude Opus 5.5 3e132069c0 feat(seasonal): Autumn pass 2 — no tint, a gust, a real preview (#237)
CI / Build & Quality Checks (pull_request) Failing after 1m30s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 5s
Draft for human review (#237 must be approved and closed by a human).

- Removed the full-screen amber wash, sun-shaft bands and vignette: they
  turned the user's theme beige (muddy brown on dark) and lowered contrast
  everywhere. The leaves carry the season on their own. Also removed the
  dust motes (invisible specks) and the now-unused keyframes.
- Gust: every 42 s (first ~8 s after load) five leaves blow across the screen
  together, rising and spinning, in ~5 s. Linear timing — an eased curve is
  applied per keyframe segment and made them hang mid-air for a second.
- Softer flutter (leaves flattened into slivers mid-flip), stronger leaf
  colour (opacity 0.58–0.73, less pale gold tip), brighter on dark themes.
- Reduced motion on phones: no icon rail there, so the resting leaves tuck
  further off-edge and smaller instead of landing on the timeline icons.
- Settings swatch: new optional `preview` prop on seasonal overlays; Autumn
  draws a warm backdrop with arranged leaves (the reduced scene rendered in
  the tile as a black square with two edge fragments).

Rendered and inspected: light/dark/phone at 10/15/20 s, reduced on desktop
and phone, gust tracked frame by frame, the Settings swatch; 60 fps with the
theme on vs off (p95 frame 16.7 ms both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 13:58:15 -04:00
Lotus CIandClaude Opus 5.5 e080059f37 feat(seasonal): Autumn: leaves actually fall; real leaf shapes and palette (#237)
CI / Build & Quality Checks (pull_request) Failing after 17m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
Draft for human review (#237 must be approved and closed by a human).

- Bug: every falling leaf sat in a leaf-sized wrapper pinned at the top of
  the screen with `contain: layout paint style`. Paint containment clips
  children to that box, so a leaf stopped being drawn as soon as it fell out
  of its first ~40 px: the animated theme showed almost nothing while the DOM
  reported 10–16 leaves on screen. Now `contain: layout style`.
- Leaf shapes: a flag-style maple (bold lobes, stem) that stays recognisable
  at 30 px (the old outline read as a star/splat), an oak with a stem, and an
  aspen; gradient body with darker veins.
- Palette: crimson, pumpkin orange, gold, russet, burnt orange (was muted
  tans that read as beige on the light theme).
- 12 leaves at 28–52 px, opacity 0.5–0.65 so text under a passing leaf stays
  readable; brightened on the dark themes, where they sank into the wash.
- Reduced motion: resting leaves moved into empty layout areas (middle of
  the left rail, under the member list), partly off-edge, instead of on the
  sidebar, members, composer and corner controls.

Rendered and inspected at 10/15/20 s in light, dark, phone and reduced
motion (screenshots on #237).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 13:36:13 -04:00
Lotus CIandClaude Opus 5.5 75951f9040 feat(search): in:, before:/after:, has:, is:pinned operators (#106)
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 10m39s
Typed operators alongside the existing from:, each one setting the filter the
buttons already set (so typing and clicking end in the same state, shown in
the filter bar):

  in:general  in:"voice lounge"   room by name / alias / id (unique partial
                                  name ok); a room outside this page's scope
                                  switches to all rooms
  after:2026-09-01  before:7d     date range; relative h/d/w/m/y
  has:link | image | video | file contains-link / media filters
  is:pinned                       pinned-only filter

- searchOperators.ts: pure parser + room resolver (unit-tested); quoted
  values; keys case-insensitive; only at a word start, so URLs and times
  aren't mistaken for operators; anything it doesn't understand stays in the
  searched text with a notice listing the valid operators.
- Notices: no matching room; operators with no words or from: ("Add a word to
  search for, or a from:@user.") — then the text is kept in the box.
- from:bob now resolves to a known @bob:server (this homeserver first); the
  old path sent "@bob", which matched nobody unless picked from autocomplete.

Verified in Chromium on a local Synapse: `raid in:"Thread Lab" from:bob`
sets rooms=!…Thread Lab, senders=@bob:localhost and finds bob's message;
dates, an unknown room, operators-only and has:gif give the expected params
and notices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 12:54:10 -04:00
8 changed files with 623 additions and 303 deletions
@@ -3,13 +3,13 @@ import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types';
import { SeasonTheme, SeasonalOverlayProps } from './types';
import { resolveSeasonTheme } from './seasonSchedule';
// [Gitea #167] Each overlay (its particles + keyframes) is its own chunk,
// fetched only when that season is active or previewed in Settings — the
// date/override decision stays in the main bundle.
const lazyOverlay = (load: () => Promise<{ default: React.ComponentType<{ reduced: boolean }> }>) =>
const lazyOverlay = (load: () => Promise<{ default: React.ComponentType<SeasonalOverlayProps> }>) =>
React.lazy(load);
const HalloweenOverlay = lazyOverlay(() =>
import('./themes/Halloween').then((m) => ({ default: m.HalloweenOverlay })),
@@ -52,7 +52,11 @@ export type { SeasonTheme };
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactNode {
function buildOverlayContent(
theme: SeasonTheme,
reduced: boolean,
preview = false,
): React.ReactNode {
switch (theme) {
case 'halloween':
return <HalloweenOverlay reduced={reduced} />;
@@ -61,7 +65,7 @@ function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactN
case 'newyear':
return <NewYearOverlay reduced={reduced} />;
case 'autumn':
return <AutumnOverlay reduced={reduced} />;
return <AutumnOverlay reduced={reduced} preview={preview} />;
case 'aprilfools':
return <AprilFoolsOverlay reduced={reduced} />;
case 'lunar':
@@ -122,7 +126,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
containerType: 'inline-size',
}}
>
<Suspense fallback={null}>{buildOverlayContent(theme, true)}</Suspense>
<Suspense fallback={null}>{buildOverlayContent(theme, true, true)}</Suspense>
</div>
);
}
@@ -1,35 +1,23 @@
import { keyframes } from '@vanilla-extract/css';
import { keyframes, style } from '@vanilla-extract/css';
/**
* Autumn overlay keyframes. Every animation touches ONLY `transform` and
* `opacity` so the compositor can run them on the GPU without triggering
* layout or paint. keyframes() returns the generated animation-name string,
* which is applied inline in Autumn.tsx.
*
* Motion philosophy: warm, slow, cozy. Leaves tumble and rotate as they fall
* with a per-leaf sway decoupled on a wrapper; sun shafts breathe; dust motes
* drift up through the light; the whole frame has a barely-there warm pulse.
* `opacity` so the compositor can run them without layout or paint.
*/
/**
* A leaf falls from above to below the viewport while continuously rotating.
* A single tall translateY serves every leaf — per-leaf duration/delay/scale
* create the parallax variety. Horizontal travel is intentionally small here
* because the real lateral motion comes from the sway wrapper below.
* A leaf falls from above to below the viewport while slowly turning. One tall
* translateY serves every leaf; per-leaf duration/delay/size give parallax.
*/
export const animLeafFall = keyframes({
'0%': { transform: 'translate3d(0, -12vh, 0) rotate(-30deg)', opacity: '0' },
'8%': { opacity: '1' },
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(200deg)' },
'92%': { opacity: '0.85' },
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(430deg)', opacity: '0' },
'6%': { opacity: '1' },
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(160deg)' },
'94%': { opacity: '0.9' },
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(340deg)', opacity: '0' },
});
/**
* Lateral sway applied to a leaf's wrapper so the descent reads as wind
* catching the blade. Decoupled from the fall so the two compose into an
* organic, non-repeating-looking path.
*/
/** Lateral sway on the leaf's wrapper so the descent reads as wind. */
export const animLeafSway = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(34px, 0, 0)' },
@@ -37,55 +25,40 @@ export const animLeafSway = keyframes({
});
/**
* A second flutter on the leaf's inner shape: a gentle skew/scale wobble that
* mimics the blade catching air as it spins. Cheap, transform-only.
* Soft flutter on the blade. [#237] Gentler than before (scaleX 0.82 made
* leaves flatten into slivers mid-flip).
*/
export const animLeafFlutter = keyframes({
'0%': { transform: 'rotate(-8deg) scaleX(1)' },
'50%': { transform: 'rotate(8deg) scaleX(0.82)' },
'100%': { transform: 'rotate(-8deg) scaleX(1)' },
'0%': { transform: 'rotate(-10deg) scaleX(1)' },
'50%': { transform: 'rotate(10deg) scaleX(0.92)' },
'100%': { transform: 'rotate(-10deg) scaleX(1)' },
});
/**
* Low-sun light shaft: a long soft beam slowly slides and breathes. Uses
* translateX + opacity (never background-position) so it stays on the
* compositor. Scale on Y makes the beam subtly elongate as it brightens.
* [#237] A gust: the leaf enters from the left, is blown across and up while
* spinning, and leaves on the right in ~6 s (14 % of a 42 s cycle). The rest
* of the cycle it waits off-screen, invisible.
*/
export const animSunShaft = keyframes({
'0%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
'50%': { transform: 'translate3d(4%, 0, 0) scaleY(1.06)', opacity: '0.75' },
'100%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
export const animGust = keyframes({
'0%': { transform: 'translate3d(-12vw, 0, 0) rotate(0deg)', opacity: '0' },
'1%': { opacity: '1' },
// Fast in, then carried steadily across and up (linear timing: an eased
// curve would apply per segment and stop the leaves mid-air).
'3.5%': { transform: 'translate3d(22vw, -3vh, 0) rotate(200deg)' },
'8%': { transform: 'translate3d(62vw, -12vh, 0) rotate(420deg)' },
'12.5%': { opacity: '1' },
'14%': { transform: 'translate3d(112vw, -24vh, 0) rotate(700deg)', opacity: '0' },
'100%': { transform: 'translate3d(112vw, -24vh, 0) rotate(700deg)', opacity: '0' },
});
/**
* Dust / pollen mote: a tiny speck drifts upward through the light, swaying,
* pulsing softly in brightness as it catches the sun. transform + opacity.
* Leaf blade. On the dark themes the crimson/russet leaves sank into the
* background, so they are lifted there.
*/
export const animMoteDrift = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
'15%': { opacity: '0.85' },
'40%': { transform: 'translate3d(16px, -30vh, 0) scale(1)' },
'70%': { transform: 'translate3d(-12px, -58vh, 0) scale(0.85)', opacity: '0.6' },
'90%': { opacity: '0.2' },
'100%': { transform: 'translate3d(10px, -84vh, 0) scale(0.6)', opacity: '0' },
});
/**
* Independent twinkle for motes — a brightness flicker layered on the drift so
* specks shimmer as if turning in the light. Opacity only.
*/
export const animMoteTwinkle = keyframes({
'0%': { opacity: '0.5' },
'50%': { opacity: '1' },
'100%': { opacity: '0.5' },
});
/**
* Barely-there breathing of the warm vignette frame so the static tint feels
* alive without any distracting motion. Opacity only.
*/
export const animEmberPulse = keyframes({
'0%': { opacity: '0.82' },
'50%': { opacity: '1' },
'100%': { opacity: '0.82' },
export const leafBlade = style({
selectors: {
'body.dark-theme &, body.butter-theme &': {
filter: 'brightness(1.6) saturate(1.2)',
},
},
});
+232 -235
View File
@@ -1,97 +1,108 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animLeafFall,
animLeafSway,
animLeafFlutter,
animSunShaft,
animMoteDrift,
animMoteTwinkle,
animEmberPulse,
} from './Autumn.css';
import { animGust, animLeafFall, animLeafFlutter, animLeafSway, leafBlade } from './Autumn.css';
/**
* AutumnOverlay — warm falling leaves.
* AutumnOverlay — falling leaves over the app as it is.
*
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
* z-index, so this component only returns absolutely-positioned aria-hidden
* children and never sets position:fixed / z-index / pointer-events.
*
* Composition (back to front):
* 1. amber -> rust ambient gradient wash (cozy low-sun atmosphere)
* 2. soft angled sun shafts breathing high across the scene
* 3. drifting pollen / dust motes catching the light
* 4. maple & oak leaf silhouettes tumbling and rotating as they fall
* 5. a warm low-saturation vignette that frames + protects text contrast
* [#237] Deliberately no full-screen tint, light shafts or vignette: they
* turned the user's theme beige (muddy brown on dark) and lowered contrast
* everywhere. The leaves carry the season on their own.
*
* All motion is transform/opacity only (compositor-friendly). When `reduced`
* is true we render a static-but-gorgeous scene: a handful of leaves at rest,
* still sun shafts, and the warm vignette — no `animation` anywhere. The
* settings preview always passes reduced=true, so the still form stands alone.
* Composition:
* 1. maple / oak / aspen leaves drifting down with sway and a soft flutter
* 2. every ~42 s a gust: a small group of leaves blows across the screen
* All motion is transform/opacity only. When `reduced` is true: a few leaves
* at rest in empty parts of the layout, no animation, no gust. The settings
* preview always passes reduced=true, so the still form stands alone.
*/
// Warm autumn palette in oklch. Kept low-saturation enough to never fight the
// chat text underneath. Each leaf picks a tone for variety.
const LEAF_TONES = [
'oklch(0.75 0.15 70)', // amber
'oklch(0.55 0.16 40)', // rust
'oklch(0.82 0.13 85)', // warm gold
'oklch(0.62 0.16 55)', // burnt orange
'oklch(0.5 0.14 35)', // deep ember
// [#237] A real autumn palette: [body, tip highlight, vein]. The previous
// muted tans read as beige smudges on the light theme.
const LEAF_TONES: [string, string, string][] = [
['oklch(0.56 0.19 30)', 'oklch(0.68 0.17 45)', 'oklch(0.38 0.12 28)'], // crimson
['oklch(0.68 0.17 52)', 'oklch(0.8 0.15 75)', 'oklch(0.45 0.12 45)'], // pumpkin orange
['oklch(0.76 0.15 80)', 'oklch(0.85 0.13 92)', 'oklch(0.52 0.1 65)'], // gold
['oklch(0.5 0.1 50)', 'oklch(0.62 0.11 60)', 'oklch(0.34 0.06 45)'], // russet brown
['oklch(0.62 0.18 40)', 'oklch(0.74 0.16 60)', 'oklch(0.42 0.12 35)'], // burnt orange
];
// Two leaf silhouettes as inline SVG path data (no external assets, CSP-safe).
// `maple` = classic five-lobed maple; `oak` = rounded-lobe oak blade.
const MAPLE_PATH =
'M50 4 L57 30 L78 18 L66 40 L92 40 L70 52 L84 74 L58 62 L56 92 L50 70 ' +
'L44 92 L42 62 L16 74 L30 52 L8 40 L34 40 L22 18 L43 30 Z';
const OAK_PATH =
'M50 4 C58 14 56 22 64 24 C74 22 74 32 68 36 C78 38 76 48 68 50 ' +
'C78 54 74 64 66 64 C70 74 60 78 54 72 C54 84 50 96 50 96 ' +
'C50 96 46 84 46 72 C40 78 30 74 34 64 C26 64 22 54 32 50 ' +
'C24 48 22 38 32 36 C26 32 26 22 36 24 C44 22 42 14 50 4 Z';
type LeafKind = 'maple' | 'oak' | 'aspen';
/** Build a CSS-ready data-URI of a single tinted leaf silhouette. */
function leafDataUri(kind: 'maple' | 'oak', fill: string): string {
const path = kind === 'maple' ? MAPLE_PATH : OAK_PATH;
// A faint vein line gives the blade depth without extra DOM nodes.
// Leaf silhouettes (viewBox 0 0 100 100, stem at the bottom). The maple is the
// flag-style silhouette on purpose: its bold lobes stay recognisable at 30 px,
// where the previous star-like outline read as a splat.
const LEAF_SHAPES: Record<LeafKind, { body: string; veins: string }> = {
maple: {
body:
'M50 5 L54 20 L62 14 L60 30 L74 22 L70 36 L86 34 L78 46 L90 52 L74 56 L78 66 ' +
'L60 62 L56 72 L52 64 L52 94 L48 94 L48 64 L44 72 L40 62 L22 66 L26 56 L10 52 ' +
'L22 46 L14 34 L30 36 L26 22 L40 30 L38 14 L46 20 Z',
veins: 'M50 66 L50 14 M50 58 L80 40 M50 58 L20 40 M50 64 L70 60 M50 64 L30 60',
},
oak: {
body:
'M50 4 C58 14 56 22 64 24 C74 22 74 32 68 36 C78 38 76 48 68 50 ' +
'C78 54 74 64 66 64 C70 74 60 78 54 72 C54 80 52 86 52 96 L48 96 ' +
'C48 86 46 80 46 72 C40 78 30 74 34 64 C26 64 22 54 32 50 ' +
'C24 48 22 38 32 36 C26 32 26 22 36 24 C44 22 42 14 50 4 Z',
veins:
'M50 76 L50 12 M50 34 L64 28 M50 34 L36 28 M50 50 L66 44 M50 50 L34 44 M50 64 L62 60 M50 64 L38 60',
},
aspen: {
body: 'M50 8 C74 22 80 54 52 84 L52 96 L48 96 L48 84 C20 54 26 22 50 8 Z',
veins: 'M50 84 L50 16 M50 40 L64 32 M50 40 L36 32 M50 58 L66 50 M50 58 L34 50',
},
};
/** Build a CSS-ready data-URI of one leaf: gradient body, darker veins. */
function leafDataUri(kind: LeafKind, [body, tip, vein]: [string, string, string]): string {
const shape = LEAF_SHAPES[kind];
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>` +
`<path d='${path}' fill='${fill}' fill-opacity='0.92'/>` +
`<path d='M50 96 L50 24' stroke='oklch(0.42 0.12 38)' stroke-opacity='0.35' ` +
`stroke-width='2.5' fill='none' stroke-linecap='round'/>` +
`<defs><linearGradient id='g' x1='0' y1='1' x2='0.4' y2='0'>` +
`<stop offset='0' stop-color='${body}'/><stop offset='1' stop-color='${tip}'/>` +
`</linearGradient></defs>` +
`<path d='${shape.body}' fill='url(#g)'/>` +
`<path d='${shape.veins}' stroke='${vein}' stroke-opacity='0.7' stroke-width='2' ` +
`fill='none' stroke-linecap='round'/>` +
`</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
type Leaf = {
kind: 'maple' | 'oak';
uri: string;
left: number; // vw column
size: number; // px
left: number;
size: number;
duration: number; // s — fall time
delay: number; // s
swayDuration: number; // s — wrapper sway
flutterDuration: number; // s — inner flutter
tilt: number; // deg — resting rotation (used for static scene)
swayDuration: number;
flutterDuration: number;
opacity: number;
};
type Mote = {
left: number;
bottom: number;
type GustLeaf = {
uri: string;
top: number; // % of height where it enters
size: number;
duration: number;
delay: number;
twinkle: number;
delay: number; // s within the gust
opacity: number;
};
// A few hand-placed leaves at rest for the reduced/static scene — arranged so
// they read as "settled" near edges and corners, never over the busy center.
/** Seconds between gusts, and when the first one comes after load. */
const GUST_PERIOD = 42;
const GUST_FIRST = 8;
// Leaves at rest for the reduced/static scene, in areas that are empty in the
// layout (middle of the left icon rail, the space under the member list),
// partly off-edge — never on the sidebar, members, composer or controls.
const RESTING_LEAVES: ReadonlyArray<{
kind: 'maple' | 'oak';
kind: LeafKind;
left: number;
top: number;
size: number;
@@ -99,212 +110,198 @@ const RESTING_LEAVES: ReadonlyArray<{
tone: number;
opacity: number;
}> = [
{ kind: 'maple', left: 6, top: 14, size: 46, tilt: -22, tone: 0, opacity: 0.4 },
{ kind: 'oak', left: 88, top: 22, size: 38, tilt: 28, tone: 1, opacity: 0.34 },
{ kind: 'maple', left: 16, top: 78, size: 54, tilt: 16, tone: 3, opacity: 0.42 },
{ kind: 'oak', left: 80, top: 82, size: 44, tilt: -34, tone: 4, opacity: 0.36 },
{ kind: 'maple', left: 50, top: 90, size: 40, tilt: 8, tone: 2, opacity: 0.32 },
{ kind: 'oak', left: 70, top: 8, size: 32, tilt: -12, tone: 2, opacity: 0.3 },
{ kind: 'maple', left: 0.5, top: 44, size: 60, tilt: 24, tone: 0, opacity: 0.62 },
{ kind: 'aspen', left: 1.5, top: 54, size: 44, tilt: -30, tone: 2, opacity: 0.6 },
{ kind: 'oak', left: 99, top: 62, size: 54, tilt: 160, tone: 3, opacity: 0.6 },
{ kind: 'maple', left: 97.5, top: 72, size: 46, tilt: 200, tone: 4, opacity: 0.6 },
];
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic pseudo-random field, computed ONCE. No per-frame state.
const { leaves, motes } = useMemo(() => {
const LEAF_COUNT = 16;
const MOTE_COUNT = 12;
// Composition for the Settings swatch (sizes are % of the tile width).
const PREVIEW_LEAVES: ReadonlyArray<{
kind: LeafKind;
left: number;
top: number;
size: number;
tilt: number;
tone: number;
}> = [
{ kind: 'maple', left: 30, top: 38, size: 42, tilt: -18, tone: 0 },
{ kind: 'oak', left: 70, top: 30, size: 30, tilt: 34, tone: 3 },
{ kind: 'aspen', left: 62, top: 70, size: 30, tilt: -60, tone: 2 },
{ kind: 'maple', left: 86, top: 80, size: 24, tilt: 20, tone: 1 },
{ kind: 'maple', left: 12, top: 82, size: 20, tilt: 40, tone: 4 },
];
const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
const kind: 'maple' | 'oak' = i % 3 === 0 ? 'oak' : 'maple';
const tone = LEAF_TONES[i % LEAF_TONES.length];
const sizeBucket = i % 4; // 0..3 → depth bucket for parallax
const size = 22 + sizeBucket * 9; // 22..49 px
export function AutumnOverlay({ reduced, preview }: SeasonalOverlayProps) {
// Deterministic field, computed ONCE. No per-frame state.
const { leaves, gust } = useMemo(() => {
const kinds = ['maple', 'oak', 'maple', 'aspen'] as const;
const builtLeaves: Leaf[] = Array.from({ length: 12 }, (_, i) => {
const depth = i % 4; // 0..3 → parallax bucket
return {
kind,
uri: leafDataUri(kind, tone),
left: (i * 6.13 + 4) % 100,
size,
// Larger (nearer) leaves fall a touch faster; all slow + cozy.
duration: 16 - sizeBucket * 1.6 + (i % 3) * 1.3,
delay: -((i * 1.37) % 16), // negative → staggered, already mid-fall
uri: leafDataUri(kinds[i % 4], LEAF_TONES[i % LEAF_TONES.length]),
left: (i * 8.3 + 4) % 100,
size: 28 + depth * 8, // 28..52 px
// Nearer leaves fall a touch faster; all slow and calm.
duration: 16 - depth * 1.6 + (i % 3) * 1.3,
delay: -((i * 1.37) % 16), // negative → already mid-fall on load
swayDuration: 5 + (i % 5) * 0.8,
flutterDuration: 1.6 + (i % 4) * 0.45,
tilt: ((i * 53) % 70) - 35,
opacity: 0.34 + sizeBucket * 0.08, // nearer → slightly bolder
flutterDuration: 2 + (i % 4) * 0.5,
// Nearer → bolder. Small, moving and semi-transparent, so text under a
// passing leaf stays readable.
opacity: 0.58 + depth * 0.05,
};
});
const builtMotes: Mote[] = Array.from({ length: MOTE_COUNT }, (_, i) => ({
left: (i * 8.7 + 5) % 100,
bottom: (i * 4.3) % 30, // start in lower third, drift up
size: 2 + (i % 3),
duration: 16 + (i % 6) * 2.4,
delay: -((i * 2.1) % 16),
twinkle: 2.2 + (i % 4) * 0.6,
opacity: 0.4 + (i % 3) * 0.12,
const builtGust: GustLeaf[] = Array.from({ length: 5 }, (_, i) => ({
uri: leafDataUri(kinds[(i + 1) % 4], LEAF_TONES[(i * 2) % LEAF_TONES.length]),
top: 30 + ((i * 13) % 40),
size: 30 + (i % 3) * 10,
delay: GUST_FIRST + i * 0.35,
opacity: 0.7,
}));
return { leaves: builtLeaves, motes: builtMotes };
return { leaves: builtLeaves, gust: builtGust };
}, []);
return (
<>
{/* 1. Ambient amber → rust atmospheric wash. Layered oklch gradients give
depth: a warm low-sun glow from the upper-left, a rust pool at the
base, and a faint gold core. Kept very low opacity. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(120% 90% at 18% 8%, oklch(0.82 0.13 85 / 0.16) 0%, transparent 55%)',
'radial-gradient(130% 100% at 50% 118%, oklch(0.55 0.16 40 / 0.18) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.75 0.15 70 / 0.07) 0%, transparent 40%, oklch(0.5 0.14 35 / 0.08) 100%)',
].join(','),
contain: 'layout paint style',
}}
/>
{/* 2. Soft angled low-sun light shafts. Two long beams skewed to suggest
late-afternoon light raking across the room. */}
{[
{ left: -8, rotate: 18, w: 38, opacity: 0.5, dur: 17, delay: 0 },
{ left: 46, rotate: 14, w: 30, opacity: 0.38, dur: 22, delay: -6 },
{ left: 78, rotate: 22, w: 26, opacity: 0.32, dur: 19, delay: -11 },
].map((shaft, i) => (
if (preview) {
// The Settings swatch: a warm backdrop (fine inside the tile, unlike over
// the app) with a few leaves arranged across it.
return (
<>
<div
key={`shaft-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: '-30%',
left: `${shaft.left}%`,
width: `${shaft.w}vw`,
height: '160%',
transformOrigin: 'top center',
transform: `rotate(${shaft.rotate}deg)`,
backgroundImage:
'linear-gradient(90deg, transparent 0%, oklch(0.85 0.12 82 / 0.5) 50%, transparent 100%)',
filter: 'blur(14px)',
mixBlendMode: 'screen',
opacity: reduced ? shaft.opacity * 0.85 : undefined,
willChange: reduced ? undefined : 'transform, opacity',
contain: 'layout paint style',
animation: reduced
? 'none'
: `${animSunShaft} ${shaft.dur}s ease-in-out ${shaft.delay}s infinite`,
inset: 0,
background:
'linear-gradient(160deg, oklch(0.42 0.09 55) 0%, oklch(0.32 0.08 40) 60%, oklch(0.24 0.06 35) 100%)',
}}
/>
))}
{/* 3. Drifting pollen / dust motes catching the light. Static scene omits
them — stillness reads cleaner at rest. */}
{!reduced &&
motes.map((m, i) => (
{PREVIEW_LEAVES.map((leaf, i) => (
<div
key={`mote-${i}`}
key={`pv-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${m.left}%`,
bottom: `${m.bottom}%`,
width: `${m.size}px`,
height: `${m.size}px`,
left: `${leaf.left}%`,
top: `${leaf.top}%`,
width: `${leaf.size}%`,
aspectRatio: '1',
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
filter: 'brightness(1.25)',
}}
/>
))}
</>
);
}
if (reduced) {
// Phones have no icon rail and the timeline runs edge to edge, so the
// resting leaves tuck further off-edge and smaller there.
const narrow = typeof window !== 'undefined' && window.matchMedia('(max-width: 750px)').matches;
return (
<>
{RESTING_LEAVES.map((leaf, i) => (
<div
key={`rest-${i}`}
aria-hidden="true"
className={leafBlade}
style={{
position: 'absolute',
left: narrow ? `${leaf.left < 50 ? -2 : 102}%` : `${leaf.left}%`,
top: `${leaf.top}%`,
width: `${narrow ? leaf.size * 0.8 : leaf.size}px`,
height: `${narrow ? leaf.size * 0.8 : leaf.size}px`,
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
opacity: leaf.opacity,
}}
/>
))}
</>
);
}
return (
<>
{leaves.map((leaf, i) => (
// Sway wrapper: horizontal wind, decoupled from the fall. No `paint`
// containment: it would clip the leaf to this box at the top of the
// screen, so it would never be drawn once falling (#237).
<div
key={`leaf-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${leaf.left}%`,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
willChange: 'transform',
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
}}
>
{/* Fall wrapper: vertical descent + tumble. */}
<div
style={{
width: '100%',
height: '100%',
willChange: 'transform, opacity',
animation: `${animMoteDrift} ${m.duration}s linear ${m.delay}s infinite`,
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
}}
>
{/* The leaf itself, with a soft flutter. */}
<div
className={leafBlade}
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background:
'radial-gradient(circle, oklch(0.88 0.1 85 / 0.95) 0%, oklch(0.78 0.12 70 / 0.4) 60%, transparent 100%)',
opacity: m.opacity,
animation: `${animMoteTwinkle} ${m.twinkle}s ease-in-out infinite`,
backgroundImage: leaf.uri,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
opacity: leaf.opacity,
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
}}
/>
</div>
))}
</div>
))}
{/* 4. Falling / resting maple & oak leaves. */}
{reduced
? RESTING_LEAVES.map((leaf, i) => (
<div
key={`rest-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${leaf.left}%`,
top: `${leaf.top}%`,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
opacity: leaf.opacity,
filter: 'drop-shadow(0 2px 3px oklch(0.3 0.08 40 / 0.35))',
}}
/>
))
: leaves.map((leaf, i) => (
// Sway wrapper: horizontal wind motion, decoupled from the fall.
<div
key={`leaf-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${leaf.left}%`,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
willChange: 'transform',
contain: 'layout paint style',
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
}}
>
{/* Fall wrapper: vertical descent + tumble rotation. */}
<div
style={{
width: '100%',
height: '100%',
willChange: 'transform, opacity',
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
}}
>
{/* Inner blade: the actual silhouette + flutter wobble. */}
<div
style={{
width: '100%',
height: '100%',
backgroundImage: leaf.uri,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
opacity: leaf.opacity,
filter: 'drop-shadow(0 1px 2px oklch(0.3 0.08 40 / 0.3))',
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
}}
/>
</div>
</div>
))}
{/* 5. Warm low-saturation vignette. Frames the scene and gently darkens
edges — protecting central chat text contrast. Breathes faintly. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(120% 110% at 50% 45%, transparent 52%, oklch(0.38 0.07 45 / 0.14) 82%, oklch(0.3 0.06 40 / 0.22) 100%)',
contain: 'layout paint style',
opacity: reduced ? 1 : undefined,
animation: reduced ? 'none' : `${animEmberPulse} 9s ease-in-out infinite`,
}}
/>
{/* Gust: a handful of leaves blown across the screen together, then a
long calm. Off-screen (and invisible) the rest of the cycle. */}
{gust.map((leaf, i) => (
<div
key={`gust-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${leaf.top}%`,
left: 0,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
opacity: 0,
willChange: 'transform, opacity',
animation: `${animGust} ${GUST_PERIOD}s linear ${leaf.delay}s infinite`,
}}
>
<div
className={leafBlade}
style={{
width: '100%',
height: '100%',
backgroundImage: leaf.uri,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
opacity: leaf.opacity,
}}
/>
</div>
))}
</>
);
}
+5
View File
@@ -21,4 +21,9 @@ export type SeasonTheme =
// passes reduced=true, so the static form has to stand on its own.
export type SeasonalOverlayProps = {
reduced: boolean;
/**
* Rendering in the small Settings swatch rather than over the app. Optional:
* a theme may draw a composition made for the tile (always also `reduced`).
*/
preview?: boolean;
};
@@ -50,6 +50,7 @@ import { clearAll as clearSearchCache } from '../../utils/searchCache';
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { ParsedSearch, resolveRoomOperand } from './searchOperators';
import { SearchFilters } from './SearchFilters';
import { VirtualTile } from '../../components/virtualizer';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
@@ -205,6 +206,8 @@ export function MessageSearch({
// currently pinned in their room (`m.room.pinned_events`). Server-unaffected.
const [pinnedOnly, setPinnedOnly] = useState(false);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
// [Gitea #106] Why a typed operator couldn't be applied (unknown room, …).
const [operatorNotice, setOperatorNotice] = useState<string>();
const [searchParams, setSearchParams] = useSearchParams();
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
@@ -400,6 +403,94 @@ export function MessageSearch({
setRecentSearches((prev) => addRecentSearch(prev, term));
};
// [Gitea #106] Typed operators → the same filters the buttons set.
const handleQuery = (parsed: ParsedSearch, raw: string): boolean => {
const notices: string[] = [];
const roomChoices = allRooms.map((roomId) => {
const room = mx.getRoom(roomId);
return {
roomId,
name: room?.name ?? '',
aliases: [room?.getCanonicalAlias(), ...(room?.getAltAliases() ?? [])].filter(
(a): a is string => !!a,
),
};
});
const roomIds: string[] = [];
parsed.rooms.forEach((value) => {
const id = resolveRoomOperand(value, roomChoices);
if (id) {
if (!roomIds.includes(id)) roomIds.push(id);
} else notices.push(`No room matches “in:${value}”.`);
});
const mergedSenders = [...(searchParamsSenders ?? [])];
// from:bob → a known @bob:server (this homeserver first), as autocomplete would.
const myServer = mx.getUserId()?.split(':')[1];
const resolveUser = (id: string): string => {
if (id.includes(':')) return id;
const local = id.slice(1).toLowerCase();
const found = new Set<string>();
mx.getRooms().forEach((room) =>
room.getMembers().forEach((m) => {
if (m.userId.slice(1).split(':')[0].toLowerCase() === local) found.add(m.userId);
}),
);
const list = [...found];
return list.find((u) => u.split(':')[1] === myServer) ?? list[0] ?? id;
};
parsed.senders.map(resolveUser).forEach((id) => {
if (!mergedSenders.includes(id)) mergedSenders.push(id);
});
if (!parsed.term && mergedSenders.length === 0) {
notices.push('Add a word to search for, or a from:@user.');
}
if (parsed.unknown.length > 0) {
notices.push(
`Not a search filter: ${parsed.unknown.join(', ')} (searched as text). Try in:, from:, before:, after:, has:link/image/video/file, is:pinned.`,
);
}
setOperatorNotice(notices.length > 0 ? notices.join(' ') : undefined);
if (!parsed.term && mergedSenders.length === 0) return false;
setSearchParams((prevParams) => {
const p = new URLSearchParams(prevParams);
p.delete('term');
p.append('term', parsed.term);
if (parsed.senders.length > 0) {
p.delete('senders');
p.append('senders', encodeSearchParamValueArray(mergedSenders));
}
if (roomIds.length > 0) {
p.delete('rooms');
p.append('rooms', encodeSearchParamValueArray(roomIds));
// A room outside this page's own scope needs the all-rooms scope.
if (allowGlobal && roomIds.some((id) => !rooms.includes(id))) {
p.delete('global');
p.append('global', 'true');
}
}
if (parsed.fromTs !== undefined) {
p.delete('fromTs');
p.append('fromTs', String(parsed.fromTs));
}
if (parsed.toTs !== undefined) {
p.delete('toTs');
p.append('toTs', String(parsed.toTs));
}
if (parsed.containsUrl) {
p.delete('containsUrl');
p.append('containsUrl', 'true');
}
return p;
});
if (parsed.msgTypes.length > 0) {
setMsgTypeFilters((prev) => [...new Set([...prev, ...parsed.msgTypes])]);
}
if (parsed.pinnedOnly) setPinnedOnly(true);
setRecentSearches((prev) => addRecentSearch(prev, raw));
return true;
};
const handleRecentSearch = (term: string) => {
if (searchInputRef.current) {
searchInputRef.current.value = term;
@@ -548,10 +639,16 @@ export function MessageSearch({
onSearch={handleSearch}
onReset={handleSearchClear}
onSenderAdd={handleSenderAdd}
onQuery={handleQuery}
recentSearches={recentSearches}
onRecentSearch={handleRecentSearch}
onClearRecentSearches={handleClearRecentSearches}
/>
{operatorNotice && (
<Text size="T200" role="status" style={{ color: color.Warning.Main }}>
{operatorNotice}
</Text>
)}
<SearchFilters
defaultRoomsFilterName={defaultRoomsFilterName}
allowGlobal={allowGlobal}
@@ -582,7 +679,7 @@ export function MessageSearch({
<PageHero
icon={<Icon size="600" src={Icons.Message} />}
title="Search Messages"
subTitle="Find helpful messages in your community by searching with related keywords, or type from:@user to see all messages from someone."
subTitle="Search by keyword. Narrow it with from:@user, in:room, after:2026-09-01 or before:7d, has:link / has:image, and is:pinned."
/>
</PageHeroSection>
</PageHeroEmpty>
@@ -24,6 +24,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ParsedSearch, hasOperators, parseSearchQuery } from './searchOperators';
// Matches "from:@?anything" anywhere — @ is optional
const FROM_REGEX = /from:@?([^\s:][^\s]*)/gi;
@@ -43,6 +44,9 @@ type SearchInputProps = {
onSearch: (term: string) => void;
onReset: () => void;
onSenderAdd?: (userId: string) => void;
/** [Gitea #106] Receives in:/before:/after:/has:/is: (and from:) as filters. */
/** Returns false when the query wasn't run, so the text is kept as typed. */
onQuery?: (parsed: ParsedSearch, raw: string) => boolean;
recentSearches?: string[];
onRecentSearch?: (term: string) => void;
onClearRecentSearches?: () => void;
@@ -54,6 +58,7 @@ export function SearchInput({
onSearch,
onReset,
onSenderAdd,
onQuery,
recentSearches,
onRecentSearch,
onClearRecentSearches,
@@ -167,6 +172,19 @@ export function SearchInput({
const rawValue = searchInput.value.trim();
if (onQuery) {
const parsed = parseSearchQuery(rawValue);
if (!parsed.term && !hasOperators(parsed)) return;
// Recognised operators become filters (chips / controls); drop them
// from the visible input like from: always did.
const ran = onQuery(parsed, rawValue);
if (ran && hasOperators(parsed) && searchInputRef.current) {
searchInputRef.current.value = parsed.term;
}
closeAutocomplete();
return;
}
// Extract from:user fragments and convert to sender filters
const fromMatches = [...rawValue.matchAll(FROM_REGEX)];
fromMatches.forEach((match) => {
@@ -0,0 +1,86 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseDateOperand, parseSearchQuery, resolveRoomOperand } from './searchOperators';
const NOW = new Date(2026, 8, 25, 12, 0, 0).getTime();
const DAY = 86_400_000;
describe('parseSearchQuery', () => {
it('plain text stays the term', () => {
const p = parseSearchQuery('loot rules tonight', NOW);
assert.equal(p.term, 'loot rules tonight');
assert.deepEqual(p.senders, []);
});
it('pulls every operator out of the term', () => {
const p = parseSearchQuery(
'raid from:bob in:general after:7d before:2026-09-24 has:link has:image is:pinned plan',
NOW,
);
assert.equal(p.term, 'raid plan');
assert.deepEqual(p.senders, ['@bob']);
assert.deepEqual(p.rooms, ['general']);
assert.equal(p.fromTs, NOW - 7 * DAY);
assert.equal(p.toTs, new Date(2026, 8, 24).getTime());
assert.equal(p.containsUrl, true);
assert.deepEqual(p.msgTypes, ['m.image']);
assert.equal(p.pinnedOnly, true);
assert.deepEqual(p.unknown, []);
});
it('keeps @ on from:, accepts quoted in:, and is case-insensitive on keys', () => {
const p = parseSearchQuery('FROM:@alice:x.org In:"voice lounge" hello', NOW);
assert.deepEqual(p.senders, ['@alice:x.org']);
assert.deepEqual(p.rooms, ['voice lounge']);
assert.equal(p.term, 'hello');
});
it('leaves operators it cannot understand in the term', () => {
const p = parseSearchQuery('has:gif after:someday is:starred hi', NOW);
assert.equal(p.term, 'has:gif after:someday is:starred hi');
assert.deepEqual(p.unknown, ['has:gif', 'after:someday', 'is:starred']);
});
it('does not treat a URL or a mid-word colon as an operator', () => {
const p = parseSearchQuery('see https://in:example.org and 10:30 before-lunch', NOW);
assert.equal(p.term, 'see https://in:example.org and 10:30 before-lunch');
assert.deepEqual(p.rooms, []);
});
it('has: file/video map to the media filters without duplicates', () => {
const p = parseSearchQuery('has:file has:files has:video', NOW);
assert.deepEqual(p.msgTypes, ['m.file', 'm.video']);
assert.equal(p.term, '');
});
});
describe('parseDateOperand', () => {
it('relative units', () => {
assert.equal(parseDateOperand('3h', NOW), NOW - 3 * 3_600_000);
assert.equal(parseDateOperand('2w', NOW), NOW - 14 * DAY);
assert.equal(parseDateOperand('1m', NOW), NOW - 30 * DAY);
});
it('rejects impossible dates and junk', () => {
assert.equal(parseDateOperand('2026-02-30', NOW), undefined);
assert.equal(parseDateOperand('yesterday', NOW), undefined);
});
});
describe('resolveRoomOperand', () => {
const rooms = [
{ roomId: '!a:x', name: 'General', aliases: ['#general:x.org'] },
{ roomId: '!b:x', name: 'Voice Lounge', aliases: [] },
{ roomId: '!c:x', name: 'Off Topic', aliases: ['#offtopic:x.org'] },
];
it('by alias, alias localpart, name, room id', () => {
assert.equal(resolveRoomOperand('#general:x.org', rooms), '!a:x');
assert.equal(resolveRoomOperand('offtopic', rooms), '!c:x');
assert.equal(resolveRoomOperand('voice lounge', rooms), '!b:x');
assert.equal(resolveRoomOperand('!b:x', rooms), '!b:x');
});
it('a unique partial name match, otherwise nothing', () => {
assert.equal(resolveRoomOperand('lounge', rooms), '!b:x');
assert.equal(resolveRoomOperand('o', rooms), undefined);
assert.equal(resolveRoomOperand('nope', rooms), undefined);
});
});
@@ -0,0 +1,140 @@
import type { MsgTypeFilter } from './useMessageSearch';
/**
* [Gitea #106] Discord-style operators typed into the search box. Each one
* maps onto a filter the search already has (sender chips, room picker, date
* range, "contains link", media type, pinned), so typing and clicking end up
* in the same state. Pure and unit-tested.
*
* from:@user sender (existing)
* in:general in:"a b" room, by name or alias (resolved by the caller)
* after:2026-09-01 from the start of that day (local time)
* before:2026-09-01 up to the start of that day
* after:7d before:2w relative: h, d, w, m (30 d), y (365 d) ago
* has:link | image | video | file
* is:pinned
*/
export type ParsedSearch = {
term: string;
senders: string[];
rooms: string[];
fromTs?: number;
toTs?: number;
containsUrl?: boolean;
msgTypes: MsgTypeFilter[];
pinnedOnly?: boolean;
/** Operators we didn't understand, left in the term as typed. */
unknown: string[];
};
const UNIT_MS: Record<string, number> = {
h: 3_600_000,
d: 86_400_000,
w: 7 * 86_400_000,
m: 30 * 86_400_000,
y: 365 * 86_400_000,
};
/** A date operand → timestamp, or undefined if it isn't one. Exported for tests. */
export function parseDateOperand(value: string, now: number): number | undefined {
const rel = /^(\d{1,4})([hdwmy])$/i.exec(value);
if (rel) return now - Number(rel[1]) * UNIT_MS[rel[2].toLowerCase()];
const abs = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value);
if (abs) {
const [y, m, d] = [Number(abs[1]), Number(abs[2]), Number(abs[3])];
const date = new Date(y, m - 1, d);
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
return undefined;
}
return date.getTime();
}
return undefined;
}
const HAS_TYPES: Record<string, MsgTypeFilter> = {
image: 'm.image',
images: 'm.image',
video: 'm.video',
videos: 'm.video',
file: 'm.file',
files: 'm.file',
};
// key:"quoted value" or key:value
const OPERATOR = /(^|\s)(from|in|after|before|has|is):(?:"([^"]*)"|(\S*))/gi;
export function parseSearchQuery(raw: string, now = Date.now()): ParsedSearch {
const out: ParsedSearch = { term: '', senders: [], rooms: [], msgTypes: [], unknown: [] };
const kept: string[] = [];
let last = 0;
raw.replace(
OPERATOR,
(match, lead: string, keyRaw: string, quoted?: string, bare?: string, offset?: number) => {
const key = keyRaw.toLowerCase();
const value = (quoted ?? bare ?? '').trim();
const start = (offset ?? 0) + lead.length;
kept.push(raw.slice(last, start));
last = start + match.length - lead.length;
let understood = value !== '';
if (understood) {
if (key === 'from') {
out.senders.push(value.startsWith('@') ? value : `@${value}`);
} else if (key === 'in') {
out.rooms.push(value);
} else if (key === 'after' || key === 'before') {
const ts = parseDateOperand(value, now);
if (ts === undefined) understood = false;
else if (key === 'after') out.fromTs = ts;
else out.toTs = ts;
} else if (key === 'has') {
const v = value.toLowerCase();
if (v === 'link' || v === 'links' || v === 'url') out.containsUrl = true;
else if (HAS_TYPES[v]) {
if (!out.msgTypes.includes(HAS_TYPES[v])) out.msgTypes.push(HAS_TYPES[v]);
} else understood = false;
} else if (key === 'is') {
if (value.toLowerCase() === 'pinned') out.pinnedOnly = true;
else understood = false;
}
}
if (!understood) {
out.unknown.push(match.trim());
kept.push(match.trim());
}
return match;
},
);
kept.push(raw.slice(last));
out.term = kept.join(' ').replace(/\s+/g, ' ').trim();
return out;
}
/** True when the query contains any operator at all (for the submit path). */
export const hasOperators = (p: ParsedSearch): boolean =>
p.senders.length > 0 ||
p.rooms.length > 0 ||
p.fromTs !== undefined ||
p.toTs !== undefined ||
!!p.containsUrl ||
p.msgTypes.length > 0 ||
!!p.pinnedOnly;
/** Resolve an `in:` value to a room id among `rooms`. Exported for tests. */
export function resolveRoomOperand(
value: string,
rooms: { roomId: string; name: string; aliases: string[] }[],
): string | undefined {
const v = value.toLowerCase().replace(/^#/, '');
const exact = rooms.find(
(r) =>
r.roomId.toLowerCase() === value.toLowerCase() ||
r.aliases.some((a) => {
const al = a.toLowerCase().replace(/^#/, '');
return al === v || al.split(':')[0] === v;
}) ||
r.name.toLowerCase() === v,
);
if (exact) return exact.roomId;
const partial = rooms.filter((r) => r.name.toLowerCase().includes(v));
return partial.length === 1 ? partial[0].roomId : undefined;
}