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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user