Compare commits

...

18 Commits

Author SHA1 Message Date
Eli Ribble d8ee3d5f0f Use my vendored jmap-client-ts
This allows me to modify the client in-place and get immediate feedback
that it's working.

There's likely a better way to do this. I'm not a node developer.
2024-08-30 10:14:05 -07:00
Eli Ribble 3aebdb471a Add jmap-client-ts fork
I need to add a bunch of stuff to this client.
2024-08-30 10:13:46 -07:00
Eli Ribble cf20d9aff1 Show convenient relative time in the email summary 2024-08-30 07:43:49 -07:00
Eli Ribble 895bb8ae47 Don't set inner HTML for text email content.
It was a bad copy-paste from the HTML display.
2024-08-29 11:49:04 -07:00
Eli Ribble 64d4e98996 Clean up some lint warnings 2024-08-29 11:43:41 -07:00
Eli Ribble eb09b39de3 Add beginning of email header information
The format is butt-ugly, but at least I'm showing info.
2024-08-29 11:41:41 -07:00
Eli Ribble 356173e4a3 Create button to choose between HTML and text-based email content. 2024-08-29 11:30:20 -07:00
Eli Ribble a65514f707 Add note to add support for sentAt.
It's in the spec, and I get it from the server, but it's not in the
client library I'm using.
2024-08-29 11:25:59 -07:00
Eli Ribble 3ef58b2259 Show the error when the email list fails.
Useful when I was tring to query for more stuff.
2024-08-29 10:47:35 -07:00
Eli Ribble 5c293219f3 Add 'from' and 'received at' to the email summary.
Really useful in deciding what to read.
2024-08-29 10:47:09 -07:00
Eli Ribble d71f18cce1 Show text content, and if unavailable, the HTML content.
This required actually requesting all the body values, and mapping from
the body values to to body parts for display.
2024-08-29 10:27:00 -07:00
Eli Ribble 4e1922c5fa Split email cache into full content and stubs.
This also introduces status for in-flight requests to avoid perpetual,
unnecessary loops of change-the-re-get-data.
2024-08-29 08:41:34 -07:00
Eli Ribble fb53a7506f Standardize on links for refresh.
Turns out I was doubling-up unnecessarily and had an event handler _and_
a hash change detection. This just made things complex. Now I use the
hash for both the mailbox and the email navigation.

I don't yet do anything with the email part.
2024-08-28 10:35:48 -07:00
Eli Ribble bf1ad0326d Show debug log messages on triggering changes 2024-08-28 10:16:40 -07:00
Eli Ribble d6b675f7b6 Allow selecting an email, and adding it to the hash.
We don't do anything with it yet, though
2024-08-28 10:16:11 -07:00
Eli Ribble e68a21dcc4 Move email list text to the left, fill the viewport, add border.
Much prettier.
2024-08-28 09:38:50 -07:00
Eli Ribble 0d2e43ae83 Add ability to support custom styles
Not using it yet, just thinking about usability and design a bit.
2024-08-28 09:38:23 -07:00
Eli Ribble a34d8f53b3 Show email subject lines.
This includes a bunch of new things. I've introduced "ensureEmail..." to
indicate that the UI would like some data to be populated, but if it is
already present we don't need to do anything.

I've also introduced a cache for emails that is keyed on the email ID. I
don't know if email IDs are unique. They look like they should be
globally unique within a given server, but I'm not sure and the standard
is unclear. It'll need some experimentation.
2024-08-28 09:21:31 -07:00
19 changed files with 580 additions and 108 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "src/jmap-client-ts"]
path = src/client/jmap-client-ts
url = git@github.com:EliRibble/jmap-client-ts.git

37
package-lock.json generated
View File

@ -16,13 +16,14 @@
"@types/node": "^16.18.104",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"axios": "^0.21.4",
"base-64": "^1.0.0",
"bootstrap": "^5.3.3",
"jmap-client-ts": "^1.0.0",
"react": "^18.3.1",
"react-bootstrap": "^2.10.4",
"react-dom": "^18.3.1",
"react-scripts": "5.0.1",
"sass": "^1.77.8",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
@ -5256,6 +5257,14 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/axobject-query": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
@ -9319,6 +9328,11 @@
"url": "https://opencollective.com/immer"
}
},
"node_modules/immutable": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
"integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw=="
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -11993,11 +12007,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jmap-client-ts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/jmap-client-ts/-/jmap-client-ts-1.0.0.tgz",
"integrity": "sha512-qL31lYUpLAqrVFMaMsogorcAcpZRTSjWPwnxdl61mDNY9DINQvrKIkYCMstva4cxTbL1kQFG4aT8K9wOesrj7g=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -15524,6 +15533,22 @@
"resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz",
"integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA=="
},
"node_modules/sass": {
"version": "1.77.8",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz",
"integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==",
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/sass-loader": {
"version": "12.6.0",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz",

View File

@ -11,13 +11,14 @@
"@types/node": "^16.18.104",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"axios": "^0.21.4",
"base-64": "^1.0.0",
"bootstrap": "^5.3.3",
"jmap-client-ts": "^1.0.0",
"react": "^18.3.1",
"react-bootstrap": "^2.10.4",
"react-dom": "^18.3.1",
"react-scripts": "5.0.1",
"sass": "^1.77.8",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},

View File

@ -1,14 +1,15 @@
import "./App.css";
import "bootstrap/dist/css/bootstrap.min.css";
import "./style.scss";
import Client, { IAuth } from "./client/Client";
import { AccountIdMap, IAccount, IMailbox } from "./client/types";
import { AccountIdMap, IAccount, IEmail, IMailbox } from "./client/types";
import AppLayout from "./AppLayout";
import AuthModal from "./AuthModal";
import React from "react";
interface ILocation {
accountId: string;
emailId: string;
mailboxId: string;
}
@ -16,6 +17,7 @@ type AppState = {
auth: IAuth;
account: IAccount | null;
accounts: AccountIdMap;
email: IEmail | null;
location: ILocation;
mailbox: IMailbox | null;
};
@ -37,7 +39,8 @@ class App extends React.Component<AppProps, AppState> {
account: null,
accounts: {},
auth: { email: "", password: "" },
location: { accountId: "", mailboxId: "" },
email: null,
location: { accountId: "", emailId: "", mailboxId: "" },
mailbox: null,
};
@ -46,14 +49,20 @@ class App extends React.Component<AppProps, AppState> {
const parts = hash.split("/");
const accountId = parts[0];
const mailboxId = parts[1];
const emailId = parts[2];
this.setState({
...this.state,
account: this.account(),
email: this.client.email(emailId),
location: {
accountId: accountId,
emailId: emailId,
mailboxId: mailboxId,
},
mailbox: this.client.mailbox(accountId, mailboxId),
});
if (!this.state.account) return;
this.client.ensureEmailList(accountId, mailboxId);
}
// When the user provides credentials
@ -70,16 +79,6 @@ class App extends React.Component<AppProps, AppState> {
this.client.doLogin({ email, password });
}
onMailboxSelect(mailboxId: string) {
if (!this.state.account) return;
this.client.emailList(this.state.account.id, mailboxId, []);
this.setState({
...this.state,
mailbox: this.client.mailbox(this.state.account.id, mailboxId),
});
window.location.hash = this.state.location.accountId + "/" + mailboxId;
}
// Load up auth credentials from the local store
loadAuth() {
const data = localStorage.getItem("auth");
@ -109,6 +108,7 @@ class App extends React.Component<AppProps, AppState> {
accounts: this.client.state.session
? this.client.state.session.accounts
: {},
email: this.client.email(this.state.location.emailId),
mailbox: this.mailbox(),
});
});
@ -131,10 +131,9 @@ class App extends React.Component<AppProps, AppState> {
account={this.state.account}
accounts={this.state.accounts}
client={this.client}
email={this.state.email}
emailId={this.state.location.emailId}
mailbox={this.state.mailbox}
onMailboxSelect={(m) => {
this.onMailboxSelect(m);
}}
/>
<AuthModal
show={this.client.state.session == null}

View File

@ -4,22 +4,23 @@ import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import AccountList from "./AccountList";
import EmailList from "./EmailList";
import EmailArea from "./EmailArea";
import MailboxList from "./MailboxList";
import Client from "./client/Client";
import { AccountIdMap, IAccount, IMailbox } from "./client/types";
import { AccountIdMap, IAccount, IEmail, IMailbox } from "./client/types";
type TopProps = {
account: IAccount | null;
accounts: AccountIdMap;
client: Client;
email: IEmail | null;
emailId: string;
mailbox: IMailbox | null;
onMailboxSelect: (mailboxId: string) => void;
};
const AppLayout: React.FC<TopProps> = (props) => {
return (
<Container>
<Container fluid>
<Row>
<Col>
<AccountList account={props.account} accounts={props.accounts} />
@ -29,16 +30,14 @@ const AppLayout: React.FC<TopProps> = (props) => {
</Row>
<Row>
<Col lg="1">
<MailboxList
account={props.account}
client={props.client}
onMailboxSelect={props.onMailboxSelect}
/>
<MailboxList account={props.account} client={props.client} />
</Col>
<Col lg="11">
<EmailList
<EmailArea
account={props.account}
client={props.client}
email={props.email}
emailId={props.emailId}
mailbox={props.mailbox}
/>
</Col>

44
src/EmailArea.tsx Normal file
View File

@ -0,0 +1,44 @@
import React from "react";
import Stack from "react-bootstrap/Stack";
import { IAccount, IEmail, IMailbox } from "./client/types";
import Client from "./client/Client";
import EmailContent from "./EmailContent";
import EmailList from "./EmailList";
type EmailAreaProps = {
account: IAccount | null;
client: Client;
email: IEmail | null;
emailId: string;
mailbox: IMailbox | null;
};
const EmailArea: React.FC<EmailAreaProps> = (props) => {
if (props.emailId === "") {
return (
<EmailList
account={props.account}
client={props.client}
mailbox={props.mailbox}
/>
);
} else {
return (
<Stack className="text-start">
<EmailList
account={props.account}
client={props.client}
mailbox={props.mailbox}
/>
<EmailContent
account={props.account}
client={props.client}
email={props.email}
emailId={props.emailId}
/>
</Stack>
);
}
};
export default EmailArea;

120
src/EmailContent.tsx Normal file
View File

@ -0,0 +1,120 @@
import Button from "react-bootstrap/Button";
import Col from "react-bootstrap/Col";
import Container from "react-bootstrap/Container";
import Form from "react-bootstrap/Form";
import Placeholder from "react-bootstrap/Placeholder";
import React from "react";
import Row from "react-bootstrap/Row";
import Client from "./client/Client";
import EmailContentHTML from "./EmailContentHTML";
import EmailContentText from "./EmailContentText";
import { IAccount, IEmail } from "./client/types";
type EmailContentProps = {
account: IAccount | null;
client: Client | null;
email: IEmail | null;
emailId: string;
};
type EmailContentState = {
showHTML: boolean;
};
class EmailContent extends React.Component<
EmailContentProps,
EmailContentState
> {
state = {
showHTML: false,
};
componentDidMount() {
this.ensureData();
}
componentDidUpdate() {
this.ensureData();
}
ensureData() {
if (this.props.account == null) return;
if (this.props.client == null) return;
this.props.client.ensureEmailContent(
this.props.account.id,
this.props.emailId,
);
}
render() {
const email = this.props.email;
if (email == null || email.bodyValues == null) {
return <Placeholder />;
}
let content;
if (this.state.showHTML) {
if (email.htmlBody != null) {
content = <EmailContentHTML email={email} />;
} else {
return <p>No HTML content</p>;
}
} else {
if (email.textBody != null) {
content = <EmailContentText email={email} />;
} else {
return <p>No text content/</p>;
}
}
return (
<Container fluid>
<Row>
<Form>
<Form.Group
as={Row}
className="mb-3"
controlId="formHorizontalEmail"
>
<Form.Label column sm={2}>
From
</Form.Label>
<Col sm={10}>
<p>
{email.from == null
? "unknown"
: email.from.map((f) => f.name)}
</p>
</Col>
<Form.Label column sm={2}>
Received
</Form.Label>
<Col sm={10}>{email.receivedAt}</Col>
</Form.Group>
</Form>
</Row>
<Row>
<Button
onClick={() => {
this.switchDisplay();
}}
>
Switch
</Button>
</Row>
<Row>{content}</Row>
</Container>
);
}
switchDisplay() {
if (this.state.showHTML) {
this.setState({
showHTML: false,
});
} else {
this.setState({
showHTML: true,
});
}
}
}
export default EmailContent;

33
src/EmailContentHTML.tsx Normal file
View File

@ -0,0 +1,33 @@
import Placeholder from "react-bootstrap/Placeholder";
import React from "react";
import Stack from "react-bootstrap/Stack";
import { IEmail } from "./client/types";
type EmailContentHTMLProps = {
email: IEmail;
};
const EmailContentHTML: React.FC<EmailContentHTMLProps> = (props) => {
const email = props.email;
if (email.htmlBody == null) {
return <Placeholder />;
}
return (
<Stack>
{email.htmlBody.map((h) =>
h.partId === undefined ? (
<Placeholder />
) : (
<div
key={h.partId}
dangerouslySetInnerHTML={{
__html: email.bodyValues![h.partId].value,
}}
/>
),
)}
</Stack>
);
};
export default EmailContentHTML;

28
src/EmailContentText.tsx Normal file
View File

@ -0,0 +1,28 @@
import Placeholder from "react-bootstrap/Placeholder";
import React from "react";
import Stack from "react-bootstrap/Stack";
import { IEmail } from "./client/types";
type EmailContentTextProps = {
email: IEmail;
};
const EmailContentText: React.FC<EmailContentTextProps> = (props) => {
const email = props.email;
if (email.textBody == null) {
return <Placeholder />;
}
return (
<Stack>
{email.textBody.map((t) =>
t.partId === undefined ? (
<pre>undefined partId</pre>
) : (
<pre key={t.partId}>{email.bodyValues![t.partId].value}</pre>
),
)}
</Stack>
);
};
export default EmailContentText;

View File

@ -3,10 +3,11 @@ import Stack from "react-bootstrap/Stack";
import Client from "./client/Client";
import { IAccount, IMailbox } from "./client/types";
import EmailSummary from "./EmailSummary";
type EmailListProps = {
account: IAccount | null;
client: Client | null;
client: Client;
mailbox: IMailbox | null;
};
@ -17,37 +18,31 @@ class EmailList extends React.Component<EmailListProps, EmailListState> {
if (this.props.account == null) return;
if (this.props.client == null) return;
if (this.props.mailbox == null) return;
this.props.client.emailList(
this.props.client.ensureEmailList(
this.props.account.id,
this.props.mailbox.id,
[],
);
}
render() {
if (
this.props.account == null ||
this.props.mailbox == null ||
this.props.mailbox.emailIds == null
this.props.account === null ||
this.props.mailbox === null ||
this.props.mailbox.emailIds === null
) {
return <Stack />;
} else if (this.props.mailbox.emails == null) {
return (
<Stack>
{this.props.mailbox.emailIds.map((e) => (
<div className="p-2" key={e}>
Email {e}
</div>
))}
</Stack>
);
} else {
return (
<Stack>
{this.props.mailbox.emails.map((m) => (
<div className="p-2" key={m.id}>
{m.subject}
</div>
<Stack className="text-start">
{this.props.mailbox.emailIds.slice(0, 5).map((e) => (
<EmailSummary
account={this.props.account!}
client={this.props.client}
emailId={e}
emailStub={this.props.client.emailStub(e)!}
key={e}
mailbox={this.props.mailbox!}
/>
))}
</Stack>
);

62
src/EmailSummary.tsx Normal file
View File

@ -0,0 +1,62 @@
import Placeholder from "react-bootstrap/Placeholder";
import React from "react";
import Client from "./client/Client";
import DateTime from "./components/DateTime";
import { IAccount, IEmailStub, IMailbox } from "./client/types";
type EmailSummaryProps = {
account: IAccount;
client: Client;
emailId: string;
emailStub: IEmailStub | null;
mailbox: IMailbox;
};
type EmailSummaryState = {};
class EmailSummary extends React.Component<
EmailSummaryProps,
EmailSummaryState
> {
componentDidMount() {
this.ensureData();
}
componentDidUpdate() {
this.ensureData();
}
ensureData() {
this.props.client.ensureEmailStub(
this.props.account.id,
this.props.emailId,
);
}
render() {
const href =
"#" +
this.props.account.id +
"/" +
this.props.mailbox.id +
"/" +
this.props.emailId;
const stub = this.props.emailStub;
if (stub === null) {
return <Placeholder />;
}
return (
<div className="p-2 border" key={this.props.emailId}>
<a className="btn" href={href}>
<DateTime d={stub.receivedAt} />
<span>
{" - " +
(stub.from == null ? "?" : stub.from[0].name) +
" - " +
stub.subject}
</span>
</a>
</div>
);
}
}
export default EmailSummary;

View File

@ -1,12 +1,19 @@
import ListGroup from "react-bootstrap/ListGroup";
import React from "react";
type MailboxProps = {
accountId: string;
id: string;
name: string;
};
const Mailbox: React.FC<MailboxProps> = ({ accountId, id, name }) => {
const href = "#" + accountId + "/" + id;
function Mailbox() {
return (
<ListGroup>
<ListGroup.Item>Inbox</ListGroup.Item>
<ListGroup.Item>Spam</ListGroup.Item>
</ListGroup>
<a href={href} className="p-2" key={id}>
{name}
</a>
);
}
};
export default Mailbox;

View File

@ -1,14 +1,13 @@
import React from "react";
import Button from "react-bootstrap/Button";
import Stack from "react-bootstrap/Stack";
import Client from "./client/Client";
import { IAccount } from "./client/types";
import Mailbox from "./Mailbox";
type MailboxListProps = {
account: IAccount | null;
client: Client | null;
onMailboxSelect: (mailboxId: string) => void;
};
type MailboxListState = {};
@ -20,10 +19,6 @@ class MailboxList extends React.Component<MailboxListProps, MailboxListState> {
this.props.client.mailboxList(this.props.account.id, []);
}
onMailboxClick(id: string) {
this.props.onMailboxSelect(id);
}
render() {
return this.props.account == null ||
this.props.account.mailboxes == null ? (
@ -31,15 +26,12 @@ class MailboxList extends React.Component<MailboxListProps, MailboxListState> {
) : (
<Stack>
{this.props.account.mailboxes.map((m) => (
<Button
className="p-2"
<Mailbox
accountId={this.props.account!.id}
id={m.id}
key={m.id}
onClick={() => {
this.onMailboxClick(m.id);
}}
>
{m.name}
</Button>
name={m.name}
/>
))}
</Stack>
);

View File

@ -3,10 +3,10 @@
* None of the dependencies should leak, in types or otherwise
*/
import * as base64 from "base-64";
import * as jmapclient from "jmap-client-ts";
import { FetchTransport } from "jmap-client-ts/lib/utils/fetch-transport";
import * as jmapclient from "./jmap-client-ts/src";
import { FetchTransport } from "./jmap-client-ts/src/utils/fetch-transport";
import { IAccount, IMailbox, ISession } from "./types";
import { IAccount, IEmail, IEmailStub, IMailbox, ISession } from "./types";
type Callback = () => void;
@ -16,31 +16,33 @@ export interface IAuth {
}
export interface ClientState {
inFlight: {
emailContents: Set<string>;
emailStubs: Set<string>;
mailboxes: Set<string>;
};
session: ISession | null;
}
export default class Client {
// All objects which currently are listening for changes
callbacks: Array<Callback> = [];
jclient: jmapclient.Client | null = null;
state: ClientState = {
inFlight: {
emailContents: new Set(),
emailStubs: new Set(),
mailboxes: new Set(),
},
session: null,
};
// Get the currently active account
account(accountId: string): IAccount | null {
if (!(this.state.session && this.state.session.accounts)) return null;
return this.state.session.accounts[accountId];
}
// All objects which currently are listening for changes
callbacks: Array<Callback> = [];
jclient: jmapclient.Client | null = null;
state: ClientState = {
session: null,
};
onChange(f: Callback) {
this.callbacks.push(f);
}
// Ensure that login happens. If this is called many times, only login once.
ensureLogin(auth: IAuth) {
if (this.jclient != null) return;
this.doLogin(auth);
}
// Make the request to get system metadata
doLogin(auth: IAuth) {
const domain = auth.email.split("@")[1];
@ -65,29 +67,149 @@ export default class Client {
return;
}
// Ensure we have the full email content
ensureEmailContent(accountId: string, emailId: string) {
if (this.state.session == null) return;
const existing = this.state.session.emails[emailId];
if (existing != null) return;
this.emailGetContent(accountId, emailId);
}
// Ensure we have the email summary
ensureEmailStub(accountId: string, emailId: string) {
if (this.state.session == null) return;
const existing = this.state.session.emailStubs[emailId];
if (existing != null) return;
this.emailGetStub(accountId, emailId);
}
// Ensure we have the list of emails for the provided account and mailbox
ensureEmailList(accountId: string, mailboxId: string) {
const mailbox = this.mailbox(accountId, mailboxId);
if (mailbox != null && mailbox.emailIds != null) return;
this.emailList(accountId, mailboxId, []);
}
// Ensure that login happens. If this is called many times, only login once.
ensureLogin(auth: IAuth) {
if (this.jclient != null) return;
this.doLogin(auth);
}
email(emailId: string): IEmail | null {
if (this.state.session == null) return null;
return this.state.session.emails[emailId];
}
emailGetContent(accountId: string, emailId: string) {
if (this.jclient == null) return;
if (this.state.session == null) return;
if (this.state.inFlight.emailContents.has(emailId)) return;
this.state.inFlight.emailContents.add(emailId);
const msg = "Email get content";
this.jclient
.email_get({
accountId: accountId,
ids: [emailId],
fetchAllBodyValues: true,
})
.then((response) => {
console.log(msg, "response", response);
response.list.forEach((e) => {
const existing = this.state.session!.emails[e.id];
if (existing !== undefined && existing.subject !== e.subject) {
console.error(
"Expectations violation: email ID is not unique within the server!",
);
}
this.state.session!.emails[e.id] = {
...e,
id: e.id,
};
this._triggerChange(msg + e.id);
});
})
.catch((x) => {
console.error("Failed to get email content", emailId, x);
})
.finally(() => {
this.state.inFlight.emailContents.delete(emailId);
});
}
emailGetStub(accountId: string, emailId: string) {
if (this.jclient == null) return;
if (this.state.session == null) return;
if (this.state.inFlight.emailStubs.has(emailId)) return;
this.state.inFlight.emailStubs.add(emailId);
const msg = "Email get summary";
this.jclient
.email_get({
accountId: accountId,
ids: [emailId],
properties: ["from", "receivedAt", "subject"],
})
.then((response) => {
console.log(msg, "response", response);
response.list.forEach((e) => {
if (this.state.session == null) return;
const existing = this.state.session.emailStubs[e.id];
if (existing !== undefined && existing.subject !== e.subject) {
console.error(
"Expectations violation: email ID is not unique within the server!",
);
}
this.state.session.emailStubs[e.id] = {
from: e.from,
id: e.id,
receivedAt: e.receivedAt,
subject: e.subject,
};
this._triggerChange(msg + e.id);
});
})
.catch((x) => {
console.error("Failed to get email stub", emailId, x);
})
.finally(() => {
this.state.inFlight.emailStubs.delete(emailId);
});
}
emailList(accountId: string, mailboxId: string, ids: Array<string>) {
if (this.jclient == null) return;
/*this.jclient.email_get({
accountId: accountId,
ids: [],
properties: ["threadId"]
});*/
this.jclient
.email_query({
accountId: accountId,
filter: { inMailbox: mailboxId },
sort: [
{
property: "receivedAt",
isAscending: false,
},
],
})
.then((response) => {
const mailbox = this.mailbox(accountId, mailboxId);
if (mailbox == null) return;
mailbox.emailIds = response.ids;
this._triggerChange();
this._triggerChange("Email list " + mailboxId);
})
.catch(() => {
console.error("OH NOES");
.catch((e) => {
console.error("Failed to get email list from mailbox", mailboxId, e);
});
}
emailStub(emailId: string): IEmailStub | null {
if (this.state.session == null) return null;
const result = this.state.session.emailStubs[emailId];
if (result === undefined) {
return null;
}
return result;
}
mailbox(accountId: string, mailboxId: string): IMailbox | null {
if (this.state.session == null) return null;
const account = this.state.session.accounts[accountId];
@ -117,15 +239,19 @@ export default class Client {
mailboxes.push({
...m,
emailIds: null,
emails: null,
});
});
account.mailboxes = mailboxes;
this._triggerChange();
this._triggerChange("Mailboxes " + accountId);
});
}
_triggerChange() {
onChange(f: Callback) {
this.callbacks.push(f);
}
_triggerChange(msg: string) {
console.log("Client change", msg);
this.callbacks.forEach((c) => {
c();
});
@ -145,8 +271,10 @@ export default class Client {
{ ...account, id: key.toString(), mailboxes: null },
]),
),
emails: {},
emailStubs: {},
};
if (!this.state.session) return;
this._triggerChange();
this._triggerChange("Session");
}
}

@ -0,0 +1 @@
Subproject commit fdab37996a4709b13962b67d1aa49e85a8305755

View File

@ -1,8 +1,18 @@
import client from "jmap-client-ts/lib/types";
import * as client from "./jmap-client-ts/src/types";
export interface IEmailStub {
from: Array<client.IEmailAddress> | null;
id: string;
receivedAt: string;
subject: string;
}
export interface IMailbox extends client.IMailboxProperties {
emailIds: Array<string> | null;
emails: Array<client.IEmailProperties> | null;
}
export interface IEmail extends client.IEmailProperties {
//sentAt: IutcDate|null,
}
export interface IAccount extends client.IAccount {
@ -11,7 +21,11 @@ export interface IAccount extends client.IAccount {
}
export type AccountIdMap = { [accountId: string]: IAccount };
export type EmailStubIdMap = { [emailId: string]: IEmailStub };
export type EmailIdMap = { [emailId: string]: IEmail };
export interface ISession extends client.ISession {
accounts: AccountIdMap;
emails: EmailIdMap;
emailStubs: EmailStubIdMap;
}

View File

@ -0,0 +1,19 @@
import React from "react";
type DateTimeProps = {
d: string;
};
const DateTime: React.FC<DateTimeProps> = ({ d }) => {
const datetime = Date.parse(d);
const now = Date.now();
const diff = (now - datetime) / 1000;
if (diff < 30) return <span>moments ago</span>;
if (diff < 60) return <span>{diff}s</span>;
if (diff < 60 * 60) return <span>{Math.round(diff / 60)}m</span>;
if (diff < 60 * 60 * 48) return <span>{Math.round(diff / (60 * 60))}h</span>;
if (diff < 60 * 60 * 24 * 365)
return <span>{Math.round(diff / (60 * 60 * 24))}d</span>;
return <span>{Math.round(diff / (60 * 60 * 24 * 365))}y</span>;
};
export default DateTime;

1
src/style.scss Normal file
View File

@ -0,0 +1 @@
@import '~bootstrap/scss/bootstrap';

View File

@ -16,5 +16,6 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/client/jmap-client-ts/tests"]
}