Compare commits

..

3 Commits

Author SHA1 Message Date
Eli Ribble 171a8aa1c0 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.
2024-09-04 13:29:52 -07:00
Eli Ribble e93cbbaab2 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.
2024-09-04 12:49:38 -07:00
Eli Ribble c686f5c8d9 Subscribe to event type and message.
This mirrors a change in the client where we extract the type of the
event.
2024-09-04 12:04:45 -07:00
7 changed files with 205 additions and 40 deletions

View File

@ -4,7 +4,7 @@ import Stack from "react-bootstrap/Stack";
import { IAccount, IEmail, IMailbox } from "./client/types"; import { IAccount, IEmail, IMailbox } from "./client/types";
import Client from "./client/Client"; import Client from "./client/Client";
import EmailContent from "./EmailContent"; import EmailContent from "./EmailContent";
import EmailList from "./EmailList"; import Mailbox from "./Mailbox";
type EmailAreaProps = { type EmailAreaProps = {
account: IAccount | null; account: IAccount | null;
@ -17,7 +17,7 @@ type EmailAreaProps = {
const EmailArea: React.FC<EmailAreaProps> = (props) => { const EmailArea: React.FC<EmailAreaProps> = (props) => {
if (props.emailId === "") { if (props.emailId === "") {
return ( return (
<EmailList <Mailbox
account={props.account} account={props.account}
client={props.client} client={props.client}
mailbox={props.mailbox} mailbox={props.mailbox}
@ -26,7 +26,7 @@ const EmailArea: React.FC<EmailAreaProps> = (props) => {
} else { } else {
return ( return (
<Stack className="text-start"> <Stack className="text-start">
<EmailList <Mailbox
account={props.account} account={props.account}
client={props.client} client={props.client}
mailbox={props.mailbox} mailbox={props.mailbox}

View File

@ -1,19 +1,37 @@
import React from "react"; import React from "react";
import { IAccount, IMailbox } from "./client/types";
import Client from "./client/Client";
import EmailList from "./EmailList";
type MailboxProps = { type MailboxProps = {
accountId: string; account: IAccount | null;
id: string; client: Client;
name: string; mailbox: IMailbox | null;
}; };
const Mailbox: React.FC<MailboxProps> = ({ accountId, id, name }) => { type MailboxState = {};
const href = "#" + accountId + "/" + id;
return ( class Mailbox extends React.Component<MailboxProps, MailboxState> {
<a href={href} className="p-2" key={id}> componentDidUpdate() {
{name} if (this.props.account == null) return;
</a> 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 (
<EmailList
account={this.props.account}
client={this.props.client}
mailbox={this.props.mailbox}
/>
);
}
}
export default Mailbox; export default Mailbox;

View File

@ -3,7 +3,7 @@ import Stack from "react-bootstrap/Stack";
import Client from "./client/Client"; import Client from "./client/Client";
import { IAccount } from "./client/types"; import { IAccount } from "./client/types";
import Mailbox from "./Mailbox"; import MailboxSummary from "./MailboxSummary";
type MailboxListProps = { type MailboxListProps = {
account: IAccount | null; account: IAccount | null;
@ -25,8 +25,8 @@ class MailboxList extends React.Component<MailboxListProps, MailboxListState> {
<Stack /> <Stack />
) : ( ) : (
<Stack> <Stack>
{this.props.account.mailboxes.map((m) => ( {Object.values(this.props.account.mailboxes).map((m) => (
<Mailbox <MailboxSummary
accountId={this.props.account!.id} accountId={this.props.account!.id}
id={m.id} id={m.id}
key={m.id} key={m.id}

19
src/MailboxSummary.tsx Normal file
View File

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

View File

@ -13,6 +13,7 @@ import {
IEmailStub, IEmailStub,
IMailbox, IMailbox,
ISession, ISession,
MailboxIdMap,
MailboxRole, MailboxRole,
PushMessage, PushMessage,
} from "./types"; } from "./types";
@ -25,6 +26,7 @@ export interface IAuth {
} }
export interface ClientState { export interface ClientState {
emailState: string | null;
inFlight: { inFlight: {
emailContents: Set<string>; emailContents: Set<string>;
emailStubs: Set<string>; emailStubs: Set<string>;
@ -33,7 +35,9 @@ export interface ClientState {
mailboxes: { mailboxes: {
trash: IMailbox; trash: IMailbox;
} | null; } | null;
mailboxState: string | null;
session: ISession | null; session: ISession | null;
threadState: string | null;
} }
class Account implements IAccount { class Account implements IAccount {
@ -42,7 +46,7 @@ class Account implements IAccount {
accountCapabilities: { [key: string]: jmaptypes.IMailCapabilities }; accountCapabilities: { [key: string]: jmaptypes.IMailCapabilities };
isPersonal: boolean; isPersonal: boolean;
isReadOnly: boolean; isReadOnly: boolean;
mailboxes: Array<IMailbox> | null; mailboxes: MailboxIdMap | null;
name: string; name: string;
constructor(id: string, props: jmaptypes.IAccount) { constructor(id: string, props: jmaptypes.IAccount) {
@ -55,10 +59,9 @@ class Account implements IAccount {
} }
mailboxByRole(role: MailboxRole): IMailbox | null { mailboxByRole(role: MailboxRole): IMailbox | null {
if (this.mailboxes === null) return null; if (this.mailboxes === null) return null;
for (let i = 0; i < this.mailboxes.length; i++) { for (const mailbox of Object.values(this.mailboxes)) {
const m = this.mailboxes[i]; if (mailbox.role === role) {
if (m.role === role) { return mailbox;
return m;
} }
} }
return null; return null;
@ -69,13 +72,16 @@ export default class Client {
callbacks: Array<Callback> = []; callbacks: Array<Callback> = [];
jclient: jmapclient.Client | null = null; jclient: jmapclient.Client | null = null;
state: ClientState = { state: ClientState = {
emailState: null,
inFlight: { inFlight: {
emailContents: new Set(), emailContents: new Set(),
emailStubs: new Set(), emailStubs: new Set(),
mailboxes: new Set(), mailboxes: new Set(),
}, },
mailboxes: null, mailboxes: null,
mailboxState: null,
session: null, session: null,
threadState: null,
}; };
// Get the currently active account // Get the currently active account
@ -174,6 +180,14 @@ export default class Client {
this.doLogin(auth); 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 { email(emailId: string): IEmail | null {
if (this.state.session == null) return null; if (this.state.session == null) return null;
const result = this.state.session.emails[emailId]; const result = this.state.session.emails[emailId];
@ -247,8 +261,10 @@ export default class Client {
receivedAt: e.receivedAt, receivedAt: e.receivedAt,
subject: e.subject, subject: e.subject,
}; };
this._triggerChange(msg + e.id);
}); });
this._triggerChange(msg);
//this.state.session.state = response.sessionState;
this.state.emailState = response.state;
}) })
.catch((x) => { .catch((x) => {
console.error("Failed to get email stub", emailId, x); console.error("Failed to get email stub", emailId, x);
@ -295,12 +311,39 @@ export default class Client {
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];
if (account.mailboxes == null) return null; if (account.mailboxes == null) return null;
for (let i = 0; i < account.mailboxes.length; i++) { const mailbox = account.mailboxes[mailboxId];
if (account.mailboxes[i].id === mailboxId) { return mailbox;
return account.mailboxes[i];
} }
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;
} }
return null; 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<string>) { mailboxList(accountId: string, ids: Array<string>) {
if (this.jclient == null) return; if (this.jclient == null) return;
@ -315,14 +358,18 @@ export default class Client {
.then((response) => { .then((response) => {
if (this.state.session == null) return; if (this.state.session == null) return;
const account = this.state.session.accounts[response.accountId]; const account = this.state.session.accounts[response.accountId];
const mailboxes: Array<IMailbox> = []; const mailboxes: MailboxIdMap = {};
response.list.forEach((m) => { 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, ...m,
emailIds: null, emailIds: existing === null ? null : existing.emailIds,
}); };
}); });
account.mailboxes = mailboxes; account.mailboxes = mailboxes;
this.state.mailboxState = response.state;
this._triggerChange("Mailboxes " + accountId); this._triggerChange("Mailboxes " + accountId);
}); });
} }
@ -337,13 +384,70 @@ export default class Client {
c(); 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.mailboxState,
};
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) {
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.threadState,
};
this.jclient.thread_changes(args).then((r) => {
console.log("Handle thread changes ", r);
});
}
_onSession() { _onSession() {
console.log("Session received");
// For the type checker // For the type checker
if (!this.jclient) return; if (!this.jclient) return;
const session = this.jclient.getSession(); const session = this.jclient.getSession();
console.log("Session received: ", session);
// Subscribe to server-pushed events // Subscribe to server-pushed events
if (session.eventSourceUrl) { if (session.eventSourceUrl) {
this._subscribeToEventSource(session.eventSourceUrl); this._subscribeToEventSource(session.eventSourceUrl);
@ -358,6 +462,7 @@ export default class Client {
), ),
emails: {}, emails: {},
emailStubs: {}, emailStubs: {},
mailboxes: {},
}; };
if (!this.state.session) return; if (!this.state.session) return;
this._triggerChange("Session"); this._triggerChange("Session");
@ -373,7 +478,28 @@ export default class Client {
this.jclient.subscribeToEvents( this.jclient.subscribeToEvents(
eventSourceUrl, eventSourceUrl,
(type: string, message: PushMessage) => { (type: string, message: PushMessage) => {
if (type === "ping") {
return;
}
if (type === "state") {
console.log("Got an event!", type, message); 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);
}
}, },
); );
} }

@ -1 +1 @@
Subproject commit 5cf6129a517224b90f79cb96f212d57c5bceb51f Subproject commit ee2af71a8e764c1a8153a7dcd71f4085f970243f

View File

@ -1,10 +1,10 @@
import * as client from "./jmap-client-ts/src/types"; import * as client from "./jmap-client-ts/src/types";
export type MailboxIdMap = { [mailboxId: string]: boolean }; export type MailboxIdMapPresence = { [mailboxId: string]: boolean };
export interface IEmailStub { export interface IEmailStub {
from: Array<client.IEmailAddress>; from: Array<client.IEmailAddress>;
id: string; id: string;
mailboxIds: MailboxIdMap; mailboxIds: MailboxIdMapPresence;
receivedAt: string; receivedAt: string;
subject: string; subject: string;
} }
@ -12,6 +12,7 @@ export interface IEmailStub {
export interface IMailbox extends client.IMailboxProperties { export interface IMailbox extends client.IMailboxProperties {
emailIds: Array<string> | null; emailIds: Array<string> | null;
} }
export type MailboxIdMap = { [mailboxId: string]: IMailbox };
export interface IEmail extends client.IEmailProperties { export interface IEmail extends client.IEmailProperties {
//sentAt: IutcDate|null, //sentAt: IutcDate|null,
@ -19,7 +20,7 @@ export interface IEmail extends client.IEmailProperties {
export interface IAccount extends client.IAccount { export interface IAccount extends client.IAccount {
id: string; id: string;
mailboxes: Array<IMailbox> | null; mailboxes: MailboxIdMap | null;
mailboxByRole(role: MailboxRole): IMailbox | null; mailboxByRole(role: MailboxRole): IMailbox | null;
} }
@ -42,4 +43,5 @@ export interface ISession extends client.ISession {
accounts: AccountIdMap; accounts: AccountIdMap;
emails: EmailIdMap; emails: EmailIdMap;
emailStubs: EmailStubIdMap; emailStubs: EmailStubIdMap;
mailboxes: MailboxIdMap;
} }