diff --git a/package-lock.json b/package-lock.json index 717d7ed..087a80b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@internxt/css-config": "^1.1.0", + "@internxt/lib": "^1.4.1", "@internxt/sdk": "^1.15.1", "@internxt/ui": "^0.1.11", "@phosphor-icons/react": "^2.1.10", @@ -1173,6 +1174,28 @@ "typescript": ">=4.2.0" } }, + "node_modules/@internxt/lib": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@internxt/lib/-/lib-1.4.1.tgz", + "integrity": "sha512-sWNp57IKCk0HjzTdPSuxOgZWvrSDWGYrzNOq90LIZTzr1HwkxObicUaZqSzmw4uDKrJhsdFdzwdywk3g8gwDDA==", + "license": "MIT", + "dependencies": { + "uuid": "^11.1.0" + } + }, + "node_modules/@internxt/lib/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/@internxt/prettier-config": { "version": "1.0.2", "resolved": "git+ssh://git@github.com/internxt/prettier-config.git#9fa74e9a2805e1538b50c3809324f1c9d0f3e4f9", diff --git a/package.json b/package.json index 69af1b8..88ccd86 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@internxt/css-config": "^1.1.0", + "@internxt/lib": "^1.4.1", "@internxt/sdk": "^1.15.1", "@internxt/ui": "^0.1.11", "@phosphor-icons/react": "^2.1.10", diff --git a/src/App.tsx b/src/App.tsx index c109209..a444e2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,12 @@ import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import { routes } from './routes'; import { NavigationService } from './services/navigation'; -import { useEffect } from 'react'; +import { Activity, useEffect } from 'react'; import { useAppDispatch } from './store/hooks'; import { initializeUserThunk } from './store/slices/user/thunks'; import { Toaster } from 'react-hot-toast'; +import { ActionDialog, useActionDialog } from './context/dialog-manager'; +import { ComposeMessageDialog } from './components/compose-message'; const router = createBrowserRouter(routes); const navigation = router.navigate; @@ -13,6 +15,8 @@ NavigationService.instance.init(navigation); function App() { const dispatch = useAppDispatch(); + const { isDialogOpen } = useActionDialog(); + const isComposeMessageDialogOpen = isDialogOpen(ActionDialog.ComposeMessage); useEffect(() => { dispatch(initializeUserThunk()); @@ -27,6 +31,9 @@ function App() { }} /> + + + ); } diff --git a/src/components/Sidenav/index.tsx b/src/components/Sidenav/index.tsx index b2e752e..36c535d 100644 --- a/src/components/Sidenav/index.tsx +++ b/src/components/Sidenav/index.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { Sidenav as SidenavComponent } from '@internxt/ui'; +import { Button, Sidenav as SidenavComponent } from '@internxt/ui'; import logo from '../../assets/logos/Internxt/small-logo.svg'; import { useTranslationContext } from '@/i18n'; import { NavigationService } from '@/services/navigation'; @@ -11,7 +11,8 @@ import { useSuiteLauncher } from '@/hooks/navigation/useSuiteLauncher'; import { useSidenavNavigation } from '@/hooks/navigation/useSidenavNavigation'; import { useGetStorageLimitQuery, useGetStorageUsageQuery } from '@/store/queries/storage/storage.query'; import { useAppSelector } from '@/store/hooks'; -import { bytesToString } from '@/utils/bytesToString'; +import { bytesToString } from '@/utils/bytes-to-string'; +import { ActionDialog, useActionDialog } from '@/context/dialog-manager'; const Sidenav = () => { const { translate } = useTranslationContext(); @@ -19,6 +20,7 @@ const Sidenav = () => { const { isLoading: isLoadingPlanLimit, data: planLimit = 1 } = useGetStorageLimitQuery(); const { isLoading: isLoadingPlanUsage, data: planUsage = 0 } = useGetStorageUsageQuery(); const storagePercentage = planLimit > 0 ? Math.min((planUsage / planLimit) * 100, 100) : 0; + const { openDialog } = useActionDialog(); const { itemsNavigation } = useSidenavNavigation(); const { suiteArray } = useSuiteLauncher(); @@ -48,6 +50,10 @@ const Sidenav = () => { }); }; + const onPrimaryActionClicked = () => { + openDialog(ActionDialog.ComposeMessage); + }; + return (
{ onClick: onLogoClicked, className: '!pt-0 pb-3', }} + primaryAction={ + + } suiteLauncher={{ suiteArray: suiteArray, soonText: translate('modals.upgradePlanDialog.soonBadge'), @@ -69,7 +80,7 @@ const Sidenav = () => { limit: bytesToString({ size: planLimit }), percentage: storagePercentage, onUpgradeClick: () => {}, - upgradeLabel: isUpgradeAvailable() ? translate('preferences.account.plans.upgrade') : undefined, + upgradeLabel: isUpgradeAvailable() ? translate('actions.upgrade') : undefined, isLoading: isLoadingPlanUsage || isLoadingPlanLimit, }} /> diff --git a/src/components/compose-message/components/RecipientInput.tsx b/src/components/compose-message/components/RecipientInput.tsx index 7ac10fa..9f76ae2 100644 --- a/src/components/compose-message/components/RecipientInput.tsx +++ b/src/components/compose-message/components/RecipientInput.tsx @@ -1,6 +1,8 @@ import { useState, type KeyboardEvent } from 'react'; import type { Recipient } from '../types'; import UserChip from '@/components/user-chip'; +import { DEFAULT_USER_NAME } from '@/constants'; +import isValidEmail from '@internxt/lib/dist/src/auth/isValidEmail'; interface RecipientInputProps { label: string; @@ -37,7 +39,7 @@ export const RecipientInput = ({ if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const email = inputValue.trim().replace(/,$/, ''); - if (email) { + if (email && isValidEmail(email)) { onAddRecipient(email); setInputValue(''); } @@ -48,7 +50,7 @@ export const RecipientInput = ({ const handleBlur = () => { const email = inputValue.trim(); - if (email) { + if (email && isValidEmail(email)) { onAddRecipient(email); setInputValue(''); } @@ -60,15 +62,15 @@ export const RecipientInput = ({
{recipients.map((recipient) => ( onRemoveRecipient(recipient.id)} /> ))} setInputValue(e.target.value)} onKeyDown={handleKeyDown} diff --git a/src/components/compose-message/components/RichTextEditor.tsx b/src/components/compose-message/components/RichTextEditor.tsx new file mode 100644 index 0000000..3ba29e0 --- /dev/null +++ b/src/components/compose-message/components/RichTextEditor.tsx @@ -0,0 +1,13 @@ +import { EditorContent, Editor } from '@tiptap/react'; + +export interface RichTextEditorProps { + editor: Editor | null; +} + +const RichTextEditor = ({ editor }: RichTextEditorProps) => ( +
+ +
+); + +export default RichTextEditor; diff --git a/src/components/compose-message/components/editorBar/EditorBarButton.tsx b/src/components/compose-message/components/editorBar/EditorBarButton.tsx new file mode 100644 index 0000000..8b91a72 --- /dev/null +++ b/src/components/compose-message/components/editorBar/EditorBarButton.tsx @@ -0,0 +1,17 @@ +export interface EditorBarButtonProps { + onClick: () => void; + isActive?: boolean; + disabled?: boolean; + children: React.ReactNode; +} + +export const EditorBarButton = ({ onClick, isActive, disabled, children }: EditorBarButtonProps) => ( + +); diff --git a/src/components/compose-message/components/editorBar/EditorBarGroup.tsx b/src/components/compose-message/components/editorBar/EditorBarGroup.tsx new file mode 100644 index 0000000..8157f30 --- /dev/null +++ b/src/components/compose-message/components/editorBar/EditorBarGroup.tsx @@ -0,0 +1,17 @@ +import type { EditorBarItem } from '../../types'; +import { EditorBarButton } from './EditorBarButton'; + +export interface EditorBarGroupProps { + items: EditorBarItem[]; + disabled?: boolean; +} + +export const EditorBarGroup = ({ items, disabled }: EditorBarGroupProps) => ( +
+ {items.map((item) => ( + + + + ))} +
+); diff --git a/src/components/compose-message/components/editorBar/index.tsx b/src/components/compose-message/components/editorBar/index.tsx new file mode 100644 index 0000000..53a0508 --- /dev/null +++ b/src/components/compose-message/components/editorBar/index.tsx @@ -0,0 +1,245 @@ +import { + TextBIcon, + TextItalicIcon, + TextUnderlineIcon, + TextStrikethroughIcon, + ListBulletsIcon, + ListNumbersIcon, + TextAlignLeftIcon, + TextAlignCenterIcon, + TextAlignRightIcon, + LinkIcon, + EraserIcon, + ImageIcon, + CaretDownIcon, + PaintBucketIcon, +} from '@phosphor-icons/react'; +import { Editor } from '@tiptap/react'; +import { EditorBarButton } from './EditorBarButton'; +import { EditorBarGroup } from './EditorBarGroup'; +import type { EditorBarItem } from '../../types'; +import { useEditorBar } from '../../hooks/useEditorBar'; +import { Activity } from 'react'; +import { COLORS, FONT_SIZES, FONTS } from '../../config'; + +export interface EditorBarProps { + editor: Editor; + disabled?: boolean; +} + +export const EditorBar = ({ editor, disabled }: EditorBarProps) => { + const { + showColorPicker, + showFontPicker, + showSizePicker, + currentFont, + currentSize, + colorPickerRef, + fontPickerRef, + sizePickerRef, + setShowColorPicker, + setShowFontPicker, + setShowSizePicker, + setLink, + addImage, + setColor, + setFont, + setFontSize, + } = useEditorBar(editor); + + const textStyles = [ + { + id: 'bold', + icon: TextBIcon, + onClick: () => editor.chain().focus().toggleBold().run(), + isActive: editor.isActive('bold'), + }, + { + id: 'italic', + icon: TextItalicIcon, + onClick: () => editor.chain().focus().toggleItalic().run(), + isActive: editor.isActive('italic'), + }, + { + id: 'underline', + icon: TextUnderlineIcon, + onClick: () => editor.chain().focus().toggleUnderline().run(), + isActive: editor.isActive('underline'), + }, + { + id: 'strike', + icon: TextStrikethroughIcon, + onClick: () => editor.chain().focus().toggleStrike().run(), + isActive: editor.isActive('strike'), + }, + ] satisfies EditorBarItem[]; + + const textList = [ + { + id: 'bulletList', + icon: ListBulletsIcon, + onClick: () => editor.chain().focus().toggleBulletList().run(), + isActive: editor.isActive('bulletList'), + }, + { + id: 'orderedList', + icon: ListNumbersIcon, + onClick: () => editor.chain().focus().toggleOrderedList().run(), + isActive: editor.isActive('orderedList'), + }, + ] satisfies EditorBarItem[]; + + const textAlignment = [ + { + id: 'alignLeft', + icon: TextAlignLeftIcon, + onClick: () => editor.chain().focus().setTextAlign('left').run(), + isActive: editor.isActive({ textAlign: 'left' }), + }, + { + id: 'alignCenter', + icon: TextAlignCenterIcon, + onClick: () => editor.chain().focus().setTextAlign('center').run(), + isActive: editor.isActive({ textAlign: 'center' }), + }, + { + id: 'alignRight', + icon: TextAlignRightIcon, + onClick: () => editor.chain().focus().setTextAlign('right').run(), + isActive: editor.isActive({ textAlign: 'right' }), + }, + ] satisfies EditorBarItem[]; + + const messageAttachment = [ + { + id: 'link', + icon: LinkIcon, + onClick: setLink, + isActive: editor.isActive('link'), + }, + { + id: 'clear', + icon: EraserIcon, + onClick: () => editor.chain().focus().unsetAllMarks().clearNodes().run(), + }, + ] satisfies EditorBarItem[]; + + return ( +
+ {/* Font selector */} +
+ + +
+ {FONTS.map((font) => ( + + ))} +
+
+
+
+ {/* Size selector */} +
+ + +
+ {FONT_SIZES.map((size) => ( + + ))} +
+
+
+
+ {/* Color picker */} +
+ setShowColorPicker(!showColorPicker)} disabled={disabled}> + + + +
+
+ {COLORS.map((color) => ( +
+
+
+
+
+ {/* Text styles */} + +
+ {/* Lists */} + +
+ {/* Text alignment */} + +
+ {/* Link, clear, image */} + +
+ {/* Image */} + + + +
+ ); +}; diff --git a/src/components/compose-message/hooks/useEditorBar.ts b/src/components/compose-message/hooks/useEditorBar.ts index 6f64338..a35ac90 100644 --- a/src/components/compose-message/hooks/useEditorBar.ts +++ b/src/components/compose-message/hooks/useEditorBar.ts @@ -1,5 +1,6 @@ import { useCallback, useState, useRef, useEffect, useReducer } from 'react'; import { Editor } from '@tiptap/react'; +import { useClickOutside } from '@/hooks/useClickOutside'; export const useEditorBar = (editor: Editor | null) => { const [showColorPicker, setShowColorPicker] = useState(false); @@ -25,22 +26,9 @@ export const useEditorBar = (editor: Editor | null) => { }; }, [editor]); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (colorPickerRef.current && !colorPickerRef.current.contains(event.target as Node)) { - setShowColorPicker(false); - } - if (fontPickerRef.current && !fontPickerRef.current.contains(event.target as Node)) { - setShowFontPicker(false); - } - if (sizePickerRef.current && !sizePickerRef.current.contains(event.target as Node)) { - setShowSizePicker(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); + useClickOutside(colorPickerRef, () => setShowColorPicker(false)); + useClickOutside(fontPickerRef, () => setShowFontPicker(false)); + useClickOutside(sizePickerRef, () => setShowSizePicker(false)); // !TODO: use custom modal to attach a URL const setLink = useCallback(() => { diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx new file mode 100644 index 0000000..00d022a --- /dev/null +++ b/src/components/compose-message/index.tsx @@ -0,0 +1,148 @@ +import { PaperclipIcon, XIcon } from '@phosphor-icons/react'; +import { useCallback } from 'react'; +import type { Recipient } from './types'; +import { RecipientInput } from './components/RecipientInput'; +import { Button, Input } from '@internxt/ui'; +import RichTextEditor from './components/RichTextEditor'; +import { EditorBar } from './components/editorBar'; +import { ActionDialog, useActionDialog } from '@/context/dialog-manager'; +import { useTranslationContext } from '@/i18n'; +import useComposeMessage from './hooks/useComposeMessage'; +import { useEditor } from '@tiptap/react'; +import { EDITOR_CONFIG } from './config'; + +export interface DraftMessage { + subject?: string; + to?: Recipient[]; + cc?: Recipient[]; + bcc?: Recipient[]; + body?: string; +} + +export const ComposeMessageDialog = () => { + const { translate } = useTranslationContext(); + const { closeDialog: onComposeMessageDialogClose, getDialogData: getComposeMessageDialogData } = useActionDialog(); + + const draft = (getComposeMessageDialogData(ActionDialog.ComposeMessage) ?? {}) as DraftMessage; + const { + showBcc, + showCc, + subjectValue, + toRecipients, + bccRecipients, + ccRecipients, + onAddBccRecipient, + onAddCcRecipient, + onRemoveBccRecipient, + onAddToRecipient, + onRemoveCcRecipient, + onRemoveToRecipient, + onShowBccRecipient, + onShowCcRecipient, + onSubjectChange, + } = useComposeMessage(); + + const title = draft.subject ?? translate('modals.composeMessageDialog.title'); + const editor = useEditor(EDITOR_CONFIG); + + const onClose = useCallback(() => { + onComposeMessageDialogClose(ActionDialog.ComposeMessage); + }, [onComposeMessageDialogClose]); + + const handlePrimaryAction = useCallback(() => { + const html = editor?.getHTML(); + console.log('html', html); + onClose(); + }, [editor, onClose]); + + if (!editor) return null; + + return ( +
+ + +
+
+
+

{title}

+ +
+ onAddToRecipient?.(email)} + onRemoveRecipient={(id) => onRemoveToRecipient?.(id)} + showCcBcc + onCcClick={onShowCcRecipient} + onBccClick={onShowBccRecipient} + showCcButton={!showCc} + showBccButton={!showBcc} + ccButtonText={translate('modals.composeMessageDialog.cc')} + bccButtonText={translate('modals.composeMessageDialog.bcc')} + disabled={false} + /> + {showCc && ( + onAddCcRecipient?.(email)} + onRemoveRecipient={(id) => onRemoveCcRecipient?.(id)} + disabled={false} + /> + )} + {showBcc && ( + onAddBccRecipient?.(email)} + onRemoveRecipient={(id) => onRemoveBccRecipient?.(id)} + disabled={false} + /> + )} +
+

+ {translate('modals.composeMessageDialog.subject')} +

+ +
+
+ +
+
+ +
+ {/* !TODO: Handle attachments */} + +
+ + +
+
+
+ ); +}; diff --git a/src/constants.ts b/src/constants.ts index 7bfa0b7..11fa395 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,4 +6,5 @@ export const INTERNXT_BASE_URL = 'https://internxt.com'; export const HUNDRED_TB = 100 * 1024 * 1024 * 1024 * 1024; +export const DEFAULT_USER_NAME = 'My Internxt'; export const INTERNXT_EMAIL_DOMAINS = ['@inxt.me', '@inxt.eu', '@encrypt.eu'] as const; diff --git a/src/hooks/useClickOutside.test.ts b/src/hooks/useClickOutside.test.ts new file mode 100644 index 0000000..805a04d --- /dev/null +++ b/src/hooks/useClickOutside.test.ts @@ -0,0 +1,73 @@ +import { renderHook } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { useRef } from 'react'; +import { useClickOutside } from './useClickOutside'; + +describe('Click outside - Custom hook', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + test('When clicking outside the ref element, then the function should be called', () => { + const onClickOutside = vi.fn(); + const { result } = renderHook(() => { + const ref = useRef(document.createElement('div')); + useClickOutside(ref, onClickOutside); + return ref; + }); + + document.body.appendChild(result.current.current!); + + const outsideElement = document.createElement('button'); + document.body.appendChild(outsideElement); + outsideElement.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + + expect(onClickOutside).toHaveBeenCalledOnce(); + + document.body.removeChild(result.current.current!); + document.body.removeChild(outsideElement); + }); + + test('When clicking inside the ref element, then it should do nothing', () => { + const onClickOutside = vi.fn(); + const { result } = renderHook(() => { + const ref = useRef(document.createElement('div')); + useClickOutside(ref, onClickOutside); + return ref; + }); + + document.body.appendChild(result.current.current!); + + result.current.current!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + + expect(onClickOutside).not.toHaveBeenCalled(); + + document.body.removeChild(result.current.current!); + }); + + test('When ref is null, then it should do nothing', () => { + const onClickOutside = vi.fn(); + renderHook(() => { + const ref = useRef(null); + useClickOutside(ref, onClickOutside); + }); + + document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + + expect(onClickOutside).not.toHaveBeenCalled(); + }); + + test('When the hook unmounts, then it should remove the event listener', () => { + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); + const onClickOutside = vi.fn(); + + const { unmount } = renderHook(() => { + const ref = useRef(document.createElement('div')); + useClickOutside(ref, onClickOutside); + }); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function)); + }); +}); diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts new file mode 100644 index 0000000..cb3c614 --- /dev/null +++ b/src/hooks/useClickOutside.ts @@ -0,0 +1,13 @@ +import { useEffect, type RefObject } from 'react'; + +export const useClickOutside = (ref: RefObject, onClickOutside: () => void) => { + useEffect(() => { + const handler = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + onClickOutside(); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [ref, onClickOutside]); +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0dfa486..6aaa4e2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -7,7 +7,9 @@ "newMessage": "New Message", "search": "Search", "trashAll": "Move all to trash", - "archiveAll": "Move all to archive" + "archiveAll": "Move all to archive", + "upgrade": "Upgrade", + "send": "Send" }, "filter": { "all": "All", @@ -114,6 +116,14 @@ "description": "Upgrade now to keep your files optimized and free up space." } }, + "composeMessageDialog": { + "title": "New Message", + "to": "To", + "cc": "Cc", + "bcc": "Bcc", + "subject": "Subject", + "message": "Message" + }, "preferences": { "title": "Preferences", "sections": { diff --git a/src/i18n/provider/TranslationProvider.tsx b/src/i18n/provider/TranslationProvider.tsx index 59e21b1..063c9a0 100644 --- a/src/i18n/provider/TranslationProvider.tsx +++ b/src/i18n/provider/TranslationProvider.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import type { Translate, TranslateArray } from '../types'; import { TranslationContext } from './useTranslationContext'; +import type { Translate, TranslateArray } from '@/i18n'; export interface TranslationContextProps { translate: Translate; diff --git a/src/main.tsx b/src/main.tsx index 98ca6eb..8864e7e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,18 +6,21 @@ import { TranslationProvider } from './i18n/index.ts'; import { store } from './store/index.ts'; import { userActions } from './store/slices/user/index.ts'; import { Provider } from 'react-redux'; +import { DialogManagerProvider } from './context/dialog-manager/DialogManager.context.tsx'; import { ThemeProvider } from './context/theme/ThemeProvider.tsx'; store.dispatch(userActions.initialize()); createRoot(document.getElementById('root')!).render( - - + + + - - + + + , ); diff --git a/src/utils/bytesToString/bytesToString.test.ts b/src/utils/bytes-to-string/bytesToString.test.ts similarity index 100% rename from src/utils/bytesToString/bytesToString.test.ts rename to src/utils/bytes-to-string/bytesToString.test.ts diff --git a/src/utils/bytesToString/index.ts b/src/utils/bytes-to-string/index.ts similarity index 100% rename from src/utils/bytesToString/index.ts rename to src/utils/bytes-to-string/index.ts