feat(editor): F4.6e the e-mail program and e-mail server on a computer's desktop

The oracle MailWindowRun uses Java's e-mail windows as a person does and
answers their dialogs. The core sets and saves the program's account,
deletes a mail, checks a form as Java's window does, and gives the server
its domain, new and removed accounts and the window's own line; the room
sends both programs' views and takes their actions; the editor has both
windows. Better than Java: a name field for a new account, a user name the
server has is refused, deleting hits the folder shown, a new domain goes
into the accounts file at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:44:36 +00:00
co-authored by Claude Opus 5.5
parent aa54e5400e
commit 11f13bca84
33 changed files with 2841 additions and 52 deletions
@@ -0,0 +1,196 @@
// A computer's e-mail server, after Java's GUIApplicationEmailServerWindow:
// the start button and the mail domain above (the domain only while the
// server is off, red while Java's pattern does not take it), then three
// pages — a new account, the list of accounts with the number of mails each
// holds and the button that removes the one chosen (after Java's question),
// and the server's lines. The server is the room's; the panel shows its view
// and lines and checks a new account as Java's window does before it hands
// it over.
//
// Better than Java: the new account's name has a field (Java's window has
// its label and no field: every account is "Vorname Nachname" — still so
// without a name); a user name the server has already is refused (Java's
// window says it made it).
import { useEffect, useLayoutEffect, useRef, useState, type FormEvent } from 'react';
import { isDomainName, splitName, type EmailServerLogEntry, type LogLine } from '@mdg-filius/core';
import type { EmailServerView } from '@mdg-filius/protocol';
import { useTranslate, type Translate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { emailServerLine } from './mailLines.js';
const NO_LINES: readonly LogLine<EmailServerLogEntry>[] = [];
type Page = 'newAccount' | 'accounts' | 'log';
/** What Java's window says to a new account before it hands it over (emailserver_msg25, msg24), and that the server has it already. */
export function newAccountProblem(username: string, password: string, accounts: readonly { readonly username: string }[]): 'missing' | 'whiteSpace' | 'exists' | undefined {
if (username === '' || password === '') return 'missing';
if (/[ \t\n\v\f\r]/.test(username)) return 'whiteSpace';
return accounts.some((account) => account.username.toLowerCase() === username.toLowerCase()) ? 'exists' : undefined;
}
/**
* A new account's names from the name typed (the e-mail program's split);
* without one, Java's placeholders "Vorname Nachname" (emailserver_msg21,
* msg20): its server takes no new account while one has an empty name.
*/
export function accountNames(name: string, t: Translate): { readonly firstName: string; readonly lastName: string } {
const names = splitName(name);
return names.firstName === '' && names.lastName === '' ? { firstName: t('emailServer.firstNamePlaceholder'), lastName: t('emailServer.lastNamePlaceholder') } : names;
}
export function EmailServerPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
const t = useTranslate();
const topic = `app:${node}/emailServer` as const;
const { watch, act } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = simulation.views.get(topic) as EmailServerView | null | undefined;
const lines = (simulation.apps.get(topic)?.lines as readonly LogLine<EmailServerLogEntry>[] | undefined) ?? NO_LINES;
const [page, setPage] = useState<Page>('newAccount');
const [domain, setDomain] = useState(view?.domain ?? '');
useEffect(() => setDomain(view?.domain ?? ''), [view?.domain]);
const [username, setUsername] = useState('');
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [notice, setNotice] = useState<string | null>(null);
const [chosen, setChosen] = useState<string | null>(null);
const [asking, setAsking] = useState(false);
const log = useRef<HTMLPreElement>(null);
const text = lines.flatMap((line) => {
const shown = emailServerLine(line, t);
return shown === undefined ? [] : [`${shown}\n`];
}).join('');
useLayoutEffect(() => {
const element = log.current;
if (element !== null) element.scrollTop = element.scrollHeight;
}, [text, page]);
if (view === null || view === undefined) return <div className="mail-server" />;
const accounts = view.accounts;
const chosenAccount = accounts.find((account) => account.username === chosen);
const commitDomain = () => {
if (domain !== view.domain && !view.active) act(node, 'emailServer', 'setDomain', { domain });
};
const create = (event: FormEvent) => {
event.preventDefault();
const problem = newAccountProblem(username, password, accounts);
if (problem !== undefined) {
setNotice(t(`emailServer.${problem}`, { name: username }));
return;
}
act(node, 'emailServer', 'addAccount', { username, password, ...accountNames(name, t) });
setNotice(t('emailServer.created', { name: username }));
setUsername('');
setName('');
setPassword('');
};
return (
<div className="mail-server">
<div className="mail-server-bar">
<button type="button" onClick={() => act(node, 'emailServer', 'setActive', { active: !view.active })}>
{t(view.active ? 'emailServer.stop' : 'emailServer.start')}
</button>
<label>
{t('emailServer.domain')}
<input
value={domain}
disabled={view.active}
aria-invalid={isDomainName(domain) && domain !== '' ? undefined : 'true'}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
onChange={(event) => setDomain(event.target.value)}
onBlur={commitDomain}
onKeyDown={(event) => event.key === 'Enter' && commitDomain()}
/>
</label>
</div>
<div className="mail-tabs" role="tablist">
{(['newAccount', 'accounts', 'log'] as const).map((each) => (
<button key={each} type="button" role="tab" aria-selected={page === each} onClick={() => setPage(each)}>
{t(`emailServer.${each}`)}
</button>
))}
</div>
{page === 'newAccount' ? (
<form className="mail-form" role="tabpanel" aria-label={t('emailServer.newAccount')} onSubmit={create}>
<label>
<span>{t('emailServer.username')}</span>
<input
value={username}
aria-invalid={/^[a-zA-Z0-9._-]*$/.test(username) ? undefined : 'true'}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label>
<span>{t('emailServer.name')}</span>
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
<span>{t('emailServer.password')}</span>
<input type="password" value={password} autoComplete="new-password" onChange={(event) => setPassword(event.target.value)} />
</label>
<button type="submit">{t('emailServer.create')}</button>
{notice === null ? null : (
<p className="mail-notice" role="status">
{notice}
</p>
)}
</form>
) : null}
{page === 'accounts' ? (
<div className="mail-accounts" role="tabpanel" aria-label={t('emailServer.accounts')}>
<table>
<thead>
<tr>
<th>{t('emailServer.address')}</th>
<th>{t('emailServer.mails')}</th>
</tr>
</thead>
<tbody>
{accounts.map((account) => (
<tr key={account.username} aria-selected={account.username === chosen} onClick={() => setChosen(account.username)}>
<td>{`${account.username}@${view.domain}`}</td>
<td>{account.mails}</td>
</tr>
))}
</tbody>
</table>
{asking && chosenAccount !== undefined ? (
<div className="mail-question" role="alertdialog" aria-label={t('emailServer.confirmTitle')}>
<p>{t('emailServer.confirmDelete', { name: chosenAccount.username })}</p>
<button
type="button"
onClick={() => {
act(node, 'emailServer', 'removeAccount', { username: chosenAccount.username });
setAsking(false);
setChosen(null);
}}
>
{t('emailServer.yes')}
</button>
<button type="button" onClick={() => setAsking(false)}>
{t('emailServer.cancel')}
</button>
</div>
) : (
<button type="button" disabled={chosenAccount === undefined} onClick={() => setAsking(true)}>
{t('emailServer.deleteAccount')}
</button>
)}
</div>
) : null}
{page === 'log' ? (
<pre className="mail-log" ref={log} role="tabpanel" aria-label={t('emailServer.log')} tabIndex={0}>
{text}
</pre>
) : null}
</div>
);
}