diff --git a/package-lock.json b/package-lock.json index 8121cc1..74754c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "bootstrap": "^5.3.3", "react": "^18.3.1", "react-bootstrap": "^2.10.4", + "react-bootstrap-icons": "^1.11.4", "react-dom": "^18.3.1", "react-scripts": "5.0.1", "sass": "^1.77.8", @@ -14746,6 +14747,17 @@ } } }, + "node_modules/react-bootstrap-icons": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/react-bootstrap-icons/-/react-bootstrap-icons-1.11.4.tgz", + "integrity": "sha512-lnkOpNEZ/Zr7mNxvjA9efuarCPSgtOuGA55XiRj7ASJnBjb1wEAdtJOd2Aiv9t07r7FLI1IgyZPg9P6jqWD/IA==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">=16.8.6" + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", diff --git a/package.json b/package.json index 6627f40..bf024bc 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "bootstrap": "^5.3.3", "react": "^18.3.1", "react-bootstrap": "^2.10.4", + "react-bootstrap-icons": "^1.11.4", "react-dom": "^18.3.1", "react-scripts": "5.0.1", "sass": "^1.77.8", diff --git a/src/EmailContent.tsx b/src/EmailContent.tsx index 953d043..e1df3c4 100644 --- a/src/EmailContent.tsx +++ b/src/EmailContent.tsx @@ -88,6 +88,10 @@ class EmailContent extends React.Component< Received {email.receivedAt} + + Sent + + {email.sentAt} diff --git a/src/EmailSummary.tsx b/src/EmailSummary.tsx index a9514ea..66d3517 100644 --- a/src/EmailSummary.tsx +++ b/src/EmailSummary.tsx @@ -1,3 +1,7 @@ +import { Trash } from "react-bootstrap-icons"; +import Button from "react-bootstrap/Button"; +import ButtonGroup from "react-bootstrap/ButtonGroup"; +import ButtonToolbar from "react-bootstrap/ButtonToolbar"; import Placeholder from "react-bootstrap/Placeholder"; import React from "react"; @@ -55,6 +59,21 @@ class EmailSummary extends React.Component< stub.subject} + + + + + + ); } diff --git a/src/client/Client.tsx b/src/client/Client.tsx index 4ed9de5..25eb9ab 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -3,10 +3,19 @@ * None of the dependencies should leak, in types or otherwise */ import * as base64 from "base-64"; +import * as jmaptypes from "./jmap-client-ts/src/types"; import * as jmapclient from "./jmap-client-ts/src"; import { FetchTransport } from "./jmap-client-ts/src/utils/fetch-transport"; -import { IAccount, IEmail, IEmailStub, IMailbox, ISession } from "./types"; +import { + IAccount, + IEmail, + IEmailStub, + IMailbox, + ISession, + MailboxRole, + PushMessage, +} from "./types"; type Callback = () => void; @@ -21,9 +30,40 @@ export interface ClientState { emailStubs: Set; mailboxes: Set; }; + mailboxes: { + trash: IMailbox; + } | null; session: ISession | null; } +class Account implements IAccount { + id: string; + + accountCapabilities: { [key: string]: jmaptypes.IMailCapabilities }; + isPersonal: boolean; + isReadOnly: boolean; + mailboxes: Array | null; + name: string; + + constructor(id: string, props: jmaptypes.IAccount) { + this.id = id; + this.accountCapabilities = props.accountCapabilities; + this.isPersonal = props.isPersonal; + this.isReadOnly = props.isReadOnly; + this.mailboxes = null; + this.name = props.name; + } + 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; + } + } + return null; + } +} export default class Client { // All objects which currently are listening for changes callbacks: Array = []; @@ -34,6 +74,7 @@ export default class Client { emailStubs: new Set(), mailboxes: new Set(), }, + mailboxes: null, session: null, }; @@ -67,6 +108,43 @@ export default class Client { return; } + emailMoveTrash(account: IAccount, emailId: string) { + if (this.jclient === null) return; + console.log("Trashing", emailId); + const email = this.emailStub(emailId); + if (email === null) return; + const trashMailbox = account.mailboxByRole("trash"); + if (trashMailbox === null) { + console.error( + "Cannot trash ", + emailId, + " because ", + account.id, + " does not have a 'trash' mailbox", + ); + return; + } + let mailboxIds = Object.keys(email.mailboxIds).reduce( + (acc, key) => { + acc[key as keyof typeof email.mailboxIds] = false; + return acc; + }, + {} as Record, + ); + mailboxIds[trashMailbox.id] = true; + const props = { + accountId: account.id, + update: { + [email.id]: { + mailboxIds: mailboxIds, + }, + }, + }; + this.jclient.email_set(props).then((response) => { + console.log("Trashed", emailId); + }); + } + // Ensure we have the full email content ensureEmailContent(accountId: string, emailId: string) { if (this.state.session == null) return; @@ -98,7 +176,9 @@ export default class Client { email(emailId: string): IEmail | null { if (this.state.session == null) return null; - return this.state.session.emails[emailId]; + const result = this.state.session.emails[emailId]; + if (result === undefined) return null; + return result; } emailGetContent(accountId: string, emailId: string) { @@ -148,7 +228,7 @@ export default class Client { .email_get({ accountId: accountId, ids: [emailId], - properties: ["from", "receivedAt", "subject"], + properties: ["from", "mailboxIds", "receivedAt", "subject"], }) .then((response) => { console.log(msg, "response", response); @@ -163,6 +243,7 @@ export default class Client { this.state.session.emailStubs[e.id] = { from: e.from, id: e.id, + mailboxIds: e.mailboxIds, receivedAt: e.receivedAt, subject: e.subject, }; @@ -263,12 +344,16 @@ export default class Client { if (!this.jclient) return; const session = this.jclient.getSession(); + // Subscribe to server-pushed events + if (session.eventSourceUrl) { + this._subscribeToEventSource(session.eventSourceUrl); + } this.state.session = { ...session, accounts: Object.fromEntries( Object.entries(session.accounts).map(([key, account]) => [ key, - { ...account, id: key.toString(), mailboxes: null }, + new Account(key.toString(), account), ]), ), emails: {}, @@ -277,4 +362,16 @@ export default class Client { if (!this.state.session) return; this._triggerChange("Session"); } + + _subscribeToEventSource(url: string) { + // For typechecker + if (this.jclient === null) return; + const eventSourceUrl = url + .replace("{types}", "*") + .replace("{closeafter}", "no") + .replace("{ping}", "60"); + this.jclient.subscribeToEvents(eventSourceUrl, (e) => { + console.log("Got an event!", e); + }); + } } diff --git a/src/client/jmap-client-ts b/src/client/jmap-client-ts index fdab379..2ef5f5b 160000 --- a/src/client/jmap-client-ts +++ b/src/client/jmap-client-ts @@ -1 +1 @@ -Subproject commit fdab37996a4709b13962b67d1aa49e85a8305755 +Subproject commit 2ef5f5b7fa0a22a499bd32831ac24622f17e10e6 diff --git a/src/client/types.tsx b/src/client/types.tsx index a479d70..e8fa48c 100644 --- a/src/client/types.tsx +++ b/src/client/types.tsx @@ -1,8 +1,10 @@ import * as client from "./jmap-client-ts/src/types"; +export type MailboxIdMap = { [mailboxId: string]: boolean }; export interface IEmailStub { from: Array | null; id: string; + mailboxIds: MailboxIdMap; receivedAt: string; subject: string; } @@ -18,8 +20,20 @@ export interface IEmail extends client.IEmailProperties { export interface IAccount extends client.IAccount { id: string; mailboxes: Array | null; -} + mailboxByRole(role: MailboxRole): IMailbox | null; +} +export type MailboxRole = + | "all" + | "archive" + | "drafts" + | "flagged" + | "important" + | "junk" + | "sent" + | "subscribed" + | "trash"; +export type PushMessage = client.PushMessage; export type AccountIdMap = { [accountId: string]: IAccount }; export type EmailStubIdMap = { [emailId: string]: IEmailStub }; export type EmailIdMap = { [emailId: string]: IEmail };