From 4e1922c5fa9bfcdbee462fefdd0e606ef2dea6ad Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 28 Aug 2024 19:25:20 -0700 Subject: [PATCH] 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. --- src/App.tsx | 3 + src/AppLayout.tsx | 7 +- src/EmailArea.tsx | 44 +++++++++++++ src/EmailContent.tsx | 44 +++++++++++++ src/EmailList.tsx | 2 +- src/EmailSummary.tsx | 13 ++-- src/client/Client.tsx | 145 +++++++++++++++++++++++++++++++----------- src/client/types.tsx | 8 ++- 8 files changed, 219 insertions(+), 47 deletions(-) create mode 100644 src/EmailArea.tsx create mode 100644 src/EmailContent.tsx diff --git a/src/App.tsx b/src/App.tsx index 297f4df..78eec8c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ class App extends React.Component { this.setState({ ...this.state, account: this.account(), + email: this.client.email(emailId), location: { accountId: accountId, emailId: emailId, @@ -107,6 +108,7 @@ class App extends React.Component { accounts: this.client.state.session ? this.client.state.session.accounts : {}, + email: this.client.email(this.state.location.emailId), mailbox: this.mailbox(), }); }); @@ -130,6 +132,7 @@ class App extends React.Component { accounts={this.state.accounts} client={this.client} email={this.state.email} + emailId={this.state.location.emailId} mailbox={this.state.mailbox} /> = (props) => { - diff --git a/src/EmailArea.tsx b/src/EmailArea.tsx new file mode 100644 index 0000000..3d3c049 --- /dev/null +++ b/src/EmailArea.tsx @@ -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 = (props) => { + if (props.emailId === "") { + return ( + + ); + } else { + return ( + + + + + ); + } +}; +export default EmailArea; diff --git a/src/EmailContent.tsx b/src/EmailContent.tsx new file mode 100644 index 0000000..6506fdf --- /dev/null +++ b/src/EmailContent.tsx @@ -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 ; + } else { + return

{this.props.email.preview}

; + } + } +} +export default EmailContent; diff --git a/src/EmailList.tsx b/src/EmailList.tsx index fc9151a..69ebd3d 100644 --- a/src/EmailList.tsx +++ b/src/EmailList.tsx @@ -40,7 +40,7 @@ class EmailList extends React.Component { account={this.props.account} client={this.props.client} emailId={e} - email={this.props.client!.email(e)} + emailStub={this.props.client!.emailStub(e)} key={e} mailbox={this.props.mailbox} /> diff --git a/src/EmailSummary.tsx b/src/EmailSummary.tsx index 5520ba1..f777e4a 100644 --- a/src/EmailSummary.tsx +++ b/src/EmailSummary.tsx @@ -2,13 +2,13 @@ import React from "react"; import Placeholder from "react-bootstrap/Placeholder"; import Client from "./client/Client"; -import { IAccount, IEmail, IMailbox } from "./client/types"; +import { IAccount, IEmailStub, IMailbox } from "./client/types"; type EmailSummaryProps = { account: IAccount | null; client: Client | null; - email: IEmail | null; emailId: string; + emailStub: IEmailStub | null; mailbox: IMailbox | null; }; type EmailSummaryState = {}; @@ -27,7 +27,10 @@ class EmailSummary extends React.Component< ensureData() { if (this.props.account == 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() { @@ -48,8 +51,8 @@ class EmailSummary extends React.Component< return ( diff --git a/src/client/Client.tsx b/src/client/Client.tsx index e96c6e8..368ba74 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -6,7 +6,7 @@ import * as base64 from "base-64"; import * as jmapclient from "jmap-client-ts"; 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; @@ -16,45 +16,31 @@ export interface IAuth { } export interface ClientState { + inFlight: { + emailContents: Set; + emailStubs: Set; + mailboxes: Set; + }; session: ISession | null; } 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 callbacks: Array = []; jclient: jmapclient.Client | null = null; state: ClientState = { + inFlight: { + emailContents: new Set(), + emailStubs: new Set(), + mailboxes: new Set(), + }, session: null, }; - onChange(f: Callback) { - this.callbacks.push(f); - } - - // 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); + // 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]; } // Make the request to get system metadata @@ -81,36 +67,110 @@ 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]; } - emailGet(accountId: string, emailId: string) { + 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], - properties: ["mailboxIds", "subject"], }) .then((response) => { - console.log("Email response", response); + console.log(msg, "response", response); 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) { console.error( "Expectations violation: email ID is not unique within the server!", ); } - this.state.session.emails[e.id] = e; - this._triggerChange("Email " + e.id); + this.state.session!.emails[e.id] = { + ...e, + id: e.id, + }; + this._triggerChange(msg + e.id); }); }) .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 { if (this.state.session == null) return null; const account = this.state.session.accounts[accountId]; @@ -161,7 +226,6 @@ export default class Client { mailboxes.push({ ...m, emailIds: null, - emails: null, }); }); account.mailboxes = mailboxes; @@ -169,6 +233,10 @@ export default class Client { }); } + onChange(f: Callback) { + this.callbacks.push(f); + } + _triggerChange(msg: string) { console.log("Client change", msg); this.callbacks.forEach((c) => { @@ -191,6 +259,7 @@ export default class Client { ]), ), emails: {}, + emailStubs: {}, }; if (!this.state.session) return; this._triggerChange("Session"); diff --git a/src/client/types.tsx b/src/client/types.tsx index 08b9cf9..3906350 100644 --- a/src/client/types.tsx +++ b/src/client/types.tsx @@ -1,8 +1,12 @@ import client from "jmap-client-ts/lib/types"; +export interface IEmailStub { + id: string; + subject: string; +} + export interface IMailbox extends client.IMailboxProperties { emailIds: Array | null; - emails: Array | null; } export interface IEmail extends client.IEmailProperties {} @@ -13,9 +17,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; }