From c686f5c8d989f780bd2f09248e261c7d40de95e6 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 4 Sep 2024 11:41:33 -0700 Subject: [PATCH 1/3] Subscribe to event type and message. This mirrors a change in the client where we extract the type of the event. --- src/client/Client.tsx | 9 ++++++--- src/client/jmap-client-ts | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/client/Client.tsx b/src/client/Client.tsx index bd3e460..e67bd71 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -370,8 +370,11 @@ export default class Client { .replace("{types}", "*") .replace("{closeafter}", "no") .replace("{ping}", "60"); - this.jclient.subscribeToEvents(eventSourceUrl, (e) => { - console.log("Got an event!", e); - }); + this.jclient.subscribeToEvents( + eventSourceUrl, + (type: string, message: PushMessage) => { + console.log("Got an event!", type, message); + }, + ); } } diff --git a/src/client/jmap-client-ts b/src/client/jmap-client-ts index 2ef5f5b..160a66c 160000 --- a/src/client/jmap-client-ts +++ b/src/client/jmap-client-ts @@ -1 +1 @@ -Subproject commit 2ef5f5b7fa0a22a499bd32831ac24622f17e10e6 +Subproject commit 160a66caf7f385b18e25f224a64f75f213997bb2 From e93cbbaab20ee51c8f5650bc5dcad16f0f4c125a Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 4 Sep 2024 12:49:38 -0700 Subject: [PATCH 2/3] Pull changes on change event. We don't do much yet with the changes, and clearly this state should be incorporated into the client in a different way, but for now I want to commit to this checkpoint as it represents an update to my understanding of how the standard works. Specifically, whenever we do a get against a basic type we are given a state. We need to use that to request changes whenever we are alerted to changes. From there we update the cache and the display on the client should change in the reactive way. I don't yet do the actual update, but I'm nearly there. --- src/client/Client.tsx | 81 +++++++++++++++++++++++++++++++++++++-- src/client/jmap-client-ts | 2 +- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/client/Client.tsx b/src/client/Client.tsx index e67bd71..2adc0a5 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -25,6 +25,7 @@ export interface IAuth { } export interface ClientState { + emailState: string | null; inFlight: { emailContents: Set; emailStubs: Set; @@ -33,7 +34,9 @@ export interface ClientState { mailboxes: { trash: IMailbox; } | null; + mailboxState: string | null; session: ISession | null; + threadState: string | null; } class Account implements IAccount { @@ -69,13 +72,16 @@ export default class Client { callbacks: Array = []; jclient: jmapclient.Client | null = null; state: ClientState = { + emailState: null, inFlight: { emailContents: new Set(), emailStubs: new Set(), mailboxes: new Set(), }, mailboxes: null, + mailboxState: null, session: null, + threadState: null, }; // Get the currently active account @@ -247,8 +253,10 @@ export default class Client { receivedAt: e.receivedAt, subject: e.subject, }; - this._triggerChange(msg + e.id); }); + this._triggerChange(msg); + //this.state.session.state = response.sessionState; + this.state.emailState = response.state; }) .catch((x) => { console.error("Failed to get email stub", emailId, x); @@ -337,13 +345,57 @@ export default class Client { c(); }); } + _onChangedEmail(accountId: string, state: string) { + console.log("Email changed", state); + if (this.jclient === null) return; + if (this.state.session === null) return; + const account = this.account(accountId); + if (account === null) return; + if (this.state.emailState === null) return; + const args = { + accountId: accountId, + sinceState: this.state.emailState, + }; + this.jclient.email_changes(args).then((r) => { + console.log("Handle email changes ", r); + }); + } + _onChangedMailbox(accountId: string, state: string) { + console.log("Mailbox changed", state); + if (this.jclient === null) return; + if (this.state.session === null) return; + const account = this.account(accountId); + if (account === null) return; + if (this.state.mailboxState === null) return; + const args = { + accountId: accountId, + sinceState: this.state.session.state, + }; + this.jclient.mailbox_changes(args).then((r) => { + console.log("Handle mailbox changes ", r); + }); + } + _onChangedThread(accountId: string, state: string) { + console.log("Thread changed", state); + if (this.jclient === null) return; + if (this.state.session === null) return; + const account = this.account(accountId); + if (account === null) return; + if (this.state.threadState === null) return; + const args = { + accountId: accountId, + sinceState: this.state.session.state, + }; + this.jclient.thread_changes(args).then((r) => { + console.log("Handle thread changes ", r); + }); + } _onSession() { - console.log("Session received"); - // For the type checker if (!this.jclient) return; const session = this.jclient.getSession(); + console.log("Session received: ", session); // Subscribe to server-pushed events if (session.eventSourceUrl) { this._subscribeToEventSource(session.eventSourceUrl); @@ -373,7 +425,28 @@ export default class Client { this.jclient.subscribeToEvents( eventSourceUrl, (type: string, message: PushMessage) => { - console.log("Got an event!", type, message); + if (type === "ping") { + return; + } + if (type === "state") { + console.log("Got an event!", type, message); + const stateChange = message as jmaptypes.IStateChange; + for (const [accountId, changes] of Object.entries( + stateChange.changed, + )) { + for (const [changeType, state] of Object.entries(changes)) { + if (changeType === "Email") { + this._onChangedEmail(accountId, state); + } else if (changeType === "Mailbox") { + this._onChangedMailbox(accountId, state); + } else if (changeType === "Thread") { + this._onChangedThread(accountId, state); + } + } + } + } else { + console.log("Not sure what to do with event", type); + } }, ); } diff --git a/src/client/jmap-client-ts b/src/client/jmap-client-ts index 160a66c..ee2af71 160000 --- a/src/client/jmap-client-ts +++ b/src/client/jmap-client-ts @@ -1 +1 @@ -Subproject commit 160a66caf7f385b18e25f224a64f75f213997bb2 +Subproject commit ee2af71a8e764c1a8153a7dcd71f4085f970243f From 171a8aa1c0842a4c398a7ffa2620079e4a6cad4f Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 4 Sep 2024 13:29:52 -0700 Subject: [PATCH 3/3] Update the mailbox list when we get changes. This is a pretty big change. I'm now storing the mailboxes within the account as a map of IDs, which is more consistent with the rest of the client and makes it easier to update a single mailbox when we know it has changes. I've also added a new function for getting a single mailbox. This isn't really well coded - I need to refactor the client before I can do that properly. But for now the proof-of-concept works: I'm getting push events from the server, I'm getting the changes from my last update state, and I'm applying them to the UI. This is essentially the whole point for doing React and doing JMAP. Woot. --- src/EmailArea.tsx | 6 +-- src/Mailbox.tsx | 40 ++++++++++++++----- src/MailboxList.tsx | 6 +-- src/MailboxSummary.tsx | 19 +++++++++ src/client/Client.tsx | 91 +++++++++++++++++++++++++++++++++--------- src/client/types.tsx | 8 ++-- 6 files changed, 131 insertions(+), 39 deletions(-) create mode 100644 src/MailboxSummary.tsx diff --git a/src/EmailArea.tsx b/src/EmailArea.tsx index 3d3c049..b9c6254 100644 --- a/src/EmailArea.tsx +++ b/src/EmailArea.tsx @@ -4,7 +4,7 @@ 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"; +import Mailbox from "./Mailbox"; type EmailAreaProps = { account: IAccount | null; @@ -17,7 +17,7 @@ type EmailAreaProps = { const EmailArea: React.FC = (props) => { if (props.emailId === "") { return ( - = (props) => { } else { return ( - = ({ accountId, id, name }) => { - const href = "#" + accountId + "/" + id; +type MailboxState = {}; - return ( - - {name} - - ); -}; +class Mailbox extends React.Component { + componentDidUpdate() { + if (this.props.account == null) return; + if (this.props.client == null) return; + if (this.props.mailbox == null) return; + this.props.client.ensureMailbox( + this.props.account.id, + this.props.mailbox.id, + ); + } + + render() { + return ( + + ); + } +} export default Mailbox; diff --git a/src/MailboxList.tsx b/src/MailboxList.tsx index 3bbe6a6..8d8e847 100644 --- a/src/MailboxList.tsx +++ b/src/MailboxList.tsx @@ -3,7 +3,7 @@ import Stack from "react-bootstrap/Stack"; import Client from "./client/Client"; import { IAccount } from "./client/types"; -import Mailbox from "./Mailbox"; +import MailboxSummary from "./MailboxSummary"; type MailboxListProps = { account: IAccount | null; @@ -25,8 +25,8 @@ class MailboxList extends React.Component { ) : ( - {this.props.account.mailboxes.map((m) => ( - ( + = ({ accountId, id, name }) => { + const href = "#" + accountId + "/" + id; + + return ( + + {name} + + ); +}; + +export default Mailbox; diff --git a/src/client/Client.tsx b/src/client/Client.tsx index 2adc0a5..9f35f35 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -13,6 +13,7 @@ import { IEmailStub, IMailbox, ISession, + MailboxIdMap, MailboxRole, PushMessage, } from "./types"; @@ -45,7 +46,7 @@ class Account implements IAccount { accountCapabilities: { [key: string]: jmaptypes.IMailCapabilities }; isPersonal: boolean; isReadOnly: boolean; - mailboxes: Array | null; + mailboxes: MailboxIdMap | null; name: string; constructor(id: string, props: jmaptypes.IAccount) { @@ -58,10 +59,9 @@ class Account implements IAccount { } mailboxByRole(role: MailboxRole): IMailbox | null { if (this.mailboxes === null) return null; - for (let i = 0; i < this.mailboxes.length; i++) { - const m = this.mailboxes[i]; - if (m.role === role) { - return m; + for (const mailbox of Object.values(this.mailboxes)) { + if (mailbox.role === role) { + return mailbox; } } return null; @@ -180,6 +180,14 @@ export default class Client { this.doLogin(auth); } + // Ensure we have the full email content + ensureMailbox(accountId: string, mailboxId: string) { + if (this.state.session == null) return; + const existing = this.state.session.mailboxes[mailboxId]; + if (existing != null) return; + this.mailboxList(accountId, [mailboxId]); + } + email(emailId: string): IEmail | null { if (this.state.session == null) return null; const result = this.state.session.emails[emailId]; @@ -303,12 +311,39 @@ export default class Client { if (this.state.session == null) return null; const account = this.state.session.accounts[accountId]; if (account.mailboxes == null) return null; - for (let i = 0; i < account.mailboxes.length; i++) { - if (account.mailboxes[i].id === mailboxId) { - return account.mailboxes[i]; - } - } - return null; + const mailbox = account.mailboxes[mailboxId]; + return mailbox; + } + mailboxGet(accountId: string, id: string) { + if (this.jclient == null) return; + if (this.state.session == null) return; + // TODO: do this in a single request with 2 queries when the client can handle it. + this.jclient + .mailbox_get({ + accountId: accountId, + ids: [id], + }) + .then((response) => { + if (this.state.session == null) return; + const account = this.state.session.accounts[response.accountId]; + if (response.list.length !== 1) { + console.error( + "Should only get 1 mailbox in mailboxGet. Got ", + response.list, + ); + return; + } + const m = response.list[0]; + if (account.mailboxes === null) { + account.mailboxes = {}; + } + account.mailboxes[m.id] = { + ...m, + emailIds: null, + }; + this.state.mailboxState = response.state; + this.emailList(accountId, id, []); + }); } mailboxList(accountId: string, ids: Array) { if (this.jclient == null) return; @@ -323,14 +358,18 @@ export default class Client { .then((response) => { if (this.state.session == null) return; const account = this.state.session.accounts[response.accountId]; - const mailboxes: Array = []; + const mailboxes: MailboxIdMap = {}; response.list.forEach((m) => { - mailboxes.push({ + // If we already have a mailbox with emails then don't throw that data away + const existing = + account.mailboxes === null ? null : account.mailboxes[m.id]; + mailboxes[m.id] = { ...m, - emailIds: null, - }); + emailIds: existing === null ? null : existing.emailIds, + }; }); account.mailboxes = mailboxes; + this.state.mailboxState = response.state; this._triggerChange("Mailboxes " + accountId); }); } @@ -369,10 +408,23 @@ export default class Client { if (this.state.mailboxState === null) return; const args = { accountId: accountId, - sinceState: this.state.session.state, + sinceState: this.state.mailboxState, }; - this.jclient.mailbox_changes(args).then((r) => { - console.log("Handle mailbox changes ", r); + this.jclient.mailbox_changes(args).then((response) => { + console.log("Handle mailbox changes ", response); + for (let i = 0; i < response.created.length; i++) { + const mailboxId = response.created[i]; + this.mailboxGet(accountId, mailboxId); + } + for (let i = 0; i < response.updated.length; i++) { + const mailboxId = response.updated[i]; + this.mailboxGet(accountId, mailboxId); + } + if (this.state.session === null) return; + for (let i = 0; i < response.destroyed.length; i++) { + const mailboxId = response.destroyed[i]; + delete this.state.session.mailboxes[mailboxId]; + } }); } _onChangedThread(accountId: string, state: string) { @@ -384,7 +436,7 @@ export default class Client { if (this.state.threadState === null) return; const args = { accountId: accountId, - sinceState: this.state.session.state, + sinceState: this.state.threadState, }; this.jclient.thread_changes(args).then((r) => { console.log("Handle thread changes ", r); @@ -410,6 +462,7 @@ export default class Client { ), emails: {}, emailStubs: {}, + mailboxes: {}, }; if (!this.state.session) return; this._triggerChange("Session"); diff --git a/src/client/types.tsx b/src/client/types.tsx index a251cd0..1b0e56f 100644 --- a/src/client/types.tsx +++ b/src/client/types.tsx @@ -1,10 +1,10 @@ import * as client from "./jmap-client-ts/src/types"; -export type MailboxIdMap = { [mailboxId: string]: boolean }; +export type MailboxIdMapPresence = { [mailboxId: string]: boolean }; export interface IEmailStub { from: Array; id: string; - mailboxIds: MailboxIdMap; + mailboxIds: MailboxIdMapPresence; receivedAt: string; subject: string; } @@ -12,6 +12,7 @@ export interface IEmailStub { export interface IMailbox extends client.IMailboxProperties { emailIds: Array | null; } +export type MailboxIdMap = { [mailboxId: string]: IMailbox }; export interface IEmail extends client.IEmailProperties { //sentAt: IutcDate|null, @@ -19,7 +20,7 @@ export interface IEmail extends client.IEmailProperties { export interface IAccount extends client.IAccount { id: string; - mailboxes: Array | null; + mailboxes: MailboxIdMap | null; mailboxByRole(role: MailboxRole): IMailbox | null; } @@ -42,4 +43,5 @@ export interface ISession extends client.ISession { accounts: AccountIdMap; emails: EmailIdMap; emailStubs: EmailStubIdMap; + mailboxes: MailboxIdMap; }