Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 6 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ function App() {
const { isDialogOpen } = useActionDialog();
const isComposeMessageDialogOpen = isDialogOpen(ActionDialog.ComposeMessage);

const initializeUser = async () => {
await dispatch(initializeUserThunk()).unwrap();
await dispatch(refreshAvatarThunk()).unwrap();
};
Comment thread
xabg2 marked this conversation as resolved.

useEffect(() => {
dispatch(initializeUserThunk())
.unwrap()
.then(() => dispatch(refreshAvatarThunk()));
initializeUser();
}, []);

return (
Expand Down
8 changes: 5 additions & 3 deletions src/components/user-chip/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ const UserChip = ({ avatar, name, email, onRemove }: UserChipProps) => {
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const tooltipId = useId();
const userName = name.split(' ')[0] || email.split('@')[0];
Comment thread
xabg2 marked this conversation as resolved.
Outdated

const handleMouseEnter = () => {
const rect = ref.current?.getBoundingClientRect();
if (rect) setPosition({ top: rect.bottom, left: rect.left });
const topDistance = rect ? rect.bottom + 4 : 0;
if (rect) setPosition({ top: topDistance, left: rect.left });
};

const handleOnRemove = (e: MouseEvent<SVGSVGElement>) => {
Expand All @@ -36,7 +38,7 @@ const UserChip = ({ avatar, name, email, onRemove }: UserChipProps) => {
aria-describedby={position ? tooltipId : undefined}
>
<div className="flex flex-row gap-0.5 items-center px-2 py-1 rounded-md bg-gray-5 cursor-default">
<span className="text-sm font-medium text-gray-60">{name.split(' ')[0]}</span>
<span className="text-sm font-medium text-gray-60">{userName}</span>
{onRemove && (
<XIcon
className={`flex transition-opacity duration-100 ${position ? 'opacity-100' : 'opacity-0'}`}
Expand All @@ -50,7 +52,7 @@ const UserChip = ({ avatar, name, email, onRemove }: UserChipProps) => {
{position &&
createPortal(
<div id={tooltipId} role="tooltip" className="fixed z-10" style={{ top: position.top, left: position.left }}>
<UserCheap avatar={avatar} fullName={name} email={email} />
<UserCheap avatar={avatar} fullName={name || email} email={email} />
Comment thread
xabg2 marked this conversation as resolved.
Outdated
Comment thread
xabg2 marked this conversation as resolved.
Outdated
</div>,
document.body,
)}
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export const HUNDRED_TB = 100 * 1024 * 1024 * 1024 * 1024;

export const DEFAULT_USER_NAME = 'My Internxt';
export const INTERNXT_EMAIL_DOMAINS = ['@inxt.me', '@inxt.eu', '@encrypt.eu'] as const;

export const DEFAULT_FOLDER_LIMIT = 15;
1 change: 1 addition & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './navigation';
export * from './oauth';
export * from './storage';
export * from './shared';
export * from './mail';
48 changes: 48 additions & 0 deletions src/errors/mail/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export class FetchMailboxesInfoError extends Error {
constructor(
errorMsg?: string,
public requestId?: string,
) {
super('Error while fetching mailboxes info: ' + errorMsg);
this.requestId = requestId;

Object.setPrototypeOf(this, FetchMailboxesInfoError.prototype);
}
}
Comment thread
xabg2 marked this conversation as resolved.

export class FetchListFolderError extends Error {
constructor(
errorMsg?: string,
public requestId?: string,
) {
super('Error while fetching list folder: ' + errorMsg);
this.requestId = requestId;

Object.setPrototypeOf(this, FetchListFolderError.prototype);
}
}

export class FetchMessageError extends Error {
constructor(
errorMsg?: string,
public requestId?: string,
) {
super('Error while fetching message: ' + errorMsg);
this.requestId = requestId;

Object.setPrototypeOf(this, FetchMessageError.prototype);
}
}

export class UpdateMailError extends Error {
constructor(
errorMsg?: string,
action?: 'markAsRead' | 'markAsFlagged' | 'markAsUnflagged',
public requestId?: string,
) {
super(`Error while updating mail when ${action}: ` + errorMsg);
this.requestId = requestId;

Object.setPrototypeOf(this, UpdateMailError.prototype);
}
}
62 changes: 53 additions & 9 deletions src/features/mail/MailView.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,79 @@
import { useState } from 'react';
import { useTranslationContext } from '@/i18n';
import type { FolderType } from '@/types/mail';
import { getMockedMail } from '@/test-utils/fixtures';
import PreviewMail from './components/mail-preview';
import type { User } from './components/mail-preview/header';
import TrayList from './components/tray';
import Settings from './components/settings';
import { useGetListFolderQuery, useGetMailMessageQuery, useMarkAsReadMutation } from '@/store/queries/mail/mail.query';
import { DateService } from '@/services/date';
import { DEFAULT_FOLDER_LIMIT } from '@/constants';

interface MailViewProps {
folder: FolderType;
}

const MailView = ({ folder }: MailViewProps) => {
const { translate } = useTranslationContext();
const mockedMail = getMockedMail();
const from = mockedMail.from[0];
const to = mockedMail.to;
const cc = mockedMail.cc;
const bcc = mockedMail.bcc;
const [activeMailId, setActiveMailId] = useState<string | undefined>(undefined);
const query = {
mailbox: folder,
limit: DEFAULT_FOLDER_LIMIT,
position: 0,
};

const { data: listFolder, isLoading: isLoadingListFolder } = useGetListFolderQuery(query, {
pollingInterval: 30000,
skip: !folder,
});
const { data: activeMail } = useGetMailMessageQuery({ emailId: activeMailId! }, { skip: !activeMailId });
const [markAsRead] = useMarkAsReadMutation();
Comment thread
xabg2 marked this conversation as resolved.

const folderName = translate(`mail.${folder}`);

const from = activeMail?.from[0];
const to = activeMail?.to ?? [];
const cc = activeMail?.cc ?? [];
const bcc = activeMail?.bcc ?? [];

const onSelectEmail = async (id: string, isRead?: boolean) => {
setActiveMailId(id);

if (isRead) return;

await markAsRead({
emailId: id,
query,
});
};
Comment thread
xabg2 marked this conversation as resolved.

return (
<div className="flex flex-row w-full h-full">
{/* Tray */}
<TrayList folderName={folderName} />
<TrayList
folderName={folderName}
listFolder={listFolder?.emails}
isLoadingListFolder={isLoadingListFolder}
activeMailId={activeMailId}
onMailSelected={onSelectEmail}
/>
Comment thread
xabg2 marked this conversation as resolved.
{/* Mail Preview */}
<div className="flex flex-col w-full">
<div className="flex w-full justify-end">
<Settings />
</div>
<PreviewMail bcc={bcc} cc={cc as User[]} from={from} to={to} mail={mockedMail} />
{activeMail && from ? (
<PreviewMail
from={{ name: from.name ?? '', email: from.email }}
to={to.map((u) => ({ name: u.name ?? '', email: u.email }))}
cc={cc.map((u) => ({ name: u.name ?? '', email: u.email }))}
bcc={bcc.map((u) => ({ name: u.name ?? '', email: u.email }))}
mail={{
subject: activeMail.subject,
receivedAt: DateService.formatWithTime(activeMail.receivedAt),
htmlBody: (activeMail.htmlBody as string | null) ?? '',
}}
/>
) : null}
</div>
</div>
);
Expand Down
41 changes: 39 additions & 2 deletions src/features/mail/components/tray/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,56 @@
import { Tray } from '@internxt/ui';
import type { EmailListResponse } from '@internxt/sdk';
import Header from './header';
import { TrayEmptyState } from './tray-empty-state';
import { DateService } from '@/services/date';

interface TrayListProps {
folderName: string;
listFolder: EmailListResponse['emails'] | undefined;
isLoadingListFolder: boolean;
activeMailId?: string;
hasMoreItems?: boolean;
loadMore?: () => void;
onMailSelected?: (id: string, isRead?: boolean) => void;
Comment thread
xabg2 marked this conversation as resolved.
}

const TrayList = ({ folderName }: TrayListProps) => {
const TrayList = ({
folderName,
listFolder,
isLoadingListFolder,
activeMailId,
hasMoreItems,
loadMore,
onMailSelected,
}: TrayListProps) => {
const formattedMails =
listFolder?.map((mail) => ({
id: mail.id,
from: {
name: mail.from[0]?.name ?? mail.from[0]?.email ?? '',
avatar: '',
},
subject: mail.subject,
createdAt: DateService.formatMailTimestamp(mail.receivedAt),
body: mail.preview,
read: mail.isRead,
})) ?? [];

return (
<div className="flex flex-col border-r border-gray-5 h-full">
<div className="flex z-10">
<Header folderName={folderName} />
</div>
<div className="flex-1 w-full overflow-hidden">
<Tray loading={true} mails={[]} emptyState={<TrayEmptyState folderName={folderName} />} />
<Tray
loading={isLoadingListFolder}
mails={formattedMails}
activeEmail={activeMailId}
hasMoreItems={hasMoreItems}
onLoadMore={loadMore}
emptyState={<TrayEmptyState folderName={folderName} />}
onMailSelected={onMailSelected}
/>
</div>
</div>
);
Expand Down
21 changes: 20 additions & 1 deletion src/hooks/navigation/useSidenavNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,24 @@ import type { SidenavOption } from '@internxt/ui/dist/components/sidenav/Sidenav
import { useTranslationContext } from '@/i18n';
import { AppView } from '@/routes/paths';
import { NavigationService } from '@/services/navigation';
import { useGetMailboxesInfoQuery } from '@/store/queries/mail/mail.query';
import type { MailboxResponse } from '@internxt/sdk';

export const useSidenavNavigation = () => {
const { translate } = useTranslationContext();
const { pathname } = useLocation();
const { data: mailboxes } = useGetMailboxesInfoQuery(undefined, {
pollingInterval: 30000,
});

const unreadByMailbox = useMemo(
() =>
Object.fromEntries(mailboxes?.map((m) => [m.type, m.unreadEmails]) ?? []) as Record<
Exclude<MailboxResponse['type'], null>,
number | undefined
>,
[mailboxes],
);

const isActiveButton = useCallback((path: string) => !!matchPath(path, pathname), [pathname]);

Expand All @@ -24,6 +38,7 @@ export const useSidenavNavigation = () => {
icon: TrayIcon,
iconDataCy: 'sideNavInboxIcon',
isVisible: true,
notifications: unreadByMailbox['inbox'],
onClick: () => onSidenavItemClick(AppView.Inbox),
},
{
Expand All @@ -32,6 +47,7 @@ export const useSidenavNavigation = () => {
icon: FileIcon,
iconDataCy: 'sideNavDraftsIcon',
isVisible: true,
notifications: unreadByMailbox['drafts'],
onClick: () => onSidenavItemClick(AppView.Drafts),
},
{
Expand All @@ -40,6 +56,7 @@ export const useSidenavNavigation = () => {
icon: PaperPlaneTiltIcon,
iconDataCy: 'sideNavSentIcon',
isVisible: true,
notifications: unreadByMailbox['sent'],
onClick: () => onSidenavItemClick(AppView.Sent),
},
{
Expand All @@ -48,6 +65,7 @@ export const useSidenavNavigation = () => {
icon: WarningOctagonIcon,
iconDataCy: 'sideNavSpamIcon',
isVisible: true,
notifications: unreadByMailbox['spam'],
onClick: () => onSidenavItemClick(AppView.Spam),
},
{
Expand All @@ -56,10 +74,11 @@ export const useSidenavNavigation = () => {
icon: TrashIcon,
iconDataCy: 'sideNavTrashIcon',
isVisible: true,
notifications: unreadByMailbox['trash'],
onClick: () => onSidenavItemClick(AppView.Trash),
},
],
[translate, onSidenavItemClick, isActiveButton],
[unreadByMailbox, translate, onSidenavItemClick, isActiveButton],
);

return { itemsNavigation };
Expand Down
20 changes: 20 additions & 0 deletions src/services/date/date.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ describe('Date Service', () => {
});
});

describe('Formatting the mail list item timestamp', () => {
test('When the date is today, then it should return only the time in 24h format', () => {
const sameDay = new Date(FIXED_NOW - 1000 * 60 * 30).toISOString();
const result = DateService.formatMailTimestamp(sameDay);
expect(result).toBe('11:02');
});

test('When the date is this year but not today, then it should return month, day and time', () => {
const sameYear = '2024-03-15T15:48:00Z';
const result = DateService.formatMailTimestamp(sameYear);
expect(result).toBe('Mar 15, 15:48');
});

test('When the date is from a different year, then it should include the year', () => {
const differentYear = '2023-04-10T15:48:00Z';
const result = DateService.formatMailTimestamp(differentYear);
expect(result).toBe('Apr 10, 2023, 15:48');
});
});

describe('From now on', () => {
test('When getting relative time for a recent date, then it should return a relative string', () => {
const recent = new Date(FIXED_NOW - 1000 * 60 * 60 * 2).toISOString();
Expand Down
21 changes: 21 additions & 0 deletions src/services/date/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@ export class DateService {
return dayjs(date).format('LL, h:mm A');
}

/**
* Smart timestamp for mail tray:
* - Same day → "20:35"
* - Same year → "Apr 10, 15:48"
* - Different year → "Apr 10, 2025, 15:48"
*/
public static formatMailTimestamp(date: DateInput): string {
const d = dayjs(date);
const now = dayjs();

if (d.isSame(now, 'day')) {
return d.format('HH:mm');
}

if (d.isSame(now, 'year')) {
return d.format('MMM D, HH:mm');
}

return d.format('MMM D, YYYY, HH:mm');
}

/** "2 hours ago", "in 3 days" */
public static fromNow(date: DateInput): string {
return dayjs(date).fromNow();
Expand Down
Loading
Loading