Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.
lib/mailbox/ archiveDate, emptyFolder, folderMove, labelTree,
mailboxName, mailboxRoute
lib/sieve/ sieve, sieveApply, sieveFolders
lib/input/ keyboard, swipe, touch, listSelection, dropUpload
lib/notify/ notify, webpush, webpushEnable
lib/sw/ swCache, swFacts, staleBuild
lib/text/ html, markdown, text, emlName
FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:
- appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
*Files*, where the client keeps signature images and synced settings.
It stays flat.
- format holds no formatting of text. It re-exports the date and clock
formatters, so it belongs with dates/datetime, not with text/.
- preview is the file viewer deciding what it can show without
downloading, and source is where to point someone asking for this
instance's AGPL source. Neither is about text.
- notify is not Web Push. It is the tab title, the favicon badge and
the new-mail sound -- in-app notification, which is why it sits with
webpush rather than under sw/ with the service worker's own concerns.
threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.
No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
105 lines
5.7 KiB
TypeScript
105 lines
5.7 KiB
TypeScript
import { useEffect, useRef, useState, type FormEvent } from "react";
|
|
import { useLocation, useSearch } from "wouter";
|
|
import { Search, SlidersHorizontal, X } from "lucide-react";
|
|
import { useMail } from "@/store/mail";
|
|
import { keyboard } from "@/lib/input/keyboard";
|
|
import { DateField } from "@/ui/datefield";
|
|
import { t } from "@/lib/i18n";
|
|
|
|
export function SearchBar() {
|
|
const [location, navigate] = useLocation();
|
|
const search = useSearch();
|
|
const [q, setQ] = useState("");
|
|
const [adv, setAdv] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const mailboxes = useMail((s) => s.mailboxes);
|
|
const [advFields, setAdvFields] = useState({ from: "", to: "", subject: "", words: "", hasAttachment: false, unread: false, folder: "", after: "", before: "" });
|
|
|
|
// Sync from URL when on /search
|
|
useEffect(() => {
|
|
if (location.startsWith("/search")) {
|
|
const params = new URLSearchParams(search);
|
|
setQ(params.get("q") ?? "");
|
|
} else setQ("");
|
|
}, [location, search]);
|
|
|
|
useEffect(() => keyboard.pushScope("search", [{ keys: "/", description: "Search mail", group: "Navigation", handler: () => inputRef.current?.focus() }]), []);
|
|
|
|
const submit = (e?: FormEvent) => {
|
|
e?.preventDefault();
|
|
const query = q.trim();
|
|
if (!query) return;
|
|
setAdv(false);
|
|
navigate(`/search?q=${encodeURIComponent(query)}`);
|
|
inputRef.current?.blur();
|
|
};
|
|
|
|
const applyAdvanced = () => {
|
|
const parts: string[] = [];
|
|
const f = advFields;
|
|
if (f.from) parts.push(`from:${quote(f.from)}`);
|
|
if (f.to) parts.push(`to:${quote(f.to)}`);
|
|
if (f.subject) parts.push(`subject:${quote(f.subject)}`);
|
|
if (f.words) parts.push(f.words);
|
|
if (f.hasAttachment) parts.push("has:attachment");
|
|
if (f.unread) parts.push("is:unread");
|
|
if (f.folder) parts.push(`in:${quote(f.folder)}`);
|
|
if (f.after) parts.push(`after:${f.after}`);
|
|
if (f.before) parts.push(`before:${f.before}`);
|
|
const query = parts.join(" ");
|
|
setQ(query);
|
|
setAdv(false);
|
|
if (query) navigate(`/search?q=${encodeURIComponent(query)}`);
|
|
};
|
|
|
|
return (
|
|
<form className="searchbar" role="search" onSubmit={submit}>
|
|
<div className="search-input">
|
|
<Search size={18} className="muted" />
|
|
<input ref={inputRef} type="search" placeholder={t("Search mail (from:, to:, subject:, has:attachment, is:unread, in:, before:, after:)")} value={q} onChange={(e) => setQ(e.target.value)} aria-label={t("Search mail")} enterKeyHint="search" />
|
|
{q && (
|
|
<button type="button" className="icon-btn sm" aria-label={t("Clear")} onClick={() => { setQ(""); if (location.startsWith("/search")) navigate("/mail"); }}>
|
|
<X size={16} />
|
|
</button>
|
|
)}
|
|
<button type="button" className={`icon-btn sm ${adv ? "active" : ""}`} aria-label={t("Advanced search")} title={t("Advanced search")} onClick={() => setAdv((v) => !v)}>
|
|
<SlidersHorizontal size={16} />
|
|
</button>
|
|
</div>
|
|
{adv && (
|
|
<div className="search-panel">
|
|
<div className="grid">
|
|
<label className="field"><span className="label">{t("From")}</span><input className="input sm" value={advFields.from} onChange={(e) => setAdvFields({ ...advFields, from: e.target.value })} /></label>
|
|
<label className="field"><span className="label">{t("To")}</span><input className="input sm" value={advFields.to} onChange={(e) => setAdvFields({ ...advFields, to: e.target.value })} /></label>
|
|
<label className="field"><span className="label">{t("Subject")}</span><input className="input sm" value={advFields.subject} onChange={(e) => setAdvFields({ ...advFields, subject: e.target.value })} /></label>
|
|
<label className="field"><span className="label">{t("Has the words")}</span><input className="input sm" value={advFields.words} onChange={(e) => setAdvFields({ ...advFields, words: e.target.value })} /></label>
|
|
<label className="field"><span className="label">{t("Folder")}</span>
|
|
<select className="select" style={{ height: 32 }} value={advFields.folder} onChange={(e) => setAdvFields({ ...advFields, folder: e.target.value })}>
|
|
<option value="">{t("All mail")}</option>
|
|
{Object.values(mailboxes).sort((a, b) => a.name.localeCompare(b.name)).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<div className="field"><span className="label">{t("Date")}</span>
|
|
<div className="row"><DateField aria-label={t("After")} value={advFields.after} onChange={(v) => setAdvFields({ ...advFields, after: v })} /><span className="muted">{t("to")}</span><DateField aria-label={t("Before")} value={advFields.before} onChange={(v) => setAdvFields({ ...advFields, before: v })} /></div>
|
|
</div>
|
|
</div>
|
|
<div className="row" style={{ justifyContent: "space-between", marginTop: 4 }}>
|
|
<div className="row gap-16">
|
|
<label className="check"><input type="checkbox" checked={advFields.hasAttachment} onChange={(e) => setAdvFields({ ...advFields, hasAttachment: e.target.checked })} /> {t("Has attachment")}</label>
|
|
<label className="check"><input type="checkbox" checked={advFields.unread} onChange={(e) => setAdvFields({ ...advFields, unread: e.target.checked })} /> {t("Unread only")}</label>
|
|
</div>
|
|
<div className="row">
|
|
<button type="button" className="btn btn-ghost" onClick={() => setAdv(false)}>{t("Cancel")}</button>
|
|
<button type="button" className="btn btn-primary" onClick={applyAdvanced}>{t("Search")}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function quote(s: string): string {
|
|
return /\s/.test(s) ? `"${s.replace(/"/g, "")}"` : s;
|
|
}
|