2c3dba55e6
Replaced raw style={{ opacity: N }} de-emphasis on folds <Text> with the
`priority` prop across search, schedule, profile, and tray UI. Left the cases
that aren't Text-priority candidates (an Icon opacity, a Box-row opacity, and a
Text with an explicit color token).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
346 lines
11 KiB
TypeScript
346 lines
11 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';
|
|
|
|
// 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;
|
|
recentSearches?: string[];
|
|
onRecentSearch?: (term: string) => void;
|
|
onClearRecentSearches?: () => void;
|
|
};
|
|
export function SearchInput({
|
|
active,
|
|
loading,
|
|
searchInputRef,
|
|
onSearch,
|
|
onReset,
|
|
onSenderAdd,
|
|
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();
|
|
|
|
// 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>
|
|
);
|
|
}
|