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<AppProps, AppState> { 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<AppProps, AppState> { 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<AppProps, AppState> { accounts={this.state.accounts} client={this.client} email={this.state.email} + emailId={this.state.location.emailId} mailbox={this.state.mailbox} /> <AuthModal diff --git a/src/AppLayout.tsx b/src/AppLayout.tsx index b9b6921..c3409f8 100644 --- a/src/AppLayout.tsx +++ b/src/AppLayout.tsx @@ -4,7 +4,7 @@ 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, IEmail, IMailbox } from "./client/types"; @@ -14,6 +14,7 @@ type TopProps = { accounts: AccountIdMap; client: Client; email: IEmail | null; + emailId: string; mailbox: IMailbox | null; }; @@ -32,9 +33,11 @@ const AppLayout: React.FC<TopProps> = (props) => { <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> 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<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; 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 <Placeholder />; + } else { + return <p>{this.props.email.preview}</p>; + } + } +} +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<EmailListProps, EmailListState> { 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 ( <div className="p-2 border" key={this.props.emailId}> <a className="btn" href={href}> - {this.props.email != null - ? this.props.email.subject + {this.props.emailStub != null + ? this.props.emailStub.subject : this.props.emailId} </a> </div> 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<string>; + emailStubs: Set<string>; + mailboxes: Set<string>; + }; 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<Callback> = []; 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<string> | null; - emails: Array<client.IEmailProperties> | 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; }