Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/components/preferences/components/Section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { type ReactNode } from 'react';
import { CaretLeftIcon, XIcon } from '@phosphor-icons/react';

interface SectionProps {
className?: string;
children: ReactNode;
title: string;
onBackButtonClicked?: () => void;
onClose: () => void;
}

const Section = ({ className = '', children, title, onBackButtonClicked, onClose }: SectionProps) => {
return (
<div className={`relative w-full rounded-tr-2xl ${className}`}>
<div className="absolute z-50 flex w-full items-center justify-between rounded-tr-2xl p-2.5 pl-6 before:absolute before:inset-0 before:-z-1 before:bg-surface/85 before:backdrop-blur-3xl before:transition-colors">
<div className="flex items-center">
{onBackButtonClicked && (
<button onClick={onBackButtonClicked}>
<div className="mr-2.5 flex h-9 w-9 items-center justify-center rounded-lg text-gray-100 hover:bg-highlight/4 active:bg-highlight/8">
<CaretLeftIcon size={22} />
</div>
</button>
)}
<h2 className="text-base font-medium text-gray-100">{title}</h2>
</div>
<button
className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-highlight/4 active:bg-highlight/8"
onClick={() => onClose()}
>
<XIcon size={22} />
</button>
</div>
<div className="flex max-h-640 flex-col space-y-8 overflow-y-auto p-6 pt-16">{children}</div>
</div>
);
};

export default Section;
35 changes: 35 additions & 0 deletions src/components/preferences/components/SectionItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export interface SectionItemProps {
text: string;
isActive?: boolean;
isSection?: boolean;
isDisabled?: boolean;
onClick?: () => void;
}

const SectionItem = ({ text, isActive, isDisabled, isSection, onClick }: SectionItemProps) => {
const isClickable = !!onClick && !isDisabled;
const clickableContainerClass = isClickable ? 'hover:bg-gray-5' : '';
const activeContainerClass = isActive ? 'bg-primary' : clickableContainerClass;
const containerClass = isDisabled ? '' : activeContainerClass;
const clickableClass = isClickable ? 'hover:cursor-pointer' : '';
const activeTextClass = isActive ? 'text-white' : 'text-gray-80';
const disabledTextClass = isDisabled ? 'text-gray-40' : activeTextClass;
const sectionTextClass = isSection ? 'font-semibold' : '';

const Element = isClickable ? 'button' : 'div';

return (
<Element
className={`flex h-10 w-full items-center justify-between rounded-lg px-3 py-2
${clickableClass} ${containerClass}`}
onClick={isDisabled ? undefined : onClick}
{...(isClickable ? { type: 'button' as const } : {})}
>
<div className="flex items-center">
<span className={`text-base font-normal ${disabledTextClass} ${sectionTextClass}`}>{text}</span>
</div>
</Element>
);
};

export default SectionItem;
25 changes: 25 additions & 0 deletions src/components/preferences/components/SectionList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { PreferencesSection, PreferencesSectionItem } from '@/types/preferences';
import SectionItem from './SectionItem';

interface SectionListProps {
sectionItems: (PreferencesSectionItem & { text: string })[];
activeSection: PreferencesSection;
onSelectSection: (section: PreferencesSection) => void;
}

const SectionList = ({ sectionItems, activeSection, onSelectSection }: SectionListProps) => {
return (
<div className="overflow-x-auto">
{sectionItems.map((item) => (
<SectionItem
key={item.id}
text={item.text}
isActive={item.id === activeSection}
onClick={() => onSelectSection(item.id)}
/>
))}
</div>
);
};

export default SectionList;
21 changes: 21 additions & 0 deletions src/components/preferences/components/SectionListWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useTranslationContext } from '@/i18n';
import { PREFERENCES_SECTIONS, type PreferencesSection } from '@/types/preferences';
import SectionList from './SectionList';

interface SectionListWrapperProps {
activeSection: PreferencesSection;
onSelectSection: (section: PreferencesSection) => void;
}

const SectionListWrapper = ({ activeSection, onSelectSection }: SectionListWrapperProps) => {
const { translate } = useTranslationContext();

const sectionItems = PREFERENCES_SECTIONS.map((item) => ({
...item,
text: translate(`modals.preferences.sections.${item.id}.title`),
}));

return <SectionList sectionItems={sectionItems} activeSection={activeSection} onSelectSection={onSelectSection} />;
};

export default SectionListWrapper;
46 changes: 46 additions & 0 deletions src/components/preferences/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { usePreferencesNavigation } from '@/hooks/preferences/usePreferencesNavigation';
import { useTranslationContext } from '@/i18n';
import type { PreferencesSection } from '@/types/preferences';
import { Modal } from '@internxt/ui';
import type { FC } from 'react';
import { useEffect } from 'react';
import SectionListWrapper from './components/SectionListWrapper';
import GeneralSection from './sections/general';

const SECTION_COMPONENTS: Record<PreferencesSection, FC<{ onClose: () => void }>> = {
general: GeneralSection,
};

export const PreferencesDialog = () => {
const { translate } = useTranslationContext();
const { isOpen, activeSection, openSection, close } = usePreferencesNavigation();

const title = translate(`modals.preferences.sections.${activeSection}.title`);

useEffect(() => {
if (!isOpen) return;
const previousTitle = document.title;
document.title = `${title} | Internxt Mail`;
return () => {
document.title = previousTitle;
};
}, [isOpen, title]);

const ActiveSectionComponent = SECTION_COMPONENTS[activeSection];

return (
<Modal
maxWidth="max-w-4xl"
className="m-0 flex max-h-640 h-screen overflow-hidden shadow-sm"
isOpen={isOpen}
onClose={close}
>
<section className="w-56 shrink-0 border-r border-gray-10 px-2.5">
<h1 className="py-3 pl-4 text-xl font-semibold">{translate('modals.preferences.title')}</h1>
<SectionListWrapper activeSection={activeSection} onSelectSection={openSection} />
</section>

<ActiveSectionComponent onClose={close} />
</Modal>
);
};
14 changes: 14 additions & 0 deletions src/components/preferences/sections/general/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useTranslationContext } from '@/i18n';
import Section from '../../components/Section';

const GeneralSection = ({ onClose }: { onClose: () => void }) => {
const { translate } = useTranslationContext();
return (
<Section className="max-w-2xl" title={translate('modals.preferences.sections.general.title')} onClose={onClose}>
{/* TODO: Add appearance, language and support components */}

Check warning on line 8 in src/components/preferences/sections/general/index.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=internxt_mail-web&issues=AZ0g3qiuVKwjrmok9hte&open=AZ0g3qiuVKwjrmok9hte&pullRequest=26
<p>{translate('modals.preferences.sections.general.title')}</p>
</Section>
);
};

export default GeneralSection;
1 change: 0 additions & 1 deletion src/context/dialog-manager/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export enum ActionDialog {
ComposeMessage = 'compose-message',
Settings = 'settings',
}

export interface ActionDialogState {
Expand Down
8 changes: 7 additions & 1 deletion src/features/mail/MailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getMockedMail } from '@/test-utils/fixtures';
import PreviewMail from './components/mail-preview';
import type { User } from './components/mail-preview/header';
import TrayList from './components/tray';
import Settings from './components/settings';

interface MailViewProps {
folder: FolderType;
Expand All @@ -24,7 +25,12 @@ const MailView = ({ folder }: MailViewProps) => {
{/* Tray */}
<TrayList folderName={folderName} />
{/* Mail Preview */}
<PreviewMail bcc={bcc} cc={cc as User[]} from={from} to={to} mail={mockedMail} />
<div className="flex flex-col w-full gap-5">
<div className="flex w-full justify-end">
<Settings />
</div>
<PreviewMail bcc={bcc} cc={cc as User[]} from={from} to={to} mail={mockedMail} />
</div>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useTranslationContext } from '@/i18n';
import type { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';
import { Avatar, Popover } from '@internxt/ui';
import { GearIcon, SignOutIcon } from '@phosphor-icons/react';
import type { ReactNode } from 'react';

interface AccountPopoverProps {
className?: string;
user: UserSettings;
percentageUsed: number;
onLogout: () => void;
openPreferences: () => void;
}

export default function AccountPopover({
className = '',
user,
percentageUsed,
onLogout,
openPreferences,
}: Readonly<AccountPopoverProps>) {
const { translate } = useTranslationContext();
const name = user?.name ?? '';
const lastName = user?.lastname ?? '';
const fullName = name + ' ' + lastName;

const avatarWrapper = <Avatar diameter={36} style={{ minWidth: 36 }} fullName={fullName} src={user.avatar} />;

const separator = <div className="border-translate mx-3 my-0.5 border-gray-10" />;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Typo: border-translate is not a valid Tailwind class.

This appears to be a typo. If you intend to render a horizontal divider with a top border, use border-t instead.

🐛 Proposed fix
-  const separator = <div className="border-translate mx-3 my-0.5 border-gray-10" />;
+  const separator = <div className="border-t mx-3 my-0.5 border-gray-10" />;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const separator = <div className="border-translate mx-3 my-0.5 border-gray-10" />;
const separator = <div className="border-t mx-3 my-0.5 border-gray-10" />;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/mail/components/settings/components/account-popover/index.tsx`
at line 29, The separator constant uses an invalid Tailwind class
"border-translate"; update the separator JSX (constant separator) to use the
correct top-border class (e.g., replace "border-translate" with "border-t") so
the divider renders as a horizontal line while keeping the existing spacing and
color classes ("mx-3 my-0.5 border-gray-10"); ensure you update the className
string in the separator declaration inside the account-popover component.


const panel = (
<div className="w-52">
<div className="flex items-center p-3">
{avatarWrapper}
<div className="ml-2 min-w-0">
<p className="truncate font-medium text-gray-80" title={fullName} style={{ lineHeight: 1 }}>
{fullName}
</p>
<p className="truncate text-sm text-gray-50" title={user.email}>
{user.email}
</p>
</div>
</div>

<div className="flex items-center justify-between px-3 pb-1">
<p className="text-sm text-gray-50">{translate('accountPopover.spaceUsed', { space: percentageUsed })}</p>
</div>
{separator}
<button
className="flex w-full cursor-pointer items-center px-3 py-2 text-gray-80 no-underline hover:bg-gray-1 hover:text-gray-80 dark:hover:bg-gray-10"
onClick={openPreferences}
>
<GearIcon size={20} />
<p className="ml-3">{translate('accountPopover.settings')}</p>
</button>
<Item onClick={onLogout}>
<SignOutIcon size={20} />
<p className="ml-3 truncate" data-test="logout">
{translate('accountPopover.logout')}
</p>
</Item>
</div>
);

return (
<Popover className={className} childrenButton={avatarWrapper} panel={() => panel} data-test="app-header-dropdown" />

Check warning on line 66 in src/features/mail/components/settings/components/account-popover/index.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move this component definition out of the parent component and pass data as props.

See more on https://sonarcloud.io/project/issues?id=internxt_mail-web&issues=AZ0g3qf-VKwjrmok9htd&open=AZ0g3qf-VKwjrmok9htd&pullRequest=26
);
}

interface ItemProps {
children: ReactNode;
onClick: () => void;
}

function Item({ children, onClick }: Readonly<ItemProps>) {
return (
<button
className="flex cursor-pointer items-center px-3 py-2 text-gray-80 hover:bg-gray-1 dark:hover:bg-gray-10"
style={{ lineHeight: 1.25 }}
onClick={onClick}
>
{children}
</button>
);
}
43 changes: 43 additions & 0 deletions src/features/mail/components/settings/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { usePreferencesNavigation } from '@/hooks/preferences/usePreferencesNavigation';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { useGetStorageLimitQuery, useGetStorageUsageQuery } from '@/store/queries/storage/storage.query';
import { Button } from '@internxt/ui';
import { GearIcon } from '@phosphor-icons/react';
import AccountPopover from './components/account-popover';
import { logoutThunk } from '@/store/slices/user/thunks';

const Settings = () => {
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.user.user);
const { data: usage } = useGetStorageUsageQuery();
const { data: limit } = useGetStorageLimitQuery();
const { openSection } = usePreferencesNavigation();

const percentageUsed = usage != null && limit != null && limit > 0 ? Math.round((usage / limit) * 100) : 0;

if (!user) return null;

const openPreferences = () => {
openSection('general');
};

const onLogout = () => {
dispatch(logoutThunk());
};
Comment on lines +24 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

**Consider handling logout errors gracefully.**Looking at the logoutThunk from the context snippet, if the API call to AuthService.instance.logOut() fails, the thunk rejects without clearing local state. The user remains logged in with no feedback. "Error handling and feedback: Provide clear error messages or feedback to users if there are any issues when they sign out."

The current implementation silently fails if the network request errors. Consider either:

  1. Handling the error in the thunk to always clear local state (graceful degradation)
  2. Or catching the rejected promise here and showing user feedback
🔧 Option 1: Clear local state even if API fails (in logoutThunk)
// In src/store/slices/user/thunks/logOutThunk/index.ts
export const logoutThunk = createAsyncThunk<void, void, { state: RootState }>(
  'user/logout',
  async (_: void, { dispatch }) => {
    try {
      await AuthService.instance.logOut();
    } catch {
      // Log error but continue with local logout
      console.error('Server logout failed, clearing local session');
    }
    dispatch(userActions.resetState());
    NavigationService.instance.navigate({ id: AppView.Welcome });
  },
);
🔧 Option 2: Handle rejection in the component
   const onLogout = () => {
-    dispatch(logoutThunk());
+    dispatch(logoutThunk())
+      .unwrap()
+      .catch(() => {
+        // Show toast or error message
+      });
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/mail/components/settings/index.tsx` around lines 24 - 26, The
onLogout handler currently just dispatches logoutThunk() which can reject if
AuthService.instance.logOut() fails, leaving local state intact and the user
without feedback; update onLogout to await or attach a .catch to
dispatch(logoutThunk()) so you can handle rejection: on error show user feedback
(toast/dialog) and still clear local state by dispatching
userActions.resetState() or invoking the thunk’s local-logout path (or prefer
updating logoutThunk to always clear state and navigate via
NavigationService.instance.navigate({ id: AppView.Welcome })); reference
onLogout, logoutThunk, AuthService.instance.logOut, userActions.resetState, and
NavigationService when making the change.


return (
<div className="flex flex-row w-full gap-1 items-center justify-end p-4">
<Button variant="ghost" onClick={openPreferences} className="px-2!">
<GearIcon size={24} />
</Button>
<AccountPopover
user={user}
percentageUsed={percentageUsed}
openPreferences={openPreferences}
onLogout={onLogout}
/>
</div>
);
};

export default Settings;
38 changes: 38 additions & 0 deletions src/hooks/preferences/usePreferencesNavigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useSearchParams } from 'react-router-dom';
import { useCallback, useMemo } from 'react';
import { PREFERENCES_SECTIONS, type PreferencesSection } from '@/types/preferences';

const DEFAULT_SECTION: PreferencesSection = 'general';

const isValidSection = (value: string): value is PreferencesSection => {
return PREFERENCES_SECTIONS.some((item) => item.id === value);
};

export const usePreferencesNavigation = () => {
const [searchParams, setSearchParams] = useSearchParams();

const isOpen = searchParams.get('preferences') === 'open';

const activeSection: PreferencesSection = useMemo(() => {
const section = searchParams.get('section');

if (section && isValidSection(section)) {
return section;
}

return DEFAULT_SECTION;
}, [searchParams]);

const openSection = useCallback(
(section: PreferencesSection) => {
setSearchParams({ preferences: 'open', section }, { replace: true });
},
[setSearchParams],
);

const close = useCallback(() => {
setSearchParams({}, { replace: true });
}, [setSearchParams]);

return { isOpen, activeSection, openSection, close };
};
Loading
Loading