diff --git a/index.html b/index.html index 61017f2..ab5cf04 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + Internxt Mail diff --git a/package-lock.json b/package-lock.json index f85f5ae..717d7ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,11 @@ "": { "name": "mail-web", "version": "0.0.0", + "hasInstallScript": true, "dependencies": { "@internxt/css-config": "^1.1.0", "@internxt/sdk": "^1.15.1", - "@internxt/ui": "=0.1.10", + "@internxt/ui": "^0.1.11", "@phosphor-icons/react": "^2.1.10", "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/vite": "^4.2.1", @@ -24,7 +25,8 @@ "@tiptap/react": "^3.20.0", "@tiptap/starter-kit": "^3.20.0", "axios": "^1.13.6", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", "i18next": "^25.8.13", "prettysize": "^2.0.0", "react": "^19.2.0", @@ -45,6 +47,7 @@ "@internxt/prettier-config": "internxt/prettier-config#v1.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@types/dompurify": "^3.0.5", "@types/node": "^25.3.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -1194,7 +1197,9 @@ } }, "node_modules/@internxt/ui": { - "version": "0.1.10", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@internxt/ui/-/ui-0.1.11.tgz", + "integrity": "sha512-TxxRUcEw+EoHnfkmBwJgmENqkYqz8NjQtYxzgNCk2cD+BMzREXv1c57fLYhq4jiGQIISb3HauI53p32WDN+bPg==", "license": "MIT", "dependencies": { "@internxt/css-config": "1.1.0", @@ -4122,6 +4127,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "license": "MIT" @@ -4199,6 +4214,13 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "license": "MIT" @@ -5445,7 +5467,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, "node_modules/debug": { @@ -5588,6 +5612,15 @@ "url": "https://bevry.me/fund" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-case": { "version": "3.0.4", "license": "MIT", diff --git a/package.json b/package.json index 5e44903..69af1b8 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dependencies": { "@internxt/css-config": "^1.1.0", "@internxt/sdk": "^1.15.1", - "@internxt/ui": "=0.1.10", + "@internxt/ui": "^0.1.11", "@phosphor-icons/react": "^2.1.10", "@reduxjs/toolkit": "^2.11.2", "@tailwindcss/vite": "^4.2.1", @@ -36,7 +36,8 @@ "@tiptap/react": "^3.20.0", "@tiptap/starter-kit": "^3.20.0", "axios": "^1.13.6", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", "i18next": "^25.8.13", "prettysize": "^2.0.0", "react": "^19.2.0", @@ -57,6 +58,7 @@ "@internxt/prettier-config": "internxt/prettier-config#v1.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@types/dompurify": "^3.0.5", "@types/node": "^25.3.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/src/assets/fonts/InstrumentSans/InstrumentSans-Bold.woff2 b/src/assets/fonts/InstrumentSans/InstrumentSans-Bold.woff2 new file mode 100644 index 0000000..8b94bcb Binary files /dev/null and b/src/assets/fonts/InstrumentSans/InstrumentSans-Bold.woff2 differ diff --git a/src/assets/fonts/InstrumentSans/InstrumentSans-Medium.woff2 b/src/assets/fonts/InstrumentSans/InstrumentSans-Medium.woff2 new file mode 100644 index 0000000..1360f22 Binary files /dev/null and b/src/assets/fonts/InstrumentSans/InstrumentSans-Medium.woff2 differ diff --git a/src/assets/fonts/InstrumentSans/InstrumentSans-Regular.woff2 b/src/assets/fonts/InstrumentSans/InstrumentSans-Regular.woff2 new file mode 100644 index 0000000..1e0a971 Binary files /dev/null and b/src/assets/fonts/InstrumentSans/InstrumentSans-Regular.woff2 differ diff --git a/src/assets/fonts/InstrumentSans/InstrumentSans-SemiBold.woff2 b/src/assets/fonts/InstrumentSans/InstrumentSans-SemiBold.woff2 new file mode 100644 index 0000000..2285afc Binary files /dev/null and b/src/assets/fonts/InstrumentSans/InstrumentSans-SemiBold.woff2 differ diff --git a/src/assets/fonts/InstrumentSans/InstrumentSans-variable.woff2 b/src/assets/fonts/InstrumentSans/InstrumentSans-variable.woff2 new file mode 100644 index 0000000..6cc0b91 Binary files /dev/null and b/src/assets/fonts/InstrumentSans/InstrumentSans-variable.woff2 differ diff --git a/src/assets/fonts/InstrumentSans/OFL.txt b/src/assets/fonts/InstrumentSans/OFL.txt new file mode 100644 index 0000000..27cbc2f --- /dev/null +++ b/src/assets/fonts/InstrumentSans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/fonts/InstrumentSans/font.css b/src/assets/fonts/InstrumentSans/font.css new file mode 100644 index 0000000..60fa6e1 --- /dev/null +++ b/src/assets/fonts/InstrumentSans/font.css @@ -0,0 +1,10 @@ +/* Instrument Sans font family */ + +@font-face { + font-family: 'Instrument Sans'; + font-style: normal; + font-display: swap; + src: + local('Instrument Sans'), + url('./InstrumentSans-variable.woff2') format('woff2'); +} diff --git a/src/components/user-chip/index.tsx b/src/components/user-chip/index.tsx new file mode 100644 index 0000000..17e6825 --- /dev/null +++ b/src/components/user-chip/index.tsx @@ -0,0 +1,45 @@ +import { UserCheap } from '@internxt/ui'; +import { useId, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +interface UserChipProps { + avatar?: string; + name: string; + email: string; +} + +const UserChip = ({ avatar, name, email }: UserChipProps) => { + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const ref = useRef(null); + const tooltipId = useId(); + + const handleMouseEnter = () => { + const rect = ref.current?.getBoundingClientRect(); + if (rect) setPosition({ top: rect.bottom, left: rect.left }); + }; + + return ( +
setPosition(null)} + role="tooltip" + aria-describedby={position ? tooltipId : undefined} + > + + {name.split(' ')[0]} + + + {position && + createPortal( + , + document.body, + )} +
+ ); +}; + +export default UserChip; diff --git a/src/features/mail/MailView.tsx b/src/features/mail/MailView.tsx index c45dd2f..3603606 100644 --- a/src/features/mail/MailView.tsx +++ b/src/features/mail/MailView.tsx @@ -1,8 +1,9 @@ import { useTranslationContext } from '@/i18n'; -import Header from './components/header'; -import { Tray } from '@internxt/ui'; -import { TrayEmptyState } from './components/trayEmptyState'; import type { FolderType } from '@/types/mail'; +import { getMockedMail } from '@/test-utils/fixtures'; +import PreviewMail from './components/mail-preview'; +import type { User } from './components/mail-preview/header'; +import TrayList from './components/tray'; interface MailViewProps { folder: FolderType; @@ -10,19 +11,20 @@ interface MailViewProps { const MailView = ({ folder }: MailViewProps) => { const { translate } = useTranslationContext(); + const mockedMail = getMockedMail(); + const from = mockedMail.from[0]; + const to = mockedMail.to; + const cc = mockedMail.cc; + const bcc = mockedMail.bcc; const folderName = translate(`mail.${folder}`); return (
-
-
-
-
-
- } /> -
-
+ {/* Tray */} + + {/* Mail Preview */} +
); }; diff --git a/src/features/mail/components/mail-preview/header/index.tsx b/src/features/mail/components/mail-preview/header/index.tsx new file mode 100644 index 0000000..854c33b --- /dev/null +++ b/src/features/mail/components/mail-preview/header/index.tsx @@ -0,0 +1,68 @@ +import UserChip from '@/components/user-chip'; +import { useTranslationContext } from '@/i18n'; +import { DateService } from '@/services/date'; +import { Avatar } from '@internxt/ui'; +import { PaperclipIcon } from '@phosphor-icons/react'; + +export interface User { + email: string; + name: string; + avatar?: string; +} + +interface HeaderProps { + sender: User; + date: string; + to: User[]; + cc: User[]; + bcc: User[]; + attachmentsLength?: number; +} + +const RecipientLine = ({ label, users }: { label: string; users: User[] }) => { + if (users.length === 0) return null; + + return ( + + {label}: + + {users.map((user, index) => ( + + ))} + + + ); +}; + +const PreviewHeader = ({ sender, date, to, cc, bcc, attachmentsLength }: HeaderProps) => { + const formattedDate = DateService.formatWithTime(date); + const { translate } = useTranslationContext(); + + return ( +
+
+ +
+

{sender.name}

+
+ + + +
+
+
+ +
+ {formattedDate} + {(attachmentsLength ?? 0) > 0 ? ( + + + {attachmentsLength} + + ) : null} +
+
+ ); +}; + +export default PreviewHeader; diff --git a/src/features/mail/components/mail-preview/index.tsx b/src/features/mail/components/mail-preview/index.tsx new file mode 100644 index 0000000..921c4df --- /dev/null +++ b/src/features/mail/components/mail-preview/index.tsx @@ -0,0 +1,25 @@ +import PreviewHeader, { type User } from './header'; +import Preview from './preview'; + +interface PreviewMailProps { + from: User; + to: User[]; + cc: User[]; + bcc: User[]; + mail: { + subject: string; + receivedAt: string; + htmlBody: string; + }; +} + +const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => { + return ( +
+ + +
+ ); +}; + +export default PreviewMail; diff --git a/src/features/mail/components/mail-preview/preview/index.tsx b/src/features/mail/components/mail-preview/preview/index.tsx new file mode 100644 index 0000000..6326a55 --- /dev/null +++ b/src/features/mail/components/mail-preview/preview/index.tsx @@ -0,0 +1,54 @@ +import DOMPurify from 'dompurify'; + +const purify = DOMPurify(); + +purify.addHook('afterSanitizeAttributes', (node) => { + const tag = node.tagName?.toLowerCase(); + + if (tag === 'img' || tag === 'video' || tag === 'audio' || tag === 'source') { + const src = node.getAttribute('src') ?? ''; + if (src && !src.startsWith('data:') && !src.startsWith('cid:')) { + node.removeAttribute('src'); + } + } + + if (node.hasAttribute('background')) { + const bg = node.getAttribute('background') ?? ''; + if (bg && !bg.startsWith('data:') && !bg.startsWith('cid:')) { + node.removeAttribute('background'); + } + } + + if (tag === 'a') { + node.setAttribute('rel', 'noopener noreferrer'); + } +}); + +interface PreviewProps { + subject: string; + body: string; + attachments?: string[]; +} + +const Preview = ({ subject, body, attachments }: PreviewProps) => { + const sanitizedBody = purify.sanitize(body); + + return ( +
+
+

{subject}

+
+
+ + {attachments?.map((attachment) => ( +
+ + {attachment} + +
+ ))} +
+ ); +}; + +export default Preview; diff --git a/src/features/mail/components/header/index.tsx b/src/features/mail/components/tray/header/index.tsx similarity index 100% rename from src/features/mail/components/header/index.tsx rename to src/features/mail/components/tray/header/index.tsx diff --git a/src/features/mail/components/tray/index.tsx b/src/features/mail/components/tray/index.tsx new file mode 100644 index 0000000..73f925d --- /dev/null +++ b/src/features/mail/components/tray/index.tsx @@ -0,0 +1,22 @@ +import { Tray } from '@internxt/ui'; +import Header from './header'; +import { TrayEmptyState } from './trayEmptyState'; + +interface TrayListProps { + folderName: string; +} + +const TrayList = ({ folderName }: TrayListProps) => { + return ( +
+
+
+
+
+ } /> +
+
+ ); +}; + +export default TrayList; diff --git a/src/features/mail/components/searchInput/index.tsx b/src/features/mail/components/tray/searchInput/index.tsx similarity index 100% rename from src/features/mail/components/searchInput/index.tsx rename to src/features/mail/components/tray/searchInput/index.tsx diff --git a/src/features/mail/components/trayEmptyState/index.tsx b/src/features/mail/components/tray/trayEmptyState/index.tsx similarity index 100% rename from src/features/mail/components/trayEmptyState/index.tsx rename to src/features/mail/components/tray/trayEmptyState/index.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9ba829e..66fd3b9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -17,6 +17,11 @@ "starred": "Starred", "unstarred": "Unstarred" }, + "labels": { + "To": "To", + "cc": "CC", + "bcc": "BCC" + }, "welcome": { "title": { "highlighted": "Email that protects", @@ -63,6 +68,11 @@ "trash": "Trash", "tray": { "isEmpty": "{{folderName}} is empty" + }, + "preview": { + "to": "To", + "cc": "CC", + "bcc": "BCC" } }, "errors": { diff --git a/src/i18n/services/i18n.service.ts b/src/i18n/services/i18n.service.ts index 3a5d735..2298dca 100644 --- a/src/i18n/services/i18n.service.ts +++ b/src/i18n/services/i18n.service.ts @@ -3,14 +3,21 @@ import i18next from 'i18next'; import dayjs from 'dayjs'; import en from 'dayjs/locale/en'; +import es from 'dayjs/locale/es'; +import fr from 'dayjs/locale/fr'; +import it from 'dayjs/locale/it'; import enJson from '../locales/en.json'; +import { LocalStorageService } from '@/services/local-storage'; const dayJsLocale: Record = { en, + es, + fr, + it, }; -const deviceLang: string = localStorage.getItem('i18nextLng') ?? navigator.language.split('-')[0]; +const deviceLang: string = LocalStorageService.instance.get('i18nextLng') ?? navigator.language.split('-')[0]; dayjs.locale(dayJsLocale[deviceLang] || dayJsLocale['en']); diff --git a/src/index.css b/src/index.css index e6db2ae..1fd8105 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,5 @@ +@import url('./assets/fonts/InstrumentSans/font.css'); + @import 'tailwindcss'; @config '../node_modules/@internxt/css-config/dist/index.js'; @source '../src/**/*.{js,ts,jsx,tsx}'; @@ -6,9 +8,11 @@ :root, #root { overscroll-behavior: none; + font-family: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif; } @theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif; --color-surface: rgb(var(--color-surface-rgb)); --color-highlight: rgb(var(--color-highlight-rgb)); --color-primary: rgb(var(--color-primary-rgb)); diff --git a/src/routes/guards/ProtectedRoute.tsx b/src/routes/guards/ProtectedRoute.tsx index 83eb56d..c718d59 100644 --- a/src/routes/guards/ProtectedRoute.tsx +++ b/src/routes/guards/ProtectedRoute.tsx @@ -6,6 +6,7 @@ export const ProtectedRoute = () => { const { isAuthenticated } = useAppSelector((state) => state.user); const location = useLocation(); + // !TODO: Check if the user already updated the mail to send him to the Inbox instead if (!isAuthenticated) { const to = getRouteConfig(AppView.Welcome).path; return ; diff --git a/src/services/date/date.service.test.ts b/src/services/date/date.service.test.ts new file mode 100644 index 0000000..e4ec579 --- /dev/null +++ b/src/services/date/date.service.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { DateService } from '.'; +import 'dayjs/locale/en'; + +const FIXED_DATE = '2024-04-10T11:32:00Z'; +const FIXED_NOW = new Date(FIXED_DATE).getTime(); + +describe('Date Service', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); + DateService.setLocale('en'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('formatting the date', () => { + test('When formatting a date, then it should return the long localized format', () => { + const result = DateService.format(FIXED_DATE); + expect(result).toStrictEqual('April 10, 2024'); + }); + + test('When formatting a date with a custom template, then it should apply it', () => { + const result = DateService.format(FIXED_DATE, 'YYYY-MM-DD'); + expect(result).toBe('2024-04-10'); + }); + + test('When formatting a Date object, then it should work correctly', () => { + const result = DateService.format(new Date(FIXED_DATE), 'YYYY-MM-DD'); + expect(result).toBe('2024-04-10'); + }); + }); + + describe('Formatting the date with time', () => { + test('When formatting a date with time, then it should include the time', () => { + const result = DateService.formatWithTime(FIXED_DATE); + expect(result).toMatch(/April 10, 2024,/); + expect(result).toMatch(/:\d{2} (AM|PM)/); + }); + }); + + describe('From now on', () => { + test('When getting relative time for a recent date, then it should return a relative string', () => { + const recent = new Date(FIXED_NOW - 1000 * 60 * 60 * 2).toISOString(); + const result = DateService.fromNow(recent); + expect(result).toStrictEqual('2 hours ago'); + }); + + test('When getting relative time for a future date, then it should return a future string', () => { + const future = new Date(FIXED_NOW + 1000 * 60 * 60 * 3).toISOString(); + const result = DateService.fromNow(future); + expect(result).toStrictEqual('in 3 hours'); + }); + }); + + describe('from', () => { + test('When comparing two dates, then it should return relative time between them', () => { + const date = '2024-04-10T09:00:00Z'; + const reference = '2024-04-10T11:00:00Z'; + const result = DateService.from(date, reference); + expect(result).toStrictEqual('2 hours ago'); + }); + + test('When date is after the reference, then it should return a future string', () => { + const date = '2024-04-10T13:00:00Z'; + const reference = '2024-04-10T11:00:00Z'; + const result = DateService.from(date, reference); + expect(result).toStrictEqual('in 2 hours'); + }); + }); +}); diff --git a/src/services/date/index.ts b/src/services/date/index.ts new file mode 100644 index 0000000..ad9191f --- /dev/null +++ b/src/services/date/index.ts @@ -0,0 +1,34 @@ +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; + +dayjs.extend(relativeTime); +dayjs.extend(localizedFormat); + +type DateInput = string | Date | dayjs.Dayjs; + +export class DateService { + public static setLocale(locale: string): void { + dayjs.locale(locale); + } + + /** "April 10, 2024" */ + public static format(date: DateInput, template = 'LL'): string { + return dayjs(date).format(template); + } + + /** "April 10, 2024, 11:32 AM" */ + public static formatWithTime(date: DateInput): string { + return dayjs(date).format('LL, h:mm A'); + } + + /** "2 hours ago", "in 3 days" */ + public static fromNow(date: DateInput): string { + return dayjs(date).fromNow(); + } + + /** "2 hours ago" / "2 hours later" relative to another date */ + public static from(date: DateInput, referenceDate: DateInput): string { + return dayjs(date).from(referenceDate); + } +} diff --git a/src/services/sdk/auth/auth.service.test.ts b/src/services/sdk/auth/auth.service.test.ts new file mode 100644 index 0000000..157305c --- /dev/null +++ b/src/services/sdk/auth/auth.service.test.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { SdkManager } from '..'; +import { AuthService } from '.'; +import { LocalStorageService } from '@/services/local-storage'; + +describe('Auth Service', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('When the user logs out, then the token should be cleared', async () => { + const mockedToken = 'random-token'; + const mockedAuthClient = { + logout: vi.fn().mockResolvedValue(undefined), + } as any; + + vi.spyOn(LocalStorageService.instance, 'getToken').mockReturnValue(mockedToken); + vi.spyOn(SdkManager.instance, 'getAuth').mockReturnValue(mockedAuthClient); + + await AuthService.instance.logOut(); + + expect(mockedAuthClient.logout).toHaveBeenCalledWith(mockedToken); + }); +}); diff --git a/src/services/sdk/auth/index.ts b/src/services/sdk/auth/index.ts index 51c8e2c..8c70b48 100644 --- a/src/services/sdk/auth/index.ts +++ b/src/services/sdk/auth/index.ts @@ -7,6 +7,6 @@ export class AuthService { public logOut = async (): Promise => { const token = LocalStorageService.instance.getToken(); const authClient = SdkManager.instance.getAuth(); - return authClient.logout(token ?? ''); + await authClient.logout(token ?? ''); }; } diff --git a/src/services/sdk/index.ts b/src/services/sdk/index.ts index 6bd6580..c51dfb2 100644 --- a/src/services/sdk/index.ts +++ b/src/services/sdk/index.ts @@ -3,13 +3,15 @@ import type { ApiSecurity, AppDetails } from '@internxt/sdk/dist/shared'; import packageJson from '../../../package.json'; import { ConfigService } from '../config'; import { LocalStorageService } from '../local-storage'; +import { AuthService } from './auth'; +import { NavigationService } from '../navigation'; +import { AppView } from '@/routes/paths'; export type SdkManagerApiSecurity = ApiSecurity & { newToken: string }; export class SdkManager { public static readonly instance: SdkManager = new SdkManager(); private static apiSecurity?: SdkManagerApiSecurity; - private readonly localStorage = LocalStorageService; public static readonly init = (apiSecurity: SdkManagerApiSecurity) => { SdkManager.apiSecurity = apiSecurity; @@ -30,10 +32,14 @@ export class SdkManager { private getNewTokenApiSecurity(): ApiSecurity { return { - token: this.localStorage.instance?.getToken() ?? '', + token: LocalStorageService.instance?.getToken() ?? '', unauthorizedCallback: () => { - if (this.localStorage.instance) { - this.localStorage.instance.clearCredentials(); + if (LocalStorageService.instance) { + LocalStorageService.instance.clearCredentials(); + AuthService.instance.logOut().catch((error) => { + console.error(error); + }); + NavigationService.instance.navigate({ id: AppView.Welcome }); } }, }; diff --git a/src/services/sdk/sdk.service.test.ts b/src/services/sdk/sdk.service.test.ts index 81b96d3..c2d592d 100644 --- a/src/services/sdk/sdk.service.test.ts +++ b/src/services/sdk/sdk.service.test.ts @@ -3,6 +3,16 @@ import { beforeEach, describe, expect, test, vi, afterEach } from 'vitest'; import { SdkManager } from '.'; import { ConfigService } from '../config'; import { LocalStorageService } from '../local-storage'; +import { AuthService } from './auth'; +import { NavigationService } from '../navigation'; + +vi.mock('./auth', () => ({ + AuthService: { + instance: { + logOut: vi.fn().mockResolvedValue(undefined), + }, + }, +})); vi.mock('@internxt/sdk', () => ({ Auth: { @@ -57,6 +67,7 @@ describe('SDK Manager', () => { vi.spyOn(LocalStorageService.instance, 'getToken').mockReturnValue('mock-token'); vi.spyOn(LocalStorageService.instance, 'clearCredentials').mockReturnValue(); + NavigationService.instance.init(vi.fn()); }); afterEach(() => { @@ -174,14 +185,14 @@ describe('SDK Manager', () => { ); }); - test('When Users client receives unauthorized response, then credentials should be cleared', () => { + test('When Users client receives unauthorized response, then logOut should be called', () => { SdkManager.instance.getUsers(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const securityArg = (Drive.Users.client as any).mock.calls[0][2]; securityArg.unauthorizedCallback(); - expect(LocalStorageService.instance.clearCredentials).toHaveBeenCalled(); + expect(AuthService.instance.logOut).toHaveBeenCalled(); }); }); @@ -204,14 +215,14 @@ describe('SDK Manager', () => { ); }); - test('When Storage client receives unauthorized response, then credentials should be cleared', () => { + test('When Storage client receives unauthorized response, then logOut should be called', () => { SdkManager.instance.getStorage(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const securityArg = (Drive.Storage.client as any).mock.calls[0][2]; securityArg.unauthorizedCallback(); - expect(LocalStorageService.instance.clearCredentials).toHaveBeenCalled(); + expect(AuthService.instance.logOut).toHaveBeenCalled(); }); }); @@ -234,14 +245,14 @@ describe('SDK Manager', () => { ); }); - test('When Payments client receives unauthorized response, then credentials should be cleared', () => { + test('When Payments client receives unauthorized response, then logOut should be called', () => { SdkManager.instance.getPayments(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const securityArg = (Drive.Payments.client as any).mock.calls[0][2]; securityArg.unauthorizedCallback(); - expect(LocalStorageService.instance.clearCredentials).toHaveBeenCalled(); + expect(AuthService.instance.logOut).toHaveBeenCalled(); }); }); }); diff --git a/src/test-utils/fixtures.ts b/src/test-utils/fixtures.ts index 17de8b6..874267c 100644 --- a/src/test-utils/fixtures.ts +++ b/src/test-utils/fixtures.ts @@ -134,3 +134,92 @@ export const getMockedLoginCredentials = () => { newToken: 'test-token', }; }; + +const createEmailAddress = () => ({ + name: faker.person.fullName(), + email: faker.internet.email(), + avatar: faker.helpers.maybe(() => faker.image.avatar(), { probability: 0.5 }), +}); + +export const getMockedMail = () => ({ + id: faker.string.uuid(), + threadId: faker.string.uuid(), + from: [createEmailAddress()], + to: [createEmailAddress()], + cc: faker.helpers.maybe(() => [createEmailAddress()], { probability: 0.3 }) ?? [], + bcc: [], + replyTo: [createEmailAddress()], + subject: faker.lorem.sentence(), + receivedAt: faker.date.recent().toISOString(), + sentAt: faker.date.recent().toISOString(), + preview: faker.lorem.sentences(2), + textBody: faker.lorem.paragraphs(2), + htmlBody: `

${faker.lorem.paragraphs(2)}

`, + isRead: faker.datatype.boolean(), + isFlagged: faker.datatype.boolean(), + hasAttachment: faker.datatype.boolean(), + size: faker.number.int({ min: 1024, max: 16384 }), +}); + +export const getMockedMails = (count = 3) => ({ + emails: Array.from({ length: count }, () => { + const mail = getMockedMail(); + return { + id: mail.id, + threadId: mail.threadId, + from: mail.from, + to: mail.to, + subject: mail.subject, + receivedAt: mail.receivedAt, + preview: mail.preview, + isRead: mail.isRead, + isFlagged: mail.isFlagged, + hasAttachment: mail.hasAttachment, + size: mail.size, + }; + }), + total: faker.number.int({ min: count, max: 500 }), +}); + +export const getMockedMailBoxes = () => [ + { + id: faker.string.uuid(), + name: 'Inbox', + type: 'inbox', + parentId: null, + totalEmails: faker.number.int({ min: 50, max: 500 }), + unreadEmails: faker.number.int({ min: 0, max: 20 }), + }, + { + id: faker.string.uuid(), + name: 'Sent', + type: 'sent', + parentId: null, + totalEmails: faker.number.int({ min: 10, max: 200 }), + unreadEmails: 0, + }, + { + id: faker.string.uuid(), + name: 'Drafts', + type: 'drafts', + parentId: null, + totalEmails: faker.number.int({ min: 0, max: 20 }), + unreadEmails: 0, + }, + { + id: faker.string.uuid(), + name: 'Spam', + type: 'spam', + parentId: null, + totalEmails: faker.number.int({ min: 0, max: 50 }), + unreadEmails: faker.number.int({ min: 0, max: 50 }), + }, + { + id: faker.string.uuid(), + name: 'Trash', + type: 'trash', + parentId: null, + totalEmails: faker.number.int({ min: 0, max: 30 }), + unreadEmails: 0, + }, +];