From fde75a163365a7995116d09254530fb3bfea6963 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:42:43 +0100 Subject: [PATCH 1/6] feat: compose message dialog and dialog manager context --- src/App.tsx | 9 +- src/components/Sidenav/index.tsx | 15 +- .../components/RecipientInput.tsx | 106 ++++++++ .../components/RichTextEditor.tsx | 15 ++ .../components/editorBar/EditorBarButton.tsx | 17 ++ .../components/editorBar/EditorBarGroup.tsx | 17 ++ .../components/editorBar/config.ts | 105 ++++++++ .../editorBar/extension/FontSize.ts | 57 ++++ .../components/editorBar/index.tsx | 245 ++++++++++++++++++ .../hooks/useComposeMessage.ts | 68 +++++ .../compose-message/hooks/useEditorBar.ts | 120 +++++++++ src/components/compose-message/index.tsx | 149 +++++++++++ src/components/compose-message/types/index.ts | 15 ++ src/components/user-chip/index.tsx | 18 +- .../dialog-manager/DialogManager.context.tsx | 35 +++ src/context/dialog-manager/index.ts | 3 + src/context/dialog-manager/types/index.ts | 18 ++ .../dialog-manager/useActionDialog.tsx | 32 +++ src/i18n/locales/en.json | 12 +- src/i18n/provider/TranslationProvider.tsx | 2 +- src/main.tsx | 9 +- 21 files changed, 1055 insertions(+), 12 deletions(-) create mode 100644 src/components/compose-message/components/RecipientInput.tsx create mode 100644 src/components/compose-message/components/RichTextEditor.tsx create mode 100644 src/components/compose-message/components/editorBar/EditorBarButton.tsx create mode 100644 src/components/compose-message/components/editorBar/EditorBarGroup.tsx create mode 100644 src/components/compose-message/components/editorBar/config.ts create mode 100644 src/components/compose-message/components/editorBar/extension/FontSize.ts create mode 100644 src/components/compose-message/components/editorBar/index.tsx create mode 100644 src/components/compose-message/hooks/useComposeMessage.ts create mode 100644 src/components/compose-message/hooks/useEditorBar.ts create mode 100644 src/components/compose-message/index.tsx create mode 100644 src/components/compose-message/types/index.ts create mode 100644 src/context/dialog-manager/DialogManager.context.tsx create mode 100644 src/context/dialog-manager/index.ts create mode 100644 src/context/dialog-manager/types/index.ts create mode 100644 src/context/dialog-manager/useActionDialog.tsx diff --git a/src/App.tsx b/src/App.tsx index c109209..bb96fd7 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: isNewMessageDialogOpen } = useActionDialog(); + const isComposeMessageDialogOpen = isNewMessageDialogOpen(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..4577aaa 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'; @@ -12,6 +12,7 @@ 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 { 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 new file mode 100644 index 0000000..b92642a --- /dev/null +++ b/src/components/compose-message/components/RecipientInput.tsx @@ -0,0 +1,106 @@ +import { useState, type KeyboardEvent } from 'react'; +import type { Recipient } from '../types'; +import UserChip from '@/components/user-chip'; + +interface RecipientInputProps { + label: string; + recipients: Recipient[]; + onAddRecipient: (email: string) => void; + onRemoveRecipient: (id: string) => void; + showCcBcc?: boolean; + onCcClick?: () => void; + onBccClick?: () => void; + showCcButton?: boolean; + showBccButton?: boolean; + ccButtonText?: string; + bccButtonText?: string; + disabled?: boolean; +} + +export const RecipientInput = ({ + label, + recipients, + onAddRecipient, + onRemoveRecipient, + showCcBcc = false, + onCcClick, + onBccClick, + showCcButton = true, + showBccButton = true, + ccButtonText = 'CC', + bccButtonText = 'BCC', + disabled, +}: RecipientInputProps) => { + const [inputValue, setInputValue] = useState(''); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const email = inputValue.trim().replace(/,$/, ''); + if (email) { + onAddRecipient(email); + setInputValue(''); + } + } else if (e.key === 'Backspace' && inputValue === '' && recipients.length > 0) { + onRemoveRecipient(recipients.at(-1)!.id); + } + }; + + const handleBlur = () => { + const email = inputValue.trim(); + if (email) { + onAddRecipient(email); + setInputValue(''); + } + }; + + return ( +
+

{label}

+
+ {recipients.map((recipient) => ( + onRemoveRecipient(recipient.id)} + /> + ))} + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + disabled={disabled} + className={`flex-1 min-w-30 bg-transparent text-sm text-gray-100 placeholder:text-gray-40 focus:outline-none py-0.5 ${disabled ? 'cursor-not-allowed' : ''}`} + /> + {showCcBcc && (showCcButton || showBccButton) && ( +
+ {showCcButton && ( + + )} + {showBccButton && ( + + )} +
+ )} +
+
+ ); +}; diff --git a/src/components/compose-message/components/RichTextEditor.tsx b/src/components/compose-message/components/RichTextEditor.tsx new file mode 100644 index 0000000..b23a379 --- /dev/null +++ b/src/components/compose-message/components/RichTextEditor.tsx @@ -0,0 +1,15 @@ +import { EditorContent, Editor } from '@tiptap/react'; + +export interface RichTextEditorProps { + editor: Editor; +} + +const RichTextEditor = ({ editor }: RichTextEditorProps) => { + return ( +
+ +
+ ); +}; + +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..fc87116 --- /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/config.ts b/src/components/compose-message/components/editorBar/config.ts new file mode 100644 index 0000000..0412c66 --- /dev/null +++ b/src/components/compose-message/components/editorBar/config.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import StarterKit from '@tiptap/starter-kit'; +import { Underline } from '@tiptap/extension-underline'; +import { TextAlign } from '@tiptap/extension-text-align'; +import { Link } from '@tiptap/extension-link'; +import { Image } from '@tiptap/extension-image'; +import { TextStyle } from '@tiptap/extension-text-style'; +import { Color } from '@tiptap/extension-color'; +import { FontFamily } from '@tiptap/extension-font-family'; +import { FontSize } from './extension/FontSize'; + +export const FONTS = [ + { label: 'Arial', value: 'Arial, sans-serif' }, + { label: 'Times New Roman', value: 'Times New Roman, serif' }, + { label: 'Georgia', value: 'Georgia, serif' }, + { label: 'Verdana', value: 'Verdana, sans-serif' }, + { label: 'Courier New', value: 'Courier New, monospace' }, +]; + +export const FONT_SIZES = ['10', '12', '14', '16', '18', '20', '24', '28', '32']; + +export const COLORS = [ + '#000000', + '#434343', + '#666666', + '#999999', + '#CCCCCC', + '#EFEFEF', + '#F3F3F3', + '#FFFFFF', + '#FF0000', + '#FF9900', + '#FFFF00', + '#00FF00', + '#00FFFF', + '#0000FF', + '#9900FF', + '#FF00FF', +]; + +export const EDITOR_CONFIG = { + editable: true, + extensions: [ + StarterKit.configure({ + bulletList: { + keepMarks: true, + keepAttributes: true, + HTMLAttributes: { + class: 'list-disc ml-4', + }, + }, + orderedList: { + keepMarks: true, + keepAttributes: true, + HTMLAttributes: { + class: 'list-decimal ml-4', + }, + }, + listItem: { + HTMLAttributes: { + class: 'ml-2', + }, + }, + }), + Underline, + TextAlign.configure({ + types: ['heading', 'paragraph'], + }), + Link.configure({ + openOnClick: false, + HTMLAttributes: { + class: 'text-primary underline', + }, + }), + Image.configure({ + HTMLAttributes: { + class: 'max-w-full h-auto', + }, + }), + TextStyle, + Color, + FontFamily, + FontSize, + ], + editorProps: { + attributes: { + class: 'focus:outline-none h-full', + }, + handlePaste: (view: any, event: any) => { + const text = event.clipboardData?.getData('text/plain'); + const { from, to } = view.state.selection; + const hasSelection = from !== to; + + if (text && hasSelection) { + const urlPattern = /^(https?:\/\/|www\.)[^\s]+$/i; + if (urlPattern.test(text)) { + const url = text.startsWith('www.') ? `https://${text}` : text; + view.dispatch(view.state.tr.addMark(from, to, view.state.schema.marks.link.create({ href: url }))); + return true; + } + } + return false; + }, + }, +}; diff --git a/src/components/compose-message/components/editorBar/extension/FontSize.ts b/src/components/compose-message/components/editorBar/extension/FontSize.ts new file mode 100644 index 0000000..924f3f9 --- /dev/null +++ b/src/components/compose-message/components/editorBar/extension/FontSize.ts @@ -0,0 +1,57 @@ +import { Extension } from '@tiptap/core'; + +declare module '@tiptap/core' { + interface Commands { + fontSize: { + setFontSize: (size: string) => ReturnType; + unsetFontSize: () => ReturnType; + }; + } +} + +export const FontSize = Extension.create({ + name: 'fontSize', + + addOptions() { + return { + types: ['textStyle'], + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + fontSize: { + default: null, + parseHTML: (element) => element.style.fontSize?.replace(/['"]+/g, ''), + renderHTML: (attributes) => { + if (!attributes.fontSize) { + return {}; + } + return { + style: `font-size: ${attributes.fontSize}`, + }; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setFontSize: + (fontSize: string) => + ({ chain }) => { + return chain().setMark('textStyle', { fontSize }).run(); + }, + unsetFontSize: + () => + ({ chain }) => { + return chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run(); + }, + }; + }, +}); 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..2242c3a --- /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 { COLORS, FONT_SIZES, FONTS } from './config'; +import { useEditorBar } from '../../hooks/useEditorBar'; +import { Activity } from 'react'; + +export interface ActionBarProps { + editor: Editor; + disabled?: boolean; +} + +export const EditorBar = ({ editor, disabled }: ActionBarProps) => { + 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 textAligment = [ + { + 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/useComposeMessage.ts b/src/components/compose-message/hooks/useComposeMessage.ts new file mode 100644 index 0000000..b6478b4 --- /dev/null +++ b/src/components/compose-message/hooks/useComposeMessage.ts @@ -0,0 +1,68 @@ +import { useCallback, useState } from 'react'; +import type { DraftMessage } from '..'; +import type { Recipient } from '../types'; + +const useComposeMessage = (draft?: DraftMessage) => { + const [subjectValue, setSubjectValue] = useState(draft?.subject ?? ''); + const [toRecipients, setToRecipients] = useState(draft?.to ?? []); + const [ccRecipients, setCcRecipients] = useState(draft?.cc ?? []); + const [bccRecipients, setBccRecipients] = useState(draft?.bcc ?? []); + const [showCc, setShowCc] = useState(ccRecipients?.length > 0); + const [showBcc, setShowBcc] = useState(bccRecipients?.length > 0); + + const onShowCcRecipient = () => { + setShowCc(true); + }; + + const onShowBccRecipient = () => { + setShowBcc(true); + }; + + const onSubjectChange = useCallback((value: string) => { + setSubjectValue(value); + }, []); + + const onAddToRecipient = useCallback((email: string) => { + setToRecipients((prev) => [...prev, { id: crypto.randomUUID(), email }]); + }, []); + + const onRemoveToRecipient = useCallback((id: string) => { + setToRecipients((prev) => prev.filter((r) => r.id !== id)); + }, []); + + const onAddCcRecipient = useCallback((email: string) => { + setCcRecipients((prev) => [...prev, { id: crypto.randomUUID(), email }]); + }, []); + + const onRemoveCcRecipient = useCallback((id: string) => { + setCcRecipients((prev) => prev.filter((r) => r.id !== id)); + }, []); + + const onAddBccRecipient = useCallback((email: string) => { + setBccRecipients((prev) => [...prev, { id: crypto.randomUUID(), email }]); + }, []); + + const onRemoveBccRecipient = useCallback((id: string) => { + setBccRecipients((prev) => prev.filter((r) => r.id !== id)); + }, []); + + return { + subjectValue, + toRecipients, + ccRecipients, + bccRecipients, + showCc, + showBcc, + onShowCcRecipient, + onShowBccRecipient, + onSubjectChange, + onAddToRecipient, + onRemoveToRecipient, + onAddCcRecipient, + onRemoveCcRecipient, + onAddBccRecipient, + onRemoveBccRecipient, + }; +}; + +export default useComposeMessage; diff --git a/src/components/compose-message/hooks/useEditorBar.ts b/src/components/compose-message/hooks/useEditorBar.ts new file mode 100644 index 0000000..6f64338 --- /dev/null +++ b/src/components/compose-message/hooks/useEditorBar.ts @@ -0,0 +1,120 @@ +import { useCallback, useState, useRef, useEffect, useReducer } from 'react'; +import { Editor } from '@tiptap/react'; + +export const useEditorBar = (editor: Editor | null) => { + const [showColorPicker, setShowColorPicker] = useState(false); + const [showFontPicker, setShowFontPicker] = useState(false); + const [showSizePicker, setShowSizePicker] = useState(false); + const [currentFont, setCurrentFont] = useState('Arial'); + const [currentSize, setCurrentSize] = useState('14'); + + const colorPickerRef = useRef(null); + const fontPickerRef = useRef(null); + const sizePickerRef = useRef(null); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + useEffect(() => { + if (!editor) return; + + editor.on('selectionUpdate', forceUpdate); + editor.on('transaction', forceUpdate); + + return () => { + editor.off('selectionUpdate', forceUpdate); + editor.off('transaction', forceUpdate); + }; + }, [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); + }, []); + + // !TODO: use custom modal to attach a URL + const setLink = useCallback(() => { + if (!editor) return; + + const previousUrl = editor.getAttributes('link').href; + const url = globalThis.prompt('URL', previousUrl); + + if (url === null) return; + + if (url === '') { + editor.chain().focus().extendMarkRange('link').unsetLink().run(); + return; + } + + editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); + }, [editor]); + + // !TODO: use custom modal to attach an Image + const addImage = useCallback(() => { + if (!editor) return; + + const url = globalThis.prompt('Image URL'); + + if (url) { + editor.chain().focus().setImage({ src: url }).run(); + } + }, [editor]); + + const setColor = useCallback( + (color: string) => { + if (!editor) return; + editor.chain().focus().setColor(color).run(); + setShowColorPicker(false); + }, + [editor], + ); + + const setFont = useCallback( + (font: { label: string; value: string }) => { + if (!editor) return; + editor.chain().focus().setFontFamily(font.value).run(); + setCurrentFont(font.label); + setShowFontPicker(false); + }, + [editor], + ); + + const setFontSize = useCallback( + (size: string) => { + if (!editor) return; + editor.chain().focus().setFontSize(`${size}px`).run(); + setCurrentSize(size); + setShowSizePicker(false); + }, + [editor], + ); + + return { + showColorPicker, + showFontPicker, + showSizePicker, + currentFont, + currentSize, + colorPickerRef, + fontPickerRef, + sizePickerRef, + setShowColorPicker, + setShowFontPicker, + setShowSizePicker, + setLink, + addImage, + setColor, + setFont, + setFontSize, + }; +}; diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx new file mode 100644 index 0000000..2ce3e36 --- /dev/null +++ b/src/components/compose-message/index.tsx @@ -0,0 +1,149 @@ +import { PaperclipIcon, XIcon } from '@phosphor-icons/react'; +import { useCallback, useState } 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, type Editor } from '@tiptap/react'; +import { EDITOR_CONFIG } from './components/editorBar/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 [readyEditor, setReadyEditor] = useState(null); + useEditor({ ...EDITOR_CONFIG, onCreate: ({ editor }) => setReadyEditor(editor) }); + + const onClose = useCallback(() => { + onComposeMessageDialogClose(ActionDialog.ComposeMessage); + }, [onComposeMessageDialogClose]); + + const handlePrimaryAction = useCallback(() => { + const html = readyEditor?.getHTML(); + console.log('html', html); + onClose(); + }, [readyEditor, onClose]); + + if (!readyEditor) 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/components/compose-message/types/index.ts b/src/components/compose-message/types/index.ts new file mode 100644 index 0000000..2a36eac --- /dev/null +++ b/src/components/compose-message/types/index.ts @@ -0,0 +1,15 @@ +import type { Icon } from '@phosphor-icons/react'; + +export interface Recipient { + id: string; + email: string; + name?: string; + avatar?: string; +} + +export interface EditorBarItem { + id: string; + icon: Icon; + onClick: () => void; + isActive?: boolean; +} diff --git a/src/components/user-chip/index.tsx b/src/components/user-chip/index.tsx index 17e6825..c6c1eb1 100644 --- a/src/components/user-chip/index.tsx +++ b/src/components/user-chip/index.tsx @@ -1,4 +1,5 @@ import { UserCheap } from '@internxt/ui'; +import { XIcon } from '@phosphor-icons/react'; import { useId, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -6,9 +7,10 @@ interface UserChipProps { avatar?: string; name: string; email: string; + onRemove?: () => void; } -const UserChip = ({ avatar, name, email }: UserChipProps) => { +const UserChip = ({ avatar, name, email, onRemove }: UserChipProps) => { const [position, setPosition] = useState<{ top: number; left: number } | null>(null); const ref = useRef(null); const tooltipId = useId(); @@ -27,9 +29,17 @@ const UserChip = ({ avatar, name, email }: UserChipProps) => { role="tooltip" aria-describedby={position ? tooltipId : undefined} > - - {name.split(' ')[0]} - +
+ {name.split(' ')[0]} + {onRemove && ( + + )} +
{position && createPortal( diff --git a/src/context/dialog-manager/DialogManager.context.tsx b/src/context/dialog-manager/DialogManager.context.tsx new file mode 100644 index 0000000..933b884 --- /dev/null +++ b/src/context/dialog-manager/DialogManager.context.tsx @@ -0,0 +1,35 @@ +import { useMemo, useState, useCallback, type ReactNode, type FC } from 'react'; +import type { ActionDialog, ActionDialogState, DialogActionConfig } from './types'; +import { DialogManagerContext } from './useActionDialog'; + +export const DialogManagerProvider: FC<{ children: ReactNode }> = ({ children }) => { + const [actionDialogs, setActionDialogs] = useState>>({}); + + const openDialog = useCallback((dialogKey: ActionDialog, config?: DialogActionConfig) => { + setActionDialogs((prevDialogs) => { + const newDialogs = config?.closeAllDialogsFirst ? {} : { ...prevDialogs }; + newDialogs[dialogKey] = { isOpen: true, key: dialogKey, data: config?.data }; + return newDialogs; + }); + }, []); + + const closeDialog = useCallback((dialogKey: ActionDialog) => { + setActionDialogs((prevDialogs) => { + return { + ...prevDialogs, + [dialogKey]: { ...prevDialogs[dialogKey], isOpen: false, data: null }, + }; + }); + }, []); + + const memoizedValue = useMemo( + () => ({ + actionDialogs, + openDialog, + closeDialog, + }), + [actionDialogs, openDialog, closeDialog], + ); + + return {children}; +}; diff --git a/src/context/dialog-manager/index.ts b/src/context/dialog-manager/index.ts new file mode 100644 index 0000000..0b7f4b2 --- /dev/null +++ b/src/context/dialog-manager/index.ts @@ -0,0 +1,3 @@ +export { DialogManagerProvider } from './DialogManager.context'; +export { ActionDialog, type DialogActionConfig } from './types'; +export { DialogManagerContext, useActionDialog } from './useActionDialog'; diff --git a/src/context/dialog-manager/types/index.ts b/src/context/dialog-manager/types/index.ts new file mode 100644 index 0000000..19fef58 --- /dev/null +++ b/src/context/dialog-manager/types/index.ts @@ -0,0 +1,18 @@ +export enum ActionDialog { + ComposeMessage = 'compose-message', + Settings = 'settings', +} + +export interface ActionDialogState { + isOpen: boolean; + key: ActionDialog; + data?: unknown; +} + +export type DialogActionConfig = { closeAllDialogsFirst?: boolean; data?: unknown }; + +export type ActionDialogContextProps = { + actionDialogs: Partial>; + openDialog: (key: ActionDialog, config?: DialogActionConfig) => void; + closeDialog: (key: ActionDialog, config?: DialogActionConfig) => void; +}; diff --git a/src/context/dialog-manager/useActionDialog.tsx b/src/context/dialog-manager/useActionDialog.tsx new file mode 100644 index 0000000..d6ed875 --- /dev/null +++ b/src/context/dialog-manager/useActionDialog.tsx @@ -0,0 +1,32 @@ +import { createContext, useCallback, useContext } from 'react'; +import type { ActionDialog, ActionDialogContextProps } from './types'; + +export const DialogManagerContext = createContext(undefined); + +export const useActionDialog = () => { + const ctx = useContext(DialogManagerContext); + if (!ctx) { + throw new Error('The context is not initialized. Please it inside the provider'); + } + + const isDialogOpen = useCallback( + (key: ActionDialog) => { + return ctx.actionDialogs[key]?.isOpen || false; + }, + [ctx.actionDialogs], + ); + + const getDialogData = useCallback( + (key: ActionDialog) => { + return ctx.actionDialogs[key]?.data || null; + }, + [ctx.actionDialogs], + ); + + return { + isDialogOpen, + getDialogData, + openDialog: ctx.openDialog, + closeDialog: ctx.closeDialog, + }; +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9ba829e..4c1032b 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", @@ -103,6 +105,14 @@ "title": "Unlock Cleaner", "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" } } } 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 88a2893..66b7c6c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,15 +6,18 @@ 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'; store.dispatch(userActions.initialize()); createRoot(document.getElementById('root')!).render( - - - + + + + + , ); From 74f05a3728349c185f521e94a1423a66984eca44 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:49:18 +0100 Subject: [PATCH 2/6] fix: remove duplicated files --- .../components/editorBar/config.ts | 105 ------------------ .../editorBar/extension/FontSize.ts | 57 ---------- 2 files changed, 162 deletions(-) delete mode 100644 src/components/compose-message/components/editorBar/config.ts delete mode 100644 src/components/compose-message/components/editorBar/extension/FontSize.ts diff --git a/src/components/compose-message/components/editorBar/config.ts b/src/components/compose-message/components/editorBar/config.ts deleted file mode 100644 index 0412c66..0000000 --- a/src/components/compose-message/components/editorBar/config.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import StarterKit from '@tiptap/starter-kit'; -import { Underline } from '@tiptap/extension-underline'; -import { TextAlign } from '@tiptap/extension-text-align'; -import { Link } from '@tiptap/extension-link'; -import { Image } from '@tiptap/extension-image'; -import { TextStyle } from '@tiptap/extension-text-style'; -import { Color } from '@tiptap/extension-color'; -import { FontFamily } from '@tiptap/extension-font-family'; -import { FontSize } from './extension/FontSize'; - -export const FONTS = [ - { label: 'Arial', value: 'Arial, sans-serif' }, - { label: 'Times New Roman', value: 'Times New Roman, serif' }, - { label: 'Georgia', value: 'Georgia, serif' }, - { label: 'Verdana', value: 'Verdana, sans-serif' }, - { label: 'Courier New', value: 'Courier New, monospace' }, -]; - -export const FONT_SIZES = ['10', '12', '14', '16', '18', '20', '24', '28', '32']; - -export const COLORS = [ - '#000000', - '#434343', - '#666666', - '#999999', - '#CCCCCC', - '#EFEFEF', - '#F3F3F3', - '#FFFFFF', - '#FF0000', - '#FF9900', - '#FFFF00', - '#00FF00', - '#00FFFF', - '#0000FF', - '#9900FF', - '#FF00FF', -]; - -export const EDITOR_CONFIG = { - editable: true, - extensions: [ - StarterKit.configure({ - bulletList: { - keepMarks: true, - keepAttributes: true, - HTMLAttributes: { - class: 'list-disc ml-4', - }, - }, - orderedList: { - keepMarks: true, - keepAttributes: true, - HTMLAttributes: { - class: 'list-decimal ml-4', - }, - }, - listItem: { - HTMLAttributes: { - class: 'ml-2', - }, - }, - }), - Underline, - TextAlign.configure({ - types: ['heading', 'paragraph'], - }), - Link.configure({ - openOnClick: false, - HTMLAttributes: { - class: 'text-primary underline', - }, - }), - Image.configure({ - HTMLAttributes: { - class: 'max-w-full h-auto', - }, - }), - TextStyle, - Color, - FontFamily, - FontSize, - ], - editorProps: { - attributes: { - class: 'focus:outline-none h-full', - }, - handlePaste: (view: any, event: any) => { - const text = event.clipboardData?.getData('text/plain'); - const { from, to } = view.state.selection; - const hasSelection = from !== to; - - if (text && hasSelection) { - const urlPattern = /^(https?:\/\/|www\.)[^\s]+$/i; - if (urlPattern.test(text)) { - const url = text.startsWith('www.') ? `https://${text}` : text; - view.dispatch(view.state.tr.addMark(from, to, view.state.schema.marks.link.create({ href: url }))); - return true; - } - } - return false; - }, - }, -}; diff --git a/src/components/compose-message/components/editorBar/extension/FontSize.ts b/src/components/compose-message/components/editorBar/extension/FontSize.ts deleted file mode 100644 index 924f3f9..0000000 --- a/src/components/compose-message/components/editorBar/extension/FontSize.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Extension } from '@tiptap/core'; - -declare module '@tiptap/core' { - interface Commands { - fontSize: { - setFontSize: (size: string) => ReturnType; - unsetFontSize: () => ReturnType; - }; - } -} - -export const FontSize = Extension.create({ - name: 'fontSize', - - addOptions() { - return { - types: ['textStyle'], - }; - }, - - addGlobalAttributes() { - return [ - { - types: this.options.types, - attributes: { - fontSize: { - default: null, - parseHTML: (element) => element.style.fontSize?.replace(/['"]+/g, ''), - renderHTML: (attributes) => { - if (!attributes.fontSize) { - return {}; - } - return { - style: `font-size: ${attributes.fontSize}`, - }; - }, - }, - }, - }, - ]; - }, - - addCommands() { - return { - setFontSize: - (fontSize: string) => - ({ chain }) => { - return chain().setMark('textStyle', { fontSize }).run(); - }, - unsetFontSize: - () => - ({ chain }) => { - return chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run(); - }, - }; - }, -}); From e5717866c984bb4b3c44de3f034160928c8e79fb Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:05:16 +0100 Subject: [PATCH 3/6] refactor: improving the code to make it efficient --- src/App.tsx | 4 ++-- .../components/RecipientInput.tsx | 9 +++++--- .../components/RichTextEditor.tsx | 14 ++++++------- .../components/editorBar/EditorBarButton.tsx | 2 +- .../components/editorBar/index.tsx | 10 ++++----- src/components/compose-message/index.tsx | 21 +++++++++---------- src/constants.ts | 2 ++ 7 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bb96fd7..a444e2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,8 +15,8 @@ NavigationService.instance.init(navigation); function App() { const dispatch = useAppDispatch(); - const { isDialogOpen: isNewMessageDialogOpen } = useActionDialog(); - const isComposeMessageDialogOpen = isNewMessageDialogOpen(ActionDialog.ComposeMessage); + const { isDialogOpen } = useActionDialog(); + const isComposeMessageDialogOpen = isDialogOpen(ActionDialog.ComposeMessage); useEffect(() => { dispatch(initializeUserThunk()); diff --git a/src/components/compose-message/components/RecipientInput.tsx b/src/components/compose-message/components/RecipientInput.tsx index 7ac10fa..4e96237 100644 --- a/src/components/compose-message/components/RecipientInput.tsx +++ b/src/components/compose-message/components/RecipientInput.tsx @@ -1,6 +1,7 @@ import { useState, type KeyboardEvent } from 'react'; import type { Recipient } from '../types'; import UserChip from '@/components/user-chip'; +import { DEFAULT_USER_NAME } from '@/constants'; interface RecipientInputProps { label: string; @@ -33,11 +34,13 @@ export const RecipientInput = ({ }: RecipientInputProps) => { const [inputValue, setInputValue] = useState(''); + const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const email = inputValue.trim().replace(/,$/, ''); - if (email) { + if (email && isValidEmail(email)) { onAddRecipient(email); setInputValue(''); } @@ -60,9 +63,9 @@ export const RecipientInput = ({
{recipients.map((recipient) => ( onRemoveRecipient(recipient.id)} /> diff --git a/src/components/compose-message/components/RichTextEditor.tsx b/src/components/compose-message/components/RichTextEditor.tsx index b23a379..3ba29e0 100644 --- a/src/components/compose-message/components/RichTextEditor.tsx +++ b/src/components/compose-message/components/RichTextEditor.tsx @@ -1,15 +1,13 @@ import { EditorContent, Editor } from '@tiptap/react'; export interface RichTextEditorProps { - editor: Editor; + editor: Editor | null; } -const RichTextEditor = ({ editor }: RichTextEditorProps) => { - return ( -
- -
- ); -}; +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 index fc87116..8b91a72 100644 --- a/src/components/compose-message/components/editorBar/EditorBarButton.tsx +++ b/src/components/compose-message/components/editorBar/EditorBarButton.tsx @@ -7,9 +7,9 @@ export interface EditorBarButtonProps { export const EditorBarButton = ({ onClick, isActive, disabled, children }: EditorBarButtonProps) => (
{
- +
- +
{/* !TODO: Handle attachments */} diff --git a/src/constants.ts b/src/constants.ts index 70a9577..b450b93 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,3 +5,5 @@ export const SEND_URL = 'https://send.internxt.com'; export const INTERNXT_BASE_URL = 'https://internxt.com'; export const HUNDRED_TB = 100 * 1024 * 1024 * 1024 * 1024; + +export const DEFAULT_USER_NAME = 'My Internxt'; From 3377427664e8ff0142c8aa10ae035768972b01aa Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:16:52 +0100 Subject: [PATCH 4/6] refactor: useEditorBar and RecipientInput --- .../components/RecipientInput.tsx | 13 +++- .../compose-message/hooks/useEditorBar.ts | 20 +---- src/hooks/useClickOutside.test.ts | 73 +++++++++++++++++++ src/hooks/useClickOutside.ts | 13 ++++ 4 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 src/hooks/useClickOutside.test.ts create mode 100644 src/hooks/useClickOutside.ts diff --git a/src/components/compose-message/components/RecipientInput.tsx b/src/components/compose-message/components/RecipientInput.tsx index 4e96237..7cd7b3e 100644 --- a/src/components/compose-message/components/RecipientInput.tsx +++ b/src/components/compose-message/components/RecipientInput.tsx @@ -18,6 +18,13 @@ interface RecipientInputProps { disabled?: boolean; } +const isValidEmail = (email: string) => { + const input = document.createElement('input'); + input.type = 'email'; + input.value = email; + return input.checkValidity(); +}; + export const RecipientInput = ({ label, recipients, @@ -34,8 +41,6 @@ export const RecipientInput = ({ }: RecipientInputProps) => { const [inputValue, setInputValue] = useState(''); - const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); - const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); @@ -51,7 +56,7 @@ export const RecipientInput = ({ const handleBlur = () => { const email = inputValue.trim(); - if (email) { + if (email && isValidEmail(email)) { onAddRecipient(email); setInputValue(''); } @@ -71,7 +76,7 @@ export const RecipientInput = ({ /> ))} setInputValue(e.target.value)} onKeyDown={handleKeyDown} 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/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]); +}; From 78a68b1946e87bb05d6cfe14874a542e4303e420 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:51:23 +0100 Subject: [PATCH 5/6] refactor: extract isValidEmail to a utils file and add coverage --- src/components/Sidenav/index.tsx | 2 +- .../components/RecipientInput.tsx | 8 +------- .../bytesToString.test.ts | 0 .../{bytesToString => bytes-to-string}/index.ts | 0 src/utils/is-valid-email/index.ts | 6 ++++++ src/utils/is-valid-email/isValidEmail.test.ts | 17 +++++++++++++++++ 6 files changed, 25 insertions(+), 8 deletions(-) rename src/utils/{bytesToString => bytes-to-string}/bytesToString.test.ts (100%) rename src/utils/{bytesToString => bytes-to-string}/index.ts (100%) create mode 100644 src/utils/is-valid-email/index.ts create mode 100644 src/utils/is-valid-email/isValidEmail.test.ts diff --git a/src/components/Sidenav/index.tsx b/src/components/Sidenav/index.tsx index 4577aaa..36c535d 100644 --- a/src/components/Sidenav/index.tsx +++ b/src/components/Sidenav/index.tsx @@ -11,7 +11,7 @@ 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 = () => { diff --git a/src/components/compose-message/components/RecipientInput.tsx b/src/components/compose-message/components/RecipientInput.tsx index 7cd7b3e..08844a3 100644 --- a/src/components/compose-message/components/RecipientInput.tsx +++ b/src/components/compose-message/components/RecipientInput.tsx @@ -2,6 +2,7 @@ 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 '@/utils/is-valid-email'; interface RecipientInputProps { label: string; @@ -18,13 +19,6 @@ interface RecipientInputProps { disabled?: boolean; } -const isValidEmail = (email: string) => { - const input = document.createElement('input'); - input.type = 'email'; - input.value = email; - return input.checkValidity(); -}; - export const RecipientInput = ({ label, recipients, 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 diff --git a/src/utils/is-valid-email/index.ts b/src/utils/is-valid-email/index.ts new file mode 100644 index 0000000..accb6c1 --- /dev/null +++ b/src/utils/is-valid-email/index.ts @@ -0,0 +1,6 @@ +export const isValidEmail = (email: string) => { + const input = document.createElement('input'); + input.type = 'email'; + input.value = email; + return input.checkValidity(); +}; diff --git a/src/utils/is-valid-email/isValidEmail.test.ts b/src/utils/is-valid-email/isValidEmail.test.ts new file mode 100644 index 0000000..06236ec --- /dev/null +++ b/src/utils/is-valid-email/isValidEmail.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'vitest'; +import { isValidEmail } from '.'; + +describe('Validating the email', () => { + test('When validating correct emails, then it should return true', () => { + expect(isValidEmail('user@intx.me')).toBe(true); + expect(isValidEmail('john.doe@inxt.eu')).toBe(true); + expect(isValidEmail('test+tag@encrypt.eu')).toBe(true); + }); + + test('when validating incorrect emails, then it should return false', () => { + expect(isValidEmail('not-an-email')).toBe(false); + expect(isValidEmail('@missing-user.com')).toBe(false); + expect(isValidEmail('missing-domain@')).toBe(false); + expect(isValidEmail('spaces in@email.com')).toBe(false); + }); +}); From ef92bb5041ef653ccf6e2b68b8a5ef6819fab212 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:15:09 +0100 Subject: [PATCH 6/6] fix: use isValidEmail from inxt lib --- package-lock.json | 23 +++++++++++++++++++ package.json | 1 + .../components/RecipientInput.tsx | 4 ++-- src/utils/is-valid-email/index.ts | 6 ----- src/utils/is-valid-email/isValidEmail.test.ts | 17 -------------- 5 files changed, 26 insertions(+), 25 deletions(-) delete mode 100644 src/utils/is-valid-email/index.ts delete mode 100644 src/utils/is-valid-email/isValidEmail.test.ts 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/components/compose-message/components/RecipientInput.tsx b/src/components/compose-message/components/RecipientInput.tsx index 08844a3..9f76ae2 100644 --- a/src/components/compose-message/components/RecipientInput.tsx +++ b/src/components/compose-message/components/RecipientInput.tsx @@ -2,7 +2,7 @@ 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 '@/utils/is-valid-email'; +import isValidEmail from '@internxt/lib/dist/src/auth/isValidEmail'; interface RecipientInputProps { label: string; @@ -64,7 +64,7 @@ export const RecipientInput = ({ onRemoveRecipient(recipient.id)} /> diff --git a/src/utils/is-valid-email/index.ts b/src/utils/is-valid-email/index.ts deleted file mode 100644 index accb6c1..0000000 --- a/src/utils/is-valid-email/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const isValidEmail = (email: string) => { - const input = document.createElement('input'); - input.type = 'email'; - input.value = email; - return input.checkValidity(); -}; diff --git a/src/utils/is-valid-email/isValidEmail.test.ts b/src/utils/is-valid-email/isValidEmail.test.ts deleted file mode 100644 index 06236ec..0000000 --- a/src/utils/is-valid-email/isValidEmail.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { isValidEmail } from '.'; - -describe('Validating the email', () => { - test('When validating correct emails, then it should return true', () => { - expect(isValidEmail('user@intx.me')).toBe(true); - expect(isValidEmail('john.doe@inxt.eu')).toBe(true); - expect(isValidEmail('test+tag@encrypt.eu')).toBe(true); - }); - - test('when validating incorrect emails, then it should return false', () => { - expect(isValidEmail('not-an-email')).toBe(false); - expect(isValidEmail('@missing-user.com')).toBe(false); - expect(isValidEmail('missing-domain@')).toBe(false); - expect(isValidEmail('spaces in@email.com')).toBe(false); - }); -});