From 7a147a0acba99c68456c6c58e92b20966754d21c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 17 Jun 2026 23:56:21 +0300 Subject: [PATCH 01/44] feat(notifications): type filters, date grouping, compact rows Redesign the notifications page for better scannability and less scrolling: - Add a filter bar that buckets the 51 notification types into 5 human categories (Mentions & replies, Reactions, Following, Squads, Updates). Chips only render for categories present in loaded data, and the bar hides when a single category is present. Filtering is client-side since the notifications API has no server-side type filter. - Group notifications under Today / Yesterday / date headers, reusing the existing getReadHistoryDateFormat helper. - Tighten NotificationItem density (py-4->py-3, avatar mb-4->mb-2, description mt-2->mt-1) so more fits on screen. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 6 +- .../src/components/notifications/utils.ts | 110 ++++++++++++++++ .../notifications/NotificationFilterBar.tsx | 64 ++++++++++ packages/webapp/pages/notifications.tsx | 118 +++++++++++++++--- 4 files changed, 275 insertions(+), 23 deletions(-) create mode 100644 packages/webapp/components/notifications/NotificationFilterBar.tsx diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index a0a7009c7c1..ff2f84854bc 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -214,7 +214,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return (
@@ -259,7 +259,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { />
{hasAvatar && ( - {avatarComponents} + {avatarComponents} )} {description && ( - +

= { + [NotificationFilterCategory.MentionsReplies]: [ + NotificationType.PostMention, + NotificationType.CommentMention, + NotificationType.CommentReply, + NotificationType.SquadReply, + NotificationType.ArticleNewComment, + NotificationType.SquadNewComment, + ], + [NotificationFilterCategory.Reactions]: [ + NotificationType.ArticleUpvoteMilestone, + NotificationType.CommentUpvoteMilestone, + NotificationType.UserReceivedAward, + NotificationType.UserTopReaderBadge, + NotificationType.DevCardUnlocked, + ], + [NotificationFilterCategory.Following]: [ + NotificationType.SourcePostAdded, + NotificationType.UserPostAdded, + NotificationType.CollectionUpdated, + NotificationType.PostBookmarkReminder, + NotificationType.PollResult, + NotificationType.PollResultAuthor, + NotificationType.UserFollow, + ], + [NotificationFilterCategory.Squads]: [ + NotificationType.SquadPostAdded, + NotificationType.SquadMemberJoined, + NotificationType.SquadBlocked, + NotificationType.PromotedToAdmin, + NotificationType.PromotedToModerator, + NotificationType.DemotedToMember, + NotificationType.SquadPublicApproved, + NotificationType.SquadFeatured, + NotificationType.SquadSubscribeNotification, + NotificationType.SourcePostSubmitted, + NotificationType.SourcePostApproved, + NotificationType.SourcePostRejected, + NotificationType.ArticlePicked, + NotificationType.SourceApproved, + NotificationType.SourceRejected, + NotificationType.ArticleReportApproved, + ], + [NotificationFilterCategory.Updates]: [ + NotificationType.System, + NotificationType.BriefingReady, + NotificationType.DigestReady, + NotificationType.StreakReminder, + NotificationType.StreakResetRestore, + NotificationType.ArticleAnalytics, + NotificationType.PostAnalytics, + NotificationType.Marketing, + NotificationType.Announcements, + NotificationType.NewUserWelcome, + NotificationType.InAppPurchases, + NotificationType.NewOpportunityMatch, + NotificationType.WarmIntro, + NotificationType.ExperienceCompanyEnriched, + NotificationType.LiveRoomStarted, + ], +}; + +// Order the chips appear in the filter bar. +export const notificationFilterCategoryList: NotificationFilterCategory[] = [ + NotificationFilterCategory.MentionsReplies, + NotificationFilterCategory.Reactions, + NotificationFilterCategory.Following, + NotificationFilterCategory.Squads, + NotificationFilterCategory.Updates, +]; + +export const notificationFilterCategoryLabel: Record< + NotificationFilterCategory, + string +> = { + [NotificationFilterCategory.MentionsReplies]: 'Mentions & replies', + [NotificationFilterCategory.Reactions]: 'Reactions', + [NotificationFilterCategory.Following]: 'Following', + [NotificationFilterCategory.Squads]: 'Squads', + [NotificationFilterCategory.Updates]: 'Updates', +}; + +const notificationTypeToCategory = Object.entries( + notificationCategoryToTypes, +).reduce((acc, [category, types]) => { + types.forEach((type) => { + acc[type] = category as NotificationFilterCategory; + }); + return acc; +}, {} as Partial>); + +export const getNotificationCategory = ( + type: NotificationType, +): NotificationFilterCategory => + notificationTypeToCategory[type] ?? NotificationFilterCategory.Updates; + export const NotificationContainer = classed('div', 'flex flex-col gap-6'); export const NotificationSection = classed( diff --git a/packages/webapp/components/notifications/NotificationFilterBar.tsx b/packages/webapp/components/notifications/NotificationFilterBar.tsx new file mode 100644 index 00000000000..d2ac000b855 --- /dev/null +++ b/packages/webapp/components/notifications/NotificationFilterBar.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import type { NotificationFilterCategory } from '@dailydotdev/shared/src/components/notifications/utils'; +import { notificationFilterCategoryLabel } from '@dailydotdev/shared/src/components/notifications/utils'; + +interface NotificationFilterBarProps { + categories: NotificationFilterCategory[]; + active: NotificationFilterCategory | null; + onSelect: (category: NotificationFilterCategory | null) => void; +} + +const Chip = ({ + label, + isActive, + onClick, +}: { + label: string; + isActive: boolean; + onClick: () => void; +}): ReactElement => ( + +); + +export function NotificationFilterBar({ + categories, + active, + onSelect, +}: NotificationFilterBarProps): ReactElement { + return ( +

+ onSelect(null)} + /> + {categories.map((category) => ( + onSelect(category)} + /> + ))} +
+ ); +} diff --git a/packages/webapp/pages/notifications.tsx b/packages/webapp/pages/notifications.tsx index 2f9e54ffbc2..37d1cc142c9 100644 --- a/packages/webapp/pages/notifications.tsx +++ b/packages/webapp/pages/notifications.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; import type { NextSeoProps } from 'next-seo'; import type { InfiniteData } from '@tanstack/react-query'; @@ -32,7 +32,13 @@ import { NotificationPromptSource, Origin, } from '@dailydotdev/shared/src/lib/log'; -import { NotificationType } from '@dailydotdev/shared/src/components/notifications/utils'; +import { + getNotificationCategory, + notificationFilterCategoryList, + NotificationType, + type NotificationFilterCategory, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { getReadHistoryDateFormat } from '@dailydotdev/shared/src/lib/dateFormat'; import { usePromotionModal } from '@dailydotdev/shared/src/hooks/notifications/usePromotionModal'; import { useTopReaderModal } from '@dailydotdev/shared/src/hooks/modals/useTopReaderModal'; import { usePushNotificationContext } from '@dailydotdev/shared/src/contexts/PushNotificationContext'; @@ -44,12 +50,40 @@ import { useCampaignByIdModal } from '@dailydotdev/shared/src/hooks/notification import { getLayout as getFooterNavBarLayout } from '../components/layouts/FooterNavBarLayout'; import { getLayout } from '../components/layouts/MainLayout'; import ProtectedPage from '../components/ProtectedPage'; +import { NotificationFilterBar } from '../components/notifications/NotificationFilterBar'; const hasUnread = (data: InfiniteData) => data.pages.some((page) => page.notifications.edges.some(({ node }) => !node.readAt), ); +interface NotificationDateGroup { + label: string; + items: Notification[]; +} + +// Group an already date-sorted (desc) list into "Today / Yesterday / ..." +// buckets, preserving order. +const groupByDate = (items: Notification[]): NotificationDateGroup[] => { + const groups: NotificationDateGroup[] = []; + const indexByLabel = new Map(); + + items.forEach((item) => { + const label = getReadHistoryDateFormat(new Date(item.createdAt)); + const existing = indexByLabel.get(label); + + if (existing === undefined) { + indexByLabel.set(label, groups.length); + groups.push({ label, items: [item] }); + return; + } + + groups[existing].items.push(item); + }); + + return groups; +}; + const seo: NextSeoProps = { title: 'Notifications', noindex: true, @@ -88,7 +122,47 @@ const Notifications = (): ReactElement => { const { isFetchedAfterMount, isFetched, hasNextPage } = queryResult ?? {}; - const length = queryResult?.data?.pages?.length ?? 0; + const [activeCategory, setActiveCategory] = + useState(null); + + // The notifications API has no server-side type filter, so we filter the + // already-loaded pages on the client. For the typical user every + // notification fits in the first page; heavy users see the chips refine as + // more pages load via infinite scroll. + const notifications = useMemo(() => { + const pages = queryResult?.data?.pages ?? []; + return pages.flatMap((page) => + page.notifications.edges + .map(({ node }) => node) + .filter( + (node) => + !( + isSubscribed && + node.type === NotificationType.SquadSubscribeNotification + ), + ), + ); + }, [queryResult?.data?.pages, isSubscribed]); + + const availableCategories = useMemo(() => { + const present = new Set( + notifications.map((node) => getNotificationCategory(node.type)), + ); + return notificationFilterCategoryList.filter((category) => + present.has(category), + ); + }, [notifications]); + + const groups = useMemo(() => { + const filtered = activeCategory + ? notifications.filter( + (node) => getNotificationCategory(node.type) === activeCategory, + ) + : notifications; + return groupByDate(filtered); + }, [notifications, activeCategory]); + + const hasNotifications = notifications.length > 0; const onNotificationClick = ({ id, type }: Notification) => { logEvent({ @@ -131,24 +205,27 @@ const Notifications = (): ReactElement => { Notifications )} + {hasNotifications && availableCategories.length > 1 && ( + + )} - {length > 0 && - queryResult.data.pages.map((page) => - page.notifications.edges.reduce((nodes, { node }) => { + {groups.map((group) => ( +
+

+ {group.label} +

+ {group.items.map((node) => { const { id, createdAt, readAt, type, ...props } = node; - if ( - isSubscribed && - type === NotificationType.SquadSubscribeNotification - ) { - return nodes; - } - - nodes.push( + return ( { isUnread={!readAt} onClick={() => onNotificationClick(node)} createdAt={createdAt} - />, + /> ); - - return nodes; - }, []), - )} - {(!length || !hasNextPage) && isFetched && } + })} +
+ ))} + {(!hasNotifications || !hasNextPage) && isFetched && ( + + )}
From ea7f9bfc4f508851a51ec42355bcdcdacdb79a07 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 00:13:02 +0300 Subject: [PATCH 02/44] refactor(notifications): social-style compact rows, drop date headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address feedback that the row layout wasn't actually compact and that the date headers were redundant with the per-row timestamp. - Rework NotificationItem into the social-feed row pattern (Instagram / TikTok / X / Facebook): a single avatar on the left with the type icon as an overlaid corner badge, a tight text block (title + context + inline relative timestamp) in the middle, and the post thumbnail on the right. Replaces the old icon-column + avatars-above-title + full-width attachment card, cutting row height substantially. - Remove the Today/Yesterday date section headers — the inline per-row timestamp already conveys recency. - Delete the now-unused NotificationItemAttachment component. Uses our existing palette/design tokens throughout. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 120 ++++++++++++------ .../NotificationItemAttachment.tsx | 55 -------- packages/webapp/pages/notifications.tsx | 80 ++++-------- 3 files changed, 102 insertions(+), 153 deletions(-) delete mode 100644 packages/shared/src/components/notifications/NotificationItemAttachment.tsx diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index ff2f84854bc..9b1d6bd1e2b 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -6,7 +6,6 @@ import Link from '../utilities/Link'; import type { Notification } from '../../graphql/notifications'; import { useObjectPurify } from '../../hooks/useDomPurify'; import NotificationItemIcon from './NotificationIcon'; -import NotificationItemAttachment from './NotificationItemAttachment'; import NotificationItemAvatar from './NotificationItemAvatar'; import { notificationMutingCopy, @@ -26,7 +25,12 @@ import { import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { BellDisabledIcon, BellIcon, MenuIcon } from '../icons'; import { useNotificationPreference } from '../../hooks/notifications'; -import { NotificationPreferenceStatus } from '../../graphql/notifications'; +import { + NotificationAttachmentType, + NotificationPreferenceStatus, +} from '../../graphql/notifications'; +import { CardCover } from '../cards/common/CardCover'; +import { IconSize } from '../Icon'; import { Loader } from '../Loader'; import { NotificationFollowUserButton } from './NotificationFollowUserButton'; @@ -181,12 +185,14 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return null; } - const avatarComponents = [ + const isAvatarGroup = [ NotificationType.CollectionUpdated, NotificationType.ArticleUpvoteMilestone, NotificationType.CommentUpvoteMilestone, NotificationType.WarmIntro, - ].includes(type) ? ( + ].includes(type); + + const avatarContent = isAvatarGroup ? ( {filteredAvatars.map((avatar) => ( ) : ( - filteredAvatars - .map((avatar) => ( + + {filteredAvatars.map((avatar) => ( - )) - .filter((avatar) => avatar) ?? [] + ))} + ); + const hasAvatar = filteredAvatars.length > 0; const renderLink = onClick && isClickable; + const hasOptions = Object.keys(notificationMutingCopy).includes(type); + const [attachment] = attachments ?? []; + + // The type icon: it is the lead element when there is no person involved + // (system/digest/streak) and an overlaid corner badge on the avatar + // otherwise — the signature "avatar + small action badge" pattern used by + // Instagram, TikTok and Facebook. + const leadIcon = ( + + ); return (
@@ -240,56 +257,77 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )} -
- {Object.keys(notificationMutingCopy).includes(type) && ( - - )} - {createdAt && ( - - )} -
- -
- {hasAvatar && ( - {avatarComponents} - )} + {hasAvatar ? ( + + {avatarContent} + + {leadIcon} + + + ) : ( + {leadIcon} + )} + +
{description && ( - +

)} - {type === NotificationType.UserFollow && ( - + {attachment?.title && ( + + {attachment.title} + )} - {attachments?.map(({ title: attachment, ...restAttachmentProps }) => ( - - ))} + )} + {type === NotificationType.UserFollow && ( + + + + )}

+ + {(attachment?.image || hasOptions) && ( +
+ {attachment?.image && ( + + )} + {hasOptions && ( + + + + )} +
+ )}
); } diff --git a/packages/shared/src/components/notifications/NotificationItemAttachment.tsx b/packages/shared/src/components/notifications/NotificationItemAttachment.tsx deleted file mode 100644 index f2a747cf8a2..00000000000 --- a/packages/shared/src/components/notifications/NotificationItemAttachment.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import type { NotificationAttachment } from '../../graphql/notifications'; -import { NotificationAttachmentType } from '../../graphql/notifications'; -import { IconSize } from '../Icon'; -import { CardCover } from '../cards/common/CardCover'; -import { NotificationType } from './utils'; - -interface NotificationItemAttachmentProps extends NotificationAttachment { - notificationType?: NotificationType; -} - -const truncatedNotificationTypes = new Set([ - NotificationType.SourcePostAdded, - NotificationType.UserPostAdded, - NotificationType.SquadPostAdded, -]); - -function NotificationItemAttachment({ - image, - title, - type, - notificationType, -}: NotificationItemAttachmentProps): ReactElement { - return ( -
-
- -
- - {title} - -
- ); -} - -export default NotificationItemAttachment; diff --git a/packages/webapp/pages/notifications.tsx b/packages/webapp/pages/notifications.tsx index 37d1cc142c9..d275d665e70 100644 --- a/packages/webapp/pages/notifications.tsx +++ b/packages/webapp/pages/notifications.tsx @@ -38,7 +38,6 @@ import { NotificationType, type NotificationFilterCategory, } from '@dailydotdev/shared/src/components/notifications/utils'; -import { getReadHistoryDateFormat } from '@dailydotdev/shared/src/lib/dateFormat'; import { usePromotionModal } from '@dailydotdev/shared/src/hooks/notifications/usePromotionModal'; import { useTopReaderModal } from '@dailydotdev/shared/src/hooks/modals/useTopReaderModal'; import { usePushNotificationContext } from '@dailydotdev/shared/src/contexts/PushNotificationContext'; @@ -57,33 +56,6 @@ const hasUnread = (data: InfiniteData) => page.notifications.edges.some(({ node }) => !node.readAt), ); -interface NotificationDateGroup { - label: string; - items: Notification[]; -} - -// Group an already date-sorted (desc) list into "Today / Yesterday / ..." -// buckets, preserving order. -const groupByDate = (items: Notification[]): NotificationDateGroup[] => { - const groups: NotificationDateGroup[] = []; - const indexByLabel = new Map(); - - items.forEach((item) => { - const label = getReadHistoryDateFormat(new Date(item.createdAt)); - const existing = indexByLabel.get(label); - - if (existing === undefined) { - indexByLabel.set(label, groups.length); - groups.push({ label, items: [item] }); - return; - } - - groups[existing].items.push(item); - }); - - return groups; -}; - const seo: NextSeoProps = { title: 'Notifications', noindex: true, @@ -153,14 +125,15 @@ const Notifications = (): ReactElement => { ); }, [notifications]); - const groups = useMemo(() => { - const filtered = activeCategory - ? notifications.filter( - (node) => getNotificationCategory(node.type) === activeCategory, - ) - : notifications; - return groupByDate(filtered); - }, [notifications, activeCategory]); + const filtered = useMemo( + () => + activeCategory + ? notifications.filter( + (node) => getNotificationCategory(node.type) === activeCategory, + ) + : notifications, + [notifications, activeCategory], + ); const hasNotifications = notifications.length > 0; @@ -217,27 +190,20 @@ const Notifications = (): ReactElement => { canFetchMore={checkFetchMore(queryResult)} fetchNextPage={queryResult.fetchNextPage} > - {groups.map((group) => ( -
-

- {group.label} -

- {group.items.map((node) => { - const { id, createdAt, readAt, type, ...props } = node; - - return ( - onNotificationClick(node)} - createdAt={createdAt} - /> - ); - })} -
- ))} + {filtered.map((node) => { + const { id, createdAt, readAt, type, ...props } = node; + + return ( + onNotificationClick(node)} + createdAt={createdAt} + /> + ); + })} {(!hasNotifications || !hasNextPage) && isFetched && ( )} From 93976483bd16491b69955ff3fc5c649f35006cd7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 00:18:16 +0300 Subject: [PATCH 03/44] fix(notifications): hard-clamp row text to keep rows compact Long comment/description paragraphs could still blow up row height. Cap every text line so only the relevant detail stays on screen, on mobile and desktop alike: - Title and description clamp to 2 lines, referenced post title to 1. - Use the shared `multi-truncate` helper + zero inner `

` margins so the clamp actually holds for sanitized HTML content (nested paragraphs no longer inflate height or break the ellipsis). Co-Authored-By: Claude Opus 4.8 --- .../components/notifications/NotificationItem.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 9b1d6bd1e2b..4163036ea65 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -271,24 +271,24 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null {

{description && ( - +
-

- +

)} {attachment?.title && ( - + {attachment.title} )} From 4cc54563aa8a25dc256865eb44847037bcc9f548 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 00:48:29 +0300 Subject: [PATCH 04/44] chore: re-trigger CI/preview build Co-Authored-By: Claude Opus 4.8 From 50181c50b7a54f7301c3a7098910d3fc477a9055 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 01:14:00 +0300 Subject: [PATCH 05/44] fix(notifications): align rows, date back on the right, new filter set Address design feedback: - Align every row: the lead avatar/icon now sits in a fixed-width column so titles start at the same x regardless of avatar vs icon-only rows. - Move the timestamp back to the top-right (as before) and drop the inline date line, shrinking each row's height. Post thumbnail sits under it. - Replace the broken-looking `border-y` in the page background color with a single subtle `border-b` divider. - Rework filters to the requested set: All activity / Upvotes / Mentions / Comments / Followers / Squads / Updates. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 76 ++++++++++--------- .../src/components/notifications/utils.ts | 69 +++++++++-------- .../notifications/NotificationFilterBar.tsx | 2 +- 3 files changed, 79 insertions(+), 68 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 4163036ea65..419f4bcb539 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -231,7 +231,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return (
@@ -258,18 +258,26 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )} - {hasAvatar ? ( - - {avatarContent} - - {leadIcon} + {/* Fixed-width lead column so every row's text starts at the same x */} +
+ {hasAvatar ? ( + + {avatarContent} + + {leadIcon} + - - ) : ( - {leadIcon} - )} + ) : ( + leadIcon + )} +
-
+
)} - {createdAt && ( - - )} {type === NotificationType.UserFollow && ( @@ -306,28 +307,35 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )}
- {(attachment?.image || hasOptions) && ( -
- {attachment?.image && ( - - )} +
+
{hasOptions && ( )} + {createdAt && ( + + )}
- )} + {attachment?.image && ( + + )} +
); } diff --git a/packages/shared/src/components/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index 3a89d1ed705..d69726221d2 100644 --- a/packages/shared/src/components/notifications/utils.ts +++ b/packages/shared/src/components/notifications/utils.ts @@ -347,9 +347,10 @@ export const OPPORTUNITY_KEYS = [NotificationType.NewOpportunityMatch]; // NotificationType maps to exactly one category; anything not listed below // falls back to `Updates` so new backend types never disappear from the feed. export enum NotificationFilterCategory { - MentionsReplies = 'mentions_replies', - Reactions = 'reactions', - Following = 'following', + Upvotes = 'upvotes', + Mentions = 'mentions', + Comments = 'comments', + Followers = 'followers', Squads = 'squads', Updates = 'updates', } @@ -358,30 +359,21 @@ export const notificationCategoryToTypes: Record< NotificationFilterCategory, NotificationType[] > = { - [NotificationFilterCategory.MentionsReplies]: [ + [NotificationFilterCategory.Upvotes]: [ + NotificationType.ArticleUpvoteMilestone, + NotificationType.CommentUpvoteMilestone, + ], + [NotificationFilterCategory.Mentions]: [ NotificationType.PostMention, NotificationType.CommentMention, - NotificationType.CommentReply, - NotificationType.SquadReply, + ], + [NotificationFilterCategory.Comments]: [ NotificationType.ArticleNewComment, NotificationType.SquadNewComment, + NotificationType.CommentReply, + NotificationType.SquadReply, ], - [NotificationFilterCategory.Reactions]: [ - NotificationType.ArticleUpvoteMilestone, - NotificationType.CommentUpvoteMilestone, - NotificationType.UserReceivedAward, - NotificationType.UserTopReaderBadge, - NotificationType.DevCardUnlocked, - ], - [NotificationFilterCategory.Following]: [ - NotificationType.SourcePostAdded, - NotificationType.UserPostAdded, - NotificationType.CollectionUpdated, - NotificationType.PostBookmarkReminder, - NotificationType.PollResult, - NotificationType.PollResultAuthor, - NotificationType.UserFollow, - ], + [NotificationFilterCategory.Followers]: [NotificationType.UserFollow], [NotificationFilterCategory.Squads]: [ NotificationType.SquadPostAdded, NotificationType.SquadMemberJoined, @@ -396,18 +388,27 @@ export const notificationCategoryToTypes: Record< NotificationType.SourcePostApproved, NotificationType.SourcePostRejected, NotificationType.ArticlePicked, - NotificationType.SourceApproved, - NotificationType.SourceRejected, - NotificationType.ArticleReportApproved, ], [NotificationFilterCategory.Updates]: [ NotificationType.System, + NotificationType.SourcePostAdded, + NotificationType.UserPostAdded, + NotificationType.CollectionUpdated, + NotificationType.PostBookmarkReminder, + NotificationType.PollResult, + NotificationType.PollResultAuthor, + NotificationType.UserReceivedAward, + NotificationType.UserTopReaderBadge, + NotificationType.DevCardUnlocked, + NotificationType.ArticleReportApproved, + NotificationType.ArticleAnalytics, + NotificationType.PostAnalytics, + NotificationType.SourceApproved, + NotificationType.SourceRejected, NotificationType.BriefingReady, NotificationType.DigestReady, NotificationType.StreakReminder, NotificationType.StreakResetRestore, - NotificationType.ArticleAnalytics, - NotificationType.PostAnalytics, NotificationType.Marketing, NotificationType.Announcements, NotificationType.NewUserWelcome, @@ -421,9 +422,10 @@ export const notificationCategoryToTypes: Record< // Order the chips appear in the filter bar. export const notificationFilterCategoryList: NotificationFilterCategory[] = [ - NotificationFilterCategory.MentionsReplies, - NotificationFilterCategory.Reactions, - NotificationFilterCategory.Following, + NotificationFilterCategory.Upvotes, + NotificationFilterCategory.Mentions, + NotificationFilterCategory.Comments, + NotificationFilterCategory.Followers, NotificationFilterCategory.Squads, NotificationFilterCategory.Updates, ]; @@ -432,9 +434,10 @@ export const notificationFilterCategoryLabel: Record< NotificationFilterCategory, string > = { - [NotificationFilterCategory.MentionsReplies]: 'Mentions & replies', - [NotificationFilterCategory.Reactions]: 'Reactions', - [NotificationFilterCategory.Following]: 'Following', + [NotificationFilterCategory.Upvotes]: 'Upvotes', + [NotificationFilterCategory.Mentions]: 'Mentions', + [NotificationFilterCategory.Comments]: 'Comments', + [NotificationFilterCategory.Followers]: 'Followers', [NotificationFilterCategory.Squads]: 'Squads', [NotificationFilterCategory.Updates]: 'Updates', }; diff --git a/packages/webapp/components/notifications/NotificationFilterBar.tsx b/packages/webapp/components/notifications/NotificationFilterBar.tsx index d2ac000b855..7d14b8e1d18 100644 --- a/packages/webapp/components/notifications/NotificationFilterBar.tsx +++ b/packages/webapp/components/notifications/NotificationFilterBar.tsx @@ -47,7 +47,7 @@ export function NotificationFilterBar({ aria-label="Filter notifications by type" > onSelect(null)} /> From 1b6ac1dc25c355f0cf288f9617bb6ccd369f8119 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 01:29:13 +0300 Subject: [PATCH 06/44] refactor(notifications): flat settings-style rows, sticky filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address design feedback (settings-page vibe: flat, minimal borders): - Remove the broken overlaid type-icon badge. The lead is now a single clean element — the avatar when present, otherwise the type icon — so rows no longer show clipped/overlapping bell badges. - Drop the per-row border for a flat list; separation comes from spacing, hover and the unread tint (the outer page container keeps the only border). - Make the filter bar sticky so it stays visible while scrolling (it was scrolling out of view, which is why the filters seemed missing). Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.spec.tsx | 6 ++++-- .../notifications/NotificationItem.tsx | 20 +++++-------------- .../notifications/NotificationFilterBar.tsx | 2 +- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.spec.tsx b/packages/shared/src/components/notifications/NotificationItem.spec.tsx index 6804f1457cd..3c8186f6234 100644 --- a/packages/shared/src/components/notifications/NotificationItem.spec.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.spec.tsx @@ -126,7 +126,9 @@ describe('notification avatars', () => { describe('notification item', () => { it('should display the icon of the notification', async () => { - renderComponent(); + renderComponent( + , + ); const testid = `notification-${sampleNotification.icon}`; const img = await screen.findByTestId(testid); expect(img).toBeInTheDocument(); @@ -134,7 +136,7 @@ describe('notification item', () => { it('should display the default icon if the passed icon is unknown', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const notification = { ...sampleNotification } as any; // using any to validate unknown icon + const notification = { ...sampleNotification, avatars: undefined } as any; // using any to validate unknown icon notification.icon = 'new notif'; renderComponent(); const testid = `notification-${notification.icon}`; diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 419f4bcb539..89d10a016c2 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -220,10 +220,9 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { const hasOptions = Object.keys(notificationMutingCopy).includes(type); const [attachment] = attachments ?? []; - // The type icon: it is the lead element when there is no person involved - // (system/digest/streak) and an overlaid corner badge on the avatar - // otherwise — the signature "avatar + small action badge" pattern used by - // Instagram, TikTok and Facebook. + // When there is a person/source involved we show their avatar; otherwise + // (system/digest/streak) the type icon stands in as the lead. Kept flat and + // single — no overlaid badge — to match the settings-page aesthetic. const leadIcon = ( ); @@ -231,7 +230,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return (
@@ -265,16 +264,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { !isAvatarGroup && 'w-10', )} > - {hasAvatar ? ( - - {avatarContent} - - {leadIcon} - - - ) : ( - leadIcon - )} + {hasAvatar ? avatarContent : leadIcon}
diff --git a/packages/webapp/components/notifications/NotificationFilterBar.tsx b/packages/webapp/components/notifications/NotificationFilterBar.tsx index 7d14b8e1d18..80e5396d408 100644 --- a/packages/webapp/components/notifications/NotificationFilterBar.tsx +++ b/packages/webapp/components/notifications/NotificationFilterBar.tsx @@ -42,7 +42,7 @@ export function NotificationFilterBar({ }: NotificationFilterBarProps): ReactElement { return (
From 3aa4a181903e2b074147422b07d87928dbeb6566 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 01:47:17 +0300 Subject: [PATCH 07/44] feat(notifications): always-visible filters, flat V2, article card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Always render the sticky filter bar when there are notifications (it was hidden whenever the inbox mapped to a single category — the reason the filters appeared to be missing). - Flatten the V2 layout: drop the left/right page borders on V2 for an edge-to-edge look. - Replace the cluttered 3-level text (title + subtitle + article title + floating thumbnail) with a cleaner hierarchy: event title, optional comment, and the referenced article as a single compact card (thumbnail + title) — the one place a border is used. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 71 ++++++++++--------- packages/webapp/pages/notifications.tsx | 8 ++- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 89d10a016c2..8432351719d 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -267,15 +267,15 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {hasAvatar ? avatarContent : leadIcon}
-
+
{description && ( -
+
)} - {attachment?.title && ( - - {attachment.title} - + {/* The article/post the notification refers to — one compact card + (thumbnail + title) instead of a separate text line, so the + "mentioned article" reads as a single unit. */} + {attachment && ( +
+ {attachment.image && ( + + )} + + {attachment.title} + +
)} {type === NotificationType.UserFollow && ( @@ -297,32 +317,17 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )}
-
-
- {hasOptions && ( - - - - )} - {createdAt && ( - - )} -
- {attachment?.image && ( - + {hasOptions && ( + + + + )} + {createdAt && ( + )}
diff --git a/packages/webapp/pages/notifications.tsx b/packages/webapp/pages/notifications.tsx index d275d665e70..eeb01d14dd6 100644 --- a/packages/webapp/pages/notifications.tsx +++ b/packages/webapp/pages/notifications.tsx @@ -166,7 +166,11 @@ const Notifications = (): ReactElement => { {isV2Laptop && }
{!showPushBanner && } @@ -178,7 +182,7 @@ const Notifications = (): ReactElement => { Notifications )} - {hasNotifications && availableCategories.length > 1 && ( + {hasNotifications && availableCategories.length > 0 && ( Date: Thu, 18 Jun 2026 21:12:39 +0300 Subject: [PATCH 08/44] fix(notifications): always show the full filter set The filter bar only rendered chips for categories that currently had notifications loaded, so users mostly saw just "All activity" and "Updates". Always render the full set (All activity / Upvotes / Mentions / Comments / Followers / Squads / Updates) and show a friendly empty state when a selected tab has no notifications yet. Co-Authored-By: Claude Opus 4.8 --- packages/webapp/pages/notifications.tsx | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/webapp/pages/notifications.tsx b/packages/webapp/pages/notifications.tsx index eeb01d14dd6..b026fb448b7 100644 --- a/packages/webapp/pages/notifications.tsx +++ b/packages/webapp/pages/notifications.tsx @@ -34,6 +34,7 @@ import { } from '@dailydotdev/shared/src/lib/log'; import { getNotificationCategory, + notificationFilterCategoryLabel, notificationFilterCategoryList, NotificationType, type NotificationFilterCategory, @@ -116,15 +117,6 @@ const Notifications = (): ReactElement => { ); }, [queryResult?.data?.pages, isSubscribed]); - const availableCategories = useMemo(() => { - const present = new Set( - notifications.map((node) => getNotificationCategory(node.type)), - ); - return notificationFilterCategoryList.filter((category) => - present.has(category), - ); - }, [notifications]); - const filtered = useMemo( () => activeCategory @@ -182,9 +174,9 @@ const Notifications = (): ReactElement => { Notifications )} - {hasNotifications && availableCategories.length > 0 && ( + {hasNotifications && ( @@ -208,9 +200,15 @@ const Notifications = (): ReactElement => { /> ); })} - {(!hasNotifications || !hasNextPage) && isFetched && ( - + {isFetched && filtered.length === 0 && activeCategory && ( +

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

)} + {isFetched && + !activeCategory && + (!hasNotifications || !hasNextPage) && }
From 0c123274ba72b500ba87700864b287b71417bd60 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 21:33:23 +0300 Subject: [PATCH 09/44] feat(notifications): move filter tabs into the page header Per feedback, the filters shouldn't live in a second bar. Render them as page-header tabs using the same SquadDirectoryNavbar/NavbarItem treatment (text + active underline) as the Squads directory and Explore headers: - V2: the tabs fill the page-header strip (title shown only when there are no notifications to filter). - Legacy: tabs sit directly under the page title. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationFilterBar.tsx | 50 +++++++------------ packages/webapp/pages/notifications.tsx | 37 +++++++++++--- 2 files changed, 46 insertions(+), 41 deletions(-) diff --git a/packages/webapp/components/notifications/NotificationFilterBar.tsx b/packages/webapp/components/notifications/NotificationFilterBar.tsx index 80e5396d408..5f9af67fefe 100644 --- a/packages/webapp/components/notifications/NotificationFilterBar.tsx +++ b/packages/webapp/components/notifications/NotificationFilterBar.tsx @@ -1,10 +1,10 @@ import type { ReactElement } from 'react'; import React from 'react'; +import { ButtonSize } from '@dailydotdev/shared/src/components/buttons/Button'; import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; + SquadDirectoryNavbar, + SquadDirectoryNavbarItem, +} from '@dailydotdev/shared/src/components/squads/layout/SquadDirectoryNavbar'; import type { NotificationFilterCategory } from '@dailydotdev/shared/src/components/notifications/utils'; import { notificationFilterCategoryLabel } from '@dailydotdev/shared/src/components/notifications/utils'; @@ -14,51 +14,35 @@ interface NotificationFilterBarProps { onSelect: (category: NotificationFilterCategory | null) => void; } -const Chip = ({ - label, - isActive, - onClick, -}: { - label: string; - isActive: boolean; - onClick: () => void; -}): ReactElement => ( - -); - +// Renders the notification type filters as page-header tabs, reusing the same +// navbar/underline treatment as the Squads directory and Explore headers. export function NotificationFilterBar({ categories, active, onSelect, }: NotificationFilterBarProps): ReactElement { return ( -
- onSelect(null)} /> {categories.map((category) => ( - onSelect(category)} /> ))} -
+ ); } diff --git a/packages/webapp/pages/notifications.tsx b/packages/webapp/pages/notifications.tsx index b026fb448b7..2809f2f0f1a 100644 --- a/packages/webapp/pages/notifications.tsx +++ b/packages/webapp/pages/notifications.tsx @@ -17,7 +17,7 @@ import { pageContainerClassNames, } from '@dailydotdev/shared/src/components/utilities'; import NotificationItem from '@dailydotdev/shared/src/components/notifications/NotificationItem'; -import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; +import { pageHeaderClassName } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import FirstNotification from '@dailydotdev/shared/src/components/notifications/FirstNotification'; import EnableNotification from '@dailydotdev/shared/src/components/notifications/EnableNotification'; @@ -154,9 +154,32 @@ const Notifications = (): ReactElement => { const { isV2 } = useLayoutVariant(); const isV2Laptop = isV2; + const filterTabs = ( + + ); + return ( - {isV2Laptop && } + {isV2Laptop && ( +
+ {hasNotifications ? ( + filterTabs + ) : ( + + Notifications + + )} +
+ )}
{ Notifications )} - {hasNotifications && ( - + {!isV2Laptop && hasNotifications && ( +
+ {filterTabs} +
)} Date: Thu, 18 Jun 2026 21:53:04 +0300 Subject: [PATCH 10/44] feat(notifications): move type filters into the sidebar rail panel Per feedback, the filters belong in the V2 sidebar panel (like the Settings sub-nav), not in the page header: - NotificationsRailPanel now lists every filter category (All activity, Upvotes, Mentions, Comments, Followers, Squads, Updates) as sidebar rows with icons, plus the existing Settings shortcut. - Filtering is now driven by the `?type=` query param so the sidebar (a separate tree) controls the page; the active row highlights accordingly. - V2 page header returns to a plain "Notifications" title. - Legacy/mobile layout (no rail) keeps the in-page filter tabs, also query-param driven. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationsRailPanel.tsx | 109 +++++++++++++----- packages/webapp/pages/notifications.tsx | 67 ++++++----- 2 files changed, 115 insertions(+), 61 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationsRailPanel.tsx b/packages/shared/src/components/notifications/NotificationsRailPanel.tsx index b2e2c556699..2b71f6b91bd 100644 --- a/packages/shared/src/components/notifications/NotificationsRailPanel.tsx +++ b/packages/shared/src/components/notifications/NotificationsRailPanel.tsx @@ -1,23 +1,53 @@ -import type { ReactElement } from 'react'; +import type { ComponentType, ReactElement } from 'react'; import React, { useMemo } from 'react'; import { useRouter } from 'next/router'; import { useNotificationContext } from '../../contexts/NotificationsContext'; import { Typography, TypographyType } from '../typography/Typography'; import { webappUrl } from '../../lib/constants'; -import { BellIcon, SettingsIcon } from '../icons'; +import { + AddUserIcon, + AtIcon, + BellIcon, + DiscussIcon, + MegaphoneIcon, + SettingsIcon, + SquadIcon, + UpvoteIcon, +} from '../icons'; +import type { IconProps } from '../Icon'; import type { SidebarMenuItem } from '../sidebar/common'; import { ListIcon, isSidebarItemActive } from '../sidebar/common'; import { Section } from '../sidebar/Section'; +import { + NotificationFilterCategory, + notificationFilterCategoryLabel, + notificationFilterCategoryList, +} from './utils'; -// Compact menu in the rail hover card. Lists the destinations the -// notifications rail icon can reach so the hover preview matches the -// rail's click model. Rendered as a Section so its rows align with every +const categoryIcon: Record< + NotificationFilterCategory, + ComponentType +> = { + [NotificationFilterCategory.Upvotes]: UpvoteIcon, + [NotificationFilterCategory.Mentions]: AtIcon, + [NotificationFilterCategory.Comments]: DiscussIcon, + [NotificationFilterCategory.Followers]: AddUserIcon, + [NotificationFilterCategory.Squads]: SquadIcon, + [NotificationFilterCategory.Updates]: MegaphoneIcon, +}; + +// Compact menu in the rail / v2 context panel. Lists the notification type +// filters (driven by the `?type=` query param on the notifications page) plus +// the settings shortcut, rendered as a Section so its rows align with every // other v2 rail panel (same row layout, spacing, and active highlight). export const NotificationsRailPanel = (): ReactElement => { const router = useRouter(); const activePage = router.asPath ?? router.pathname ?? ''; const { unreadCount } = useNotificationContext(); const hasUnread = !!unreadCount; + const isListPage = router.pathname === '/notifications'; + const activeType = + typeof router.query?.type === 'string' ? router.query.type : undefined; const menuItems: SidebarMenuItem[] = useMemo(() => { const allActivityPath = `${webappUrl}notifications`; @@ -25,36 +55,51 @@ export const NotificationsRailPanel = (): ReactElement => { // (the canonical /settings/notifications page keeps the Settings panel). const settingsPath = `${webappUrl}notifications/settings`; - return [ - { - title: 'All activity', - path: allActivityPath, - active: isSidebarItemActive(activePage, allActivityPath), - icon: (active: boolean) => ( - } /> + const allActivity: SidebarMenuItem = { + title: 'All activity', + path: allActivityPath, + active: isListPage && !activeType, + icon: (active: boolean) => ( + } /> + ), + ...(hasUnread && { + rightIcon: () => ( + + {unreadCount} + ), - ...(hasUnread && { - rightIcon: () => ( - - {unreadCount} - + }), + }; + + const categoryItems: SidebarMenuItem[] = notificationFilterCategoryList.map( + (category) => { + const Icon = categoryIcon[category]; + return { + title: notificationFilterCategoryLabel[category], + path: `${allActivityPath}?type=${category}`, + active: isListPage && activeType === category, + icon: (active: boolean) => ( + } /> ), - }), + }; }, - { - title: 'Settings', - path: settingsPath, - active: isSidebarItemActive(activePage, settingsPath), - icon: (active: boolean) => ( - } /> - ), - }, - ]; - }, [activePage, hasUnread, unreadCount]); + ); + + const settings: SidebarMenuItem = { + title: 'Settings', + path: settingsPath, + active: isSidebarItemActive(activePage, settingsPath), + icon: (active: boolean) => ( + } /> + ), + }; + + return [allActivity, ...categoryItems, settings]; + }, [activePage, activeType, hasUnread, isListPage, unreadCount]); return (
{ const { isFetchedAfterMount, isFetched, hasNextPage } = queryResult ?? {}; - const [activeCategory, setActiveCategory] = - useState(null); + const router = useRouter(); + // Filtering is driven by the `?type=` query param so the sidebar rail panel + // (a separate component tree) can control which category is shown. + const activeCategory = useMemo(() => { + const type = router.query?.type; + const value = typeof type === 'string' ? type : undefined; + return value && + notificationFilterCategoryList.includes( + value as NotificationFilterCategory, + ) + ? (value as NotificationFilterCategory) + : null; + }, [router.query?.type]); + + const onSelectCategory = useCallback( + (category: NotificationFilterCategory | null) => { + router.replace( + { + pathname: '/notifications', + query: category ? { type: category } : {}, + }, + undefined, + { shallow: true }, + ); + }, + [router], + ); // The notifications API has no server-side type filter, so we filter the // already-loaded pages on the client. For the typical user every @@ -154,32 +180,9 @@ const Notifications = (): ReactElement => { const { isV2 } = useLayoutVariant(); const isV2Laptop = isV2; - const filterTabs = ( - - ); - return ( - {isV2Laptop && ( -
- {hasNotifications ? ( - filterTabs - ) : ( - - Notifications - - )} -
- )} + {isV2Laptop && }
{ Notifications )} + {/* 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 && (
- {filterTabs} +
)} Date: Thu, 18 Jun 2026 21:57:34 +0300 Subject: [PATCH 11/44] refactor(notifications): cleaner two-level row, fix article gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Researched notification-list best practices (two text levels max, whitespace over per-row borders/cards, subtle timestamp, small thumbnail, gentle unread tint) and applied them: - Drop the bordered "article card". Its CardCover wrapped the image in a flex-1 box, which left a large gap between the cover and the title — the reported bug. - Collapse to two text levels: the event title + a single muted secondary line (the comment when present, otherwise the referenced post title). - The post cover rides on the right as a small fixed 48px thumbnail (shared Image with graceful fallback) next to a subtle timestamp. Result: less busy, more scannable, properly aligned rows. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.spec.tsx | 6 +- .../notifications/NotificationItem.tsx | 76 +++++++++---------- 2 files changed, 39 insertions(+), 43 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.spec.tsx b/packages/shared/src/components/notifications/NotificationItem.spec.tsx index 3c8186f6234..6c738cd714d 100644 --- a/packages/shared/src/components/notifications/NotificationItem.spec.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.spec.tsx @@ -93,7 +93,11 @@ describe('notification attachment', () => { it('should have a title', async () => { const [attachment] = sampleNotificationAttachments; - renderComponent(); + // The post title shows as the secondary line only when there is no + // comment/description to show instead. + renderComponent( + , + ); await screen.findByText(attachment.title); }); }); diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 8432351719d..de3d6c16708 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -25,12 +25,8 @@ import { import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { BellDisabledIcon, BellIcon, MenuIcon } from '../icons'; import { useNotificationPreference } from '../../hooks/notifications'; -import { - NotificationAttachmentType, - NotificationPreferenceStatus, -} from '../../graphql/notifications'; -import { CardCover } from '../cards/common/CardCover'; -import { IconSize } from '../Icon'; +import { NotificationPreferenceStatus } from '../../graphql/notifications'; +import { Image, ImageType } from '../image/Image'; import { Loader } from '../Loader'; import { NotificationFollowUserButton } from './NotificationFollowUserButton'; @@ -267,14 +263,17 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {hasAvatar ? avatarContent : leadIcon}
-
+ {/* Two text levels only (best practice): the event title, then a single + muted secondary line — the comment when there is one, otherwise the + referenced post title. The post's image rides on the right. */} +
- {description && ( + {description ? (
- )} - {/* The article/post the notification refers to — one compact card - (thumbnail + title) instead of a separate text line, so the - "mentioned article" reads as a single unit. */} - {attachment && ( -
- {attachment.image && ( - - )} - + ) : ( + attachment?.title && ( + {attachment.title} -
+ ) )} {type === NotificationType.UserFollow && ( @@ -317,17 +297,29 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )}
-
- {hasOptions && ( - - - - )} - {createdAt && ( - +
+ {hasOptions && ( + + + + )} + {createdAt && ( + + )} +
+ {attachment?.image && ( + {`Cover )}
From 269375b202c9dc9e82a3e9f44da3f3d532637fa3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 22:02:05 +0300 Subject: [PATCH 12/44] fix(notifications): only highlight the active filter in the rail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SidebarItem marks an item active when `item.active || isSidebarItemActive`, and the matcher strips the query string — so every `/notifications?type=...` row matched the current `/notifications` route and all filters looked selected at once. Filters now navigate via `action` (button) instead of `path`, so the explicit `active` flag is the only source of truth and exactly one row highlights. Settings keeps its real `path`. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationsRailPanel.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationsRailPanel.tsx b/packages/shared/src/components/notifications/NotificationsRailPanel.tsx index 2b71f6b91bd..c7f3c3aa541 100644 --- a/packages/shared/src/components/notifications/NotificationsRailPanel.tsx +++ b/packages/shared/src/components/notifications/NotificationsRailPanel.tsx @@ -50,15 +50,24 @@ export const NotificationsRailPanel = (): ReactElement => { typeof router.query?.type === 'string' ? router.query.type : undefined; const menuItems: SidebarMenuItem[] = useMemo(() => { - const allActivityPath = `${webappUrl}notifications`; // Category-owned settings shortcut: keeps the Notifications panel active // (the canonical /settings/notifications page keeps the Settings panel). const settingsPath = `${webappUrl}notifications/settings`; + // Filters navigate via `action` (button), NOT `path`. SidebarItem treats + // any `?type=` path as active for the whole `/notifications` route (its + // matcher strips the query), so a path would light up every row at once. + // With no path, the explicit `active` flag is the sole source of truth. + const navigate = (category: NotificationFilterCategory | null) => + router.push({ + pathname: '/notifications', + query: category ? { type: category } : {}, + }); + const allActivity: SidebarMenuItem = { title: 'All activity', - path: allActivityPath, active: isListPage && !activeType, + action: () => navigate(null), icon: (active: boolean) => ( } /> ), @@ -80,8 +89,8 @@ export const NotificationsRailPanel = (): ReactElement => { const Icon = categoryIcon[category]; return { title: notificationFilterCategoryLabel[category], - path: `${allActivityPath}?type=${category}`, active: isListPage && activeType === category, + action: () => navigate(category), icon: (active: boolean) => ( } /> ), @@ -99,7 +108,7 @@ export const NotificationsRailPanel = (): ReactElement => { }; return [allActivity, ...categoryItems, settings]; - }, [activePage, activeType, hasUnread, isListPage, unreadCount]); + }, [activePage, activeType, hasUnread, isListPage, router, unreadCount]); return (
Date: Thu, 18 Jun 2026 22:23:51 +0300 Subject: [PATCH 13/44] =?UTF-8?q?refactor(notifications):=20research-backe?= =?UTF-8?q?d=20row=20=E2=80=94=20uniform=20height,=20readable=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied verified findings from a deep-research pass (Material 3, Polaris, WCAG, Slack/Linear): - Fix inconsistent row gaps: the trailing thumbnail was stacked under the timestamp, so only image rows grew taller. Now the row has a uniform `min-h-16`, is vertically centered, and the thumbnail is a fixed 40px that fits inside that height — image and text-only rows are the same height. - Readability: secondary line moves from faint text-tertiary/quaternary to `text-secondary` (meets WCAG 4.5:1, M3 medium-emphasis), primary title is explicit `text-primary`. Two text levels max. - Timestamp now trails the title line (X/Twitter style) instead of stacking in a right column; relative format retained. - Secondary clamped to one line for consistent rhythm; rows stay flat (no per-row dividers, per Polaris). Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 84 ++++++++++--------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index de3d6c16708..44284af87d6 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -226,7 +226,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return (
@@ -253,31 +253,41 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )} - {/* Fixed-width lead column so every row's text starts at the same x */} + {/* Leading avatar/icon, vertically centered so a row's height never + depends on whether it has a person vs a plain icon. */}
{hasAvatar ? avatarContent : leadIcon}
- {/* Two text levels only (best practice): the event title, then a single - muted secondary line — the comment when there is one, otherwise the - referenced post title. The post's image rides on the right. */} + {/* Two text levels max: a primary line (event, with the timestamp + trailing it) and a single readable secondary line — the comment when + present, otherwise the referenced post title. */}
- +
+ + {createdAt && ( + + )} +
{description ? ( -
+
) : ( attachment?.title && ( - + {attachment.title} ) @@ -297,32 +307,24 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )}
-
-
- {hasOptions && ( - - - - )} - {createdAt && ( - - )} -
- {attachment?.image && ( - {`Cover - )} -
+ {/* Trailing content thumbnail — fixed 40px and vertically centered so it + fits inside the row's min-height and never makes the row taller than a + text-only row (the cause of the inconsistent gaps). */} + {attachment?.image && ( + {`Cover + )} + {hasOptions && ( + + + + )}
); } From e38500c3852193e61af3419d17a1fc352f0fa234 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 19 Jun 2026 08:05:28 +0300 Subject: [PATCH 14/44] feat(notifications): time-grouped feed + consistent bold titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address "too busy / no rhythm / headings look the same" feedback, modeled on Instagram/TikTok/X activity feeds: - Group the feed into coarse time sections (Today / This week / This month / Earlier) with light section headers, giving the list rhythm and breaks instead of one uniform wall. - Make every notification title a consistent bold heading regardless of type (streak, upvote, achievement now share the same weight), with the lighter text-secondary snippet beneath — a clear heading-vs-body hierarchy. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 2 +- packages/webapp/pages/notifications.tsx | 65 +++++++++++++++---- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 44284af87d6..6ddef9efc2e 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -270,7 +270,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null {
{ const { logEvent } = useLogContext(); const { clearUnreadCount } = useNotificationContext(); @@ -153,6 +163,26 @@ const Notifications = (): ReactElement => { [notifications, activeCategory], ); + const groups = useMemo(() => { + const now = new Date(); + const byKey = new Map(); + filtered.forEach((node) => { + const days = differenceInCalendarDays(now, new Date(node.createdAt)); + const group = + TIME_GROUPS.find((bucket) => days <= bucket.maxDays) ?? + TIME_GROUPS[TIME_GROUPS.length - 1]; + const list = byKey.get(group.key) ?? []; + list.push(node); + byKey.set(group.key, list); + }); + return TIME_GROUPS.filter((bucket) => byKey.has(bucket.key)).map( + (bucket) => ({ + ...bucket, + items: byKey.get(bucket.key) as Notification[], + }), + ); + }, [filtered]); + const hasNotifications = notifications.length > 0; const onNotificationClick = ({ id, type }: Notification) => { @@ -216,20 +246,27 @@ const Notifications = (): ReactElement => { canFetchMore={checkFetchMore(queryResult)} fetchNextPage={queryResult.fetchNextPage} > - {filtered.map((node) => { - const { id, createdAt, readAt, type, ...props } = node; - - return ( - onNotificationClick(node)} - createdAt={createdAt} - /> - ); - })} + {groups.map((group) => ( +
+

+ {group.label} +

+ {group.items.map((node) => { + const { id, createdAt, readAt, type, ...props } = node; + + return ( + onNotificationClick(node)} + createdAt={createdAt} + /> + ); + })} +
+ ))} {isFetched && filtered.length === 0 && activeCategory && (

No {notificationFilterCategoryLabel[activeCategory].toLowerCase()}{' '} From fffd0f348eaf26a0e147b929868c7553d1b024e3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 19 Jun 2026 10:09:21 +0300 Subject: [PATCH 15/44] refactor(notifications): fixed right edge, image below text, calmer hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The timestamp was at the right of the text column, so a trailing thumbnail shrank the column and shifted the timestamp left ("right side moves"). Timestamp is now the only right-edge element in its own fixed slot — its position is constant across every row. - Move the post image out of the right flow to the bottom of the content (small 48x80 preview), as suggested, so it never pushes the timestamp. - Calmer hierarchy to reduce busyness: bold title (headline) stays, snippet drops to the more-muted text-tertiary, timestamp is the smallest/faintest. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 83 +++++++++---------- 1 file changed, 40 insertions(+), 43 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 6ddef9efc2e..b6744a16b9b 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -226,7 +226,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return (

@@ -253,38 +253,28 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )} - {/* Leading avatar/icon, vertically centered so a row's height never - depends on whether it has a person vs a plain icon. */} + {/* Leading avatar/icon */}
{hasAvatar ? avatarContent : leadIcon}
- {/* Two text levels max: a primary line (event, with the timestamp - trailing it) and a single readable secondary line — the comment when - present, otherwise the referenced post title. */} -
-
- - {createdAt && ( - - )} -
+ {/* Content: bold title (the headline), one muted snippet, and the post + preview beneath it. Nothing here is right-aligned, so the only thing + on the right edge is the timestamp — its position never shifts. */} +
+ {description ? ( -
+
) : ( attachment?.title && ( - + {attachment.title} ) )} + {attachment?.image && ( + {`Cover + )} {type === NotificationType.UserFollow && ( @@ -307,24 +307,21 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )}
- {/* Trailing content thumbnail — fixed 40px and vertically centered so it - fits inside the row's min-height and never makes the row taller than a - text-only row (the cause of the inconsistent gaps). */} - {attachment?.image && ( - {`Cover - )} - {hasOptions && ( - - - - )} + {/* The only right-edge element: timestamp (mute menu on hover above it) */} +
+ {hasOptions && ( + + + + )} + {createdAt && ( + + )} +
); } From aab518d658914d9129d81f581ff8aaf1120eb3d6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 19 Jun 2026 19:41:24 +0300 Subject: [PATCH 16/44] feat(notifications): colored type badge + small thumbnail, inline time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworked each item around the cross-platform (Instagram/Facebook/TikTok) pattern after reviewing those social feeds: - Eye-catching colored type badge overlaid on the avatar — a solid accent circle + white glyph per category (upvote/mention/comment/follow/squad/ update) so the type reads at a glance. System rows keep the plain icon. - Replace the space-hungry bottom image with a small 44px trailing thumbnail, vertically centered. - Timestamp moves inline into the muted meta line (snippet · time) so nothing is pinned to the right edge and the layout no longer shifts when a thumbnail is present. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 117 +++++++++--------- .../src/components/notifications/utils.ts | 37 ++++++ 2 files changed, 98 insertions(+), 56 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index b6744a16b9b..5bc877776b0 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -8,6 +8,8 @@ import { useObjectPurify } from '../../hooks/useDomPurify'; import NotificationItemIcon from './NotificationIcon'; import NotificationItemAvatar from './NotificationItemAvatar'; import { + getNotificationCategory, + notificationCategoryBadge, notificationMutingCopy, NotificationType, notificationTypeNotClickable, @@ -27,12 +29,10 @@ import { BellDisabledIcon, BellIcon, MenuIcon } from '../icons'; import { useNotificationPreference } from '../../hooks/notifications'; import { NotificationPreferenceStatus } from '../../graphql/notifications'; import { Image, ImageType } from '../image/Image'; +import { IconSize } from '../Icon'; import { Loader } from '../Loader'; import { NotificationFollowUserButton } from './NotificationFollowUserButton'; - -import { DateFormat } from '../utilities'; -import { TimeFormatType } from '../../lib/dateFormat'; -import { NotificationItemDescriptionIcon } from './NotificationDescriptionIcon'; +import { getLastActivityDateFormat } from '../../lib/dateFormat'; export interface NotificationItemProps extends Pick< @@ -216,17 +216,19 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { const hasOptions = Object.keys(notificationMutingCopy).includes(type); const [attachment] = attachments ?? []; - // When there is a person/source involved we show their avatar; otherwise - // (system/digest/streak) the type icon stands in as the lead. Kept flat and - // single — no overlaid badge — to match the settings-page aesthetic. + // 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 badge = notificationCategoryBadge[getNotificationCategory(type)]; + const BadgeIcon = badge.Icon; + const timeText = createdAt ? getLastActivityDateFormat(createdAt) : ''; return (
@@ -253,53 +255,55 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )} - {/* Leading avatar/icon */} + {/* 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} + {hasAvatar ? ( + <> + {avatarContent} + + + + + ) : ( + leadIcon + )}
- {/* Content: bold title (the headline), one muted snippet, and the post - preview beneath it. Nothing here is right-aligned, so the only thing - on the right edge is the timestamp — its position never shifts. */} -
+ {/* Two levels: bold headline, then a muted meta line carrying the snippet + plus an inline timestamp — nothing is pinned to the right edge, so the + layout never shifts when a thumbnail is present. */} +
- {description ? ( -
- -
-
- ) : ( - attachment?.title && ( - - {attachment.title} +
+ {description ? ( + + ) : ( + attachment?.title && {attachment.title} + )} + {timeText && ( + + {(description || attachment?.title) && ' · '} + {timeText} - ) - )} - {attachment?.image && ( - {`Cover - )} + )} +
{type === NotificationType.UserFollow && ( @@ -307,21 +311,22 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )}
- {/* The only right-edge element: timestamp (mute menu on hover above it) */} -
- {hasOptions && ( - - - - )} - {createdAt && ( - - )} -
+ {/* Small trailing content thumbnail + hover mute menu */} + {attachment?.image && ( + {`Cover + )} + {hasOptions && ( + + + + )}
); } diff --git a/packages/shared/src/components/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index d69726221d2..6ecd587f797 100644 --- a/packages/shared/src/components/notifications/utils.ts +++ b/packages/shared/src/components/notifications/utils.ts @@ -18,6 +18,10 @@ import { AnalyticsIcon, JobIcon, MagicIcon, + AtIcon, + AddUserIcon, + SquadIcon, + MegaphoneIcon, } from '../icons'; import type { NotificationPromptSource } from '../../lib/log'; import { BookmarkReminderIcon } from '../icons/Bookmark/Reminder'; @@ -456,6 +460,39 @@ export const getNotificationCategory = ( ): NotificationFilterCategory => 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. +export const notificationCategoryBadge: Record< + NotificationFilterCategory, + { bg: string; Icon: ComponentType } +> = { + [NotificationFilterCategory.Upvotes]: { + bg: 'bg-accent-avocado-default', + Icon: UpvoteIcon, + }, + [NotificationFilterCategory.Mentions]: { + bg: 'bg-accent-cabbage-default', + Icon: AtIcon, + }, + [NotificationFilterCategory.Comments]: { + bg: 'bg-accent-blueCheese-default', + Icon: DiscussIcon, + }, + [NotificationFilterCategory.Followers]: { + bg: 'bg-accent-onion-default', + Icon: AddUserIcon, + }, + [NotificationFilterCategory.Squads]: { + bg: 'bg-accent-cheese-default', + Icon: SquadIcon, + }, + [NotificationFilterCategory.Updates]: { + bg: 'bg-accent-bun-default', + Icon: MegaphoneIcon, + }, +}; + export const NotificationContainer = classed('div', 'flex flex-col gap-6'); export const NotificationSection = classed( From 32cb482f10f8ac6e67e38e20b7a4b0ff88042611 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 11:45:21 +0300 Subject: [PATCH 17/44] fix(notifications): stop the snippet duplicating the title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On source-post notifications the post title IS the notification title, so the secondary line repeated the bold headline verbatim on most rows — the main source of the "busy" feeling. The snippet now shows the comment/description only; the post is represented by the trailing thumbnail. Rows without a comment collapse to title + time, which is far cleaner. Co-Authored-By: Claude Opus 4.8 --- .../components/notifications/NotificationItem.spec.tsx | 9 +++++---- .../src/components/notifications/NotificationItem.tsx | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.spec.tsx b/packages/shared/src/components/notifications/NotificationItem.spec.tsx index 6c738cd714d..02d0ca6f250 100644 --- a/packages/shared/src/components/notifications/NotificationItem.spec.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.spec.tsx @@ -91,14 +91,15 @@ describe('notification attachment', () => { expect(img).toHaveAttribute('src', attachment.image); }); - it('should have a title', async () => { + it('should represent the attachment via its cover image (not a duplicate title line)', async () => { const [attachment] = sampleNotificationAttachments; - // The post title shows as the secondary line only when there is no - // comment/description to show instead. renderComponent( , ); - await screen.findByText(attachment.title); + // The post is shown as a thumbnail; its title is NOT repeated as text + // (that would duplicate the notification headline). + await screen.findByAltText(`Cover preview of: ${attachment.title}`); + expect(screen.queryByText(attachment.title)).not.toBeInTheDocument(); }); }); diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 5bc877776b0..8eb604ef621 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -291,15 +291,16 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { __html: memoizedTitle, }} /> + {/* Secondary line is the comment/description only — never the post + title, which would just repeat the bold headline. The post itself + is represented by the trailing thumbnail. */}
- {description ? ( + {description && ( - ) : ( - attachment?.title && {attachment.title} )} {timeText && ( - {(description || attachment?.title) && ' · '} + {description && ' · '} {timeText} )} From fb36fec85699c3e717d55a40dc549921e2386fc6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 22:41:19 +0300 Subject: [PATCH 18/44] refactor(notifications): date pinned right, badges only for personal types From the screenshot review: - Date moves back to the fixed right-most slot (after the thumbnail) so it lands in the same place on every row and reads as a reliable scanning anchor. The thumbnail sits to its left and can no longer push it. - Colored type badge now shows only for notifications about you (upvotes, comments, mentions, follows, squad activity). Source-post/system rows (Updates) keep a clean avatar/icon, so the badge stops repeating on most rows. - Mute menu moves to an absolute hover slot so it never shifts the date. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 8eb604ef621..13f6873445b 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -9,6 +9,7 @@ import NotificationItemIcon from './NotificationIcon'; import NotificationItemAvatar from './NotificationItemAvatar'; import { getNotificationCategory, + NotificationFilterCategory, notificationCategoryBadge, notificationMutingCopy, NotificationType, @@ -221,8 +222,14 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { const leadIcon = ( ); - const badge = notificationCategoryBadge[getNotificationCategory(type)]; + 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 would just + // stamp the same loud badge on most rows, so they keep a clean avatar/icon. + const showBadge = + hasAvatar && category !== NotificationFilterCategory.Updates; const timeText = createdAt ? getLastActivityDateFormat(createdAt) : ''; return ( @@ -267,23 +274,24 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {hasAvatar ? ( <> {avatarContent} - - - + {showBadge && ( + + + + )} ) : ( leadIcon )}
- {/* Two levels: bold headline, then a muted meta line carrying the snippet - plus an inline timestamp — nothing is pinned to the right edge, so the - layout never shifts when a thumbnail is present. */} + {/* Bold headline + (only when it's a real comment) a muted snippet. The + post title is never repeated here — the thumbnail represents the post. */}
- {/* Secondary line is the comment/description only — never the post - title, which would just repeat the bold headline. The post itself - is represented by the trailing thumbnail. */} -
- {description && ( - - )} - {timeText && ( - - {description && ' · '} - {timeText} - - )} -
+ {description && ( +
+ )} {type === NotificationType.UserFollow && ( @@ -312,7 +314,8 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )}
- {/* Small trailing content thumbnail + hover mute menu */} + {/* Small content thumbnail, then the date as the fixed right-most element + so it always lands in the same place and stays easy to scan. */} {attachment?.image && ( {`Cover )} + {timeText && ( + + )} {hasOptions && ( - + )} From bc482d1b2c2595337ec54a3c93a2e2f65b47f5bc Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 22:49:49 +0300 Subject: [PATCH 19/44] feat(notifications): add settings gear to mobile header On mobile/legacy (non-v2) the page header is just the "Notifications" title. Add a gear icon on the top-right of that title row linking to notification settings, matching the settings shortcut available in the v2 rail panel. Co-Authored-By: Claude Opus 4.8 --- packages/webapp/pages/notifications.tsx | 31 ++++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/webapp/pages/notifications.tsx b/packages/webapp/pages/notifications.tsx index 739b1c606b3..6c9aec4dbac 100644 --- a/packages/webapp/pages/notifications.tsx +++ b/packages/webapp/pages/notifications.tsx @@ -20,6 +20,14 @@ import { } from '@dailydotdev/shared/src/components/utilities'; import NotificationItem from '@dailydotdev/shared/src/components/notifications/NotificationItem'; import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { SettingsIcon } from '@dailydotdev/shared/src/components/icons'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import FirstNotification from '@dailydotdev/shared/src/components/notifications/FirstNotification'; import EnableNotification from '@dailydotdev/shared/src/components/notifications/EnableNotification'; @@ -223,12 +231,23 @@ const Notifications = (): ReactElement => { {!showPushBanner && } {!isV2Laptop && ( -

- Notifications -

+
+

+ Notifications +

+ +
)} {/* On v2 the type filters live in the sidebar rail panel; on the legacy/mobile layout (no rail) keep them as in-page tabs. */} From c1774ea1d2b4f8f2882a46270f2d74ff5840b408 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 22:57:46 +0300 Subject: [PATCH 20/44] fix(notifications): hide snippet when it duplicates the title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the remaining duplication: some notification types (e.g. source posts) set the `description` to the same text as the title, so the snippet echoed the headline verbatim. Now we compare the two (tag-stripped, normalized) and only render the snippet when it actually differs — no more identical title/subtitle. Added a regression test. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.spec.tsx | 12 ++++++++++++ .../components/notifications/NotificationItem.tsx | 12 +++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.spec.tsx b/packages/shared/src/components/notifications/NotificationItem.spec.tsx index 02d0ca6f250..a8d542db73b 100644 --- a/packages/shared/src/components/notifications/NotificationItem.spec.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.spec.tsx @@ -161,6 +161,18 @@ describe('notification item', () => { renderComponent(); await screen.findByText(sampleNotificationDescription); }); + + it('should not render the description when it duplicates the title', async () => { + renderComponent( + , + ); + const matches = await screen.findAllByText('Exactly the same'); + expect(matches).toHaveLength(1); + }); }); describe('notification click if onClick prop is provided', () => { diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 13f6873445b..f9221fe80b6 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -34,6 +34,7 @@ import { IconSize } from '../Icon'; import { Loader } from '../Loader'; import { NotificationFollowUserButton } from './NotificationFollowUserButton'; import { getLastActivityDateFormat } from '../../lib/dateFormat'; +import { stripHtmlTags } from '../../lib/strings'; export interface NotificationItemProps extends Pick< @@ -232,6 +233,15 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { hasAvatar && category !== NotificationFilterCategory.Updates; const timeText = createdAt ? getLastActivityDateFormat(createdAt) : ''; + // Some notification types echo the title in the description (e.g. source + // posts). Hide the snippet when it just repeats the headline, so we never + // show the same text twice. + const normalize = (html: string) => + stripHtmlTags(html).replace(/\s+/g, ' ').trim().toLowerCase(); + const showDescription = + !!description && + normalize(memoizedDescription) !== normalize(memoizedTitle); + return (
- {description && ( + {showDescription && (
Date: Sat, 20 Jun 2026 22:59:14 +0300 Subject: [PATCH 21/44] fix(notifications): lift type badge above avatar, fill the glyph - The avatar carries z-1 while the badge had no stacking context, so the badge slipped behind the profile image. Add z-2 so it sits on top. - Render the badge glyph filled (secondary variant) instead of outline for stronger contrast on the colored circle. Co-Authored-By: Claude Opus 4.8 --- .../src/components/notifications/NotificationItem.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index f9221fe80b6..ee3664ce201 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -287,11 +287,15 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {showBadge && ( - + )} From 9a81209c6729ed88e009cdbfe69523dff74a2258 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 23:56:23 +0300 Subject: [PATCH 22/44] fix(notifications): allow snippet up to 3 lines The subtitle was clamped to a single line, cutting off most comments on mobile. Allow it to wrap to a maximum of 3 lines (ellipsis only past that; short text shows in full with no ellipsis), so activity notifications are actually readable. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/notifications/NotificationItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index ee3664ce201..045c9468695 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -315,7 +315,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { /> {showDescription && (
Date: Sun, 21 Jun 2026 11:30:33 +0300 Subject: [PATCH 23/44] docs(storybook): add NotificationItem stories for all cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Storybook page covering every notification shape — source post, briefing, award, squad post-for-review (multi-avatar), upvote milestone (avatar group), comment reply, mention, follow, wide cover image, long title, long comment, unread — plus a "Gallery (full feed)" story that renders them together so alignment/rhythm can be reviewed case by case. Co-Authored-By: Claude Opus 4.8 --- .../components/NotificationItem.stories.tsx | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 packages/storybook/stories/components/NotificationItem.stories.tsx diff --git a/packages/storybook/stories/components/NotificationItem.stories.tsx b/packages/storybook/stories/components/NotificationItem.stories.tsx new file mode 100644 index 00000000000..4f21c5fbab5 --- /dev/null +++ b/packages/storybook/stories/components/NotificationItem.stories.tsx @@ -0,0 +1,257 @@ +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 { + NotificationIconType, + NotificationType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { + NotificationAttachmentType, + NotificationAvatarType, +} from '@dailydotdev/shared/src/graphql/notifications'; +import ExtensionProviders from '../extension/_providers'; + +const img = (seed: string, size = 96) => + `https://picsum.photos/seed/${seed}/${size}`; + +const userAvatar = (seed: string, name: string) => ({ + type: NotificationAvatarType.User, + referenceId: seed, + name, + image: img(`user-${seed}`, 64), + targetUrl: `/${seed}`, +}); + +const sourceAvatar = (seed: string, name: string) => ({ + type: NotificationAvatarType.Source, + referenceId: seed, + name, + image: img(`source-${seed}`, 64), + targetUrl: `/sources/${seed}`, +}); + +const postAttachment = (seed: string, title: string) => ({ + type: NotificationAttachmentType.Post, + title, + image: img(`post-${seed}`, 160), +}); + +const meta: Meta = { + title: 'Components/NotificationItem', + component: NotificationItem, + tags: ['autodocs'], + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const base: Partial = { + onClick: fn(), + targetUrl: '/post/123', +}; + +// ---- Individual cases (one per notification shape) ------------------------ + +export const SourcePost: Story = { + name: 'Source post (no badge, with thumbnail)', + args: { + ...base, + type: NotificationType.SourcePostAdded, + icon: NotificationIconType.Bell, + title: 'New post in Agentic Digest', + avatars: [sourceAvatar('agentic', 'Agentic Digest')], + attachments: [ + postAttachment( + 'agentic', + 'MAI-Code-1-Flash beats Claude Haiku 4.5 on SWE-Bench, Gemini CLI quietly replaced', + ), + ], + createdAt: new Date(Date.now() - 2 * 3600_000), + }, +}; + +export const Briefing: Story = { + name: 'Briefing (icon only)', + args: { + ...base, + type: NotificationType.BriefingReady, + icon: NotificationIconType.DailyDev, + title: 'Your presidential briefing is ready', + createdAt: new Date(Date.now() - 3 * 3600_000), + }, +}; + +export const Award: Story = { + name: 'Award (Cores)', + args: { + ...base, + type: NotificationType.UserReceivedAward, + icon: NotificationIconType.Star, + title: 'keshavashiya awarded you +7 Cores for being awesome!', + avatars: [userAvatar('keshav', 'keshavashiya')], + createdAt: new Date(Date.now() - 3 * 3600_000), + }, +}; + +export const SquadPostForReview: Story = { + name: 'Squad post submitted (2 avatars + badge)', + args: { + ...base, + type: NotificationType.SourcePostSubmitted, + icon: NotificationIconType.Bell, + title: 'Tobias Wolf submitted a post in DevOps for review', + avatars: [sourceAvatar('devops', 'DevOps'), userAvatar('tobias', 'Tobias')], + createdAt: new Date(Date.now() - 16 * 3600_000), + }, +}; + +export const UpvoteMilestone: Story = { + name: 'Upvote milestone (avatar group + badge)', + args: { + ...base, + type: NotificationType.ArticleUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: '3 upvotes! No bugs, just vibes ✨', + description: '@kkurko you gonna see Patchy here and there', + numTotalAvatars: 3, + avatars: [ + userAvatar('u1', 'One'), + userAvatar('u2', 'Two'), + userAvatar('u3', 'Three'), + ], + createdAt: new Date(Date.now() - 16 * 3600_000), + }, +}; + +export const CommentReply: Story = { + name: 'Comment reply (badge + comment snippet)', + args: { + ...base, + type: NotificationType.CommentReply, + icon: NotificationIconType.Comment, + title: + 'Ante Barić replied to your comment on daily.dev Engineering.', + description: '@idoshamun this is the GIF reply preview text', + avatars: [userAvatar('ante', 'Ante')], + createdAt: new Date(Date.now() - 26 * 3600_000), + }, +}; + +export const Mention: Story = { + args: { + ...base, + type: NotificationType.CommentMention, + icon: NotificationIconType.Comment, + title: 'Ido Shamun mentioned you in a comment', + description: 'Hey @you, what do you think about this approach?', + avatars: [userAvatar('ido', 'Ido')], + createdAt: new Date(Date.now() - 5 * 3600_000), + }, +}; + +export const Follow: Story = { + args: { + ...base, + type: NotificationType.UserFollow, + icon: NotificationIconType.User, + title: 'Nimrod Kramer started following you', + avatars: [userAvatar('nimrod', 'Nimrod')], + createdAt: new Date(Date.now() - 8 * 3600_000), + }, +}; + +export const PostWithWideImage: Story = { + name: 'New post with non-square cover', + args: { + ...base, + type: NotificationType.SourcePostAdded, + icon: NotificationIconType.Bell, + title: 'New post in Netflix TechBlog', + avatars: [sourceAvatar('netflix', 'Netflix TechBlog')], + attachments: [postAttachment('netflix-wide', 'Cassandra Analytics at scale')], + createdAt: new Date(Date.now() - 23 * 3600_000), + }, +}; + +export const LongTitle: Story = { + args: { + ...base, + type: NotificationType.SourcePostAdded, + icon: NotificationIconType.Bell, + title: + 'New post in Security Weekly: Laravel-Lang supply chain attack rewrites all tags across three Composer packages to steal CI secrets', + avatars: [sourceAvatar('sec', 'Security Weekly')], + attachments: [postAttachment('sec', 'Laravel-Lang supply chain attack')], + createdAt: new Date(Date.now() - 30 * 3600_000), + }, +}; + +export const LongComment: Story = { + name: 'Long comment (clamps at 3 lines)', + args: { + ...base, + type: NotificationType.CommentReply, + icon: NotificationIconType.Comment, + title: 'Lee Solway replied to your comment', + description: + 'This is a much longer comment so we can verify the snippet wraps across up to three full lines and only then truncates with an ellipsis, rather than being cut off after a single line which made it impossible to read on mobile before this change.', + avatars: [userAvatar('lee', 'Lee')], + createdAt: new Date(Date.now() - 40 * 3600_000), + }, +}; + +export const Unread: Story = { + args: { + ...base, + type: NotificationType.CommentReply, + icon: NotificationIconType.Comment, + title: 'Ido Shamun replied to your comment', + description: 'Looks great, shipping it!', + avatars: [userAvatar('ido', 'Ido')], + isUnread: true, + createdAt: new Date(Date.now() - 1 * 3600_000), + }, +}; + +// ---- Gallery: the whole feed together (best for checking alignment) ------- + +const gallery: NotificationItemProps[] = [ + SourcePost.args, + Briefing.args, + Award.args, + SquadPostForReview.args, + UpvoteMilestone.args, + CommentReply.args, + PostWithWideImage.args, + LongTitle.args, + LongComment.args, + Follow.args, +].map((args, index) => ({ + ...(args as NotificationItemProps), + referenceId: `gallery-${index}`, +})); + +export const Gallery: Story = { + name: 'Gallery (full feed)', + render: () => ( + +
+ {gallery.map((notification) => ( + + ))} +
+
+ ), +}; From 7fe78097f9abd119d33c9e7b3e6b2fe9873ae4eb Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 21 Jun 2026 11:32:41 +0300 Subject: [PATCH 24/44] fix(notifications): single primary avatar for consistent alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lead column was only fixed-width for single-avatar rows, so 2-avatar ("submitted for review") and 3-avatar (upvote) leads overflowed — pushing the title right on some rows and overlapping it on others. Render a single primary avatar in a fixed 40px lead so every title starts at the same x and nothing overlaps. The colored type badge + title carry the type/count. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.spec.tsx | 17 ++++--- .../notifications/NotificationItem.tsx | 49 ++++--------------- 2 files changed, 21 insertions(+), 45 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.spec.tsx b/packages/shared/src/components/notifications/NotificationItem.spec.tsx index a8d542db73b..278f4271079 100644 --- a/packages/shared/src/components/notifications/NotificationItem.spec.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.spec.tsx @@ -113,18 +113,23 @@ describe('notification avatars', () => { it('should display the avatar of the user', async () => { const [, user] = sampleNotificationAvatars; - renderComponent(); + // Only the primary (first) avatar is shown, so render the user as the + // single avatar to verify user avatars render. + renderComponent( + , + ); const img = await screen.findByAltText(`${user.referenceId}'s profile`); expect(img).toHaveAttribute('src', user.image); }); it('should not display anything if the type is unknown', async () => { + const [source] = sampleNotificationAvatars; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const notif = { ...sampleNotification } as any; - notif.avatars[1].type = 'Test'; - const [, user] = sampleNotificationAvatars; - renderComponent(); - const img = screen.queryByAltText(`${user.referenceId}'s profile`); + const unknownAvatar = { ...source, type: 'Test' } as any; + renderComponent( + , + ); + const img = screen.queryByAltText(`${source.referenceId}'s profile`); expect(img).not.toBeInTheDocument(); }); }); diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 045c9468695..5dc72afaf64 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -17,8 +17,6 @@ import { notificationTypeTheme, } from './utils'; import { KeyboardCommand } from '../../lib/element'; -import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; -import { ProfilePictureGroup } from '../ProfilePictureGroup'; import { DropdownMenu, DropdownMenuContent, @@ -162,7 +160,6 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { attachments, onClick, targetUrl, - numTotalAvatars, referenceId, createdAt, } = props; @@ -183,37 +180,16 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return null; } - const isAvatarGroup = [ - NotificationType.CollectionUpdated, - NotificationType.ArticleUpvoteMilestone, - NotificationType.CommentUpvoteMilestone, - NotificationType.WarmIntro, - ].includes(type); + // One primary avatar keeps every row's lead a fixed width, so titles line up + // and multiple avatars never overflow onto the text. The count/context + // ("3 upvotes", "submitted for review") lives in the title, and the colored + // badge conveys the type. + const [primaryAvatar] = filteredAvatars; + const avatarContent = primaryAvatar ? ( + + ) : null; - const avatarContent = isAvatarGroup ? ( - - {filteredAvatars.map((avatar) => ( - - ))} - - ) : ( - - {filteredAvatars.map((avatar) => ( - - ))} - - ); - - const hasAvatar = filteredAvatars.length > 0; + const hasAvatar = !!avatarContent; const renderLink = onClick && isClickable; const hasOptions = Object.keys(notificationMutingCopy).includes(type); const [attachment] = attachments ?? []; @@ -275,12 +251,7 @@ 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} From 418a503a4e7205ee07c1ac3722545ba983fafb1b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 21 Jun 2026 12:23:26 +0300 Subject: [PATCH 25/44] feat(notifications): overlapping avatar stack for multiple actors Support multiple user images (e.g. several upvoters) as an overlapping stack of up to 3 small faces, then a "+N" chip. Single actors keep one rich avatar. Both render inside a fixed-width (w-12) lead so titles stay aligned no matter the avatar count, and the type badge hugs the avatar cluster via an inner wrapper. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.spec.tsx | 6 +- .../notifications/NotificationItem.tsx | 91 +++++++++++++------ 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.spec.tsx b/packages/shared/src/components/notifications/NotificationItem.spec.tsx index 278f4271079..34923cc73cd 100644 --- a/packages/shared/src/components/notifications/NotificationItem.spec.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.spec.tsx @@ -106,7 +106,11 @@ describe('notification attachment', () => { describe('notification avatars', () => { it('should display the avatar of the source', async () => { const [source] = sampleNotificationAvatars; - renderComponent(); + // A single avatar renders the rich source/user avatar (multiple actors + // render as an overlapping stack instead). + renderComponent( + , + ); const img = await screen.findByAltText(`${source.referenceId}'s profile`); expect(img).toHaveAttribute('src', source.image); }); diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 5dc72afaf64..b2dc450e903 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -17,6 +17,7 @@ import { notificationTypeTheme, } from './utils'; import { KeyboardCommand } from '../../lib/element'; +import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; import { DropdownMenu, DropdownMenuContent, @@ -160,6 +161,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { attachments, onClick, targetUrl, + numTotalAvatars, referenceId, createdAt, } = props; @@ -180,16 +182,49 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return null; } - // One primary avatar keeps every row's lead a fixed width, so titles line up - // and multiple avatars never overflow onto the text. The count/context - // ("3 upvotes", "submitted for review") lives in the title, and the colored - // badge conveys the type. + // Multiple actors (e.g. several upvoters) render as an overlapping stack; + // a single actor renders one avatar. Both sit in a fixed-width lead so the + // title always starts at the same x regardless of avatar count. const [primaryAvatar] = filteredAvatars; - const avatarContent = primaryAvatar ? ( - - ) : null; + const hasAvatar = filteredAvatars.length > 0; + const totalAvatars = numTotalAvatars ?? filteredAvatars.length; + const maxFaces = 3; + const showAvatarCount = totalAvatars > maxFaces; + const stackFaces = filteredAvatars.slice( + 0, + showAvatarCount ? maxFaces - 1 : maxFaces, + ); - const hasAvatar = !!avatarContent; + let avatarContent: ReactElement | null = null; + if (filteredAvatars.length === 1) { + avatarContent = ( + + ); + } else if (filteredAvatars.length > 1) { + avatarContent = ( +
+ {stackFaces.map((avatar, index) => ( +
0 && '-ml-3')} + > + +
+ ))} + {showAvatarCount && ( + + +{totalAvatars - stackFaces.length} + + )} +
+ ); + } const renderLink = onClick && isClickable; const hasOptions = Object.keys(notificationMutingCopy).includes(type); const [attachment] = attachments ?? []; @@ -251,28 +286,24 @@ 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} - {showBadge && ( - - - - )} - - ) : ( - leadIcon - )} +
+
+ {hasAvatar ? avatarContent : leadIcon} + {showBadge && ( + + + + )} +
{/* Bold headline + (only when it's a real comment) a muted snippet. The From c779514a6b94c6ab29a33b63f78580c85df00777 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 21 Jun 2026 12:27:35 +0300 Subject: [PATCH 26/44] docs(storybook): comprehensive NotificationItem showcase with alignment guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the small gallery with a "Showcase (all types)" story that renders every shape in one continuous feed — comments, mentions, followers, upvote stacks (3 and +N overflow), squad multi-avatar, source posts (with/without cover, long title), icon-only updates, award, streak, announcement — plus vertical guides marking where every title should start and every date should end, to verify consistent alignment at a glance. Co-Authored-By: Claude Opus 4.8 --- .../components/NotificationItem.stories.tsx | 297 +++++++++++------- 1 file changed, 178 insertions(+), 119 deletions(-) diff --git a/packages/storybook/stories/components/NotificationItem.stories.tsx b/packages/storybook/stories/components/NotificationItem.stories.tsx index 4f21c5fbab5..3879b05c8bf 100644 --- a/packages/storybook/stories/components/NotificationItem.stories.tsx +++ b/packages/storybook/stories/components/NotificationItem.stories.tsx @@ -62,28 +62,33 @@ const base: Partial = { targetUrl: '/post/123', }; -// ---- Individual cases (one per notification shape) ------------------------ +// ---- Individual cases ------------------------------------------------------ + +export const SingleUserAvatar: Story = { + args: { + ...base, + type: NotificationType.CommentReply, + icon: NotificationIconType.Comment, + title: 'Ido Shamun replied to your comment', + description: 'Looks great — shipping it!', + avatars: [userAvatar('ido', 'Ido')], + createdAt: new Date(Date.now() - 2 * 3600_000), + }, +}; export const SourcePost: Story = { - name: 'Source post (no badge, with thumbnail)', args: { ...base, type: NotificationType.SourcePostAdded, icon: NotificationIconType.Bell, title: 'New post in Agentic Digest', avatars: [sourceAvatar('agentic', 'Agentic Digest')], - attachments: [ - postAttachment( - 'agentic', - 'MAI-Code-1-Flash beats Claude Haiku 4.5 on SWE-Bench, Gemini CLI quietly replaced', - ), - ], + attachments: [postAttachment('agentic', 'MAI-Code-1-Flash beats Claude Haiku')], createdAt: new Date(Date.now() - 2 * 3600_000), }, }; -export const Briefing: Story = { - name: 'Briefing (icon only)', +export const IconOnly: Story = { args: { ...base, type: NotificationType.BriefingReady, @@ -93,32 +98,8 @@ export const Briefing: Story = { }, }; -export const Award: Story = { - name: 'Award (Cores)', - args: { - ...base, - type: NotificationType.UserReceivedAward, - icon: NotificationIconType.Star, - title: 'keshavashiya awarded you +7 Cores for being awesome!', - avatars: [userAvatar('keshav', 'keshavashiya')], - createdAt: new Date(Date.now() - 3 * 3600_000), - }, -}; - -export const SquadPostForReview: Story = { - name: 'Squad post submitted (2 avatars + badge)', - args: { - ...base, - type: NotificationType.SourcePostSubmitted, - icon: NotificationIconType.Bell, - title: 'Tobias Wolf submitted a post in DevOps for review', - avatars: [sourceAvatar('devops', 'DevOps'), userAvatar('tobias', 'Tobias')], - createdAt: new Date(Date.now() - 16 * 3600_000), - }, -}; - -export const UpvoteMilestone: Story = { - name: 'Upvote milestone (avatar group + badge)', +export const AvatarStackThree: Story = { + name: 'Avatar stack (3 upvoters)', args: { ...base, type: NotificationType.ArticleUpvoteMilestone, @@ -135,120 +116,198 @@ export const UpvoteMilestone: Story = { }, }; -export const CommentReply: Story = { - name: 'Comment reply (badge + comment snippet)', +export const AvatarStackOverflow: Story = { + name: 'Avatar stack (12 upvoters → +10)', + args: { + ...base, + type: NotificationType.ArticleUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: '12 upvotes! You are on fire 🔥', + numTotalAvatars: 12, + avatars: [ + userAvatar('o1', 'One'), + userAvatar('o2', 'Two'), + userAvatar('o3', 'Three'), + ], + createdAt: new Date(Date.now() - 18 * 3600_000), + }, +}; + +export const Unread: Story = { args: { ...base, type: NotificationType.CommentReply, icon: NotificationIconType.Comment, - title: - 'Ante Barić replied to your comment on daily.dev Engineering.', - description: '@idoshamun this is the GIF reply preview text', + title: 'Ante Barić replied to your comment', + description: 'Nice catch, fixing now.', avatars: [userAvatar('ante', 'Ante')], - createdAt: new Date(Date.now() - 26 * 3600_000), + isUnread: true, + createdAt: new Date(Date.now() - 1 * 3600_000), }, }; -export const Mention: Story = { - args: { - ...base, +// ---- Showcase: every shape in one continuous feed -------------------------- + +const showcaseDefs: Array> = [ + // Comments (blue badge) — single user avatar + { + type: NotificationType.CommentReply, + icon: NotificationIconType.Comment, + title: 'Ido Shamun replied to your comment', + description: 'Looks great — shipping it!', + avatars: [userAvatar('ido', 'Ido')], + createdAt: new Date(Date.now() - 1 * 3600_000), + }, + { + type: NotificationType.ArticleNewComment, + icon: NotificationIconType.Comment, + title: 'Nimrod Kramer commented on your post', + description: + 'This is a much longer comment so we can confirm the snippet wraps to a maximum of three lines and then truncates with an ellipsis instead of being cut off after one line.', + avatars: [userAvatar('nimrod', 'Nimrod')], + attachments: [postAttachment('c1', 'The post being commented on')], + createdAt: new Date(Date.now() - 2 * 3600_000), + }, + // Mentions (cabbage badge) + { type: NotificationType.CommentMention, icon: NotificationIconType.Comment, - title: 'Ido Shamun mentioned you in a comment', + title: 'Lee Solway mentioned you in a comment', description: 'Hey @you, what do you think about this approach?', - avatars: [userAvatar('ido', 'Ido')], - createdAt: new Date(Date.now() - 5 * 3600_000), + avatars: [userAvatar('lee', 'Lee')], + createdAt: new Date(Date.now() - 4 * 3600_000), }, -}; - -export const Follow: Story = { - args: { - ...base, + // Followers (onion badge) + follow button + { type: NotificationType.UserFollow, icon: NotificationIconType.User, - title: 'Nimrod Kramer started following you', - avatars: [userAvatar('nimrod', 'Nimrod')], - createdAt: new Date(Date.now() - 8 * 3600_000), + title: 'Tobias Wolf started following you', + avatars: [userAvatar('tobias', 'Tobias')], + createdAt: new Date(Date.now() - 6 * 3600_000), }, -}; - -export const PostWithWideImage: Story = { - name: 'New post with non-square cover', - args: { - ...base, + // Upvotes (green badge) — stack of 3 + { + type: NotificationType.ArticleUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: '3 upvotes! No bugs, just vibes ✨', + description: '@kkurko you gonna see Patchy here and there', + numTotalAvatars: 3, + avatars: [ + userAvatar('a1', 'One'), + userAvatar('a2', 'Two'), + userAvatar('a3', 'Three'), + ], + createdAt: new Date(Date.now() - 16 * 3600_000), + }, + // Upvotes — overflow +N + { + type: NotificationType.CommentUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: '12 upvotes on your comment!', + numTotalAvatars: 12, + avatars: [ + userAvatar('b1', 'One'), + userAvatar('b2', 'Two'), + userAvatar('b3', 'Three'), + ], + createdAt: new Date(Date.now() - 17 * 3600_000), + }, + // Squads (cheese badge) — 2-avatar stack + { + type: NotificationType.SourcePostSubmitted, + icon: NotificationIconType.Bell, + title: 'Ankur Gupta submitted a post in WebDev for review', + avatars: [sourceAvatar('webdev', 'WebDev'), userAvatar('ankur', 'Ankur')], + createdAt: new Date(Date.now() - 18 * 3600_000), + }, + { + type: NotificationType.SquadMemberJoined, + icon: NotificationIconType.User, + title: 'GeekLuffy joined AI', + avatars: [sourceAvatar('ai', 'AI'), userAvatar('luffy', 'Luffy')], + createdAt: new Date(Date.now() - 20 * 3600_000), + }, + { + type: NotificationType.PromotedToAdmin, + icon: NotificationIconType.Star, + title: 'You were promoted to admin in DevOps', + avatars: [sourceAvatar('devops', 'DevOps')], + createdAt: new Date(Date.now() - 22 * 3600_000), + }, + // Source posts (no badge) — single source avatar, with/without thumbnail + { type: NotificationType.SourcePostAdded, icon: NotificationIconType.Bell, - title: 'New post in Netflix TechBlog', - avatars: [sourceAvatar('netflix', 'Netflix TechBlog')], - attachments: [postAttachment('netflix-wide', 'Cassandra Analytics at scale')], + 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: new Date(Date.now() - 23 * 3600_000), }, -}; - -export const LongTitle: Story = { - args: { - ...base, + { type: NotificationType.SourcePostAdded, icon: NotificationIconType.Bell, title: 'New post in Security Weekly: Laravel-Lang supply chain attack rewrites all tags across three Composer packages to steal CI secrets', avatars: [sourceAvatar('sec', 'Security Weekly')], - attachments: [postAttachment('sec', 'Laravel-Lang supply chain attack')], - createdAt: new Date(Date.now() - 30 * 3600_000), + attachments: [postAttachment('p2', 'Laravel-Lang supply chain attack')], + createdAt: new Date(Date.now() - 25 * 3600_000), }, -}; - -export const LongComment: Story = { - name: 'Long comment (clamps at 3 lines)', - args: { - ...base, - type: NotificationType.CommentReply, - icon: NotificationIconType.Comment, - title: 'Lee Solway replied to your comment', - description: - 'This is a much longer comment so we can verify the snippet wraps across up to three full lines and only then truncates with an ellipsis, rather than being cut off after a single line which made it impossible to read on mobile before this change.', - avatars: [userAvatar('lee', 'Lee')], - createdAt: new Date(Date.now() - 40 * 3600_000), + { + type: NotificationType.SourcePostAdded, + icon: NotificationIconType.Bell, + title: 'New post in Netflix TechBlog (no cover image)', + avatars: [sourceAvatar('netflix', 'Netflix TechBlog')], + createdAt: new Date(Date.now() - 26 * 3600_000), }, -}; - -export const Unread: Story = { - args: { - ...base, - type: NotificationType.CommentReply, - icon: NotificationIconType.Comment, - title: 'Ido Shamun replied to your comment', - description: 'Looks great, shipping it!', - avatars: [userAvatar('ido', 'Ido')], - isUnread: true, - createdAt: new Date(Date.now() - 1 * 3600_000), + // Updates (no badge) — icon only, various icons + { + type: NotificationType.BriefingReady, + icon: NotificationIconType.DailyDev, + title: 'Your presidential briefing is ready', + createdAt: new Date(Date.now() - 27 * 3600_000), }, -}; - -// ---- Gallery: the whole feed together (best for checking alignment) ------- + { + 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: new Date(Date.now() - 28 * 3600_000), + }, + { + type: NotificationType.UserReceivedAward, + icon: NotificationIconType.Star, + title: 'keshavashiya awarded you +7 Cores for being awesome!', + avatars: [userAvatar('keshav', 'keshavashiya')], + createdAt: new Date(Date.now() - 30 * 3600_000), + }, + { + type: NotificationType.Announcements, + icon: NotificationIconType.DailyDev, + title: 'A big new feature just landed on daily.dev', + createdAt: new Date(Date.now() - 2 * 86_400_000), + }, +]; -const gallery: NotificationItemProps[] = [ - SourcePost.args, - Briefing.args, - Award.args, - SquadPostForReview.args, - UpvoteMilestone.args, - CommentReply.args, - PostWithWideImage.args, - LongTitle.args, - LongComment.args, - Follow.args, -].map((args, index) => ({ - ...(args as NotificationItemProps), - referenceId: `gallery-${index}`, -})); +const showcase: NotificationItemProps[] = showcaseDefs.map((def, index) => ({ + onClick: fn(), + targetUrl: '/post/123', + referenceId: `showcase-${index}`, + ...def, +})) as NotificationItemProps[]; -export const Gallery: Story = { - name: 'Gallery (full feed)', +export const Showcase: Story = { + name: 'Showcase (all types — alignment check)', + parameters: { layout: 'fullscreen' }, render: () => ( -
- {gallery.map((notification) => ( +
+ {/* Alignment guides: titles should start on the left line, dates + should end on the right line, across every row. */} +
+
+ {showcase.map((notification) => ( ))}
From eb39a416c039b14ba9030c3389193df3eb9c1c77 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 21 Jun 2026 12:31:38 +0300 Subject: [PATCH 27/44] feat(notifications): compact relative date + full date on hover - Show the timestamp as a compact number+unit (now/5m/4h/5d/10w/2y) via the existing publishTimeRelativeShort helper, instead of "x ago". - On hover, show the full readable date ("15 April 2024 at 14:30") through the shared Tooltip; lift the timestamp above the row's link overlay so the hover target works. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 17 ++++++++++++----- packages/shared/src/lib/dateFormat.ts | 6 ++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index b2dc450e903..c030f03c1bb 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -32,8 +32,12 @@ import { Image, ImageType } from '../image/Image'; import { IconSize } from '../Icon'; import { Loader } from '../Loader'; import { NotificationFollowUserButton } from './NotificationFollowUserButton'; -import { getLastActivityDateFormat } from '../../lib/dateFormat'; +import { + getFullNotificationDate, + publishTimeRelativeShort, +} from '../../lib/dateFormat'; import { stripHtmlTags } from '../../lib/strings'; +import { Tooltip } from '../tooltip/Tooltip'; export interface NotificationItemProps extends Pick< @@ -242,7 +246,8 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { // stamp the same loud badge on most rows, so they keep a clean avatar/icon. const showBadge = hasAvatar && category !== NotificationFilterCategory.Updates; - const timeText = createdAt ? getLastActivityDateFormat(createdAt) : ''; + const timeText = createdAt ? publishTimeRelativeShort(createdAt) : ''; + const fullDate = createdAt ? getFullNotificationDate(createdAt) : ''; // Some notification types echo the title in the description (e.g. source // posts). Hide the snippet when it just repeats the headline, so we never @@ -343,9 +348,11 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { /> )} {timeText && ( - + + + )} {hasOptions && ( diff --git a/packages/shared/src/lib/dateFormat.ts b/packages/shared/src/lib/dateFormat.ts index 1f73e214458..540e46e32ba 100644 --- a/packages/shared/src/lib/dateFormat.ts +++ b/packages/shared/src/lib/dateFormat.ts @@ -54,6 +54,12 @@ export const publishTimeRelativeShort = ( return `${numYears}y`; }; +// Full, readable date for the notification timestamp tooltip — e.g. +// "15 April 2024 at 14:30". +export const getFullNotificationDate = ( + value: Date | number | string, +): string => format(new Date(value), "d MMMM yyyy 'at' HH:mm"); + export const publishTimeLiveTimer: typeof publishTimeRelativeShort = ( value, now = new Date(), From c312c32ed7e2e9e394f839ba541799adcf42001b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 21 Jun 2026 12:39:29 +0300 Subject: [PATCH 28/44] refactor(notifications): top-aligned row, left-aligned lead, rect thumbnail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Left-align the lead avatars/icons (justify-start) so the leftmost image lines up across rows regardless of 1/2/3 avatars. - Top-align the whole row (items-start): title, time and lead all sit at the top; the timestamp is now the top-right element. - Content thumbnail is a rounded rectangle (h-11 w-16) — circles stay reserved for source/user avatars. - Move the three-dots mute menu to the bottom-right. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index c030f03c1bb..49b1379ec2f 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -261,7 +261,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return (
@@ -291,7 +291,7 @@ 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 && ( @@ -337,25 +337,29 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {/* Small content thumbnail, then the date as the fixed right-most element so it always lands in the same place and stays easy to scan. */} - {attachment?.image && ( - {`Cover - )} - {timeText && ( - - - + {(timeText || attachment?.image) && ( +
+ {timeText && ( + + + + )} + {attachment?.image && ( + {`Cover + )} +
)} {hasOptions && ( - + )} From 0a0f452f2854a846f059af509839cfae73e22233 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 21 Jun 2026 12:43:42 +0300 Subject: [PATCH 29/44] docs(storybook): cover all 49 notification types + desktop/mobile views - Expand the NotificationItem stories to simulate every NotificationType in the product (comments, mentions, upvotes, follows, all squad events, source/collection/bookmark, achievements, streaks, briefs/digests/system, moderation, polls, opportunities, live room) so we can verify full coverage. - "All types (desktop)" renders them in one feed with alignment guides. - "Desktop vs mobile (356px)" renders the same feed side by side at 640px and the smallest supported 356px width to compare layouts. Co-Authored-By: Claude Opus 4.8 --- .../components/NotificationItem.stories.tsx | 512 ++++++++++++------ 1 file changed, 352 insertions(+), 160 deletions(-) diff --git a/packages/storybook/stories/components/NotificationItem.stories.tsx b/packages/storybook/stories/components/NotificationItem.stories.tsx index 3879b05c8bf..955c4089891 100644 --- a/packages/storybook/stories/components/NotificationItem.stories.tsx +++ b/packages/storybook/stories/components/NotificationItem.stories.tsx @@ -13,6 +13,7 @@ import { } from '@dailydotdev/shared/src/graphql/notifications'; import ExtensionProviders from '../extension/_providers'; +const hoursAgo = (h: number) => new Date(Date.now() - h * 3600_000); const img = (seed: string, size = 96) => `https://picsum.photos/seed/${seed}/${size}`; @@ -45,9 +46,7 @@ const meta: Meta = { decorators: [ (Story) => ( -
- -
+
), ], @@ -57,135 +56,66 @@ export default meta; type Story = StoryObj; -const base: Partial = { - onClick: fn(), - targetUrl: '/post/123', -}; +const framed = (node: React.ReactNode) => ( +
{node}
+); -// ---- Individual cases ------------------------------------------------------ - -export const SingleUserAvatar: Story = { - args: { - ...base, - type: NotificationType.CommentReply, +// --------------------------------------------------------------------------- +// Every notification type in the product, with representative data. +// --------------------------------------------------------------------------- +const allDefs: Array> = [ + // Comments & replies (blue badge) + { + type: NotificationType.ArticleNewComment, icon: NotificationIconType.Comment, - title: 'Ido Shamun replied to your comment', - description: 'Looks great — shipping it!', - avatars: [userAvatar('ido', 'Ido')], - createdAt: new Date(Date.now() - 2 * 3600_000), - }, -}; - -export const SourcePost: Story = { - args: { - ...base, - type: NotificationType.SourcePostAdded, - icon: NotificationIconType.Bell, - title: 'New post in Agentic Digest', - avatars: [sourceAvatar('agentic', 'Agentic Digest')], - attachments: [postAttachment('agentic', 'MAI-Code-1-Flash beats Claude Haiku')], - createdAt: new Date(Date.now() - 2 * 3600_000), - }, -}; - -export const IconOnly: Story = { - args: { - ...base, - type: NotificationType.BriefingReady, - icon: NotificationIconType.DailyDev, - title: 'Your presidential briefing is ready', - createdAt: new Date(Date.now() - 3 * 3600_000), - }, -}; - -export const AvatarStackThree: Story = { - name: 'Avatar stack (3 upvoters)', - args: { - ...base, - type: NotificationType.ArticleUpvoteMilestone, - icon: NotificationIconType.Upvote, - title: '3 upvotes! No bugs, just vibes ✨', - description: '@kkurko you gonna see Patchy here and there', - numTotalAvatars: 3, - avatars: [ - userAvatar('u1', 'One'), - userAvatar('u2', 'Two'), - userAvatar('u3', 'Three'), - ], - createdAt: new Date(Date.now() - 16 * 3600_000), - }, -}; - -export const AvatarStackOverflow: Story = { - name: 'Avatar stack (12 upvoters → +10)', - args: { - ...base, - type: NotificationType.ArticleUpvoteMilestone, - icon: NotificationIconType.Upvote, - title: '12 upvotes! You are on fire 🔥', - numTotalAvatars: 12, - avatars: [ - userAvatar('o1', 'One'), - userAvatar('o2', 'Two'), - userAvatar('o3', 'Three'), - ], - createdAt: new Date(Date.now() - 18 * 3600_000), + 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: hoursAgo(1), }, -}; - -export const Unread: Story = { - args: { - ...base, - type: NotificationType.CommentReply, + { + type: NotificationType.SquadNewComment, icon: NotificationIconType.Comment, - title: 'Ante Barić replied to your comment', - description: 'Nice catch, fixing now.', - avatars: [userAvatar('ante', 'Ante')], - isUnread: true, - createdAt: new Date(Date.now() - 1 * 3600_000), + title: 'Lee Solway commented on your post in WebDev', + description: 'Have you tried the new view transitions API?', + avatars: [userAvatar('lee', 'Lee')], + createdAt: hoursAgo(2), }, -}; - -// ---- Showcase: every shape in one continuous feed -------------------------- - -const showcaseDefs: Array> = [ - // Comments (blue badge) — single user avatar { type: NotificationType.CommentReply, icon: NotificationIconType.Comment, title: 'Ido Shamun replied to your comment', description: 'Looks great — shipping it!', avatars: [userAvatar('ido', 'Ido')], - createdAt: new Date(Date.now() - 1 * 3600_000), + createdAt: hoursAgo(3), }, { - type: NotificationType.ArticleNewComment, + type: NotificationType.SquadReply, icon: NotificationIconType.Comment, - title: 'Nimrod Kramer commented on your post', + title: 'Ante Barić replied to your comment in AI', description: - 'This is a much longer comment so we can confirm the snippet wraps to a maximum of three lines and then truncates with an ellipsis instead of being cut off after one line.', - avatars: [userAvatar('nimrod', 'Nimrod')], - attachments: [postAttachment('c1', 'The post being commented on')], - createdAt: new Date(Date.now() - 2 * 3600_000), + '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('ante', 'Ante')], + createdAt: hoursAgo(4), }, // Mentions (cabbage badge) { - type: NotificationType.CommentMention, + type: NotificationType.PostMention, icon: NotificationIconType.Comment, - title: 'Lee Solway mentioned you in a comment', - description: 'Hey @you, what do you think about this approach?', - avatars: [userAvatar('lee', 'Lee')], - createdAt: new Date(Date.now() - 4 * 3600_000), + title: 'Tsahi Matsliah mentioned you in a post', + avatars: [userAvatar('tsahi', 'Tsahi')], + createdAt: hoursAgo(5), }, - // Followers (onion badge) + follow button { - type: NotificationType.UserFollow, - icon: NotificationIconType.User, - title: 'Tobias Wolf started following you', - avatars: [userAvatar('tobias', 'Tobias')], - createdAt: new Date(Date.now() - 6 * 3600_000), + 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')], + createdAt: hoursAgo(6), }, - // Upvotes (green badge) — stack of 3 + // Reactions / upvotes (green badge) — multi-avatar stacks { type: NotificationType.ArticleUpvoteMilestone, icon: NotificationIconType.Upvote, @@ -197,9 +127,8 @@ const showcaseDefs: Array> = [ userAvatar('a2', 'Two'), userAvatar('a3', 'Three'), ], - createdAt: new Date(Date.now() - 16 * 3600_000), + createdAt: hoursAgo(7), }, - // Upvotes — overflow +N { type: NotificationType.CommentUpvoteMilestone, icon: NotificationIconType.Upvote, @@ -210,31 +139,110 @@ const showcaseDefs: Array> = [ userAvatar('b2', 'Two'), userAvatar('b3', 'Three'), ], - createdAt: new Date(Date.now() - 17 * 3600_000), + createdAt: hoursAgo(8), + }, + // Followers (onion badge) + { + type: NotificationType.UserFollow, + icon: NotificationIconType.User, + title: 'Tobias Wolf started following you', + avatars: [userAvatar('tobias', 'Tobias')], + createdAt: hoursAgo(9), + }, + // Squads (cheese badge) + { + 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: hoursAgo(10), + }, + { + type: NotificationType.SquadMemberJoined, + icon: NotificationIconType.User, + title: 'Donald Major joined DevOps', + avatars: [sourceAvatar('devops', 'DevOps'), userAvatar('donald', 'Donald')], + createdAt: hoursAgo(11), }, - // Squads (cheese badge) — 2-avatar stack { type: NotificationType.SourcePostSubmitted, icon: NotificationIconType.Bell, title: 'Ankur Gupta submitted a post in WebDev for review', avatars: [sourceAvatar('webdev', 'WebDev'), userAvatar('ankur', 'Ankur')], - createdAt: new Date(Date.now() - 18 * 3600_000), + createdAt: hoursAgo(12), }, { - type: NotificationType.SquadMemberJoined, - icon: NotificationIconType.User, - title: 'GeekLuffy joined AI', - avatars: [sourceAvatar('ai', 'AI'), userAvatar('luffy', 'Luffy')], - createdAt: new Date(Date.now() - 20 * 3600_000), + type: NotificationType.SourcePostApproved, + icon: NotificationIconType.Bell, + title: 'Your post was approved in WebDev', + avatars: [sourceAvatar('webdev', 'WebDev')], + createdAt: hoursAgo(13), + }, + { + type: NotificationType.SourcePostRejected, + icon: NotificationIconType.Block, + title: 'Your post was declined in WebDev', + avatars: [sourceAvatar('webdev', 'WebDev')], + createdAt: hoursAgo(14), + }, + { + type: NotificationType.ArticlePicked, + icon: NotificationIconType.CommunityPicks, + title: 'Your post was picked for the community 🎉', + attachments: [postAttachment('pick', 'Why we rewrote our scheduler')], + createdAt: hoursAgo(15), }, { type: NotificationType.PromotedToAdmin, icon: NotificationIconType.Star, - title: 'You were promoted to admin in DevOps', + title: 'You are now an admin in DevOps', + avatars: [sourceAvatar('devops', 'DevOps')], + createdAt: hoursAgo(16), + }, + { + type: NotificationType.PromotedToModerator, + icon: NotificationIconType.Star, + title: 'You are now a moderator in AI', + avatars: [sourceAvatar('ai', 'AI')], + createdAt: hoursAgo(17), + }, + { + type: NotificationType.DemotedToMember, + icon: NotificationIconType.User, + title: 'Your role in AI changed to member', + avatars: [sourceAvatar('ai', 'AI')], + createdAt: hoursAgo(18), + }, + { + type: NotificationType.SquadBlocked, + icon: NotificationIconType.Block, + title: 'You were removed from DevOps', avatars: [sourceAvatar('devops', 'DevOps')], - createdAt: new Date(Date.now() - 22 * 3600_000), + createdAt: hoursAgo(19), + }, + { + type: NotificationType.SquadPublicApproved, + icon: NotificationIconType.Star, + title: 'Your Squad AI is now public', + avatars: [sourceAvatar('ai', 'AI')], + createdAt: hoursAgo(20), + }, + { + type: NotificationType.SquadFeatured, + icon: NotificationIconType.Star, + title: 'Your Squad AI is now featured', + avatars: [sourceAvatar('ai', 'AI')], + createdAt: hoursAgo(21), }, - // Source posts (no badge) — single source avatar, with/without thumbnail + { + type: NotificationType.SquadSubscribeNotification, + icon: NotificationIconType.Bell, + title: 'Get notified about new posts in WebDev', + avatars: [sourceAvatar('webdev', 'WebDev')], + createdAt: hoursAgo(22), + }, + // Following / content (no badge) — source/user avatar { type: NotificationType.SourcePostAdded, icon: NotificationIconType.Bell, @@ -243,74 +251,258 @@ const showcaseDefs: Array> = [ attachments: [ postAttachment('p1', 'MAI-Code-1-Flash beats Claude Haiku 4.5 on SWE-Bench'), ], - createdAt: new Date(Date.now() - 23 * 3600_000), + createdAt: hoursAgo(23), }, { - type: NotificationType.SourcePostAdded, + type: NotificationType.UserPostAdded, icon: NotificationIconType.Bell, - title: - 'New post in Security Weekly: Laravel-Lang supply chain attack rewrites all tags across three Composer packages to steal CI secrets', - avatars: [sourceAvatar('sec', 'Security Weekly')], - attachments: [postAttachment('p2', 'Laravel-Lang supply chain attack')], - createdAt: new Date(Date.now() - 25 * 3600_000), + title: 'Ido Shamun published a new post', + avatars: [userAvatar('ido', 'Ido')], + attachments: [postAttachment('p3', 'Lessons from scaling daily.dev')], + createdAt: hoursAgo(24), }, { - type: NotificationType.SourcePostAdded, + type: NotificationType.CollectionUpdated, icon: NotificationIconType.Bell, - title: 'New post in Netflix TechBlog (no cover image)', - avatars: [sourceAvatar('netflix', 'Netflix TechBlog')], - createdAt: new Date(Date.now() - 26 * 3600_000), + title: 'A collection you follow was updated', + numTotalAvatars: 4, + avatars: [ + sourceAvatar('s1', 'One'), + sourceAvatar('s2', 'Two'), + sourceAvatar('s3', 'Three'), + ], + attachments: [postAttachment('coll', 'The state of AI agents, 2026')], + createdAt: hoursAgo(25), }, - // Updates (no badge) — icon only, various icons { - type: NotificationType.BriefingReady, - icon: NotificationIconType.DailyDev, - title: 'Your presidential briefing is ready', - createdAt: new Date(Date.now() - 27 * 3600_000), + type: NotificationType.PostBookmarkReminder, + icon: NotificationIconType.BookmarkReminder, + title: 'Read it later: a post you saved', + attachments: [postAttachment('bm', 'Designing resilient queues')], + createdAt: hoursAgo(26), + }, + // Achievements / awards (no badge) + { + type: NotificationType.UserReceivedAward, + icon: NotificationIconType.Star, + title: 'keshavashiya awarded you +7 Cores for being awesome!', + avatars: [userAvatar('keshav', 'keshavashiya')], + createdAt: hoursAgo(27), + }, + { + type: NotificationType.UserTopReaderBadge, + icon: NotificationIconType.TopReaderBadge, + title: 'You earned the Top Reader badge in JavaScript', + createdAt: hoursAgo(28), + }, + { + type: NotificationType.DevCardUnlocked, + icon: NotificationIconType.DevCard, + title: 'Your DevCard is ready to share', + createdAt: hoursAgo(29), + }, + { + type: NotificationType.ArticleAnalytics, + icon: NotificationIconType.Analytics, + title: 'Your post analytics for this week are ready', + createdAt: hoursAgo(30), + }, + { + type: NotificationType.PostAnalytics, + icon: NotificationIconType.Analytics, + title: 'Weekly performance: your posts reached 1.2k devs', + createdAt: hoursAgo(31), }, + // Streaks (no badge) — icon only { 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: new Date(Date.now() - 28 * 3600_000), + createdAt: hoursAgo(32), }, { - type: NotificationType.UserReceivedAward, - icon: NotificationIconType.Star, - title: 'keshavashiya awarded you +7 Cores for being awesome!', - avatars: [userAvatar('keshav', 'keshavashiya')], - createdAt: new Date(Date.now() - 30 * 3600_000), + type: NotificationType.StreakResetRestore, + icon: NotificationIconType.Streak, + title: 'Your streak broke — restore it now', + createdAt: hoursAgo(33), + }, + // Briefs / digests / system (no badge) — icon only + { + type: NotificationType.BriefingReady, + icon: NotificationIconType.DailyDev, + title: 'Your presidential briefing is ready', + createdAt: hoursAgo(34), + }, + { + type: NotificationType.DigestReady, + icon: NotificationIconType.DailyDev, + title: 'Your daily digest is ready', + createdAt: hoursAgo(35), + }, + { + type: NotificationType.System, + icon: NotificationIconType.DailyDev, + title: 'We updated our terms of service', + createdAt: hoursAgo(36), }, { type: NotificationType.Announcements, icon: NotificationIconType.DailyDev, title: 'A big new feature just landed on daily.dev', - createdAt: new Date(Date.now() - 2 * 86_400_000), + createdAt: hoursAgo(40), + }, + { + type: NotificationType.Marketing, + icon: NotificationIconType.DailyDev, + title: 'See what the community shipped this month', + createdAt: hoursAgo(44), + }, + { + type: NotificationType.NewUserWelcome, + icon: NotificationIconType.DailyDev, + title: 'Welcome to daily.dev! Here is how to get started', + createdAt: hoursAgo(48), + }, + { + type: NotificationType.InAppPurchases, + icon: NotificationIconType.Core, + title: 'Your Cores purchase is complete', + createdAt: hoursAgo(52), + }, + // Moderation / source suggestions (no badge) + { + type: NotificationType.SourceApproved, + icon: NotificationIconType.Bell, + title: 'The source you suggested was approved', + avatars: [sourceAvatar('newsrc', 'New Source')], + createdAt: hoursAgo(56), + }, + { + type: NotificationType.SourceRejected, + icon: NotificationIconType.Block, + title: 'The source you suggested was declined', + createdAt: hoursAgo(60), + }, + { + type: NotificationType.ArticleReportApproved, + icon: NotificationIconType.View, + title: 'Thanks — the post you reported was reviewed', + createdAt: hoursAgo(64), + }, + // Polls (no badge) + { + type: NotificationType.PollResult, + icon: NotificationIconType.View, + title: 'The results of a poll you voted on are in', + createdAt: hoursAgo(70), + }, + { + type: NotificationType.PollResultAuthor, + icon: NotificationIconType.View, + title: 'Your poll has ended — see the results', + createdAt: hoursAgo(80), + }, + // Opportunities / misc (no badge) + { + type: NotificationType.NewOpportunityMatch, + icon: NotificationIconType.Opportunity, + title: 'New match: Senior Frontend Engineer at Acme', + description: 'Based on your profile and interests', + createdAt: hoursAgo(90), + }, + { + type: NotificationType.WarmIntro, + icon: NotificationIconType.Opportunity, + title: 'You have a warm intro to Acme via 2 connections', + numTotalAvatars: 2, + avatars: [userAvatar('w1', 'One'), userAvatar('w2', 'Two')], + createdAt: hoursAgo(120), + }, + { + type: NotificationType.ExperienceCompanyEnriched, + icon: NotificationIconType.Bell, + title: 'Your work experience has been linked to Acme Corp', + createdAt: hoursAgo(200), + }, + { + type: NotificationType.LiveRoomStarted, + icon: NotificationIconType.Bell, + title: 'A live room just started in AI', + avatars: [sourceAvatar('ai', 'AI')], + createdAt: hoursAgo(400), }, ]; -const showcase: NotificationItemProps[] = showcaseDefs.map((def, index) => ({ +const allNotifications: NotificationItemProps[] = allDefs.map((def, index) => ({ onClick: fn(), targetUrl: '/post/123', - referenceId: `showcase-${index}`, + referenceId: `all-${index}`, + icon: NotificationIconType.Bell, + type: NotificationType.System, + title: 'Notification', ...def, -})) as NotificationItemProps[]; +})); + +const Feed = ({ withGuides = false }: { withGuides?: boolean }) => ( +
+ {withGuides && ( + <> +
+
+ + )} + {allNotifications.map((notification) => ( + + ))} +
+); + +// ---- Individual cases (handy for the Controls/Docs tab) -------------------- + +export const SingleUserAvatar: Story = { + render: () => framed(), +}; + +export const AvatarStack: Story = { + render: () => framed(), +}; + +export const IconOnly: Story = { + render: () => framed(), +}; + +// ---- All types, one feed (desktop, with alignment guides) ------------------ export const Showcase: Story = { - name: 'Showcase (all types — alignment check)', + name: 'All types (desktop)', + parameters: { layout: 'fullscreen' }, + render: () => framed(), +}; + +// ---- Desktop vs mobile (356px is the smallest supported width) ------------- + +export const Responsive: Story = { + name: 'Desktop vs mobile (356px)', parameters: { layout: 'fullscreen' }, render: () => ( - -
- {/* Alignment guides: titles should start on the left line, dates - should end on the right line, across every row. */} -
-
- {showcase.map((notification) => ( - - ))} +
+
+

+ Desktop (640px) +

+
+ +
+
+
+

+ Mobile — min supported (356px) +

+
+ +
- +
), }; From 3232c36ff59f7f2574360181bd39eff768c8fd3d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 21 Jun 2026 12:56:03 +0300 Subject: [PATCH 30/44] refactor(notifications): rect avatar stack w/ tooltip, square cover, taller rows - The overlapping avatar stack is now rounded rectangles (a circle stays reserved for a single source/user), and each user face keeps a hover profile tooltip. - Bring the cover image back to a square ratio (48px) sitting under the date. - Raise the row min-height (min-h-20) so the date + cover sit consistently in the same spot regardless of how long or short the text is. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 49b1379ec2f..3fbf5ab808e 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -18,6 +18,7 @@ import { } from './utils'; import { KeyboardCommand } from '../../lib/element'; import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; +import { ProfileTooltip } from '../profile/ProfileTooltip'; import { DropdownMenu, DropdownMenuContent, @@ -27,7 +28,10 @@ import { import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { BellDisabledIcon, BellIcon, MenuIcon } from '../icons'; import { useNotificationPreference } from '../../hooks/notifications'; -import { NotificationPreferenceStatus } from '../../graphql/notifications'; +import { + NotificationAvatarType, + NotificationPreferenceStatus, +} from '../../graphql/notifications'; import { Image, ImageType } from '../image/Image'; import { IconSize } from '../Icon'; import { Loader } from '../Loader'; @@ -207,22 +211,38 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { } else if (filteredAvatars.length > 1) { avatarContent = (
- {stackFaces.map((avatar, index) => ( -
0 && '-ml-3')} - > + {stackFaces.map((avatar, index) => { + // Stacked faces are rounded rectangles (a circle is reserved for a + // single source/user). Each one keeps a hover profile tooltip. + const picture = ( -
- ))} + ); + + return ( +
0 && '-ml-3')} + > + {avatar.type === NotificationAvatarType.User ? ( + + {picture} + + ) : ( + picture + )} +
+ ); + })} {showAvatarCount && ( - + +{totalAvatars - stackFaces.length} )} @@ -261,7 +281,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return (
@@ -338,7 +358,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {/* Small content thumbnail, then the date as the fixed right-most element so it always lands in the same place and stays easy to scan. */} {(timeText || attachment?.image) && ( -
+
{timeText && (