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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,7 @@ next-env.d.ts

# Notion sync cache
.notion-db-id

# Local design-system extracts
/Polycord Design System/
/polycord-design-system/
147 changes: 124 additions & 23 deletions src/features/Inbox/Inbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react';
import { expect, screen, userEvent, within } from '@storybook/test';
import { expect, userEvent, within } from '@storybook/test';
import { useTranslations } from 'next-intl';
import React, { useState } from 'react';
import { Button } from '@/components/Button';
Expand All @@ -24,54 +24,157 @@ const meta: Meta<typeof Inbox> = {
export default meta;
type Story = StoryObj<typeof Inbox>;

const NotificationsStory = () => {
const FreeNotificationsStory = () => {
const t = useTranslations('Inbox');
return (
<Inbox
notifications={[
{
id: '1',
message: t('anonymousUserCopied'),
kind: 'copy',
actorName: 'Mina Park',
actorAvatarUrl: MOCK_USER_AVATAR_URL,
timestamp: t('minutesAgo', { count: 2 }),
},
{
id: '2',
message: t('userCopied', { user: 'xhev' }),
kind: 'view',
actorName: 'Sophie Laurent',
actorAvatarUrl: MOCK_USER_AVATAR_URL,
timestamp: t('hoursAgo', { count: 1 }),
iconUrl: MOCK_USER_AVATAR_URL,
},
{
id: '3',
message: t('anonymousUserCopied'),
kind: 'copy',
isGuest: true,
timestamp: t('hoursAgo', { count: 2 }),
},
]}
/>
);
};

export const WithNotifications: Story = {
render: () => <NotificationsStory />,
export const FreeNotifications: Story = {
render: () => <FreeNotificationsStory />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = await canvas.findByRole('button', {
name: 'Notifications',
});

await expect(canvas.getByText('3')).toBeInTheDocument();
await expect(canvas.getByText('2')).toBeInTheDocument();

await userEvent.click(trigger);

const portal = within(document.body);

await expect(
await screen.findByRole('heading', { name: 'Notifications' }),
await portal.findByRole('heading', { name: 'Notifications' }),
).toBeInTheDocument();
await expect(
portal.getAllByText('A user copied your username'),
).toHaveLength(2);
await expect(
portal.getByText('See who it was with Premium'),
).toHaveAttribute('href', '/en/settings#premium');
await expect(
portal.queryByText('Sophie Laurent viewed your profile'),
).not.toBeInTheDocument();
},
};

const PremiumNotificationsStory = () => {
const t = useTranslations('Inbox');
return (
<Inbox
premium
notifications={[
{
id: '1',
kind: 'copy',
actorName: 'Mina Park',
actorAvatarUrl: MOCK_USER_AVATAR_URL,
timestamp: t('minutesAgo', { count: 3 }),
},
{
id: '2',
kind: 'view',
actorName: 'Sophie Laurent',
actorAvatarUrl: MOCK_USER_AVATAR_URL,
timestamp: t('minutesAgo', { count: 26 }),
},
{
id: '3',
kind: 'view',
isGuest: true,
timestamp: t('hoursAgo', { count: 1 }),
},
{
id: '4',
kind: 'copy',
isGuest: true,
timestamp: t('hoursAgo', { count: 2 }),
},
]}
/>
);
};

export const PremiumNotifications: Story = {
render: () => <PremiumNotificationsStory />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = await canvas.findByRole('button', {
name: 'Notifications',
});

await expect(canvas.getByText('4')).toBeInTheDocument();

await userEvent.click(trigger);

const portal = within(document.body);

await expect(
await portal.findByText('Mina Park copied your username'),
).toBeInTheDocument();
await expect(
portal.getByText('Sophie Laurent viewed your profile'),
).toBeInTheDocument();
await expect(
portal.getByText('A guest viewed your profile'),
).toBeInTheDocument();
await expect(
portal.getByText('An anonymous user copied your username'),
).toBeInTheDocument();
await expect(
portal.queryByText('See who it was with Premium'),
).not.toBeInTheDocument();
},
};

export const Empty: Story = {
args: {
notifications: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = await canvas.findByRole('button', {
name: 'Notifications',
});

await userEvent.click(trigger);

const portal = within(document.body);

await expect(
await portal.findByText('No notifications yet'),
).toBeInTheDocument();
await expect(
portal.getByText(
"You'll see new notifications here when something happens!",
),
).toBeInTheDocument();
},
};

const ToastComponent = React.memo(
Expand Down Expand Up @@ -108,38 +211,36 @@ const LiveUpdateStory = () => {
const { toasts, addToast, dismissToast } = useToastStack();

const createNotificationAndToast = (
messageKey: 'anonymousUserCopied' | 'userCopied',
iconUrl?: string,
actorName?: string,
actorAvatarUrl?: string,
) => {
const message =
messageKey === 'userCopied'
? t('userCopied', { user: 'xhev' })
: t('anonymousUserCopied');

const newNotification: Notification = {
id: Date.now().toString(),
message: message,
kind: 'copy',
actorName,
timestamp: t('minutesAgo', { count: 0 }),
iconUrl: iconUrl,
actorAvatarUrl,
};

const newToast: Omit<ToastData, 'id'> = {
title: t('newNotification'),
description: newNotification.message,
description: actorName
? t('userCopied', { user: actorName })
: t('anonymousCopyAlert'),
duration: 5000,
iconUrl: iconUrl,
iconUrl: actorAvatarUrl,
};

setNotifications((prev) => [newNotification, ...prev]);
addToast(newToast);
};

const simulateAnonNotification = () => {
createNotificationAndToast('anonymousUserCopied');
createNotificationAndToast();
};

const simulateUserNotification = () => {
createNotificationAndToast('userCopied', MOCK_USER_AVATAR_URL);
createNotificationAndToast('xhev', MOCK_USER_AVATAR_URL);
};

return (
Expand Down
27 changes: 21 additions & 6 deletions src/features/Inbox/Inbox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Popover from '@radix-ui/react-popover';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import {
MdOutlineInbox,
Expand All @@ -10,18 +11,23 @@ import { Button } from '@/components/Button';
import type { Notifications } from '@/types';
import { NotificationEntry } from './NotificationEntry';

const initializeNotifications = (initial: Notifications) =>
initial.map((n) => ({ ...n, read: false, isDeleting: false }));
const initializeNotifications = (initial: Notifications, premium: boolean) =>
initial
.filter((notification) => premium || notification.kind === 'copy')
.map((n) => ({ ...n, read: false, isDeleting: false }));

export const Inbox = ({
notifications: initialNotifications,
premium = false,
}: {
notifications: Notifications;
premium?: boolean;
}) => {
const t = useTranslations('Inbox');
const locale = useLocale();

const [notifications, setNotifications] = useState(() =>
initializeNotifications(initialNotifications),
initializeNotifications(initialNotifications, premium),
);
const [currentPage, setCurrentPage] = useState(1);
const [isMounted, setIsMounted] = useState(false);
Expand All @@ -32,9 +38,9 @@ export const Inbox = ({
}, []);

useEffect(() => {
setNotifications(initializeNotifications(initialNotifications));
setNotifications(initializeNotifications(initialNotifications, premium));
setCurrentPage(1);
}, [initialNotifications]);
}, [initialNotifications, premium]);

const unreadCount = notifications.filter((n) => !n.read).length;

Expand Down Expand Up @@ -161,6 +167,7 @@ export const Inbox = ({
currentNotifications.map((notification, index) => (
<NotificationEntry
notification={notification}
premium={premium}
key={notification.id}
onMarkAsRead={() => handleMarkAsRead(notification.id)}
onDelete={() => handleDelete(notification.id)}
Expand All @@ -186,6 +193,14 @@ export const Inbox = ({
</p>
</div>
)}
{!premium && currentNotifications.length > 0 && (
<Link
href={`/${locale}/settings#premium`}
className="rounded-md px-3 py-2.5 font-semibold text-[13px] text-primary-light transition-colors hover:bg-background-main hover:text-primary-lighter"
>
{t('seeWhoWithPremium')}
</Link>
)}
</div>

{totalPages > 1 && (
Expand Down
12 changes: 8 additions & 4 deletions src/features/Inbox/NotificationEntry.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ const DefaultNotificationStory = () => {
<NotificationEntry
notification={{
id: '1',
message: t('anonymousUserCopied'),
kind: 'copy',
actorName: 'Mina Park',
actorAvatarUrl: MOCK_USER_AVATAR_URL,
timestamp: t('minutesAgo', { count: 2 }),
iconUrl: MOCK_USER_AVATAR_URL,
read: false,
}}
premium
onMarkAsRead={() => {}}
onDelete={() => {}}
/>
Expand All @@ -57,11 +59,13 @@ const ReadNotificationStory = () => {
<NotificationEntry
notification={{
id: '2',
message: t('userCopied', { user: 'xhev' }),
kind: 'view',
actorName: 'Sophie Laurent',
actorAvatarUrl: MOCK_USER_AVATAR_URL,
timestamp: t('hoursAgo', { count: 1 }),
iconUrl: MOCK_USER_AVATAR_URL,
read: true,
}}
premium
onMarkAsRead={() => {}}
onDelete={() => {}}
/>
Expand Down
27 changes: 25 additions & 2 deletions src/features/Inbox/NotificationEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,42 @@ import type { Notification } from '@/types';

type NotificationEntryProps = {
notification: Notification & { read: boolean; isDeleting?: boolean };
premium?: boolean;
onMarkAsRead: () => void;
onDelete: () => void;
} & HTMLAttributes<HTMLDivElement>;

const getNotificationMessage = (
notification: Notification,
premium: boolean,
t: ReturnType<typeof useTranslations<'Inbox'>>,
) => {
if (!premium) return t('anonymousCopyAlert');

if (notification.kind === 'view') {
return notification.actorName
? t('userViewed', { user: notification.actorName })
: t('guestViewed');
}

return notification.actorName
? t('userCopied', { user: notification.actorName })
: t('anonymousUserCopied');
};

export const NotificationEntry: React.FC<NotificationEntryProps> = ({
notification,
premium = false,
onMarkAsRead,
onDelete,
className,
style,
...props
}) => {
const t = useTranslations('Inbox');
const avatarUrl = premium ? notification.actorAvatarUrl : undefined;
const message = getNotificationMessage(notification, premium, t);

return (
<div
{...props}
Expand All @@ -41,10 +64,10 @@ export const NotificationEntry: React.FC<NotificationEntryProps> = ({
}`}
/>

<Avatar avatarUrl={notification.iconUrl} size="sm" />
<Avatar avatarUrl={avatarUrl} size="sm" />

<div className="flex-grow overflow-hidden">
<p className="truncate text-sm">{notification.message}</p>
<p className="truncate text-sm">{message}</p>
<span className="text-gray-400 text-xs">{notification.timestamp}</span>
</div>

Expand Down
Loading
Loading