diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ac018ff --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/jmap-client-ts"] + path = src/client/jmap-client-ts + url = git@github.com:EliRibble/jmap-client-ts.git diff --git a/package-lock.json b/package-lock.json index 89f11c5..8121cc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,13 +16,14 @@ "@types/node": "^16.18.104", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "axios": "^0.21.4", "base-64": "^1.0.0", "bootstrap": "^5.3.3", - "jmap-client-ts": "^1.0.0", "react": "^18.3.1", "react-bootstrap": "^2.10.4", "react-dom": "^18.3.1", "react-scripts": "5.0.1", + "sass": "^1.77.8", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, @@ -5256,6 +5257,14 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/axobject-query": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", @@ -9319,6 +9328,11 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -11993,11 +12007,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jmap-client-ts": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/jmap-client-ts/-/jmap-client-ts-1.0.0.tgz", - "integrity": "sha512-qL31lYUpLAqrVFMaMsogorcAcpZRTSjWPwnxdl61mDNY9DINQvrKIkYCMstva4cxTbL1kQFG4aT8K9wOesrj7g==" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15524,6 +15533,22 @@ "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==" }, + "node_modules/sass": { + "version": "1.77.8", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", + "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/sass-loader": { "version": "12.6.0", "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", diff --git a/package.json b/package.json index 9247a49..6627f40 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,14 @@ "@types/node": "^16.18.104", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "axios": "^0.21.4", "base-64": "^1.0.0", "bootstrap": "^5.3.3", - "jmap-client-ts": "^1.0.0", "react": "^18.3.1", "react-bootstrap": "^2.10.4", "react-dom": "^18.3.1", "react-scripts": "5.0.1", + "sass": "^1.77.8", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, diff --git a/src/App.tsx b/src/App.tsx index a76caf9..78eec8c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,15 @@ import "./App.css"; -import "bootstrap/dist/css/bootstrap.min.css"; +import "./style.scss"; import Client, { IAuth } from "./client/Client"; -import { AccountIdMap, IAccount, IMailbox } from "./client/types"; +import { AccountIdMap, IAccount, IEmail, IMailbox } from "./client/types"; import AppLayout from "./AppLayout"; import AuthModal from "./AuthModal"; import React from "react"; interface ILocation { accountId: string; + emailId: string; mailboxId: string; } @@ -16,6 +17,7 @@ type AppState = { auth: IAuth; account: IAccount | null; accounts: AccountIdMap; + email: IEmail | null; location: ILocation; mailbox: IMailbox | null; }; @@ -37,7 +39,8 @@ class App extends React.Component { account: null, accounts: {}, auth: { email: "", password: "" }, - location: { accountId: "", mailboxId: "" }, + email: null, + location: { accountId: "", emailId: "", mailboxId: "" }, mailbox: null, }; @@ -46,14 +49,20 @@ class App extends React.Component { const parts = hash.split("/"); const accountId = parts[0]; const mailboxId = parts[1]; + const emailId = parts[2]; this.setState({ ...this.state, account: this.account(), + email: this.client.email(emailId), location: { accountId: accountId, + emailId: emailId, mailboxId: mailboxId, }, + mailbox: this.client.mailbox(accountId, mailboxId), }); + if (!this.state.account) return; + this.client.ensureEmailList(accountId, mailboxId); } // When the user provides credentials @@ -70,16 +79,6 @@ class App extends React.Component { this.client.doLogin({ email, password }); } - onMailboxSelect(mailboxId: string) { - if (!this.state.account) return; - this.client.emailList(this.state.account.id, mailboxId, []); - this.setState({ - ...this.state, - mailbox: this.client.mailbox(this.state.account.id, mailboxId), - }); - window.location.hash = this.state.location.accountId + "/" + mailboxId; - } - // Load up auth credentials from the local store loadAuth() { const data = localStorage.getItem("auth"); @@ -109,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(), }); }); @@ -131,10 +131,9 @@ class App extends React.Component { account={this.state.account} accounts={this.state.accounts} client={this.client} + email={this.state.email} + emailId={this.state.location.emailId} mailbox={this.state.mailbox} - onMailboxSelect={(m) => { - this.onMailboxSelect(m); - }} /> void; }; const AppLayout: React.FC = (props) => { return ( - + @@ -29,16 +30,14 @@ const AppLayout: React.FC = (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..953d043 --- /dev/null +++ b/src/EmailContent.tsx @@ -0,0 +1,120 @@ +import Button from "react-bootstrap/Button"; +import Col from "react-bootstrap/Col"; +import Container from "react-bootstrap/Container"; +import Form from "react-bootstrap/Form"; +import Placeholder from "react-bootstrap/Placeholder"; +import React from "react"; +import Row from "react-bootstrap/Row"; + +import Client from "./client/Client"; +import EmailContentHTML from "./EmailContentHTML"; +import EmailContentText from "./EmailContentText"; +import { IAccount, IEmail } from "./client/types"; + +type EmailContentProps = { + account: IAccount | null; + client: Client | null; + email: IEmail | null; + emailId: string; +}; + +type EmailContentState = { + showHTML: boolean; +}; + +class EmailContent extends React.Component< + EmailContentProps, + EmailContentState +> { + state = { + showHTML: false, + }; + + 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() { + const email = this.props.email; + if (email == null || email.bodyValues == null) { + return ; + } + let content; + if (this.state.showHTML) { + if (email.htmlBody != null) { + content = ; + } else { + return

No HTML content

; + } + } else { + if (email.textBody != null) { + content = ; + } else { + return

No text content/

; + } + } + return ( + + +
+ + + From + + +

+ {email.from == null + ? "unknown" + : email.from.map((f) => f.name)} +

+ + + Received + + {email.receivedAt} +
+
+
+ + + + {content} +
+ ); + } + + switchDisplay() { + if (this.state.showHTML) { + this.setState({ + showHTML: false, + }); + } else { + this.setState({ + showHTML: true, + }); + } + } +} +export default EmailContent; diff --git a/src/EmailContentHTML.tsx b/src/EmailContentHTML.tsx new file mode 100644 index 0000000..3c99379 --- /dev/null +++ b/src/EmailContentHTML.tsx @@ -0,0 +1,33 @@ +import Placeholder from "react-bootstrap/Placeholder"; +import React from "react"; +import Stack from "react-bootstrap/Stack"; + +import { IEmail } from "./client/types"; + +type EmailContentHTMLProps = { + email: IEmail; +}; + +const EmailContentHTML: React.FC = (props) => { + const email = props.email; + if (email.htmlBody == null) { + return ; + } + return ( + + {email.htmlBody.map((h) => + h.partId === undefined ? ( + + ) : ( +
+ ), + )} + + ); +}; +export default EmailContentHTML; diff --git a/src/EmailContentText.tsx b/src/EmailContentText.tsx new file mode 100644 index 0000000..ac9ef39 --- /dev/null +++ b/src/EmailContentText.tsx @@ -0,0 +1,28 @@ +import Placeholder from "react-bootstrap/Placeholder"; +import React from "react"; +import Stack from "react-bootstrap/Stack"; + +import { IEmail } from "./client/types"; + +type EmailContentTextProps = { + email: IEmail; +}; + +const EmailContentText: React.FC = (props) => { + const email = props.email; + if (email.textBody == null) { + return ; + } + return ( + + {email.textBody.map((t) => + t.partId === undefined ? ( +
undefined partId
+ ) : ( +
{email.bodyValues![t.partId].value}
+ ), + )} +
+ ); +}; +export default EmailContentText; diff --git a/src/EmailList.tsx b/src/EmailList.tsx index 34c44be..2b3d62e 100644 --- a/src/EmailList.tsx +++ b/src/EmailList.tsx @@ -3,10 +3,11 @@ import Stack from "react-bootstrap/Stack"; import Client from "./client/Client"; import { IAccount, IMailbox } from "./client/types"; +import EmailSummary from "./EmailSummary"; type EmailListProps = { account: IAccount | null; - client: Client | null; + client: Client; mailbox: IMailbox | null; }; @@ -17,37 +18,31 @@ class EmailList extends React.Component { if (this.props.account == null) return; if (this.props.client == null) return; if (this.props.mailbox == null) return; - this.props.client.emailList( + this.props.client.ensureEmailList( this.props.account.id, this.props.mailbox.id, - [], ); } render() { if ( - this.props.account == null || - this.props.mailbox == null || - this.props.mailbox.emailIds == null + this.props.account === null || + this.props.mailbox === null || + this.props.mailbox.emailIds === null ) { return ; - } else if (this.props.mailbox.emails == null) { - return ( - - {this.props.mailbox.emailIds.map((e) => ( -
- Email {e} -
- ))} -
- ); } else { return ( - - {this.props.mailbox.emails.map((m) => ( -
- {m.subject} -
+ + {this.props.mailbox.emailIds.slice(0, 5).map((e) => ( + ))} ); diff --git a/src/EmailSummary.tsx b/src/EmailSummary.tsx new file mode 100644 index 0000000..a9514ea --- /dev/null +++ b/src/EmailSummary.tsx @@ -0,0 +1,62 @@ +import Placeholder from "react-bootstrap/Placeholder"; +import React from "react"; + +import Client from "./client/Client"; +import DateTime from "./components/DateTime"; +import { IAccount, IEmailStub, IMailbox } from "./client/types"; + +type EmailSummaryProps = { + account: IAccount; + client: Client; + emailId: string; + emailStub: IEmailStub | null; + mailbox: IMailbox; +}; +type EmailSummaryState = {}; + +class EmailSummary extends React.Component< + EmailSummaryProps, + EmailSummaryState +> { + componentDidMount() { + this.ensureData(); + } + componentDidUpdate() { + this.ensureData(); + } + + ensureData() { + this.props.client.ensureEmailStub( + this.props.account.id, + this.props.emailId, + ); + } + + render() { + const href = + "#" + + this.props.account.id + + "/" + + this.props.mailbox.id + + "/" + + this.props.emailId; + const stub = this.props.emailStub; + if (stub === null) { + return ; + } + return ( + + ); + } +} +export default EmailSummary; diff --git a/src/Mailbox.tsx b/src/Mailbox.tsx index 5a34f51..1213bc5 100644 --- a/src/Mailbox.tsx +++ b/src/Mailbox.tsx @@ -1,12 +1,19 @@ -import ListGroup from "react-bootstrap/ListGroup"; +import React from "react"; + +type MailboxProps = { + accountId: string; + id: string; + name: string; +}; + +const Mailbox: React.FC = ({ accountId, id, name }) => { + const href = "#" + accountId + "/" + id; -function Mailbox() { return ( - - Inbox - Spam - + + {name} + ); -} +}; export default Mailbox; diff --git a/src/MailboxList.tsx b/src/MailboxList.tsx index f42a241..3bbe6a6 100644 --- a/src/MailboxList.tsx +++ b/src/MailboxList.tsx @@ -1,14 +1,13 @@ import React from "react"; -import Button from "react-bootstrap/Button"; import Stack from "react-bootstrap/Stack"; import Client from "./client/Client"; import { IAccount } from "./client/types"; +import Mailbox from "./Mailbox"; type MailboxListProps = { account: IAccount | null; client: Client | null; - onMailboxSelect: (mailboxId: string) => void; }; type MailboxListState = {}; @@ -20,10 +19,6 @@ class MailboxList extends React.Component { this.props.client.mailboxList(this.props.account.id, []); } - onMailboxClick(id: string) { - this.props.onMailboxSelect(id); - } - render() { return this.props.account == null || this.props.account.mailboxes == null ? ( @@ -31,15 +26,12 @@ class MailboxList extends React.Component { ) : ( {this.props.account.mailboxes.map((m) => ( - + name={m.name} + /> ))} ); diff --git a/src/client/Client.tsx b/src/client/Client.tsx index d002d1c..4ed9de5 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -3,10 +3,10 @@ * None of the dependencies should leak, in types or otherwise */ import * as base64 from "base-64"; -import * as jmapclient from "jmap-client-ts"; -import { FetchTransport } from "jmap-client-ts/lib/utils/fetch-transport"; +import * as jmapclient from "./jmap-client-ts/src"; +import { FetchTransport } from "./jmap-client-ts/src/utils/fetch-transport"; -import { IAccount, IMailbox, ISession } from "./types"; +import { IAccount, IEmail, IEmailStub, IMailbox, ISession } from "./types"; type Callback = () => void; @@ -16,31 +16,33 @@ export interface IAuth { } export interface ClientState { + inFlight: { + emailContents: Set; + emailStubs: Set; + mailboxes: Set; + }; session: ISession | null; } export default class Client { + // 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, + }; + // 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 = { - session: null, - }; - onChange(f: Callback) { - this.callbacks.push(f); - } - - // Ensure that login happens. If this is called many times, only login once. - ensureLogin(auth: IAuth) { - if (this.jclient != null) return; - this.doLogin(auth); - } // Make the request to get system metadata doLogin(auth: IAuth) { const domain = auth.email.split("@")[1]; @@ -65,29 +67,149 @@ 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]; + } + + 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], + fetchAllBodyValues: true, + }) + .then((response) => { + console.log(msg, "response", response); + response.list.forEach((e) => { + 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, + id: e.id, + }; + this._triggerChange(msg + e.id); + }); + }) + .catch((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: ["from", "receivedAt", "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] = { + from: e.from, + id: e.id, + receivedAt: e.receivedAt, + 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); + }); + } + emailList(accountId: string, mailboxId: string, ids: Array) { if (this.jclient == null) return; - /*this.jclient.email_get({ - accountId: accountId, - ids: [], - properties: ["threadId"] - });*/ this.jclient .email_query({ accountId: accountId, filter: { inMailbox: mailboxId }, + sort: [ + { + property: "receivedAt", + isAscending: false, + }, + ], }) .then((response) => { const mailbox = this.mailbox(accountId, mailboxId); if (mailbox == null) return; mailbox.emailIds = response.ids; - this._triggerChange(); + this._triggerChange("Email list " + mailboxId); }) - .catch(() => { - console.error("OH NOES"); + .catch((e) => { + console.error("Failed to get email list from mailbox", mailboxId, e); }); } + emailStub(emailId: string): IEmailStub | null { + if (this.state.session == null) return null; + const result = this.state.session.emailStubs[emailId]; + if (result === undefined) { + return null; + } + return result; + } + mailbox(accountId: string, mailboxId: string): IMailbox | null { if (this.state.session == null) return null; const account = this.state.session.accounts[accountId]; @@ -117,15 +239,19 @@ export default class Client { mailboxes.push({ ...m, emailIds: null, - emails: null, }); }); account.mailboxes = mailboxes; - this._triggerChange(); + this._triggerChange("Mailboxes " + accountId); }); } - _triggerChange() { + onChange(f: Callback) { + this.callbacks.push(f); + } + + _triggerChange(msg: string) { + console.log("Client change", msg); this.callbacks.forEach((c) => { c(); }); @@ -145,8 +271,10 @@ export default class Client { { ...account, id: key.toString(), mailboxes: null }, ]), ), + emails: {}, + emailStubs: {}, }; if (!this.state.session) return; - this._triggerChange(); + this._triggerChange("Session"); } } diff --git a/src/client/jmap-client-ts b/src/client/jmap-client-ts new file mode 160000 index 0000000..fdab379 --- /dev/null +++ b/src/client/jmap-client-ts @@ -0,0 +1 @@ +Subproject commit fdab37996a4709b13962b67d1aa49e85a8305755 diff --git a/src/client/types.tsx b/src/client/types.tsx index a4a6b3f..a479d70 100644 --- a/src/client/types.tsx +++ b/src/client/types.tsx @@ -1,8 +1,18 @@ -import client from "jmap-client-ts/lib/types"; +import * as client from "./jmap-client-ts/src/types"; + +export interface IEmailStub { + from: Array | null; + id: string; + receivedAt: string; + subject: string; +} export interface IMailbox extends client.IMailboxProperties { emailIds: Array | null; - emails: Array | null; +} + +export interface IEmail extends client.IEmailProperties { + //sentAt: IutcDate|null, } export interface IAccount extends client.IAccount { @@ -11,7 +21,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; } diff --git a/src/components/DateTime.tsx b/src/components/DateTime.tsx new file mode 100644 index 0000000..38c2148 --- /dev/null +++ b/src/components/DateTime.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +type DateTimeProps = { + d: string; +}; + +const DateTime: React.FC = ({ d }) => { + const datetime = Date.parse(d); + const now = Date.now(); + const diff = (now - datetime) / 1000; + if (diff < 30) return moments ago; + if (diff < 60) return {diff}s; + if (diff < 60 * 60) return {Math.round(diff / 60)}m; + if (diff < 60 * 60 * 48) return {Math.round(diff / (60 * 60))}h; + if (diff < 60 * 60 * 24 * 365) + return {Math.round(diff / (60 * 60 * 24))}d; + return {Math.round(diff / (60 * 60 * 24 * 365))}y; +}; +export default DateTime; diff --git a/src/style.scss b/src/style.scss new file mode 100644 index 0000000..5de3350 --- /dev/null +++ b/src/style.scss @@ -0,0 +1 @@ +@import '~bootstrap/scss/bootstrap'; diff --git a/tsconfig.json b/tsconfig.json index 9d379a3..757ec45 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,6 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/client/jmap-client-ts/tests"] }