import { MessageContent, MessageImageBanner } from "./MessageContent"; import { useEffect, useMemo, useState } from "react"; import { Avatar, Button, Confirm, EmptyState, Pill, Sheet, Toggle } from "../ui"; import { registerCommands, runCommand } from "../keys/commands"; import { useKeyContext } from "../keys/keymap"; import type { Destination, ScreenerCard } from "../ipc"; import { useMail } from "../store/useMail"; import { DESTINATIONS, destinationName, domainOf, domainRuleAllowed, useScreener, } from "../store/useScreener"; import { cap, displayName, isBrand } from "./format"; import { BodyMissing, BodySkeleton } from "./MessageBody"; import "./screener.css"; /** * The Screener: the whole stage, one card per sender waiting at the door. * * A card is the message rather than a row that opens one, which is why this is not a list column: * everything a decision needs is on the card, and the decision is three buttons wide. * * Nothing here congratulates anybody and nothing here is a count except the sentence at the top, * which is the number of people it is about. */ const WORDS = [ "Nobody", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", ]; /** Small numbers read as words in a sentence and as digits in a pill, which is where the pill is. */ const spell = (n: number): string => WORDS[n] ?? String(n); export function Screener() { const accountId = useMail((s) => s.accountId); const cards = useScreener((s) => s.cards); const phase = useScreener((s) => s.phase); const focused = useScreener((s) => s.focused); const expanded = useScreener((s) => s.expanded); const deciding = useScreener((s) => s.deciding); const load = useScreener((s) => s.load); const focus = useScreener((s) => s.focus); const step = useScreener((s) => s.step); const toggleExpanded = useScreener((s) => s.toggleExpanded); const decide = useScreener((s) => s.decide); const clearAll = useScreener((s) => s.clearAll); const [picking, setPicking] = useState(null); const [clearing, setClearing] = useState(false); useEffect(() => { void load(accountId); }, [accountId, load]); // The top card takes the keyboard as soon as there is one. Every verb on this screen acts on the // focused card, and a Screener whose first `y` does nothing is a Screener you press `y` at twice. useEffect(() => { if (focused === null && cards.length > 0) focus(cards[0].threadKey); }, [cards, focused, focus]); // The card owns `y`, `v` and `n` while the Screener is up, which is the one place in the app // where a letter means something other than what the list would have made of it. useKeyContext("screener"); // A panel is in front, so the card's keys and the view's both stand back until it closes. useKeyContext("overlay", picking !== null || clearing); // Every verb here acts on the focused card, and on an empty pile there is none, which is how a // key that would act on nothing comes to do nothing. A card whose decision is already out is // the same: its buttons are down, and the letter is the same button. useEffect(() => { const on = (run: (card: ScreenerCard) => void) => () => { const state = useScreener.getState(); const card = state.cards.find((c) => c.threadKey === state.focused); if (card && !state.deciding.includes(card.threadKey)) run(card); }; return registerCommands({ "screen-yes": on((card) => void decide(card.threadKey, card.suggestion, false)), "screen-elsewhere": on((card) => setPicking(card.threadKey)), "screen-no": on((card) => void decide(card.threadKey, "screened-out", false)), // `r` screens in to the Inbox and opens a reply, and there is no composer to open one in // yet. Half of it is worse than none of it, so nobody owns the verb and the button does // nothing, the same way Reply in the reading pane does nothing. "select-next": () => step(1), "select-prev": () => step(-1), "open-selection": () => { const key = useScreener.getState().focused; if (key) toggleExpanded(key); }, undo: () => void useScreener.getState().undo(accountId), }); }, [accountId, decide, step, toggleExpanded]); const picked = useMemo( () => cards.find((c) => c.threadKey === picking) ?? null, [cards, picking], ); return (

Screener

{cards.length > 0 ? (

{`${spell(cards.length)} ${cards.length === 1 ? "person" : "people"} wrote to you for the first time. Say where their mail goes, or that it goes nowhere. Nobody is told.`}

) : null}
{cards.length === 0 ? (
{phase === "loading" ? null : No one is waiting}
) : (
{cards.map((card) => ( focus(card.threadKey)} onDecide={(destination) => void decide(card.threadKey, destination, false)} onElsewhere={() => setPicking(card.threadKey)} onExpand={() => toggleExpanded(card.threadKey)} /> ))}

Screened-out mail sits under Screened out for as long as this account keeps mail on the device. Change your mind from a sender's contact card at any time.

)}
setPicking(null)} onChoose={(destination, wholeDomain) => { setPicking(null); if (picked) void decide(picked.threadKey, destination, wholeDomain); }} /> setClearing(false)} > Nothing is sent. Their mail goes to Screened out from now on, and any of them can be let back in from their contact card.

} confirmLabel="Screen them out" onConfirm={() => { setClearing(false); void clearAll(accountId); }} onCancel={() => setClearing(false)} />
); } interface CardProps { card: ScreenerCard; focused: boolean; open: boolean; /** The decision is out. The card stays, with its buttons down, until it comes back. */ deciding: boolean; onFocus: () => void; onDecide: (destination: Destination) => void; onElsewhere: () => void; onExpand: () => void; } function Card({ card, focused, open, deciding, onFocus, onDecide, onElsewhere, onExpand }: CardProps) { const view = useScreener((s) => s.views[card.threadKey]); const viewPhase = useScreener((s) => s.viewPhase[card.threadKey]); const retryView = useScreener((s) => s.retryView); const name = displayName(card.sender); const suggested = destinationName(card.suggestion); const message = view?.messages.filter((m) => !m.draft).at(-1); return (
{name} {card.sender.address}
{card.subject}
{card.snippet}
{`${card.reason} ยท suggested ${suggested}`}
{open && message ? : null} {open ? (
{message ? ( <> ) : viewPhase === "error" ? ( retryView(card.threadKey)} /> ) : ( )}
Replying screens this sender in to your Inbox.
) : null}
); } interface PickerProps { card: ScreenerCard | null; onClose: () => void; onChoose: (destination: Destination, wholeDomain: boolean) => void; } /** `v`. The three boxes, and the domain rule where a domain can carry one. */ function Picker({ card, onClose, onChoose }: PickerProps) { const [wholeDomain, setWholeDomain] = useState(false); useEffect(() => { if (card) setWholeDomain(false); }, [card]); if (!card) return null; const domain = domainOf(card.sender.address); // A consumer domain is not one sender, and `state::write::set_rule` refuses the rule, so the // toggle is not offered rather than offered and taken back. const allowed = domainRuleAllowed(card.sender.address); return ( {allowed ? ( ) : null} ); } export default Screener;