Files
cinny/src/app/features/message-search/SearchInput.tsx
T
Lotus CIandClaude Opus 5.5 75951f9040
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
feat(search): in:, before:/after:, has:, is:pinned operators (#106)
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

364 lines
12 KiB
TypeScript

import React, {
ChangeEventHandler,
FormEventHandler,
RefObject,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import {
Avatar,
Box,
Chip,
config,
Icon,
Icons,
Input,
Menu,
MenuItem,
Spinner,
Text,
} from 'folds';
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;
// Matches trailing "from:@?..." so autocomplete fires as the user types
const FROM_TYPING_REGEX = /from:@?([^\s]*)$/i;
type UserSuggestion = {
userId: string;
displayName?: string;
avatarUrl?: string;
};
type SearchInputProps = {
active?: boolean;
loading?: boolean;
searchInputRef: RefObject<HTMLInputElement>;
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;
};
export function SearchInput({
active,
loading,
searchInputRef,
onSearch,
onReset,
onSenderAdd,
onQuery,
recentSearches,
onRecentSearch,
onClearRecentSearches,
}: SearchInputProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const inputWrapRef = useRef<HTMLDivElement>(null);
const [fromQuery, setFromQuery] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const [focused, setFocused] = useState(false);
const [inputEmpty, setInputEmpty] = useState(true);
// Collect users from room member lists, scored for relevance.
// Score: same homeserver → +1000, each shared room → +1.
// This ensures @user:matrix.lotusguild.org ranks above @user:matrix.org.
const allUsers = useMemo<UserSuggestion[]>(() => {
const myId = mx.getUserId();
const myServer = myId ? getMxIdServer(myId) : undefined;
const map = new Map<string, UserSuggestion & { score: number }>();
mx.getRooms().forEach((room) => {
room.getMembers().forEach((member) => {
if (member.userId === myId) return;
const existing = map.get(member.userId);
if (existing) {
existing.score += 1; // +1 per additional shared room
} else {
const homeserver = getMxIdServer(member.userId);
map.set(member.userId, {
userId: member.userId,
displayName: member.name ?? undefined,
avatarUrl: member.getMxcAvatarUrl() ?? undefined,
score: myServer && homeserver === myServer ? 1000 : 1,
});
}
});
});
return Array.from(map.values()).sort((a, b) => b.score - a.score);
}, [mx]);
const suggestedUsers = useMemo(() => {
if (!showSuggestions) return [];
const q = fromQuery.toLowerCase();
return allUsers
.filter(
(u) =>
u.userId.toLowerCase().includes(q) || (u.displayName ?? '').toLowerCase().includes(q),
)
.slice(0, 8);
}, [allUsers, fromQuery, showSuggestions]);
const closeAutocomplete = useCallback(() => {
setFromQuery('');
setShowSuggestions(false);
}, []);
const handleUserSelect = useCallback(
(userId: string) => {
if (searchInputRef.current) {
searchInputRef.current.value = searchInputRef.current.value
.replace(FROM_TYPING_REGEX, '')
.replace(/\s+/g, ' ')
.trim();
}
closeAutocomplete();
onSenderAdd?.(userId);
searchInputRef.current?.focus();
},
[searchInputRef, onSenderAdd, closeAutocomplete],
);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.target.value;
setInputEmpty(value.trim() === '');
const match = FROM_TYPING_REGEX.exec(value);
if (match) {
setFromQuery(match[1]);
setShowSuggestions(true);
} else {
closeAutocomplete();
}
};
const handleFocus = () => {
setFocused(true);
};
// Close autocomplete when input loses focus — delay so item clicks fire first
const handleBlur = () => {
setTimeout(() => {
closeAutocomplete();
setFocused(false);
}, 150);
};
const handleRecentClick = (term: string) => {
if (searchInputRef.current) {
searchInputRef.current.value = term;
}
setInputEmpty(false);
onRecentSearch?.(term);
};
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { searchInput } = evt.target as HTMLFormElement & {
searchInput: HTMLInputElement;
};
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) => {
const raw = match[1];
const userId = raw.startsWith('@') ? raw : `@${raw}`;
onSenderAdd?.(userId);
});
const searchTerm = rawValue.replace(FROM_REGEX, '').replace(/\s+/g, ' ').trim() || undefined;
// Remove the from: fragments from the visible input
if (fromMatches.length > 0 && searchInputRef.current) {
searchInputRef.current.value = searchTerm ?? '';
}
// Always trigger search when senders were extracted, even with no body text
if (fromMatches.length > 0 || searchTerm) {
onSearch(searchTerm ?? '');
}
closeAutocomplete();
};
return (
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
<span data-spacing-node />
<Text size="L400">Search</Text>
{/* Relative wrapper so the absolute dropdown positions against the input */}
<Box ref={inputWrapRef} direction="Column" style={{ position: 'relative' }}>
<Input
ref={searchInputRef}
style={{ paddingRight: config.space.S300 }}
name="searchInput"
autoFocus
size="500"
variant="Background"
placeholder="Search messages or type from:@user to filter by sender"
autoComplete="off"
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
before={
active && loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Icon size="200" src={Icons.Search} />
)
}
after={
active ? (
<Chip
key="resetButton"
type="reset"
variant="Secondary"
size="400"
radii="Pill"
outlined
after={<Icon size="50" src={Icons.Cross} />}
onClick={onReset}
>
<Text size="B300">Clear</Text>
</Chip>
) : (
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
<Text size="B300">Enter</Text>
</Chip>
)
}
/>
{suggestedUsers.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 999,
marginTop: config.space.S100,
}}
>
<Menu variant="Surface" style={{ width: '100%' }}>
<Box direction="Column" style={{ padding: `${config.space.S100} 0` }}>
{suggestedUsers.map((user) => {
const displayName = user.displayName ?? getMxIdLocalPart(user.userId);
const avatarUrl = user.avatarUrl
? (mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ??
undefined)
: undefined;
return (
<MenuItem
key={user.userId}
variant="Surface"
size="300"
radii="0"
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => handleUserSelect(user.userId)}
before={
<Avatar size="200" radii="Pill">
<UserAvatar
userId={user.userId}
src={avatarUrl}
alt={displayName ?? user.userId}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Box direction="Column" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{displayName}
</Text>
<Text
size="T200"
truncate
priority="300"
style={{ fontFamily: 'monospace', fontSize: '0.75em' }}
>
{user.userId}
</Text>
</Box>
</MenuItem>
);
})}
</Box>
</Menu>
</div>
)}
{focused &&
inputEmpty &&
suggestedUsers.length === 0 &&
recentSearches &&
recentSearches.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 999,
marginTop: config.space.S100,
}}
>
<Menu variant="Surface" style={{ width: '100%' }}>
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Recent searches</Text>
<Chip
variant="SurfaceVariant"
radii="Pill"
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => onClearRecentSearches?.()}
>
<Text size="T200">Clear</Text>
</Chip>
</Box>
<Box gap="200" wrap="Wrap">
{recentSearches.map((term) => (
<Chip
key={term}
variant="SurfaceVariant"
radii="Pill"
before={<Icon size="50" src={Icons.RecentClock} />}
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => handleRecentClick(term)}
>
<Text size="T200">{term}</Text>
</Chip>
))}
</Box>
</Box>
</Menu>
</div>
)}
</Box>
</Box>
);
}