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 (
+
+ );
+};
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 (
+
+
+
+
+
+
+
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) => (