diff --git a/packages/shared/src/components/cards/common/SourceButton.tsx b/packages/shared/src/components/cards/common/SourceButton.tsx index 81f0f909f5..14b819c7d2 100644 --- a/packages/shared/src/components/cards/common/SourceButton.tsx +++ b/packages/shared/src/components/cards/common/SourceButton.tsx @@ -25,6 +25,9 @@ interface SourceButtonProps { className?: string; style?: CSSProperties; size?: ProfileImageSize; + // Defaults to a circle; callers (e.g. notifications) can pass a size token to + // render a rounded square instead. + rounded?: ProfileImageSize | 'full'; pureTextTooltip?: boolean; tooltipPosition?: HoverCardContentProps['side']; } @@ -34,6 +37,7 @@ export default function SourceButton({ tooltipPosition = 'bottom', pureTextTooltip = false, size = ProfileImageSize.Medium, + rounded = 'full', className, ...props }: SourceButtonProps): ReactElement { @@ -45,9 +49,9 @@ export default function SourceButton({ {...props} className={className} size={size} - rounded="full" + rounded={rounded} user={{ - id: source.id, + id: source.id ?? source.handle, image: source.image, username: source.handle, }} @@ -62,9 +66,9 @@ export default function SourceButton({ ) => void; } +// Real-time popup, laid out like a feed row (NotificationItem): the avatar with +// its colored category badge leads, then the headline with the actor's name +// bold and the rest regular for a scannable hierarchy. export function InAppNotificationItem({ icon, + type, title, avatars, targetUrl, @@ -42,19 +26,28 @@ export function InAppNotificationItem({ return null; } - const [avatar] = avatars ?? []; + const avatar = getNotificationLeadAvatar(avatars); + return ( - - - - - {!!avatar && } - - + +
+ +
+ {/* A div (not a

): the sanitized title can itself be wrapped in a +

, and a

inside a

is invalid and gets auto-closed by the + browser, breaking the layout. */} +

- +
); } diff --git a/packages/shared/src/components/notifications/NotificationCategoryBadge.tsx b/packages/shared/src/components/notifications/NotificationCategoryBadge.tsx new file mode 100644 index 0000000000..cacb57b51b --- /dev/null +++ b/packages/shared/src/components/notifications/NotificationCategoryBadge.tsx @@ -0,0 +1,39 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { IconSize } from '../Icon'; +import type { NotificationFilterCategory } from './utils'; +import { notificationCategoryBadge } from './utils'; + +interface NotificationCategoryBadgeProps { + category: NotificationFilterCategory; + // Corner positioning against the parent avatar/face. The single-actor lead + // notches it in (`-bottom-1 -right-1`); the smaller stacked face straddles the + // corner (`bottom-0 right-0 translate-x-1/2 translate-y-1/2`) so the badge + // covers less of the face. The parent must be `relative`. + className?: string; +} + +// The colored category badge overlaid on a notification avatar (Instagram/ +// Facebook/TikTok pattern): a solid accent fill + contrast glyph signalling the +// notification type. Shared by the single-actor lead and the multi-actor stack +// so both render an identical badge, differing only in corner positioning. +export function NotificationCategoryBadge({ + category, + className, +}: NotificationCategoryBadgeProps): ReactElement { + const badge = notificationCategoryBadge[category]; + const BadgeIcon = badge.Icon; + + return ( + + + + ); +} diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 8e7e3c6cf1..02f49c30c3 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -5,16 +5,16 @@ import { useRouter } from 'next/router'; import Link from '../utilities/Link'; import type { Notification } from '../../graphql/notifications'; import { useObjectPurify } from '../../hooks/useDomPurify'; -import NotificationItemIcon from './NotificationIcon'; import NotificationItemAvatar from './NotificationItemAvatar'; +import { NotificationItemLead } from './NotificationItemLead'; +import { NotificationCategoryBadge } from './NotificationCategoryBadge'; +import { getNotificationLeadAvatar } from './leadAvatar'; import { getNotificationCategory, NotificationFilterCategory, - notificationCategoryBadge, notificationMutingCopy, NotificationType, notificationTypeNotClickable, - notificationTypeTheme, } from './utils'; import { KeyboardCommand } from '../../lib/element'; import { ProfileTooltip } from '../profile/ProfileTooltip'; @@ -154,9 +154,6 @@ const NotificationOptionsButton = ({ // Tertiary is the flat variant — transparent, no background or // border (Float carries a faint surface-float background). variant={ButtonVariant.Tertiary} - // Visible by default on mobile (no hover); reveal on hover from - // tablet up. - className="tablet:invisible tablet:group-hover:visible" icon={} size={ButtonSize.XSmall} /> @@ -200,100 +197,79 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return null; } - const [primaryAvatar] = filteredAvatars; + // Lead with the human actor, not whatever avatar the backend listed first + // (squad comments/posts arrive source-first). Falls back to the source for + // source-only notifications. + const leadAvatar = getNotificationLeadAvatar(filteredAvatars); const hasAvatar = filteredAvatars.length > 0; // `numTotalAvatars` can arrive as 0 from the backend even when avatars are // present, so take the larger of the two rather than `??` (which keeps 0). const totalAvatars = Math.max(numTotalAvatars ?? 0, filteredAvatars.length); - // Only show the multi-image grid for more than three actors; one/two/three - // just show a single avatar (with a corner badge). - const showGrid = filteredAvatars.length > 1 && totalAvatars > 3; const renderLink = onClick && isClickable; const hasOptions = Object.keys(notificationMutingCopy).includes(type); const [attachment] = attachments ?? []; - // When there is a person/source involved we show their avatar with a colored - // type badge; otherwise (system/digest/streak) the type icon is the lead. - const leadIcon = ( - - ); const category = getNotificationCategory(type); - const badge = notificationCategoryBadge[category]; - const BadgeIcon = badge.Icon; // Badge only for notifications about you (upvotes/comments/mentions/follows/ // squad activity). Source posts & system land in `Updates` and stay clean. const showBadge = hasAvatar && category !== NotificationFilterCategory.Updates; + // More than three actors render as a tight overlapping stack of up to three + // rounded-square faces — sized like one avatar so the lead never grows wider + // and every row stays aligned. The exact count still lives in the title. + const showStack = filteredAvatars.length > 1 && totalAvatars > 3; - // Up to three actors render a single avatar (with a corner badge). More than - // three render as a 2x2 grid the size of one avatar — up to three faces plus - // the action icon — so the lead never grows wider and every row stays aligned. let avatarContent: ReactElement | null = null; - if (!showGrid) { - avatarContent = hasAvatar ? ( - - ) : null; - } else { - // 2x2 of separate, individually-rounded face boxes (no connecting frame, - // no "+N" count) plus a circular action cell, sized like one avatar so the - // lead width never grows. Each face keeps its hover profile tooltip. - const slots = showBadge ? 3 : 4; - const cells: ReactElement[] = filteredAvatars - .slice(0, slots) - .map((avatar) => { - // Only image-backed actors (the ones that actually appear in a >3 - // stack) render as a compact face. Icon-backed types (badges/briefs/ - // digests) never reach this path, but fall back to the full avatar - // renderer rather than a broken if one ever does. - const isImageBacked = - avatar.type === NotificationAvatarType.User || - avatar.type === NotificationAvatarType.Source || - avatar.type === NotificationAvatarType.Organization; - if (!isImageBacked) { + if (showStack) { + const faces = filteredAvatars.slice(0, 3); + avatarContent = ( +
+ {faces.map((avatar, index) => { + // Icon-backed types (briefs/digests/badges) never reach a >3 stack, + // but fall back to the full avatar renderer rather than a broken + // if one ever does. + const isImageBacked = + avatar.type === NotificationAvatarType.User || + avatar.type === NotificationAvatarType.Source || + avatar.type === NotificationAvatarType.Organization; + const isFront = index === faces.length - 1; + const face = isImageBacked ? ( + {`${avatar.name} + ) : ( + + ); return ( - + + {avatar.type === NotificationAvatarType.User ? ( + + {face} + + ) : ( + face + )} + {/* Same category badge as the single-actor lead, straddling the + front face's bottom-right corner (translate centering keeps it + from swamping the smaller stacked face). */} + {isFront && showBadge && ( + + )} + ); - } - const image = ( - {`${avatar.name} - ); - return avatar.type === NotificationAvatarType.User ? ( - - {image} - - ) : ( - image - ); - }); - // Pad empty face cells so the circular action always lands bottom-right. - while (cells.length < slots) { - cells.push(); - } - if (showBadge) { - cells.push( - - - , - ); - } - - avatarContent = ( -
{cells}
+ })} +
); } const timeText = createdAt ? publishTimeRelativeShort(createdAt) : ''; @@ -319,7 +295,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return (
@@ -348,50 +324,33 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {/* Leading avatar + colored type badge — the eye-catching, type-at-a- glance cue (Instagram/Facebook/TikTok). System rows with no person - fall back to the plain type icon. */} -
-
- {hasAvatar ? avatarContent : leadIcon} - {showBadge && !showGrid && ( - - - - )} -
+ fall back to the plain type icon. More than three actors render as an + overlapping stack; everything else uses the shared single-actor lead. */} +
+ {showStack ? ( + avatarContent + ) : ( + + )}
- {/* Bold headline, then the comment (if any), then the referenced post's - title so it's clear which post/article the notification is about. */} -
- - {/* Meta line leads with the time, then a dot, then the comment (or the - post title when there's no comment). */} - {(timeText || showDescription || showAttachmentTitle) && ( -
- {timeText && ( - - - - )} - {timeText && (showDescription || showAttachmentTitle) && ( - · - )} + {/* Headline (actor name bold, the rest regular for a scannable + hierarchy) with the relative time flowing inline right after it — so a + row reads "what happened" first and the time is a quiet suffix, not a + right-aligned column. Then the comment, then the post's title. */} +
+
+ + {timeText && ( + + + + )} +
+ {(showDescription || showAttachmentTitle) && ( +
{showDescription ? ( ) : ( @@ -402,7 +361,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {/* When there's both a comment and a post, name the post on its own line so it's clear which article it's about. */} {showDescription && showAttachmentTitle && ( -
+
{attachmentTitle}
)} @@ -413,13 +372,13 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )}
- {/* Trailing actions: the post cover plus a fixed-width menu slot. The - slot is always reserved whenever a row has a cover and/or a menu, so - every cover image lands at the same x regardless of whether that row - has a menu — keeping covers aligned down the whole feed. The cover is - top-aligned with the title (centered-looking on a two-line row). */} + {/* Trailing: the post cover and/or the options menu, both top-aligned with + the title (the menu is the rightmost element, so it pins to the + top-right corner). The menu column is only reserved when the row + actually has a menu — otherwise the cover sits flush to the right edge + instead of leaving an empty gap. */} {(attachment?.image || hasOptions) && ( -
+
{attachment?.image && ( )} -
- {hasOptions && ( + {hasOptions && ( +
- )} -
+
+ )}
)}
diff --git a/packages/shared/src/components/notifications/NotificationItemAvatar.tsx b/packages/shared/src/components/notifications/NotificationItemAvatar.tsx index b6d017c481..2b0f9c98be 100644 --- a/packages/shared/src/components/notifications/NotificationItemAvatar.tsx +++ b/packages/shared/src/components/notifications/NotificationItemAvatar.tsx @@ -22,7 +22,10 @@ function NotificationItemAvatar({ if (type === NotificationAvatarType.Source) { return ( diff --git a/packages/shared/src/components/notifications/NotificationItemLead.tsx b/packages/shared/src/components/notifications/NotificationItemLead.tsx new file mode 100644 index 0000000000..63eb973d86 --- /dev/null +++ b/packages/shared/src/components/notifications/NotificationItemLead.tsx @@ -0,0 +1,55 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { NotificationAvatar } from '../../graphql/notifications'; +import NotificationItemIcon from './NotificationIcon'; +import NotificationItemAvatar from './NotificationItemAvatar'; +import { NotificationCategoryBadge } from './NotificationCategoryBadge'; +import type { NotificationIconType, NotificationType } from './utils'; +import { + getNotificationCategory, + NotificationFilterCategory, + notificationTypeTheme, +} from './utils'; + +interface NotificationItemLeadProps { + type: NotificationType; + icon: NotificationIconType; + avatar?: NotificationAvatar; +} + +// Single-actor lead: the avatar with the colored category badge notched into +// its corner (Instagram/Facebook/TikTok pattern), or the plain type icon when +// there's no actor (system/digest/streak). Shared so the feed row and the +// real-time in-app popup present a notification identically. NotificationItem's +// >3-actor overlapping stack is a list-only layout and stays in that component. +export function NotificationItemLead({ + type, + icon, + avatar, +}: NotificationItemLeadProps): ReactElement { + const category = getNotificationCategory(type); + const hasAvatar = !!avatar; + // Badge only for notifications about you (upvotes/comments/mentions/follows/ + // squad activity). Source posts & system land in `Updates` and stay clean. + const showBadge = + hasAvatar && category !== NotificationFilterCategory.Updates; + + return ( +
+ {avatar ? ( + + ) : ( + + )} + {showBadge && ( + + )} +
+ ); +} diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 0b17ebff20..aa27616006 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -78,9 +78,14 @@ function NotificationsBell({ className="pointer-events-none" /> {hasNotification && ( - + // Compact corner badge: the rail bell is only `size-6` (24px), + // so the full-size shared `Bubble` (min 20px) blankets the + // glyph. A smaller pill notched into the top-right corner reads + // as a badge instead of covering the icon. `border-background- + // default` matches the sidebar surface for the cutout effect. + {getUnreadText(unreadCount)} - +
)} {!railHideLabel && ( diff --git a/packages/shared/src/components/notifications/leadAvatar.ts b/packages/shared/src/components/notifications/leadAvatar.ts new file mode 100644 index 0000000000..696d859f26 --- /dev/null +++ b/packages/shared/src/components/notifications/leadAvatar.ts @@ -0,0 +1,19 @@ +import { NotificationAvatarType } from '../../graphql/notifications'; +import type { NotificationAvatar } from '../../graphql/notifications'; + +// Which avatar leads a notification row. Always lead with the human who acted — +// the commenter, the upvoter, the follower, the person who posted — because +// that's the identity the notification is about. The backend often lists the +// source/squad first (e.g. squad comments arrive as [source, user]), which is +// why leading with `avatars[0]` showed a source logo instead of the actor. The +// source is still conveyed by the title text and the category badge. Falls back +// to the first avatar when there's no user at all (source-only notifications +// like "new post in ", role changes, or system/digest rows). +// +// Lives in its own module (not utils.ts) so the value import of +// NotificationAvatarType doesn't create a utils <-> graphql import cycle. +export const getNotificationLeadAvatar = ( + avatars: NotificationAvatar[] = [], +): NotificationAvatar | undefined => + avatars.find((avatar) => avatar.type === NotificationAvatarType.User) ?? + avatars[0]; diff --git a/packages/shared/src/components/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index 6ecd587f79..00f4b146ae 100644 --- a/packages/shared/src/components/notifications/utils.ts +++ b/packages/shared/src/components/notifications/utils.ts @@ -461,34 +461,49 @@ export const getNotificationCategory = ( notificationTypeToCategory[type] ?? NotificationFilterCategory.Updates; // Eye-catching colored type badge overlaid on the avatar (Instagram/Facebook/ -// TikTok pattern): a solid accent circle + white glyph that signals the -// notification type at a glance. +// TikTok pattern): a solid accent circle + glyph that signals the notification +// type at a glance. +// +// `fg` is chosen for contrast against the badge fill, not for decoration: the +// food-palette accents split into bright (avocado green, blueCheese cyan, bun +// orange) where a white glyph washes out, and dark (cabbage/onion purples) +// where white reads. Bright fills get a dark glyph, dark fills get white — so +// the icon stays legible in both light and dark themes (the fill hue barely +// shifts between themes, so a fixed per-badge `fg` is enough). export const notificationCategoryBadge: Record< NotificationFilterCategory, - { bg: string; Icon: ComponentType } + { bg: string; fg: string; Icon: ComponentType } > = { [NotificationFilterCategory.Upvotes]: { bg: 'bg-accent-avocado-default', + fg: 'text-black', Icon: UpvoteIcon, }, [NotificationFilterCategory.Mentions]: { bg: 'bg-accent-cabbage-default', + fg: 'text-white', Icon: AtIcon, }, [NotificationFilterCategory.Comments]: { bg: 'bg-accent-blueCheese-default', + fg: 'text-black', Icon: DiscussIcon, }, [NotificationFilterCategory.Followers]: { bg: 'bg-accent-onion-default', + fg: 'text-white', Icon: AddUserIcon, }, + // Squads shares the Followers purple — the cheese yellow read poorly and a + // white glyph on yellow had almost no contrast. [NotificationFilterCategory.Squads]: { - bg: 'bg-accent-cheese-default', + bg: 'bg-accent-onion-default', + fg: 'text-white', Icon: SquadIcon, }, [NotificationFilterCategory.Updates]: { bg: 'bg-accent-bun-default', + fg: 'text-black', Icon: MegaphoneIcon, }, }; diff --git a/packages/storybook/stories/components/NotificationItem.stories.tsx b/packages/storybook/stories/components/NotificationItem.stories.tsx index 2197136973..534142e244 100644 --- a/packages/storybook/stories/components/NotificationItem.stories.tsx +++ b/packages/storybook/stories/components/NotificationItem.stories.tsx @@ -40,7 +40,7 @@ const postAttachment = (seed: string, title: string) => ({ }); const meta: Meta = { - title: 'Components/NotificationItem', + title: 'Components/Notifications/List item — all types', component: NotificationItem, tags: ['autodocs'], decorators: [ diff --git a/packages/storybook/stories/components/notifications/Bell.stories.tsx b/packages/storybook/stories/components/notifications/Bell.stories.tsx new file mode 100644 index 0000000000..bdce0cbc39 --- /dev/null +++ b/packages/storybook/stories/components/notifications/Bell.stories.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import NotificationsBell from '@dailydotdev/shared/src/components/notifications/NotificationsBell'; +import { NotificationsContextProvider } from '@dailydotdev/shared/src/contexts/NotificationsContext'; +import { Bubble } from '@dailydotdev/shared/src/components/tooltips/utils'; +import { getUnreadText } from '@dailydotdev/shared/src/components/notifications/utils'; +import ExtensionProviders from '../../extension/_providers'; + +// The notifications bell + its unread "Bubble" badge, across the surfaces it +// renders on: the header button (laptop Float / mobile Option) and the v2 +// sidebar rail (vertical icon + "Alerts" label). The badge caps at "20+". +// +// `NotificationsContextProvider` is nested here to drive `unreadCount` — it sits +// closer to the bell than the boot-data provider, so it wins. + +const meta: Meta = { + title: 'Components/Notifications/Bell & badge', + component: NotificationsBell, + parameters: { + docs: { + description: { + component: + 'Header/rail bell button and the unread count Bubble. Readability levers: badge contrast against the bell, the 20+ cap, badge offset/size, and the rail label.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const Cell = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+
{children}
+ {label} +
+); + +const WithCount = ({ + count, + children, +}: { + count: number; + children: React.ReactNode; +}) => ( + + {children} + +); + +export const HeaderButton: Story = { + name: 'Header button — unread counts', + render: () => ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ ), +}; + +export const RailVariant: Story = { + name: 'Sidebar rail — icon + label', + render: () => ( +
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+
+ ), +}; + +// The raw badge primitive, isolated so its contrast/size can be tuned directly. +export const CountBubble: Story = { + name: 'Count bubble (raw)', + render: () => ( +
+ {[1, 5, 9, 20, 21, 142].map((n) => ( + + + + {getUnreadText(n)} + + + + ))} +
+ ), +}; diff --git a/packages/storybook/stories/components/notifications/FullPage.stories.tsx b/packages/storybook/stories/components/notifications/FullPage.stories.tsx new file mode 100644 index 0000000000..d111b00b55 --- /dev/null +++ b/packages/storybook/stories/components/notifications/FullPage.stories.tsx @@ -0,0 +1,136 @@ +import React, { useMemo, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import NotificationItem from '@dailydotdev/shared/src/components/notifications/NotificationItem'; +import { + getNotificationCategory, + notificationFilterCategoryLabel, + notificationFilterCategoryList, + NotificationFilterCategory, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { + SquadDirectoryNavbar, + SquadDirectoryNavbarItem, +} from '@dailydotdev/shared/src/components/squads/layout/SquadDirectoryNavbar'; +import { ButtonSize } from '@dailydotdev/shared/src/components/buttons/Button'; +import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; +import { SettingsIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import ExtensionProviders from '../../extension/_providers'; +import { groupByTime, sampleNotifications } from './_mock'; + +// Faithful, provider-light reconstruction of the /notifications page +// (packages/webapp/components/notifications/NotificationsFeed.tsx) using the +// real shared NotificationItem rows + the same filter bar primitive and time +// grouping. The live page is auth-gated and needs backend data, so this is the +// canvas to iterate on the page's readability. + +const meta: Meta = { + title: 'Components/Notifications/Full page', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Reconstruction of the /notifications page — header, type filters, time-grouped feed of NotificationItem rows. Use the filter tabs to scope by category. The real page lives in NotificationsFeed.tsx and is auth-gated, so this is where to review page-level readability.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const NotificationsPage = (): React.ReactElement => { + const [active, setActive] = useState(null); + const isLaptop = useViewSize(ViewSize.Laptop); + const buttonSize = isLaptop ? ButtonSize.Small : ButtonSize.XSmall; + + const filtered = useMemo( + () => + active + ? sampleNotifications.filter( + (item) => getNotificationCategory(item.type) === active, + ) + : sampleNotifications, + [active], + ); + + const groups = useMemo(() => groupByTime(filtered), [filtered]); + + return ( +
+
+

Notifications

+ +
+ +
+ + setActive(null)} + /> + {notificationFilterCategoryList.map((category) => ( + setActive(category)} + /> + ))} + +
+ + {groups.map((group) => ( +
+

+ {group.label} +

+ {group.items.map((item) => ( + + ))} +
+ ))} + + {filtered.length === 0 && active && ( +

+ No {notificationFilterCategoryLabel[active].toLowerCase()}{' '} + notifications yet. +

+ )} +
+ ); +}; + +export const Page: Story = { + name: 'Notifications page', + render: () => , +}; + +export const MobileViewport: Story = { + name: 'Notifications page (mobile)', + parameters: { viewport: { defaultViewport: 'mobile2' } }, + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/Icons.stories.tsx b/packages/storybook/stories/components/notifications/Icons.stories.tsx new file mode 100644 index 0000000000..4e175153eb --- /dev/null +++ b/packages/storybook/stories/components/notifications/Icons.stories.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import NotificationIcon from '@dailydotdev/shared/src/components/notifications/NotificationIcon'; +import { + NotificationFilterCategory, + notificationCategoryBadge, + notificationFilterCategoryLabel, + NotificationIconType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import ExtensionProviders from '../../extension/_providers'; + +// The two visual vocabularies a notification row leans on: +// 1. The lead type-icon (NotificationIcon) — what shows when there is no actor +// avatar (system / digest / streak rows). +// 2. The category badge — the colored corner glyph overlaid on an avatar that +// signals upvote / comment / mention / follow / squad at a glance. + +const meta: Meta = { + title: 'Components/Notifications/Icons & badges', + parameters: { + docs: { + description: { + component: + 'Every notification lead icon and every category badge in one place — the building blocks for a row\'s type-at-a-glance cue. Tune glyph contrast, background, and color tokens here.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const Tile = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+
{children}
+ + {label} + +
+); + +export const LeadIcons: Story = { + name: 'Lead type icons', + render: () => ( +
+ {Object.values(NotificationIconType).map((icon) => ( + + + + ))} +
+ ), +}; + +export const CategoryBadges: Story = { + name: 'Category badges', + render: () => ( +
+ {Object.values(NotificationFilterCategory).map((category) => { + const badge = notificationCategoryBadge[category]; + const BadgeIcon = badge.Icon; + return ( + + + + + + ); + })} +
+ ), +}; + +// How the badge actually sits on an avatar in the feed (corner overlay). +export const BadgeOnAvatar: Story = { + name: 'Badge over avatar', + render: () => ( +
+ {Object.values(NotificationFilterCategory) + .filter((c) => c !== NotificationFilterCategory.Updates) + .map((category) => { + const badge = notificationCategoryBadge[category]; + const BadgeIcon = badge.Icon; + return ( +
+
+ + + + +
+ + {notificationFilterCategoryLabel[category]} + +
+ ); + })} +
+ ), +}; diff --git a/packages/storybook/stories/components/notifications/InAppPopup.stories.tsx b/packages/storybook/stories/components/notifications/InAppPopup.stories.tsx new file mode 100644 index 0000000000..32288f1a25 --- /dev/null +++ b/packages/storybook/stories/components/notifications/InAppPopup.stories.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import { InAppNotificationItem } from '@dailydotdev/shared/src/components/notifications/InAppNotificationItem'; +import { + NotificationIconType, + NotificationType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { ModalClose } from '@dailydotdev/shared/src/components/modals/common/ModalClose'; +import { ButtonSize } from '@dailydotdev/shared/src/components/buttons/Button'; +import ExtensionProviders from '../../extension/_providers'; +import { userAvatar, sourceAvatar } from './_mock'; + +// The real-time, in-app toast that bounces in (top-center on mobile, bottom- +// right on desktop) when a new notification arrives. Live shell: +// InAppNotification.tsx wraps InAppNotificationItem in a pepper-subtler card +// with a close button. Reproduced statically here so its 3-line clamp and +// icon+avatar lockup can be reviewed. + +const meta: Meta = { + title: 'Components/Notifications/In-app popup', + component: InAppNotificationItem, + parameters: { + docs: { + description: { + component: + 'Real-time push-style popup. Title is sanitized HTML and clamps to 3 lines. Review: title contrast on the pepper-subtler card, icon/avatar lockup, and how long titles truncate.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +// Mirror of the live Container in InAppNotification.tsx (size + surface), minus +// the fixed positioning so it sits inline in the canvas. +const PopupShell = ({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement => ( +
+ + {children} +
+); + +export const CommentReply: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const NewPost: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const LongTitleClamp: Story = { + name: 'Long title (3-line clamp)', + render: () => ( +
+ + + +
+ ), +}; + +export const SystemNoAvatar: Story = { + name: 'System (icon only)', + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/storybook/stories/components/notifications/Overview.stories.tsx b/packages/storybook/stories/components/notifications/Overview.stories.tsx new file mode 100644 index 0000000000..a4811b47e3 --- /dev/null +++ b/packages/storybook/stories/components/notifications/Overview.stories.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import ExtensionProviders from '../../extension/_providers'; + +// Landing page for the notifications readability audit. Collects every +// notification surface in one folder and calls out the concrete levers to pull +// on each one, so improving readability is a guided pass rather than a hunt. + +const meta: Meta = { + title: 'Components/Notifications/Overview', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Start here. A map of every notification surface, where it lives in the codebase, and the specific readability levers to review on each.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +interface SurfaceRow { + story: string; + surface: string; + source: string; + levers: string[]; +} + +const surfaces: SurfaceRow[] = [ + { + story: 'Bell & badge', + surface: 'Header / rail bell + unread count Bubble', + source: 'shared/components/notifications/NotificationsBell.tsx', + levers: [ + 'Badge contrast (white on cabbage) against the bell', + 'Badge offset / size — does it clip the bell glyph?', + 'The "20+" cap (getUnreadText) — is the threshold right?', + 'Rail "Alerts" label legibility at caption2', + ], + }, + { + story: 'Icons & badges', + surface: 'Lead type icons + category badges', + source: 'shared/components/notifications/NotificationIcon.tsx + utils.ts', + levers: [ + 'Glyph contrast inside the rounded surface-float chip', + 'Category badge color per food token (avocado / cabbage / blueCheese / onion / cheese / bun)', + 'White glyph legibility at XXSmall on the colored badge', + 'Which types deserve a badge vs. fall into "Updates"', + ], + }, + { + story: 'List item — all types', + surface: 'A single feed row (NotificationItem)', + source: 'shared/components/notifications/NotificationItem.tsx', + levers: [ + 'Title weight/size (bold typo-callout) vs. 2-line clamp', + 'Meta line: time + description at typo-footnote text-tertiary — is it readable?', + 'When description AND post title both show — is the hierarchy clear?', + 'Unread state (bg-surface-float) — is it distinct enough?', + 'Avatar + corner badge + attachment alignment down the column', + ], + }, + { + story: 'Full page', + surface: 'The /notifications page', + source: 'webapp/components/notifications/NotificationsFeed.tsx', + levers: [ + 'Time-group headers (Today / This week / …) at typo-footnote text-tertiary', + 'Filter tabs density and active underline', + 'Row-to-row rhythm and divider treatment', + 'Empty-state copy per filter', + ], + }, + { + story: 'In-app popup', + surface: 'Real-time push-style popup', + source: 'shared/components/notifications/InAppNotificationItem.tsx', + levers: [ + 'Title contrast on the accent-pepper-subtler card', + '3-line clamp — where do long titles get cut?', + 'Icon + avatar lockup spacing', + ], + }, + { + story: 'Toast (live)', + surface: 'Transient app toast', + source: 'shared/components/notifications/Toast.tsx', + levers: [ + 'Inverting chip — text/icon resolve against the chip, not the page', + 'Status icon color vibrance per variant', + 'Action button + dismiss ring legibility', + ], + }, +]; + +const Pill = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const NotificationsOverview = (): React.ReactElement => ( +
+

Notifications — readability audit

+

+ Every notification surface, collected in one folder. The feedback is that + notifications aren't readable enough — so each story below isolates a + surface and lists the concrete levers to tune. Toggle the Storybook theme + (light / dark) on each to check contrast both ways. Work top to bottom. +

+ +
+ {surfaces.map((row, index) => ( +
+
+ + {index + 1}. + +

{row.surface}

+
+

+ Story:{' '} + + Notifications / {row.story} + {' '} + · {row.source} +

+
    + {row.levers.map((lever) => ( +
  • {lever}
  • + ))} +
+
+ ))} +
+ +
+

Shared readability levers

+
    +
  • + Type scale: titles use typo-callout bold; meta uses{' '} + typo-footnote. Most "hard to read" reports trace + back to the footnote meta line at text-tertiary /{' '} + text-quaternary. +
  • +
  • + Color tokens only (text-primary,{' '} + text-secondary, …) — no raw colors (ESLint{' '} + no-custom-color). +
  • +
  • + Check both themes and the mobile viewport — the kebab menu and badge + offsets differ on mobile. +
  • +
+
+
+); + +export const Start: Story = { + name: 'Start here', + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/Toast.stories.tsx b/packages/storybook/stories/components/notifications/Toast.stories.tsx new file mode 100644 index 0000000000..2db1bb2989 --- /dev/null +++ b/packages/storybook/stories/components/notifications/Toast.stories.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useQueryClient } from '@tanstack/react-query'; +import Toast from '@dailydotdev/shared/src/components/notifications/Toast'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import ExtensionProviders from '../../extension/_providers'; + +// The transient, app-level toast (bottom-left). This is the SAME component the +// app ships (Toast.tsx). Storybook mocks `useToastNotification`, so instead of +// calling the hook we seed the toast query cache directly — the exact key/shape +// the real Toast reads — and let the real component render, animate and dismiss. +// +// `variant` values mirror ToastType ('success' | 'error' | …); Toast maps them +// to the leading status icon + color. + +const TOAST_NOTIF_KEY = ['toast_notif']; + +const meta: Meta = { + title: 'Components/Notifications/Toast (live)', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Live transient toast — the real Toast.tsx. The chip background inverts per theme, so status colors and text resolve against the chip, not the page. See also the deeper design playground at Components/Toast/Redesign Comparison.', + }, + }, + }, + decorators: [ + (Story) => ( + + + {/* Auto-dismiss on so the countdown ring is visible. */} + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +type Variant = + | 'default' + | 'success' + | 'error' + | 'warning' + | 'info' + | 'loading'; + +const Triggers = (): React.ReactElement => { + const client = useQueryClient(); + + const fire = + (message: string, variant?: Variant, extra?: Record) => + () => + client.setQueryData(TOAST_NOTIF_KEY, { + message, + timer: 6000, + variant, + ...extra, + }); + + const variants: Array<{ label: string; message: string; variant?: Variant }> = + [ + { label: 'Default', message: 'Saved to your bookmarks' }, + { + label: 'Success', + message: 'Post shared successfully', + variant: 'success', + }, + { + label: 'Error', + message: 'Something went wrong, try again', + variant: 'error', + }, + { + label: 'Warning', + message: 'Your session is about to expire', + variant: 'warning', + }, + { label: 'Info', message: 'Syncing your preferences', variant: 'info' }, + { label: 'Loading', message: 'Uploading…', variant: 'loading' }, + ]; + + return ( +
+

+ Fire a toast — it appears bottom-left. Hover to pause the dismiss ring. +

+
+ {variants.map(({ label, message, variant }) => ( + + ))} +
+ +
+ ); +}; + +export const Playground: Story = { + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/UseCases.stories.tsx b/packages/storybook/stories/components/notifications/UseCases.stories.tsx new file mode 100644 index 0000000000..21a77774a0 --- /dev/null +++ b/packages/storybook/stories/components/notifications/UseCases.stories.tsx @@ -0,0 +1,455 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import NotificationItem from '@dailydotdev/shared/src/components/notifications/NotificationItem'; +import type { NotificationItemProps } from '@dailydotdev/shared/src/components/notifications/NotificationItem'; +import { + getNotificationCategory, + NotificationFilterCategory, + notificationCategoryBadge, + notificationFilterCategoryLabel, + NotificationIconType, + NotificationType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { getNotificationLeadAvatar } from '@dailydotdev/shared/src/components/notifications/leadAvatar'; +import { NotificationAvatarType } from '@dailydotdev/shared/src/graphql/notifications'; +import ExtensionProviders from '../../extension/_providers'; +import { postAttachment, sourceAvatar, userAvatar } from './_mock'; + +// One page that documents every notification scenario and EXPLAINS the avatar + +// badge logic, so the choices can be reviewed deliberately. Each card shows the +// rendered row, the avatars the API sends (in their real order), which one the +// row leads with, and why. The lead is computed with the SAME helper the +// component uses (getNotificationLeadAvatar), so this page never drifts from +// the real behavior. + +const meta: Meta = { + title: 'Components/Notifications/Use cases', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Every notification scenario with its avatar/badge rationale. Lead avatar rule: always show the human who acted (commenter, upvoter, follower, poster); fall back to the source only when there is no person; system/digest rows show the type icon.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +interface UseCase { + scenario: string; + why: string; + def: Partial; +} + +interface Section { + title: string; + blurb: string; + cases: UseCase[]; +} + +// Avatars are listed in the order the backend actually sends them — notably +// source-FIRST for squad activity, which is exactly what used to make the row +// show a source logo instead of the person who acted. +const sections: Section[] = [ + { + title: 'Comments & replies', + blurb: + 'A person commented or replied. Lead with that person — even when the API lists the squad/source first.', + cases: [ + { + scenario: 'Someone comments on your post', + why: 'API sends [user]. Leads with the commenter. Blue Comments badge.', + def: { + type: NotificationType.ArticleNewComment, + icon: NotificationIconType.Comment, + title: 'Nimrod Kramer commented on your post', + description: 'Great write-up — the caching part really helped.', + avatars: [userAvatar('nimrod', 'Nimrod Kramer')], + attachments: [postAttachment('c1', 'Scaling our cache layer')], + }, + }, + { + scenario: 'Someone comments on your post inside a Squad', + why: 'API sends [source, user]. FIXED: was showing the squad logo; now leads with the commenter (Lee). The squad is still clear from the title + badge.', + def: { + type: NotificationType.SquadNewComment, + icon: NotificationIconType.Comment, + title: 'Lee Solway commented on your post in WebDev', + description: 'Have you tried the new view transitions API?', + avatars: [ + sourceAvatar('webdev', 'WebDev'), + userAvatar('lee', 'Lee Solway'), + ], + }, + }, + { + scenario: 'Reply to your comment', + why: 'API sends [user]. Leads with the replier.', + def: { + type: NotificationType.CommentReply, + icon: NotificationIconType.Comment, + title: 'Ido Shamun replied to your comment', + description: 'Looks great — shipping it!', + avatars: [userAvatar('ido', 'Ido Shamun')], + }, + }, + { + scenario: 'Reply to your comment inside a Squad', + why: 'API sends [source, user]. FIXED: leads with the replier (Ante), not the AI squad logo.', + def: { + type: NotificationType.SquadReply, + icon: NotificationIconType.Comment, + title: 'Ante Barić replied to your comment in AI', + avatars: [sourceAvatar('ai', 'AI'), userAvatar('ante', 'Ante Barić')], + }, + }, + ], + }, + { + title: 'Mentions', + blurb: 'Someone @mentioned you. Lead with the person who mentioned you.', + cases: [ + { + scenario: 'Mentioned you in a post', + why: 'Leads with the author. Pink Mentions badge.', + def: { + type: NotificationType.PostMention, + icon: NotificationIconType.Comment, + title: 'Tsahi Matsliah mentioned you in a post', + avatars: [userAvatar('tsahi', 'Tsahi Matsliah')], + attachments: [postAttachment('m1', 'How we cut build times in half')], + }, + }, + { + scenario: 'Mentioned you in a comment', + why: 'Leads with the author of the comment.', + def: { + type: NotificationType.CommentMention, + icon: NotificationIconType.Comment, + title: 'Tsahi Matsliah mentioned you in a comment', + description: 'Hey @you, what do you think about this approach?', + avatars: [userAvatar('tsahi', 'Tsahi Matsliah')], + }, + }, + ], + }, + { + title: 'Upvotes', + blurb: + 'Reactions on your content. Lead with an upvoter; 4+ upvoters render as a face grid.', + cases: [ + { + scenario: 'A few upvotes on your post', + why: 'API sends the upvoters. 3 or fewer → single lead (the first upvoter). Green Upvotes badge.', + def: { + type: NotificationType.ArticleUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: '3 upvotes on your post! 🎉', + numTotalAvatars: 3, + avatars: [ + userAvatar('u1', 'Ada'), + userAvatar('u2', 'Bram'), + userAvatar('u3', 'Cleo'), + ], + }, + }, + { + scenario: 'Many upvotes on your comment', + why: 'FIXED case you flagged: leads with real upvoters (not you / not a source). 4+ → 2x2 face grid + the upvote badge.', + def: { + type: NotificationType.CommentUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: '24 upvotes on your comment!', + numTotalAvatars: 24, + avatars: [ + userAvatar('u4', 'Dana'), + userAvatar('u5', 'Eli'), + userAvatar('u6', 'Fae'), + userAvatar('u7', 'Gus'), + ], + }, + }, + ], + }, + { + title: 'Followers', + blurb: 'Someone followed you. Lead with the follower (purple badge).', + cases: [ + { + scenario: 'New follower', + why: 'Leads with the follower; includes the follow-back button.', + def: { + type: NotificationType.UserFollow, + icon: NotificationIconType.User, + title: 'Tobias Wolf started following you', + avatars: [userAvatar('tobias', 'Tobias Wolf')], + }, + }, + ], + }, + { + title: 'Squads', + blurb: + 'Squad activity. When a person acted, lead with the person; when it is about your role/squad status, lead with the squad.', + cases: [ + { + scenario: 'Someone posted in your Squad', + why: 'API sends [source, user]. Leads with the poster (GeekLuffy). The squad is shown by the title + purple Squads badge.', + def: { + type: NotificationType.SquadPostAdded, + icon: NotificationIconType.Bell, + title: 'GeekLuffy posted in AI', + avatars: [sourceAvatar('ai', 'AI'), userAvatar('luffy', 'GeekLuffy')], + attachments: [postAttachment('sq1', 'Fine-tuning on a budget')], + }, + }, + { + scenario: 'Someone joined your Squad', + why: 'API sends [source, user]. Leads with the new member (Donald).', + def: { + type: NotificationType.SquadMemberJoined, + icon: NotificationIconType.User, + title: 'Donald Major joined DevOps', + avatars: [ + sourceAvatar('devops', 'DevOps'), + userAvatar('donald', 'Donald Major'), + ], + }, + }, + { + scenario: 'A post was submitted for review', + why: 'API sends [source, user]. Leads with the submitter (Ankur).', + def: { + type: NotificationType.SourcePostSubmitted, + icon: NotificationIconType.Bell, + title: 'Ankur Gupta submitted a post in WebDev', + avatars: [ + sourceAvatar('webdev', 'WebDev'), + userAvatar('ankur', 'Ankur Gupta'), + ], + }, + }, + { + scenario: 'Your role in a Squad changed', + why: 'API sends [source] only — no person acts here, so it correctly leads with the squad.', + def: { + type: NotificationType.PromotedToModerator, + icon: NotificationIconType.Star, + title: 'You are now a moderator in AI', + avatars: [sourceAvatar('ai', 'AI')], + }, + }, + ], + }, + { + title: 'Updates & following', + blurb: + 'Content from sources/people you follow. Lead with the source when no person is involved, otherwise the person. No badge (these land in Updates).', + cases: [ + { + scenario: 'New post from a source you follow', + why: 'API sends [source]. No person acts → leads with the source. No badge.', + def: { + type: NotificationType.SourcePostAdded, + icon: NotificationIconType.Bell, + title: 'New post in Agentic Digest', + avatars: [sourceAvatar('agentic', 'Agentic Digest')], + attachments: [ + postAttachment('p1', 'MAI-Code-1-Flash beats Haiku on SWE-Bench'), + ], + }, + }, + { + scenario: 'Someone you follow published a post', + why: 'API sends [user]. Leads with the author.', + def: { + type: NotificationType.UserPostAdded, + icon: NotificationIconType.Bell, + title: 'Ido Shamun published a new post', + avatars: [userAvatar('ido', 'Ido Shamun')], + attachments: [postAttachment('p3', 'Lessons from scaling daily.dev')], + }, + }, + { + scenario: 'You received an award', + why: 'API sends [user] — the giver. Leads with the giver; lands in Updates so no badge.', + def: { + type: NotificationType.UserReceivedAward, + icon: NotificationIconType.Star, + title: 'keshavashiya awarded you +7 Cores!', + avatars: [userAvatar('keshav', 'keshavashiya')], + }, + }, + ], + }, + { + title: 'System, streaks & digests (no avatar)', + blurb: + 'No human actor — these lead with the type icon, never an avatar or badge.', + cases: [ + { + scenario: 'Top reader badge earned', + why: 'No avatars → the achievement icon leads.', + def: { + type: NotificationType.UserTopReaderBadge, + icon: NotificationIconType.TopReaderBadge, + title: 'You earned the Top Reader badge in JavaScript', + }, + }, + { + scenario: 'Streak about to expire', + why: 'No avatars → the streak icon leads.', + def: { + type: NotificationType.StreakReminder, + icon: NotificationIconType.Streak, + title: 'Your 7-day streak is about to expire', + description: 'Read a post today to keep it alive', + }, + }, + { + scenario: 'Daily digest / system update', + why: 'No avatars → the daily.dev icon leads.', + def: { + type: NotificationType.System, + icon: NotificationIconType.DailyDev, + title: 'We updated our terms of service', + }, + }, + ], + }, +]; + +const avatarTypeLabel: Partial> = { + [NotificationAvatarType.User]: 'user', + [NotificationAvatarType.Source]: 'source', + [NotificationAvatarType.Organization]: 'org', +}; + +const Chip = ({ + children, + tone = 'default', +}: { + children: React.ReactNode; + tone?: 'default' | 'lead'; +}) => ( + + {children} + +); + +const Case = ({ scenario, why, def }: UseCase): React.ReactElement => { + const props: NotificationItemProps = { + onClick: fn(), + targetUrl: '/post/123', + referenceId: `case-${scenario}`, + icon: NotificationIconType.Bell, + type: NotificationType.System, + title: 'Notification', + isUnread: true, + ...def, + }; + const avatars = props.avatars ?? []; + const lead = getNotificationLeadAvatar(avatars); + const category = getNotificationCategory(props.type); + const showsBadge = + avatars.length > 0 && category !== NotificationFilterCategory.Updates; + const badge = notificationCategoryBadge[category]; + + return ( +
+

{scenario}

+
+ +
+
+
+ API avatars: + {avatars.length === 0 && none → type icon} + {avatars.map((a, i) => ( + + {avatarTypeLabel[a.type] ?? a.type} · {a.name} + + ))} +
+
+ Leads with: + + {lead + ? `${avatarTypeLabel[lead.type] ?? lead.type} · ${lead.name}` + : 'type icon'} + + Badge: + {showsBadge ? ( + + + {notificationFilterCategoryLabel[category]} + + ) : ( + none + )} +
+

{why}

+
+
+ ); +}; + +const AllUseCases = (): React.ReactElement => ( +
+

+ Notification use cases +

+

+ Every scenario and the reasoning behind the avatar + badge it shows. +

+
+ Lead avatar rule. Always show the + human who acted — the commenter, upvoter, follower, or poster. The backend + often lists the squad/source first (squad activity arrives as{' '} + [source, user]), which is why rows used to show a source logo + instead of the person. We now pick the user; the source stays clear from + the title text and the colored category badge. When there is no person (a + source posting, a role change, a system/digest message) the row leads with + the source, or the type icon when there is no avatar at all. +
+ +
+ {sections.map((section) => ( +
+
+

+ {section.title} +

+

{section.blurb}

+
+ {section.cases.map((useCase) => ( + + ))} +
+ ))} +
+
+); + +export const AllCases: Story = { + name: 'All use cases', + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/_mock.tsx b/packages/storybook/stories/components/notifications/_mock.tsx new file mode 100644 index 0000000000..6815592a04 --- /dev/null +++ b/packages/storybook/stories/components/notifications/_mock.tsx @@ -0,0 +1,227 @@ +import { fn } from 'storybook/test'; +import type { NotificationItemProps } from '@dailydotdev/shared/src/components/notifications/NotificationItem'; +import { + NotificationIconType, + NotificationType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { + NotificationAttachmentType, + NotificationAvatarType, +} from '@dailydotdev/shared/src/graphql/notifications'; + +// Shared sample data for the Notifications stories so the "Full page" and +// "Overview" surfaces stay in sync with one realistic data set instead of each +// re-inventing notifications. Mirrors the real feed: a mix of types spread +// across the same time buckets the live page groups by (Today / This week / +// This month / Earlier). + +export const img = (seed: string, size = 96): string => + `https://picsum.photos/seed/${seed}/${size}`; + +const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000); +const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000); +const daysAgo = (d: number) => new Date(Date.now() - d * 86_400_000); + +export const userAvatar = (seed: string, name: string) => ({ + type: NotificationAvatarType.User, + referenceId: seed, + name, + image: img(`user-${seed}`, 64), + targetUrl: `/${seed}`, +}); + +export const sourceAvatar = (seed: string, name: string) => ({ + type: NotificationAvatarType.Source, + referenceId: seed, + name, + image: img(`source-${seed}`, 64), + targetUrl: `/sources/${seed}`, +}); + +export const postAttachment = (seed: string, title: string) => ({ + type: NotificationAttachmentType.Post, + title, + image: img(`post-${seed}`, 160), +}); + +// `createdAt` is deliberately chosen so the set lands across all four time +// groups, with a couple of unread (recent) rows at the top. +const defs: Array & { isUnread?: boolean }> = [ + // ---- Today ---- + { + type: NotificationType.ArticleNewComment, + icon: NotificationIconType.Comment, + title: 'Nimrod Kramer commented on your post', + description: 'Great write-up — the part about caching really helped.', + avatars: [userAvatar('nimrod', 'Nimrod')], + attachments: [postAttachment('c1', 'Scaling our cache layer')], + createdAt: minutesAgo(8), + isUnread: true, + }, + { + type: NotificationType.CommentMention, + icon: NotificationIconType.Comment, + title: 'Tsahi Matsliah mentioned you in a comment', + description: 'Hey @you, what do you think about this approach?', + avatars: [userAvatar('tsahi', 'Tsahi')], + attachments: [ + postAttachment('mention2', 'The state of frontend frameworks in 2026'), + ], + createdAt: minutesAgo(42), + isUnread: true, + }, + { + type: NotificationType.ArticleUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: '25 upvotes! No bugs, just vibes ✨', + description: 'Your post is on a roll today', + numTotalAvatars: 25, + avatars: [ + userAvatar('a1', 'One'), + userAvatar('a2', 'Two'), + userAvatar('a3', 'Three'), + ], + createdAt: hoursAgo(3), + isUnread: true, + }, + { + type: NotificationType.UserFollow, + icon: NotificationIconType.User, + title: 'Tobias Wolf started following you', + avatars: [userAvatar('tobias', 'Tobias')], + createdAt: hoursAgo(6), + }, + // ---- This week ---- + { + type: NotificationType.CommentReply, + icon: NotificationIconType.Comment, + title: 'Ido Shamun replied to your comment', + description: + 'This is a longer reply so we can see the snippet wrap to a maximum of three lines and then truncate with an ellipsis instead of being cut off after one line.', + avatars: [userAvatar('ido', 'Ido')], + createdAt: daysAgo(2), + }, + { + type: NotificationType.SquadPostAdded, + icon: NotificationIconType.Bell, + title: 'GeekLuffy posted in AI', + avatars: [sourceAvatar('ai', 'AI'), userAvatar('luffy', 'Luffy')], + attachments: [postAttachment('sq1', 'Fine-tuning on a budget')], + createdAt: daysAgo(3), + }, + { + type: NotificationType.SourcePostAdded, + icon: NotificationIconType.Bell, + title: 'New post in Agentic Digest', + avatars: [sourceAvatar('agentic', 'Agentic Digest')], + attachments: [ + postAttachment( + 'p1', + 'MAI-Code-1-Flash beats Claude Haiku 4.5 on SWE-Bench', + ), + ], + createdAt: daysAgo(5), + }, + // ---- This month ---- + { + type: NotificationType.UserReceivedAward, + icon: NotificationIconType.Star, + title: 'keshavashiya awarded you +7 Cores for being awesome!', + avatars: [userAvatar('keshav', 'keshavashiya')], + createdAt: daysAgo(12), + }, + { + type: NotificationType.UserTopReaderBadge, + icon: NotificationIconType.TopReaderBadge, + title: 'You earned the Top Reader badge in JavaScript', + createdAt: daysAgo(16), + }, + { + type: NotificationType.StreakReminder, + icon: NotificationIconType.Streak, + title: 'Your 7-day streak is about to expire', + description: 'Read a post today to keep it alive', + createdAt: daysAgo(20), + }, + { + type: NotificationType.BriefingReady, + icon: NotificationIconType.DailyDev, + title: 'Your presidential briefing is ready', + createdAt: daysAgo(24), + }, + // ---- Earlier ---- + { + type: NotificationType.NewOpportunityMatch, + icon: NotificationIconType.Opportunity, + title: 'New match: Senior Frontend Engineer at Acme', + description: 'Based on your profile and interests', + createdAt: daysAgo(45), + }, + { + type: NotificationType.Announcements, + icon: NotificationIconType.DailyDev, + title: 'A big new feature just landed on daily.dev', + createdAt: daysAgo(70), + }, + { + type: NotificationType.System, + icon: NotificationIconType.DailyDev, + title: 'We updated our terms of service', + createdAt: daysAgo(120), + }, +]; + +export const sampleNotifications: Array< + NotificationItemProps & { isUnread?: boolean } +> = defs.map((def, index) => ({ + onClick: fn(), + targetUrl: '/post/123', + referenceId: `mock-${index}`, + icon: NotificationIconType.Bell, + type: NotificationType.System, + title: 'Notification', + ...def, +})); + +// Same coarse buckets the live page uses (see NotificationsFeed.tsx). +export const TIME_GROUPS = [ + { key: 'today', label: 'Today', maxDays: 0 }, + { key: 'week', label: 'This week', maxDays: 7 }, + { key: 'month', label: 'This month', maxDays: 30 }, + { key: 'earlier', label: 'Earlier', maxDays: Number.POSITIVE_INFINITY }, +] as const; + +const calendarDaysAgo = (date: Date): number => { + const startOf = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + return Math.round((startOf(new Date()) - startOf(date)) / 86_400_000); +}; + +export const groupByTime = ( + items: Array, +): Array<{ + key: string; + label: string; + items: Array; +}> => { + const byKey = new Map< + string, + Array + >(); + items.forEach((item) => { + const days = item.createdAt ? calendarDaysAgo(item.createdAt) : Infinity; + const group = + TIME_GROUPS.find((bucket) => days <= bucket.maxDays) ?? + TIME_GROUPS[TIME_GROUPS.length - 1]; + const list = byKey.get(group.key) ?? []; + list.push(item); + byKey.set(group.key, list); + }); + return TIME_GROUPS.filter((bucket) => byKey.has(bucket.key)).map((bucket) => ({ + key: bucket.key, + label: bucket.label, + items: byKey.get(bucket.key) as Array< + NotificationItemProps & { isUnread?: boolean } + >, + })); +}; diff --git a/packages/webapp/components/notifications/NotificationFilterBar.tsx b/packages/webapp/components/notifications/NotificationFilterBar.tsx index 5f9af67fef..8c73ad9525 100644 --- a/packages/webapp/components/notifications/NotificationFilterBar.tsx +++ b/packages/webapp/components/notifications/NotificationFilterBar.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import { ButtonSize } from '@dailydotdev/shared/src/components/buttons/Button'; +import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; import { SquadDirectoryNavbar, SquadDirectoryNavbarItem, @@ -21,13 +22,17 @@ export function NotificationFilterBar({ active, onSelect, }: NotificationFilterBarProps): ReactElement { + // Match the Squads directory: smaller tabs on mobile, regular on laptop. + const isLaptop = useViewSize(ViewSize.Laptop); + const buttonSize = isLaptop ? ButtonSize.Small : ButtonSize.XSmall; + return ( ( { {!showPushBanner && } {!isV2Laptop && ( -
+

{ {/* On v2 the type filters live in the sidebar rail panel; on the legacy/mobile layout (no rail) keep them as in-page tabs. */} {!isV2Laptop && (hasNotifications || !!activeCategory) && ( -
+