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.
This commit is contained in:
Eli Ribble 2024-08-28 19:25:20 -07:00
parent fb53a7506f
commit 4e1922c5fa
8 changed files with 219 additions and 47 deletions

View File

@ -53,6 +53,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
...this.state, ...this.state,
account: this.account(), account: this.account(),
email: this.client.email(emailId),
location: { location: {
accountId: accountId, accountId: accountId,
emailId: emailId, emailId: emailId,
@ -107,6 +108,7 @@ class App extends React.Component<AppProps, AppState> {
accounts: this.client.state.session accounts: this.client.state.session
? this.client.state.session.accounts ? this.client.state.session.accounts
: {}, : {},
email: this.client.email(this.state.location.emailId),
mailbox: this.mailbox(), mailbox: this.mailbox(),
}); });
}); });
@ -130,6 +132,7 @@ class App extends React.Component<AppProps, AppState> {
accounts={this.state.accounts} accounts={this.state.accounts}
client={this.client} client={this.client}
email={this.state.email} email={this.state.email}
emailId={this.state.location.emailId}
mailbox={this.state.mailbox} mailbox={this.state.mailbox}
/> />
<AuthModal <AuthModal

View File

@ -4,7 +4,7 @@ import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col"; import Col from "react-bootstrap/Col";
import AccountList from "./AccountList"; import AccountList from "./AccountList";
import EmailList from "./EmailList"; import EmailArea from "./EmailArea";
import MailboxList from "./MailboxList"; import MailboxList from "./MailboxList";
import Client from "./client/Client"; import Client from "./client/Client";
import { AccountIdMap, IAccount, IEmail, IMailbox } from "./client/types"; import { AccountIdMap, IAccount, IEmail, IMailbox } from "./client/types";
@ -14,6 +14,7 @@ type TopProps = {
accounts: AccountIdMap; accounts: AccountIdMap;
client: Client; client: Client;
email: IEmail | null; email: IEmail | null;
emailId: string;
mailbox: IMailbox | null; mailbox: IMailbox | null;
}; };
@ -32,9 +33,11 @@ const AppLayout: React.FC<TopProps> = (props) => {
<MailboxList account={props.account} client={props.client} /> <MailboxList account={props.account} client={props.client} />
</Col> </Col>
<Col lg="11"> <Col lg="11">
<EmailList <EmailArea
account={props.account} account={props.account}
client={props.client} client={props.client}
email={props.email}
emailId={props.emailId}
mailbox={props.mailbox} mailbox={props.mailbox}
/> />
</Col> </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;

44
src/EmailContent.tsx Normal file
View File

@ -0,0 +1,44 @@
import React from "react";
import Placeholder from "react-bootstrap/Placeholder";
import Client from "./client/Client";
import { IAccount, IEmail } from "./client/types";
type EmailContentProps = {
account: IAccount | null;
client: Client | null;
email: IEmail | null;
emailId: string;
};
type EmailContentState = {};
class EmailContent extends React.Component<
EmailContentProps,
EmailContentState
> {
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() {
if (this.props.email == null) {
return <Placeholder />;
} else {
return <p>{this.props.email.preview}</p>;
}
}
}
export default EmailContent;

View File

@ -40,7 +40,7 @@ class EmailList extends React.Component<EmailListProps, EmailListState> {
account={this.props.account} account={this.props.account}
client={this.props.client} client={this.props.client}
emailId={e} emailId={e}
email={this.props.client!.email(e)} emailStub={this.props.client!.emailStub(e)}
key={e} key={e}
mailbox={this.props.mailbox} mailbox={this.props.mailbox}
/> />

View File

@ -2,13 +2,13 @@ import React from "react";
import Placeholder from "react-bootstrap/Placeholder"; import Placeholder from "react-bootstrap/Placeholder";
import Client from "./client/Client"; import Client from "./client/Client";
import { IAccount, IEmail, IMailbox } from "./client/types"; import { IAccount, IEmailStub, IMailbox } from "./client/types";
type EmailSummaryProps = { type EmailSummaryProps = {
account: IAccount | null; account: IAccount | null;
client: Client | null; client: Client | null;
email: IEmail | null;
emailId: string; emailId: string;
emailStub: IEmailStub | null;
mailbox: IMailbox | null; mailbox: IMailbox | null;
}; };
type EmailSummaryState = {}; type EmailSummaryState = {};
@ -27,7 +27,10 @@ class EmailSummary extends React.Component<
ensureData() { ensureData() {
if (this.props.account == null) return; if (this.props.account == null) return;
if (this.props.client == null) return; if (this.props.client == null) return;
this.props.client.ensureEmailGet(this.props.account.id, this.props.emailId); this.props.client.ensureEmailStub(
this.props.account.id,
this.props.emailId,
);
} }
render() { render() {
@ -48,8 +51,8 @@ class EmailSummary extends React.Component<
return ( return (
<div className="p-2 border" key={this.props.emailId}> <div className="p-2 border" key={this.props.emailId}>
<a className="btn" href={href}> <a className="btn" href={href}>
{this.props.email != null {this.props.emailStub != null
? this.props.email.subject ? this.props.emailStub.subject
: this.props.emailId} : this.props.emailId}
</a> </a>
</div> </div>

View File

@ -6,7 +6,7 @@ import * as base64 from "base-64";
import * as jmapclient from "jmap-client-ts"; import * as jmapclient from "jmap-client-ts";
import { FetchTransport } from "jmap-client-ts/lib/utils/fetch-transport"; import { FetchTransport } from "jmap-client-ts/lib/utils/fetch-transport";
import { IAccount, IEmail, IMailbox, ISession } from "./types"; import { IAccount, IEmail, IEmailStub, IMailbox, ISession } from "./types";
type Callback = () => void; type Callback = () => void;
@ -16,45 +16,31 @@ export interface IAuth {
} }
export interface ClientState { export interface ClientState {
inFlight: {
emailContents: Set<string>;
emailStubs: Set<string>;
mailboxes: Set<string>;
};
session: ISession | null; session: ISession | null;
} }
export default class Client { export default class Client {
// 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 // All objects which currently are listening for changes
callbacks: Array<Callback> = []; callbacks: Array<Callback> = [];
jclient: jmapclient.Client | null = null; jclient: jmapclient.Client | null = null;
state: ClientState = { state: ClientState = {
inFlight: {
emailContents: new Set(),
emailStubs: new Set(),
mailboxes: new Set(),
},
session: null, session: null,
}; };
onChange(f: Callback) { // Get the currently active account
this.callbacks.push(f); account(accountId: string): IAccount | null {
} if (!(this.state.session && this.state.session.accounts)) return null;
return this.state.session.accounts[accountId];
// Ensure we have the fully-fleshed email
ensureEmailGet(accountId: string, emailId: string) {
if (this.state.session == null) return;
const existing = this.state.session.emails[emailId];
if (existing != null) return;
this.emailGet(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);
} }
// Make the request to get system metadata // Make the request to get system metadata
@ -81,36 +67,110 @@ export default class Client {
return; 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 { email(emailId: string): IEmail | null {
if (this.state.session == null) return null; if (this.state.session == null) return null;
return this.state.session.emails[emailId]; return this.state.session.emails[emailId];
} }
emailGet(accountId: string, emailId: string) { emailGetContent(accountId: string, emailId: string) {
if (this.jclient == null) return; 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 this.jclient
.email_get({ .email_get({
accountId: accountId, accountId: accountId,
ids: [emailId], ids: [emailId],
properties: ["mailboxIds", "subject"],
}) })
.then((response) => { .then((response) => {
console.log("Email response", response); console.log(msg, "response", response);
response.list.forEach((e) => { response.list.forEach((e) => {
if (this.state.session == null) return; const existing = this.state.session!.emails[e.id];
const existing = this.state.session.emails[e.id];
if (existing !== undefined && existing.subject !== e.subject) { if (existing !== undefined && existing.subject !== e.subject) {
console.error( console.error(
"Expectations violation: email ID is not unique within the server!", "Expectations violation: email ID is not unique within the server!",
); );
} }
this.state.session.emails[e.id] = e; this.state.session!.emails[e.id] = {
this._triggerChange("Email " + e.id); ...e,
id: e.id,
};
this._triggerChange(msg + e.id);
}); });
}) })
.catch((x) => { .catch((x) => {
console.error("Failed to get email", emailId, 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: ["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] = {
id: e.id,
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);
}); });
} }
@ -132,6 +192,11 @@ export default class Client {
}); });
} }
emailStub(emailId: string): IEmailStub | null {
if (this.state.session == null) return null;
return this.state.session.emailStubs[emailId];
}
mailbox(accountId: string, mailboxId: string): IMailbox | null { mailbox(accountId: string, mailboxId: string): IMailbox | null {
if (this.state.session == null) return null; if (this.state.session == null) return null;
const account = this.state.session.accounts[accountId]; const account = this.state.session.accounts[accountId];
@ -161,7 +226,6 @@ export default class Client {
mailboxes.push({ mailboxes.push({
...m, ...m,
emailIds: null, emailIds: null,
emails: null,
}); });
}); });
account.mailboxes = mailboxes; account.mailboxes = mailboxes;
@ -169,6 +233,10 @@ export default class Client {
}); });
} }
onChange(f: Callback) {
this.callbacks.push(f);
}
_triggerChange(msg: string) { _triggerChange(msg: string) {
console.log("Client change", msg); console.log("Client change", msg);
this.callbacks.forEach((c) => { this.callbacks.forEach((c) => {
@ -191,6 +259,7 @@ export default class Client {
]), ]),
), ),
emails: {}, emails: {},
emailStubs: {},
}; };
if (!this.state.session) return; if (!this.state.session) return;
this._triggerChange("Session"); this._triggerChange("Session");

View File

@ -1,8 +1,12 @@
import client from "jmap-client-ts/lib/types"; import client from "jmap-client-ts/lib/types";
export interface IEmailStub {
id: string;
subject: string;
}
export interface IMailbox extends client.IMailboxProperties { export interface IMailbox extends client.IMailboxProperties {
emailIds: Array<string> | null; emailIds: Array<string> | null;
emails: Array<client.IEmailProperties> | null;
} }
export interface IEmail extends client.IEmailProperties {} export interface IEmail extends client.IEmailProperties {}
@ -13,9 +17,11 @@ export interface IAccount extends client.IAccount {
} }
export type AccountIdMap = { [accountId: string]: IAccount }; export type AccountIdMap = { [accountId: string]: IAccount };
export type EmailStubIdMap = { [emailId: string]: IEmailStub };
export type EmailIdMap = { [emailId: string]: IEmail }; export type EmailIdMap = { [emailId: string]: IEmail };
export interface ISession extends client.ISession { export interface ISession extends client.ISession {
accounts: AccountIdMap; accounts: AccountIdMap;
emails: EmailIdMap; emails: EmailIdMap;
emailStubs: EmailStubIdMap;
} }