From 91c1964aaddcec743d8157e764ef88fae8bbb278 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 2 Feb 2026 09:47:01 +0200 Subject: [PATCH 1/9] Grouped notification reactions Signed-off-by: prxt6529 --- .../brain/notifications/NotificationItems.tsx | 62 +++++-- .../NotificationsFollowAllBtn.tsx | 126 +++++++++++++ .../notifications/NotificationsWrapper.tsx | 7 +- .../drop-reacted/NotificationDropReacted.tsx | 28 +-- .../NotificationDropReactedGroup.tsx | 167 ++++++++++++++++++ .../drop-reacted/ReactionEmojiPreview.tsx | 42 +++++ .../hooks/useNotificationsController.ts | 51 +++++- .../hooks/useNotificationsScroll.ts | 4 +- components/brain/notifications/index.tsx | 2 + .../subcomponents/NotificationsContent.tsx | 7 +- .../utils/groupReactionNotifications.ts | 80 +++++++++ components/common/OverlappingAvatars.tsx | 101 +++++++++++ components/waves/list/WaveItemDropped.tsx | 118 +++---------- hooks/useNotificationsQuery.tsx | 24 ++- types/feed.types.ts | 23 +++ 15 files changed, 686 insertions(+), 156 deletions(-) create mode 100644 components/brain/notifications/NotificationsFollowAllBtn.tsx create mode 100644 components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx create mode 100644 components/brain/notifications/drop-reacted/ReactionEmojiPreview.tsx create mode 100644 components/brain/notifications/utils/groupReactionNotifications.ts create mode 100644 components/common/OverlappingAvatars.tsx diff --git a/components/brain/notifications/NotificationItems.tsx b/components/brain/notifications/NotificationItems.tsx index aa9fe57fa2..e359a6c597 100644 --- a/components/brain/notifications/NotificationItems.tsx +++ b/components/brain/notifications/NotificationItems.tsx @@ -1,16 +1,21 @@ import type { DropInteractionParams } from "@/components/waves/drops/Drop"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; -import type { TypedNotification } from "@/types/feed.types"; +import { + type NotificationDisplayItem, + isGroupedReactionsItem, +} from "@/types/feed.types"; import { memo, useMemo } from "react"; +import NotificationDropReactedGroup from "./drop-reacted/NotificationDropReactedGroup"; import NotificationItem from "./NotificationItem"; interface NotificationItemsProps { - readonly items: TypedNotification[]; + readonly items: NotificationDisplayItem[]; readonly activeDrop: ActiveDropState | null; readonly onReply: (param: DropInteractionParams) => void; readonly onQuote: (param: DropInteractionParams) => void; readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; + readonly onMarkGroupAsRead?: ((ids: number[]) => Promise) | undefined; } function NotificationItemsComponent({ @@ -19,14 +24,16 @@ function NotificationItemsComponent({ onReply, onQuote, onDropContentClick, + onMarkGroupAsRead, }: NotificationItemsProps) { - const keyedNotifications = useMemo( + const keyedItems = useMemo( () => - items.map((notification, index) => { - const keySuffix = notification.id ?? `fallback-${index}`; - + items.map((item, index) => { + const keySuffix = isGroupedReactionsItem(item) + ? `group-${item.drop.id}` + : item.id ?? `fallback-${index}`; return { - notification, + item, key: `notification-${keySuffix}`, domId: `feed-item-${keySuffix}`, }; @@ -36,15 +43,37 @@ function NotificationItemsComponent({ return (
- {keyedNotifications.map(({ notification, key, domId }) => ( + {keyedItems.map(({ item, key, domId }) => (
- + {isGroupedReactionsItem(item) ? ( +
+
+
+
+
+ onMarkGroupAsRead(ids) + : undefined + } + /> +
+
+ ) : ( + + )}
))}
@@ -59,7 +88,8 @@ const NotificationItems = memo( prevProps.activeDrop === nextProps.activeDrop && prevProps.onReply === nextProps.onReply && prevProps.onQuote === nextProps.onQuote && - prevProps.onDropContentClick === nextProps.onDropContentClick + prevProps.onDropContentClick === nextProps.onDropContentClick && + prevProps.onMarkGroupAsRead === nextProps.onMarkGroupAsRead ); } ); diff --git a/components/brain/notifications/NotificationsFollowAllBtn.tsx b/components/brain/notifications/NotificationsFollowAllBtn.tsx new file mode 100644 index 0000000000..8bf991a7cb --- /dev/null +++ b/components/brain/notifications/NotificationsFollowAllBtn.tsx @@ -0,0 +1,126 @@ +"use client"; + +import type { FC } from "react"; +import { useState, useContext } from "react"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import { + FOLLOW_BTN_BUTTON_CLASSES, + FOLLOW_BTN_LOADER_SIZES, + FOLLOW_BTN_SVG_CLASSES, + UserFollowBtnSize, +} from "@/components/user/utils/UserFollowBtn"; +import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { AuthContext } from "@/components/auth/Auth"; +import type { ApiIdentitySubscriptionActions } from "@/generated/models/ApiIdentitySubscriptionActions"; +import { commonApiPost } from "@/services/api/common-api"; +import { ApiIdentitySubscriptionTargetAction } from "@/generated/models/ApiIdentitySubscriptionTargetAction"; + +const SUBSCRIPTION_BODY: ApiIdentitySubscriptionActions = { + actions: Object.values(ApiIdentitySubscriptionTargetAction).filter( + (i) => i !== ApiIdentitySubscriptionTargetAction.DropVoted + ), +}; + +interface NotificationsFollowAllBtnProps { + readonly profiles: readonly ApiProfileMin[]; + readonly size?: UserFollowBtnSize | undefined; +} + +const NotificationsFollowAllBtn: FC = ({ + profiles, + size = UserFollowBtnSize.SMALL, +}) => { + const { onIdentityFollowChange } = useContext(ReactQueryWrapperContext); + const { setToast, requestAuth } = useContext(AuthContext); + const [mutating, setMutating] = useState(false); + + const toFollow = profiles.filter( + (p) => p.handle && (p.subscribed_actions?.length ?? 0) === 0 + ); + const allFollowed = toFollow.length === 0; + const label = allFollowed ? "Following All" : "Follow All"; + + const onFollowAll = async (): Promise => { + if (allFollowed) return; + setMutating(true); + const { success } = await requestAuth(); + if (!success) { + setMutating(false); + return; + } + try { + await Promise.all( + toFollow.map((profile) => + commonApiPost< + ApiIdentitySubscriptionActions, + ApiIdentitySubscriptionActions + >({ + endpoint: `identities/${profile.handle}/subscriptions`, + body: SUBSCRIPTION_BODY, + }) + ) + ); + onIdentityFollowChange(); + } catch (error) { + setToast({ + message: error as unknown as string, + type: "error", + }); + } finally { + setMutating(false); + } + }; + + return ( +
+ +
+ ); +}; + +export default NotificationsFollowAllBtn; diff --git a/components/brain/notifications/NotificationsWrapper.tsx b/components/brain/notifications/NotificationsWrapper.tsx index 1a95ecbd45..be8912f6e7 100644 --- a/components/brain/notifications/NotificationsWrapper.tsx +++ b/components/brain/notifications/NotificationsWrapper.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; -import type { TypedNotification } from "@/types/feed.types"; +import type { NotificationDisplayItem } from "@/types/feed.types"; import type { ActiveDropState} from "@/types/dropInteractionTypes"; import { @@ -34,10 +34,11 @@ const hasChatScope = (wave: ExtendedDrop["wave"]): wave is WaveWithChatScope => typeof wave === "object" && wave !== null && "chat" in wave; interface NotificationsWrapperProps { - readonly items: TypedNotification[]; + readonly items: NotificationDisplayItem[]; readonly loadingOlder: boolean; readonly activeDrop: ActiveDropState | null; readonly setActiveDrop: (drop: ActiveDropState | null) => void; + readonly markNotificationIdsAsRead?: (ids: number[]) => Promise; } export default function NotificationsWrapper({ @@ -45,6 +46,7 @@ export default function NotificationsWrapper({ loadingOlder, activeDrop, setActiveDrop, + markNotificationIdsAsRead, }: NotificationsWrapperProps) { const router = useRouter(); const { isApp } = useDeviceInfo(); @@ -109,6 +111,7 @@ export default function NotificationsWrapper({ onReply={onReply} onQuote={onQuote} onDropContentClick={onDropContentClick} + onMarkGroupAsRead={markNotificationIdsAsRead} />
); diff --git a/components/brain/notifications/drop-reacted/NotificationDropReacted.tsx b/components/brain/notifications/drop-reacted/NotificationDropReacted.tsx index df71aed0e0..95e29f79c2 100644 --- a/components/brain/notifications/drop-reacted/NotificationDropReacted.tsx +++ b/components/brain/notifications/drop-reacted/NotificationDropReacted.tsx @@ -12,6 +12,7 @@ import type { INotificationDropReacted, INotificationDropVoted, } from "@/types/feed.types"; +import ReactionEmojiPreview from "./ReactionEmojiPreview"; import NotificationsFollowBtn from "../NotificationsFollowBtn"; import NotificationDrop from "../subcomponents/NotificationDrop"; import NotificationHeader from "../subcomponents/NotificationHeader"; @@ -87,38 +88,15 @@ export default function NotificationDropReacted({ ); } else if (isReacted) { const rawId = notification.additional_context.reaction.replaceAll(":", ""); - let emojiNode: React.ReactNode = null; - - const custom = findCustomEmoji(rawId); - if (custom) { - emojiNode = ( - {rawId} - ); - } else { - const native = findNativeEmoji(rawId); - if (native) { - emojiNode = ( - - {native.skins[0]?.native} - - ); - } - } - - if (!emojiNode) { + if (!findCustomEmoji(rawId) && !findNativeEmoji(rawId)) { return null; } - actionElement = ( <> reacted - {emojiNode} + ); diff --git a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx new file mode 100644 index 0000000000..1ffc71371c --- /dev/null +++ b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx @@ -0,0 +1,167 @@ +"use client"; + +import OverlappingAvatars from "@/components/common/OverlappingAvatars"; +import { UserFollowBtnSize } from "@/components/user/utils/UserFollowBtn"; +import type { DropInteractionParams } from "@/components/waves/drops/Drop"; +import { parseIpfsUrl } from "@/helpers/Helpers"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import type { ActiveDropState } from "@/types/dropInteractionTypes"; +import type { + GroupedReactionsItem, + INotificationDropReacted, +} from "@/types/feed.types"; +import { useRef } from "react"; +import NotificationsFollowAllBtn from "../NotificationsFollowAllBtn"; +import NotificationDrop from "../subcomponents/NotificationDrop"; +import NotificationTimestamp from "../subcomponents/NotificationTimestamp"; +import { + getIsDirectMessage, + useWaveNavigation, +} from "../utils/navigationUtils"; +import ReactionEmojiPreview from "./ReactionEmojiPreview"; + +const MAX_OVERLAP_AVATARS = 5; +const MAX_OVERLAP_EMOJIS = 5; + +function notificationsLatestPerUser( + notifications: GroupedReactionsItem["notifications"] +): INotificationDropReacted[] { + const byUser = new Map(); + for (const n of notifications) { + const key = n.related_identity?.id ?? n.related_identity?.handle ?? ""; + if (!key) continue; + const existing = byUser.get(key); + if (!existing || n.id > existing.id) { + byUser.set(key, n); + } + } + const list = Array.from(byUser.values()); + list.sort((a, b) => a.id - b.id); + return list; +} + +interface NotificationDropReactedGroupProps { + readonly group: GroupedReactionsItem; + readonly activeDrop: ActiveDropState | null; + readonly onReply: (param: DropInteractionParams) => void; + readonly onQuote: (param: DropInteractionParams) => void; + readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; + readonly onMarkAsRead?: ((notificationIds: number[]) => void) | undefined; +} + +export default function NotificationDropReactedGroup({ + group, + activeDrop, + onReply, + onQuote, + onDropContentClick, + onMarkAsRead, +}: NotificationDropReactedGroupProps) { + const rootRef = useRef(null); + const hasMarkedRef = useRef(false); + const { createReplyClickHandler, createQuoteClickHandler } = + useWaveNavigation(); + const { drop, notifications, createdAt } = group; + const isDirectMessage = getIsDirectMessage(drop.wave); + const ids = notifications.map((n) => n.id); + const latestPerUser = notificationsLatestPerUser(notifications); + const reactors = latestPerUser + .slice(0, MAX_OVERLAP_AVATARS) + .map((n) => n.related_identity); + const emojiItems = latestPerUser.slice(0, MAX_OVERLAP_EMOJIS); + + const handleDropContentClick = (clickedDrop: ExtendedDrop) => { + if (!hasMarkedRef.current && onMarkAsRead) { + hasMarkedRef.current = true; + onMarkAsRead(ids); + } + onDropContentClick?.(clickedDrop); + }; + + return ( +
+
+
+ { + const key = + profile.id ?? profile.handle ?? profile.primary_address ?? ""; + const item: { + key: string; + pfpUrl: string | null; + ariaLabel: string; + fallback: string; + href?: string; + } = { + key, + pfpUrl: profile.pfp ? parseIpfsUrl(profile.pfp) : null, + ariaLabel: profile.handle + ? `View @${profile.handle}` + : "View profile", + fallback: profile.handle?.slice(0, 2).toUpperCase() ?? "?", + }; + if (profile.handle) item.href = `/${profile.handle}`; + return item; + })} + maxCount={MAX_OVERLAP_AVATARS} + size="md" + /> +
+
+
+ + New reactions + + {emojiItems.length > 0 && ( +
+ {emojiItems.map((n, index) => { + const rawId = n.additional_context.reaction.replaceAll( + ":", + "" + ); + const transformOrigin = + index === 0 + ? "left center" + : index === emojiItems.length - 1 + ? "right center" + : "center center"; + return ( +
+ +
+ ); + })} +
+ )} + +
+ {reactors.length > 0 && ( +
+ +
+ )} +
+
+ + +
+ ); +} diff --git a/components/brain/notifications/drop-reacted/ReactionEmojiPreview.tsx b/components/brain/notifications/drop-reacted/ReactionEmojiPreview.tsx new file mode 100644 index 0000000000..b9326f94b0 --- /dev/null +++ b/components/brain/notifications/drop-reacted/ReactionEmojiPreview.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEmoji } from "@/contexts/EmojiContext"; + +interface ReactionEmojiPreviewProps { + readonly rawId: string; +} + +export default function ReactionEmojiPreview({ + rawId, +}: ReactionEmojiPreviewProps) { + const { findCustomEmoji, findNativeEmoji } = useEmoji(); + + const circleClass = + "tw-relative tw-flex tw-h-8 tw-w-8 tw-flex-shrink-0 tw-items-center tw-justify-center tw-overflow-hidden tw-rounded-full tw-bg-iron-950 tw-p-1.5 tw-ring-1 tw-ring-inset tw-ring-iron-800"; + + const custom = findCustomEmoji(rawId); + if (custom) { + return ( +
+ {rawId} +
+ ); + } + + const native = findNativeEmoji(rawId); + if (native) { + return ( +
+ + {native.skins[0]?.native} + +
+ ); + } + + return null; +} diff --git a/components/brain/notifications/hooks/useNotificationsController.ts b/components/brain/notifications/hooks/useNotificationsController.ts index c18c3b0a3e..72ace24f5b 100644 --- a/components/brain/notifications/hooks/useNotificationsController.ts +++ b/components/brain/notifications/hooks/useNotificationsController.ts @@ -11,7 +11,7 @@ import { import type { CSSProperties } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useMutation } from "@tanstack/react-query"; -import type { TypedNotification } from "@/types/feed.types"; +import type { NotificationDisplayItem } from "@/types/feed.types"; import type { NotificationFilter } from "../NotificationsCauseFilter"; import { useSetTitle } from "@/contexts/TitleContext"; import { AuthContext } from "@/components/auth/Auth"; @@ -54,11 +54,12 @@ interface UseNotificationsControllerResult { readonly setActiveFilter: (filter: NotificationFilter | null) => void; readonly isAuthenticated: boolean; readonly notificationsViewStyle: CSSProperties; - readonly items: TypedNotification[]; + readonly items: NotificationDisplayItem[]; readonly isFetchingNextPage: boolean; readonly pagination: NotificationsPagination; readonly contentState: NotificationsContentState; readonly handlers: NotificationsHandlers; + readonly markNotificationIdsAsRead: (ids: number[]) => Promise; } export const useNotificationsController = @@ -127,9 +128,12 @@ export const useNotificationsController = } hasMarkedAllAsReadRef.current = true; - markAllAsRead().catch((error) => { - console.error("Failed to mark notifications as read:", error); - }); + const id = setTimeout(() => { + markAllAsRead().catch((error) => { + console.error("Failed to mark notifications as read:", error); + }); + }, 0); + return () => clearTimeout(id); }, [isAuthenticated, markAllAsRead, reload]); useEffect(() => { @@ -140,6 +144,7 @@ export const useNotificationsController = const { items, + rawItems: rawItemsFromQuery, isFetching, isFetchingNextPage, hasNextPage, @@ -155,6 +160,33 @@ export const useNotificationsController = reverse: true, cause: activeFilter?.cause?.length ? activeFilter.cause : null, }); + const rawItems = rawItemsFromQuery ?? items; + + const { mutateAsync: markNotificationIdsAsRead } = useMutation({ + mutationFn: async (ids: number[]) => { + await Promise.all( + ids.map((id) => + commonApiPostWithoutBodyAndResponse({ + endpoint: `notifications/${id}/read`, + }) + ) + ); + }, + onSuccess: async () => { + try { + invalidateNotifications(); + await removeAllDeliveredNotifications(); + } catch (error) { + console.error("Failed to clear delivered notifications:", error); + } + }, + onError: (error) => { + setToast({ + message: error instanceof Error ? error.message : String(error), + type: "error", + }); + }, + }); useEffect(() => { if (reload !== "true") { @@ -320,15 +352,16 @@ export const useNotificationsController = !hasTimedOut && !errorMessage && (!isInitialQueryDone || isFetching) && - items.length === 0; + rawItems.length === 0; const showNoItems = isAuthenticated && !errorMessage && !hasTimedOut && isInitialQueryDone && !isFetching && - items.length === 0; - const showErrorState = (!!errorMessage || hasTimedOut) && items.length === 0; + rawItems.length === 0; + const showErrorState = + (!!errorMessage || hasTimedOut) && rawItems.length === 0; const showProxyDisabledState = !!activeProfileProxy; const resolvedErrorMessage = hasTimedOut ? LOAD_TIMEOUT_MESSAGE @@ -384,5 +417,7 @@ export const useNotificationsController = pagination, contentState, handlers, + markNotificationIdsAsRead: (ids: number[]) => + markNotificationIdsAsRead(ids), }; }; diff --git a/components/brain/notifications/hooks/useNotificationsScroll.ts b/components/brain/notifications/hooks/useNotificationsScroll.ts index 07186febe2..2b27c5419c 100644 --- a/components/brain/notifications/hooks/useNotificationsScroll.ts +++ b/components/brain/notifications/hooks/useNotificationsScroll.ts @@ -8,14 +8,14 @@ import { type MutableRefObject, type UIEventHandler, } from "react"; -import type { TypedNotification } from "@/types/feed.types"; +import type { NotificationDisplayItem } from "@/types/feed.types"; import { NEAR_TOP_SCROLL_THRESHOLD_PX } from "../../constants"; import { STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX, } from "../utils/constants"; interface UseNotificationsScrollParams { - readonly items: TypedNotification[]; + readonly items: NotificationDisplayItem[]; readonly isAuthenticated: boolean; readonly isFetchingNextPage: boolean; readonly hasNextPage: boolean; diff --git a/components/brain/notifications/index.tsx b/components/brain/notifications/index.tsx index 8752ded81c..a8f055a0f8 100644 --- a/components/brain/notifications/index.tsx +++ b/components/brain/notifications/index.tsx @@ -49,6 +49,7 @@ export default function Notifications({ pagination, contentState, handlers, + markNotificationIdsAsRead, } = useNotificationsController(); const activeFilterKey = useMemo( @@ -105,6 +106,7 @@ export default function Notifications({ loadingOlder={isFetchingNextPage} activeDrop={activeDrop} setActiveDrop={setActiveDrop} + markNotificationIdsAsRead={markNotificationIdsAsRead} /> diff --git a/components/brain/notifications/subcomponents/NotificationsContent.tsx b/components/brain/notifications/subcomponents/NotificationsContent.tsx index caa839b9aa..f64b701c20 100644 --- a/components/brain/notifications/subcomponents/NotificationsContent.tsx +++ b/components/brain/notifications/subcomponents/NotificationsContent.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import MyStreamNoItems from "../../my-stream/layout/MyStreamNoItems"; import NotificationsWrapper from "../NotificationsWrapper"; import SpinnerLoader from "@/components/common/SpinnerLoader"; -import type { TypedNotification } from "@/types/feed.types"; +import type { NotificationDisplayItem } from "@/types/feed.types"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; import NotificationsStateMessage from "./NotificationsStateMessage"; @@ -20,10 +20,11 @@ interface NotificationsContentProps { readonly handleProxyDisable: () => void; readonly showLoader: boolean; readonly showNoItems: boolean; - readonly items: TypedNotification[]; + readonly items: NotificationDisplayItem[]; readonly loadingOlder: boolean; readonly activeDrop: ActiveDropState | null; readonly setActiveDrop: (activeDrop: ActiveDropState | null) => void; + readonly markNotificationIdsAsRead?: (ids: number[]) => Promise; } export default function NotificationsContent({ @@ -42,6 +43,7 @@ export default function NotificationsContent({ loadingOlder, activeDrop, setActiveDrop, + markNotificationIdsAsRead, }: NotificationsContentProps): ReactNode { if (isLoadingProfile) { return ( @@ -109,6 +111,7 @@ export default function NotificationsContent({ loadingOlder={loadingOlder} activeDrop={activeDrop} setActiveDrop={setActiveDrop} + markNotificationIdsAsRead={markNotificationIdsAsRead} /> ); } diff --git a/components/brain/notifications/utils/groupReactionNotifications.ts b/components/brain/notifications/utils/groupReactionNotifications.ts new file mode 100644 index 0000000000..06e9db5ee1 --- /dev/null +++ b/components/brain/notifications/utils/groupReactionNotifications.ts @@ -0,0 +1,80 @@ +import { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; +import type { + GroupedReactionsItem, + INotificationDropReacted, + NotificationDisplayItem, + TypedNotification, +} from "@/types/feed.types"; + +function getDropId(n: INotificationDropReacted): string { + const drop = n.related_drops[0]; + return drop?.id ?? `no-drop-${n.id}`; +} + +function isDropReacted(item: TypedNotification): item is INotificationDropReacted { + return item.cause === ApiNotificationCause.DropReacted; +} + +export function groupReactionNotifications( + items: TypedNotification[] +): NotificationDisplayItem[] { + const byDropId = new Map< + string, + { anchorIndex: number; notifications: INotificationDropReacted[] } + >(); + + items.forEach((item, index) => { + if (!isDropReacted(item)) return; + const drop = item.related_drops[0]; + if (!drop) return; + const key = drop.id; + const existing = byDropId.get(key); + if (!existing) { + byDropId.set(key, { + anchorIndex: index, + notifications: [item], + }); + return; + } + existing.notifications.push(item); + existing.anchorIndex = Math.max(existing.anchorIndex, index); + }); + + const indexToGroup = new Map(); + byDropId.forEach(({ anchorIndex, notifications }) => { + if (notifications.length < 2) return; + const latest = notifications.reduce((a, b) => + a.created_at >= b.created_at ? a : b + ); + const drop = latest.related_drops[0]!; + indexToGroup.set(anchorIndex, { + type: "grouped_reactions", + notifications, + drop, + createdAt: latest.created_at, + id: latest.id, + }); + }); + + const result: NotificationDisplayItem[] = []; + for (let i = 0; i < items.length; i += 1) { + const item = items[i]!; + if (indexToGroup.has(i)) { + result.push(indexToGroup.get(i)!); + continue; + } + if (isDropReacted(item)) { + const key = getDropId(item); + const group = byDropId.get(key); + if ( + group && + group.notifications.length >= 2 && + group.anchorIndex !== i + ) { + continue; + } + } + result.push(item); + } + return result; +} diff --git a/components/common/OverlappingAvatars.tsx b/components/common/OverlappingAvatars.tsx new file mode 100644 index 0000000000..aad8b8ad23 --- /dev/null +++ b/components/common/OverlappingAvatars.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; +import Link from "next/link"; +import type { MouseEvent } from "react"; + +export interface OverlappingAvatarItem { + readonly key: string; + readonly pfpUrl: string | null; + readonly href?: string; + readonly ariaLabel?: string; + readonly fallback?: string; +} + +interface OverlappingAvatarsProps { + readonly items: OverlappingAvatarItem[]; + readonly maxCount?: number; + readonly size?: "sm" | "md"; + readonly overlapClass?: string; + readonly onItemClick?: ( + e: MouseEvent, + item: OverlappingAvatarItem + ) => void; +} + +const SIZE_CLASS = { + sm: "tw-h-6 tw-w-6", + md: "tw-h-7 tw-w-7", +} as const; + +export default function OverlappingAvatars({ + items, + maxCount = 5, + size = "md", + overlapClass = "-tw-space-x-2", + onItemClick, +}: OverlappingAvatarsProps) { + const slice = items.slice(0, maxCount); + const sizeClass = SIZE_CLASS[size]; + const avatarRing = + "tw-rounded-md tw-bg-iron-700 tw-ring-[1.5px] tw-ring-black tw-object-cover"; + const wrapperHover = + "tw-transition-transform tw-duration-200 tw-ease-out hover:tw-scale-110 hover:!tw-z-[100]"; + + return ( +
+ {slice.map((item, index) => { + const transformOrigin = + index === 0 + ? "left center" + : index === slice.length - 1 + ? "right center" + : "center center"; + + const content = item.pfpUrl ? ( + {item.ariaLabel + ) : ( +
+ + {item.fallback ?? "?"} + +
+ ); + + const wrapper = ( +
+ {content} +
+ ); + + if (item.href) { + return ( + onItemClick?.(e, item)} + > + {wrapper} + + ); + } + return wrapper; + })} +
+ ); +} diff --git a/components/waves/list/WaveItemDropped.tsx b/components/waves/list/WaveItemDropped.tsx index 26598656a5..4b49ccae08 100644 --- a/components/waves/list/WaveItemDropped.tsx +++ b/components/waves/list/WaveItemDropped.tsx @@ -1,110 +1,38 @@ "use client"; -import type { MouseEvent} from "react"; -import { useCallback } from "react"; -import { useRouter } from "next/navigation"; +import OverlappingAvatars from "@/components/common/OverlappingAvatars"; import type { ApiWave } from "@/generated/models/ApiWave"; import { numberWithCommas } from "@/helpers/Helpers"; -import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; export default function WaveItemDropped({ wave }: { readonly wave: ApiWave }) { const contributors = wave.contributors_overview ?? []; - const router = useRouter(); - - const handleContributorClick = useCallback( - (href: string) => - (event: MouseEvent) => { - if (event.metaKey || event.ctrlKey) { - event.preventDefault(); - event.stopPropagation(); - window.open(href, "_blank", "noopener,noreferrer"); - return; - } - event.preventDefault(); - event.stopPropagation(); - router.push(href); - }, - [router] - ); - - const handleContributorAuxClick = useCallback( - (href: string) => - (event: MouseEvent) => { - if (event.button !== 1) { - return; - } - event.preventDefault(); - event.stopPropagation(); - window.open(href, "_blank", "noopener,noreferrer"); - }, - [] - ); return ( -
-
- {contributors.map((c, index) => { - const baseKey = `${c.contributor_identity ?? "anon"}-${c.contributor_pfp ?? "no-pfp"}-${index}`; - const contributorHref = c.contributor_identity - ? `/${c.contributor_identity}` - : undefined; - - const avatar = ( -
- {c.contributor_pfp ? ( - { - ) : ( -
- - {c.contributor_identity?.slice(0, 2).toUpperCase() ?? "?"} - -
- )} -
- ); - - return ( -
- {contributorHref ? ( - - ) : ( - avatar - )} -
- ); - })} +
+
+ ({ + key: `${c.contributor_identity ?? "anon"}-${c.contributor_pfp ?? "no-pfp"}-${index}`, + pfpUrl: c.contributor_pfp ?? null, + href: c.contributor_identity + ? `/${c.contributor_identity}` + : undefined, + ariaLabel: c.contributor_identity + ? `View @${c.contributor_identity}` + : "View contributor profile", + fallback: c.contributor_identity?.slice(0, 2).toUpperCase() ?? "?", + }))} + maxCount={5} + size="sm" + overlapClass="-tw-space-x-1" + onItemClick={(e) => e.stopPropagation()} + />
-
- +
+ {numberWithCommas(wave.metrics.drops_count)} - + {wave.metrics.drops_count === 1 ? "Drop" : "Drops"}
diff --git a/hooks/useNotificationsQuery.tsx b/hooks/useNotificationsQuery.tsx index 443ffb9f7d..954c3d0782 100644 --- a/hooks/useNotificationsQuery.tsx +++ b/hooks/useNotificationsQuery.tsx @@ -3,9 +3,17 @@ import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import type { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; import { commonApiFetch } from "@/services/api/common-api"; -import type { TypedNotificationsResponse } from "@/types/feed.types"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import type { + NotificationDisplayItem, + TypedNotificationsResponse, +} from "@/types/feed.types"; +import { + keepPreviousData, + useInfiniteQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useCallback, useEffect, useMemo } from "react"; +import { groupReactionNotifications } from "@/components/brain/notifications/utils/groupReactionNotifications"; interface UseNotificationsQueryProps { /** @@ -119,6 +127,7 @@ export function useNotificationsQuery({ getNextPageParam: (lastPage) => lastPage.notifications.at(-1)?.id ?? null, enabled: !!identity && !activeProfileProxy, staleTime: 60000, + placeholderData: keepPreviousData, retry: (failureCount, error: unknown) => { const status = (error as any)?.status ?? @@ -134,24 +143,27 @@ export function useNotificationsQuery({ }, }); - const items = useMemo(() => { + const rawItems = useMemo(() => { if (!query.data) { return []; } - const data = (query.data.pages as TypedNotificationsResponse[]).flatMap( (page) => page.notifications ); - return reverse ? [...data].reverse() : data; }, [query.data, reverse]); + const items = useMemo( + () => groupReactionNotifications(rawItems), + [rawItems] + ); + const isInitialQueryDone = query.isSuccess || query.isError; - // Return everything the query provides, plus our flattened items & readiness indicator. return { ...query, items, + rawItems, isInitialQueryDone, }; } diff --git a/types/feed.types.ts b/types/feed.types.ts index c7a959f6da..4ea4bd84f3 100644 --- a/types/feed.types.ts +++ b/types/feed.types.ts @@ -151,6 +151,29 @@ export type TypedNotification = | INotificationAllDrops | INotificationPriorityAlert; +export type GroupedReactionsItem = { + readonly type: "grouped_reactions"; + readonly notifications: readonly INotificationDropReacted[]; + readonly drop: ApiDrop; + readonly createdAt: number; + readonly id: number; +}; + +export type NotificationDisplayItem = + | TypedNotification + | GroupedReactionsItem; + +export function isGroupedReactionsItem( + item: NotificationDisplayItem +): item is GroupedReactionsItem { + return ( + typeof item === "object" && + item !== null && + "type" in item && + (item as GroupedReactionsItem).type === "grouped_reactions" + ); +} + /** * Fallback type for unknown/unsupported notification causes. * Used to render generic notifications that don't match known causes. From 1433e68e3bfafde81d7ea95c4ba5a6ff293bdb18 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 2 Feb 2026 09:55:37 +0200 Subject: [PATCH 2/9] WIP Signed-off-by: prxt6529 --- .../NotificationsFollowAllBtn.tsx | 67 ++++++------------- .../notifications/NotificationsFollowBtn.tsx | 65 ++++++------------ .../NotificationDropReactedGroup.tsx | 11 ++- .../notificationsFollowShared.tsx | 51 ++++++++++++++ .../utils/groupReactionNotifications.ts | 15 ++--- components/common/OverlappingAvatars.tsx | 14 ++-- types/feed.types.ts | 6 +- 7 files changed, 110 insertions(+), 119 deletions(-) create mode 100644 components/brain/notifications/notificationsFollowShared.tsx diff --git a/components/brain/notifications/NotificationsFollowAllBtn.tsx b/components/brain/notifications/NotificationsFollowAllBtn.tsx index 8bf991a7cb..e61001dbae 100644 --- a/components/brain/notifications/NotificationsFollowAllBtn.tsx +++ b/components/brain/notifications/NotificationsFollowAllBtn.tsx @@ -1,26 +1,23 @@ "use client"; -import type { FC } from "react"; -import { useState, useContext } from "react"; -import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import { AuthContext } from "@/components/auth/Auth"; +import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { FOLLOW_BTN_BUTTON_CLASSES, FOLLOW_BTN_LOADER_SIZES, - FOLLOW_BTN_SVG_CLASSES, UserFollowBtnSize, } from "@/components/user/utils/UserFollowBtn"; -import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; -import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { AuthContext } from "@/components/auth/Auth"; import type { ApiIdentitySubscriptionActions } from "@/generated/models/ApiIdentitySubscriptionActions"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; import { commonApiPost } from "@/services/api/common-api"; -import { ApiIdentitySubscriptionTargetAction } from "@/generated/models/ApiIdentitySubscriptionTargetAction"; - -const SUBSCRIPTION_BODY: ApiIdentitySubscriptionActions = { - actions: Object.values(ApiIdentitySubscriptionTargetAction).filter( - (i) => i !== ApiIdentitySubscriptionTargetAction.DropVoted - ), -}; +import type { FC } from "react"; +import { useContext, useState } from "react"; +import { + DEFAULT_SUBSCRIPTION_BODY, + FollowBtnCheckIcon, + FollowBtnPlusIcon, +} from "./notificationsFollowShared"; interface NotificationsFollowAllBtnProps { readonly profiles: readonly ApiProfileMin[]; @@ -57,14 +54,14 @@ const NotificationsFollowAllBtn: FC = ({ ApiIdentitySubscriptionActions >({ endpoint: `identities/${profile.handle}/subscriptions`, - body: SUBSCRIPTION_BODY, + body: DEFAULT_SUBSCRIPTION_BODY, }) ) ); onIdentityFollowChange(); } catch (error) { setToast({ - message: error as unknown as string, + message: error instanceof Error ? error.message : String(error), type: "error", }); } finally { @@ -80,42 +77,16 @@ const NotificationsFollowAllBtn: FC = ({ type="button" className={`${FOLLOW_BTN_BUTTON_CLASSES[size]} ${ allFollowed - ? "tw-bg-iron-800 tw-ring-iron-800 tw-text-iron-300 tw-cursor-default" - : "tw-bg-primary-500 tw-ring-primary-500 hover:tw-bg-primary-600 hover:tw-ring-primary-600 tw-text-white tw-cursor-pointer" - } tw-flex tw-items-center tw-rounded-lg tw-font-semibold tw-border-0 tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out disabled:tw-opacity-70`}> + ? "tw-cursor-default tw-bg-iron-800 tw-text-iron-300 tw-ring-iron-800" + : "tw-cursor-pointer tw-bg-primary-500 tw-text-white tw-ring-primary-500 hover:tw-bg-primary-600 hover:tw-ring-primary-600" + } tw-flex tw-items-center tw-rounded-lg tw-border-0 tw-font-semibold tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out disabled:tw-opacity-70`} + > {mutating ? ( ) : allFollowed ? ( - + ) : ( - + )} {label} diff --git a/components/brain/notifications/NotificationsFollowBtn.tsx b/components/brain/notifications/NotificationsFollowBtn.tsx index e9b82e15b2..6dc46abd58 100644 --- a/components/brain/notifications/NotificationsFollowBtn.tsx +++ b/components/brain/notifications/NotificationsFollowBtn.tsx @@ -1,24 +1,27 @@ "use client"; -import type { FC} from "react"; -import { useState, useContext } from "react"; -import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import { AuthContext } from "@/components/auth/Auth"; +import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { FOLLOW_BTN_BUTTON_CLASSES, FOLLOW_BTN_LOADER_SIZES, - FOLLOW_BTN_SVG_CLASSES, UserFollowBtnSize, } from "@/components/user/utils/UserFollowBtn"; -import { useMutation } from "@tanstack/react-query"; -import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; -import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { AuthContext } from "@/components/auth/Auth"; import type { ApiIdentitySubscriptionActions } from "@/generated/models/ApiIdentitySubscriptionActions"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; import { commonApiDeleteWithBody, commonApiPost, } from "@/services/api/common-api"; -import { ApiIdentitySubscriptionTargetAction } from "@/generated/models/ApiIdentitySubscriptionTargetAction"; +import { useMutation } from "@tanstack/react-query"; +import type { FC } from "react"; +import { useContext, useState } from "react"; +import { + DEFAULT_SUBSCRIPTION_BODY, + FollowBtnCheckIcon, + FollowBtnPlusIcon, +} from "./notificationsFollowShared"; interface NotificationsFollowBtnProps { readonly profile: ApiProfileMin; @@ -71,11 +74,7 @@ const NotificationsFollowBtn: FC = ({ ApiIdentitySubscriptionActions >({ endpoint: `identities/${profile.handle}/subscriptions`, - body: { - actions: Object.values(ApiIdentitySubscriptionTargetAction).filter( - (i) => i !== ApiIdentitySubscriptionTargetAction.DropVoted - ), - }, + body: DEFAULT_SUBSCRIPTION_BODY, }); }, onSuccess: () => { @@ -114,42 +113,16 @@ const NotificationsFollowBtn: FC = ({ type="button" className={`${FOLLOW_BTN_BUTTON_CLASSES[size]} ${ following - ? "tw-bg-iron-800 tw-ring-iron-800 tw-text-iron-300 hover:tw-bg-iron-700 hover:tw-ring-iron-700" - : "tw-bg-primary-500 tw-ring-primary-500 hover:tw-bg-primary-600 hover:tw-ring-primary-600 tw-text-white" - } tw-flex tw-items-center tw-cursor-pointer tw-rounded-lg tw-font-semibold tw-border-0 tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out`}> + ? "tw-bg-iron-800 tw-text-iron-300 tw-ring-iron-800 hover:tw-bg-iron-700 hover:tw-ring-iron-700" + : "tw-bg-primary-500 tw-text-white tw-ring-primary-500 hover:tw-bg-primary-600 hover:tw-ring-primary-600" + } tw-flex tw-cursor-pointer tw-items-center tw-rounded-lg tw-border-0 tw-font-semibold tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out`} + > {mutating ? ( ) : following ? ( - + ) : ( - + )} {label} diff --git a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx index 1ffc71371c..a44c3d0407 100644 --- a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx +++ b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx @@ -119,12 +119,11 @@ export default function NotificationDropReactedGroup({ ":", "" ); - const transformOrigin = - index === 0 - ? "left center" - : index === emojiItems.length - 1 - ? "right center" - : "center center"; + let transformOrigin: string; + if (index === 0) transformOrigin = "left center"; + else if (index === emojiItems.length - 1) + transformOrigin = "right center"; + else transformOrigin = "center center"; return (
i !== ApiIdentitySubscriptionTargetAction.DropVoted + ), +}; + +export function FollowBtnCheckIcon() { + return ( + + ); +} + +export function FollowBtnPlusIcon({ size }: { size: UserFollowBtnSize }) { + return ( + + ); +} diff --git a/components/brain/notifications/utils/groupReactionNotifications.ts b/components/brain/notifications/utils/groupReactionNotifications.ts index 06e9db5ee1..0e9f27a82c 100644 --- a/components/brain/notifications/utils/groupReactionNotifications.ts +++ b/components/brain/notifications/utils/groupReactionNotifications.ts @@ -11,7 +11,9 @@ function getDropId(n: INotificationDropReacted): string { return drop?.id ?? `no-drop-${n.id}`; } -function isDropReacted(item: TypedNotification): item is INotificationDropReacted { +function isDropReacted( + item: TypedNotification +): item is INotificationDropReacted { return item.cause === ApiNotificationCause.DropReacted; } @@ -43,8 +45,9 @@ export function groupReactionNotifications( const indexToGroup = new Map(); byDropId.forEach(({ anchorIndex, notifications }) => { if (notifications.length < 2) return; - const latest = notifications.reduce((a, b) => - a.created_at >= b.created_at ? a : b + const latest = notifications.reduce( + (a, b) => (a.created_at >= b.created_at ? a : b), + notifications[0]! ); const drop = latest.related_drops[0]!; indexToGroup.set(anchorIndex, { @@ -66,11 +69,7 @@ export function groupReactionNotifications( if (isDropReacted(item)) { const key = getDropId(item); const group = byDropId.get(key); - if ( - group && - group.notifications.length >= 2 && - group.anchorIndex !== i - ) { + if (group && group.notifications.length >= 2 && group.anchorIndex !== i) { continue; } } diff --git a/components/common/OverlappingAvatars.tsx b/components/common/OverlappingAvatars.tsx index aad8b8ad23..0a0d2d37a9 100644 --- a/components/common/OverlappingAvatars.tsx +++ b/components/common/OverlappingAvatars.tsx @@ -43,14 +43,14 @@ export default function OverlappingAvatars({ "tw-transition-transform tw-duration-200 tw-ease-out hover:tw-scale-110 hover:!tw-z-[100]"; return ( -
+
{slice.map((item, index) => { - const transformOrigin = - index === 0 - ? "left center" - : index === slice.length - 1 - ? "right center" - : "center center"; + let transformOrigin: string; + if (index === 0) transformOrigin = "left center"; + else if (index === slice.length - 1) transformOrigin = "right center"; + else transformOrigin = "center center"; const content = item.pfpUrl ? ( Date: Mon, 2 Feb 2026 10:04:50 +0200 Subject: [PATCH 3/9] WIP Signed-off-by: prxt6529 --- .../notifications/NotificationsFollowAllBtn.tsx | 13 ++++++------- .../NotificationDropReactedGroup.tsx | 16 ++++++++++------ .../notifications/notificationsFollowShared.tsx | 6 +++++- .../utils/groupReactionNotifications.ts | 12 ++++++++++-- hooks/useNotificationsQuery.tsx | 13 ++++++++----- 5 files changed, 39 insertions(+), 21 deletions(-) diff --git a/components/brain/notifications/NotificationsFollowAllBtn.tsx b/components/brain/notifications/NotificationsFollowAllBtn.tsx index e61001dbae..f8c88e4a58 100644 --- a/components/brain/notifications/NotificationsFollowAllBtn.tsx +++ b/components/brain/notifications/NotificationsFollowAllBtn.tsx @@ -81,13 +81,12 @@ const NotificationsFollowAllBtn: FC = ({ : "tw-cursor-pointer tw-bg-primary-500 tw-text-white tw-ring-primary-500 hover:tw-bg-primary-600 hover:tw-ring-primary-600" } tw-flex tw-items-center tw-rounded-lg tw-border-0 tw-font-semibold tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out disabled:tw-opacity-70`} > - {mutating ? ( - - ) : allFollowed ? ( - - ) : ( - - )} + {(() => { + if (mutating) + return ; + if (allFollowed) return ; + return ; + })()} {label}
diff --git a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx index a44c3d0407..94678cb115 100644 --- a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx +++ b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx @@ -10,7 +10,7 @@ import type { GroupedReactionsItem, INotificationDropReacted, } from "@/types/feed.types"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import NotificationsFollowAllBtn from "../NotificationsFollowAllBtn"; import NotificationDrop from "../subcomponents/NotificationDrop"; import NotificationTimestamp from "../subcomponents/NotificationTimestamp"; @@ -64,12 +64,16 @@ export default function NotificationDropReactedGroup({ const { drop, notifications, createdAt } = group; const isDirectMessage = getIsDirectMessage(drop.wave); const ids = notifications.map((n) => n.id); + const idsKey = ids.join(","); const latestPerUser = notificationsLatestPerUser(notifications); - const reactors = latestPerUser - .slice(0, MAX_OVERLAP_AVATARS) - .map((n) => n.related_identity); + const fullReactors = latestPerUser.map((n) => n.related_identity); + const reactors = fullReactors.slice(0, MAX_OVERLAP_AVATARS); const emojiItems = latestPerUser.slice(0, MAX_OVERLAP_EMOJIS); + useEffect(() => { + hasMarkedRef.current = false; + }, [idsKey]); + const handleDropContentClick = (clickedDrop: ExtendedDrop) => { if (!hasMarkedRef.current && onMarkAsRead) { hasMarkedRef.current = true; @@ -141,10 +145,10 @@ export default function NotificationDropReactedGroup({ )}
- {reactors.length > 0 && ( + {fullReactors.length > 0 && (
diff --git a/components/brain/notifications/notificationsFollowShared.tsx b/components/brain/notifications/notificationsFollowShared.tsx index 66f0bdd645..9269ad3283 100644 --- a/components/brain/notifications/notificationsFollowShared.tsx +++ b/components/brain/notifications/notificationsFollowShared.tsx @@ -30,7 +30,11 @@ export function FollowBtnCheckIcon() { ); } -export function FollowBtnPlusIcon({ size }: { size: UserFollowBtnSize }) { +export function FollowBtnPlusIcon({ + size, +}: { + readonly size: UserFollowBtnSize; +}) { return ( (); items.forEach((item, index) => { @@ -34,12 +38,16 @@ export function groupReactionNotifications( if (!existing) { byDropId.set(key, { anchorIndex: index, + anchorCreatedAt: item.created_at, notifications: [item], }); return; } existing.notifications.push(item); - existing.anchorIndex = Math.max(existing.anchorIndex, index); + if (item.created_at >= existing.anchorCreatedAt) { + existing.anchorIndex = index; + existing.anchorCreatedAt = item.created_at; + } }); const indexToGroup = new Map(); diff --git a/hooks/useNotificationsQuery.tsx b/hooks/useNotificationsQuery.tsx index 954c3d0782..ee31e63687 100644 --- a/hooks/useNotificationsQuery.tsx +++ b/hooks/useNotificationsQuery.tsx @@ -1,5 +1,6 @@ "use client"; +import { groupReactionNotifications } from "@/components/brain/notifications/utils/groupReactionNotifications"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import type { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; import { commonApiFetch } from "@/services/api/common-api"; @@ -13,7 +14,6 @@ import { useQueryClient, } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo } from "react"; -import { groupReactionNotifications } from "@/components/brain/notifications/utils/groupReactionNotifications"; interface UseNotificationsQueryProps { /** @@ -153,10 +153,13 @@ export function useNotificationsQuery({ return reverse ? [...data].reverse() : data; }, [query.data, reverse]); - const items = useMemo( - () => groupReactionNotifications(rawItems), - [rawItems] - ); + const items = useMemo(() => { + if (!query.data) return []; + const grouped = (query.data.pages as TypedNotificationsResponse[]).flatMap( + (page) => groupReactionNotifications(page.notifications) + ); + return reverse ? [...grouped].reverse() : grouped; + }, [query.data, reverse]); const isInitialQueryDone = query.isSuccess || query.isError; From 1788c25a6d2505afb5809cdd493b4195a10b2430 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 2 Feb 2026 10:36:12 +0200 Subject: [PATCH 4/9] WIP Signed-off-by: prxt6529 --- components/brain/notifications/NotificationsFollowBtn.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/components/brain/notifications/NotificationsFollowBtn.tsx b/components/brain/notifications/NotificationsFollowBtn.tsx index 6dc46abd58..2c9eee7077 100644 --- a/components/brain/notifications/NotificationsFollowBtn.tsx +++ b/components/brain/notifications/NotificationsFollowBtn.tsx @@ -46,11 +46,7 @@ const NotificationsFollowBtn: FC = ({ ApiIdentitySubscriptionActions >({ endpoint: `identities/${profile.handle}/subscriptions`, - body: { - actions: Object.values(ApiIdentitySubscriptionTargetAction).filter( - (i) => i !== ApiIdentitySubscriptionTargetAction.DropVoted - ), - }, + body: DEFAULT_SUBSCRIPTION_BODY, }); }, onSuccess: () => { From 26a8b376fa41a728c9fc8d02a751205a8c663517 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 2 Feb 2026 10:38:24 +0200 Subject: [PATCH 5/9] WIP Signed-off-by: prxt6529 --- components/waves/list/WaveItemDropped.tsx | 24 +++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/components/waves/list/WaveItemDropped.tsx b/components/waves/list/WaveItemDropped.tsx index 4b49ccae08..4005ac5329 100644 --- a/components/waves/list/WaveItemDropped.tsx +++ b/components/waves/list/WaveItemDropped.tsx @@ -11,17 +11,21 @@ export default function WaveItemDropped({ wave }: { readonly wave: ApiWave }) {
({ - key: `${c.contributor_identity ?? "anon"}-${c.contributor_pfp ?? "no-pfp"}-${index}`, - pfpUrl: c.contributor_pfp ?? null, - href: c.contributor_identity + items={contributors.map((c, index) => { + const href = c.contributor_identity ? `/${c.contributor_identity}` - : undefined, - ariaLabel: c.contributor_identity - ? `View @${c.contributor_identity}` - : "View contributor profile", - fallback: c.contributor_identity?.slice(0, 2).toUpperCase() ?? "?", - }))} + : undefined; + return { + key: `${c.contributor_identity ?? "anon"}-${c.contributor_pfp ?? "no-pfp"}-${index}`, + pfpUrl: c.contributor_pfp ?? null, + ...(href !== undefined && { href }), + ariaLabel: c.contributor_identity + ? `View @${c.contributor_identity}` + : "View contributor profile", + fallback: + c.contributor_identity?.slice(0, 2).toUpperCase() ?? "?", + }; + })} maxCount={5} size="sm" overlapClass="-tw-space-x-1" From 23178f02f2e3b94020a3e638181ab02517a2c375 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 2 Feb 2026 10:39:41 +0200 Subject: [PATCH 6/9] WIP Signed-off-by: prxt6529 --- .../subcomponents/NotificationsContent.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/components/brain/notifications/subcomponents/NotificationsContent.tsx b/components/brain/notifications/subcomponents/NotificationsContent.tsx index f64b701c20..daa2d4dab6 100644 --- a/components/brain/notifications/subcomponents/NotificationsContent.tsx +++ b/components/brain/notifications/subcomponents/NotificationsContent.tsx @@ -1,11 +1,11 @@ "use client"; +import SpinnerLoader from "@/components/common/SpinnerLoader"; +import type { ActiveDropState } from "@/types/dropInteractionTypes"; +import type { NotificationDisplayItem } from "@/types/feed.types"; import type { ReactNode } from "react"; import MyStreamNoItems from "../../my-stream/layout/MyStreamNoItems"; import NotificationsWrapper from "../NotificationsWrapper"; -import SpinnerLoader from "@/components/common/SpinnerLoader"; -import type { NotificationDisplayItem } from "@/types/feed.types"; -import type { ActiveDropState } from "@/types/dropInteractionTypes"; import NotificationsStateMessage from "./NotificationsStateMessage"; interface NotificationsContentProps { @@ -47,7 +47,7 @@ export default function NotificationsContent({ }: NotificationsContentProps): ReactNode { if (isLoadingProfile) { return ( -
+
); @@ -75,7 +75,10 @@ export default function NotificationsContent({ return ( ); } @@ -91,7 +94,7 @@ export default function NotificationsContent({ if (showLoader) { return ( -
+
); @@ -99,7 +102,7 @@ export default function NotificationsContent({ if (showNoItems) { return ( -
+
); @@ -111,7 +114,9 @@ export default function NotificationsContent({ loadingOlder={loadingOlder} activeDrop={activeDrop} setActiveDrop={setActiveDrop} - markNotificationIdsAsRead={markNotificationIdsAsRead} + {...(markNotificationIdsAsRead !== undefined && { + markNotificationIdsAsRead, + })} /> ); } From 558fdb0c6485d02fa4f217507f1b3db1411ebb85 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 2 Feb 2026 11:04:15 +0200 Subject: [PATCH 7/9] WIP Signed-off-by: prxt6529 --- .../NotificationDropReactedGroup.tsx | 134 ++++++++++-------- components/common/OverlappingAvatars.tsx | 49 +++++-- 2 files changed, 113 insertions(+), 70 deletions(-) diff --git a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx index 94678cb115..8eaab2dd0c 100644 --- a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx +++ b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx @@ -10,7 +10,7 @@ import type { GroupedReactionsItem, INotificationDropReacted, } from "@/types/feed.types"; -import { useEffect, useRef } from "react"; +import { Fragment, useEffect, useRef } from "react"; import NotificationsFollowAllBtn from "../NotificationsFollowAllBtn"; import NotificationDrop from "../subcomponents/NotificationDrop"; import NotificationTimestamp from "../subcomponents/NotificationTimestamp"; @@ -20,9 +20,6 @@ import { } from "../utils/navigationUtils"; import ReactionEmojiPreview from "./ReactionEmojiPreview"; -const MAX_OVERLAP_AVATARS = 5; -const MAX_OVERLAP_EMOJIS = 5; - function notificationsLatestPerUser( notifications: GroupedReactionsItem["notifications"] ): INotificationDropReacted[] { @@ -40,6 +37,35 @@ function notificationsLatestPerUser( return list; } +type ReactionGroup = { + readonly reaction: string; + readonly rawId: string; + readonly notifications: INotificationDropReacted[]; +}; + +function groupLatestByReaction( + latestPerUser: INotificationDropReacted[] +): ReactionGroup[] { + const byReaction = new Map(); + for (const n of latestPerUser) { + const reaction = n.additional_context.reaction; + const list = byReaction.get(reaction) ?? []; + list.push(n); + byReaction.set(reaction, list); + } + return Array.from(byReaction.entries()) + .map(([reaction, list]) => ({ + reaction, + rawId: reaction.replaceAll(":", ""), + notifications: list, + })) + .sort((a, b) => { + const aLatest = Math.max(...a.notifications.map((n) => n.created_at)); + const bLatest = Math.max(...b.notifications.map((n) => n.created_at)); + return bLatest - aLatest; + }); +} + interface NotificationDropReactedGroupProps { readonly group: GroupedReactionsItem; readonly activeDrop: ActiveDropState | null; @@ -67,8 +93,7 @@ export default function NotificationDropReactedGroup({ const idsKey = ids.join(","); const latestPerUser = notificationsLatestPerUser(notifications); const fullReactors = latestPerUser.map((n) => n.related_identity); - const reactors = fullReactors.slice(0, MAX_OVERLAP_AVATARS); - const emojiItems = latestPerUser.slice(0, MAX_OVERLAP_EMOJIS); + const reactionGroups = groupLatestByReaction(latestPerUser); useEffect(() => { hasMarkedRef.current = false; @@ -85,64 +110,55 @@ export default function NotificationDropReactedGroup({ return (
-
- { - const key = - profile.id ?? profile.handle ?? profile.primary_address ?? ""; - const item: { - key: string; - pfpUrl: string | null; - ariaLabel: string; - fallback: string; - href?: string; - } = { - key, - pfpUrl: profile.pfp ? parseIpfsUrl(profile.pfp) : null, - ariaLabel: profile.handle - ? `View @${profile.handle}` - : "View profile", - fallback: profile.handle?.slice(0, 2).toUpperCase() ?? "?", - }; - if (profile.handle) item.href = `/${profile.handle}`; - return item; - })} - maxCount={MAX_OVERLAP_AVATARS} - size="md" - /> -
- + New reactions - {emojiItems.length > 0 && ( -
- {emojiItems.map((n, index) => { - const rawId = n.additional_context.reaction.replaceAll( - ":", - "" - ); - let transformOrigin: string; - if (index === 0) transformOrigin = "left center"; - else if (index === emojiItems.length - 1) - transformOrigin = "right center"; - else transformOrigin = "center center"; - return ( -
- + {reactionGroups.map((rg, index) => { + const profiles = rg.notifications.map((n) => n.related_identity); + const avatarItems = profiles.map((profile) => { + const key = + profile.id ?? profile.handle ?? profile.primary_address ?? ""; + const href = profile.handle ? `/${profile.handle}` : undefined; + const displayName = + profile.handle ?? + (profile.id != null ? String(profile.id) : undefined); + const title = + displayName !== undefined && displayName !== "" + ? displayName + : undefined; + return { + key, + pfpUrl: profile.pfp ? parseIpfsUrl(profile.pfp) : null, + ariaLabel: profile.handle + ? `View @${profile.handle}` + : "View profile", + fallback: profile.handle?.slice(0, 2).toUpperCase() ?? "?", + ...(href !== undefined && { href }), + ...(title !== undefined && { title }), + }; + }); + return ( + + {index > 0 && ( + + • + + )} +
+ +
+
- ); - })} -
- )} +
+ + ); + })}
{fullReactors.length > 0 && ( diff --git a/components/common/OverlappingAvatars.tsx b/components/common/OverlappingAvatars.tsx index 0a0d2d37a9..8aa90f8211 100644 --- a/components/common/OverlappingAvatars.tsx +++ b/components/common/OverlappingAvatars.tsx @@ -1,8 +1,12 @@ "use client"; import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; +import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; +import useIsTouchDevice from "@/hooks/useIsTouchDevice"; import Link from "next/link"; import type { MouseEvent } from "react"; +import { useId } from "react"; +import { Tooltip } from "react-tooltip"; export interface OverlappingAvatarItem { readonly key: string; @@ -10,6 +14,7 @@ export interface OverlappingAvatarItem { readonly href?: string; readonly ariaLabel?: string; readonly fallback?: string; + readonly title?: string; } interface OverlappingAvatarsProps { @@ -35,6 +40,8 @@ export default function OverlappingAvatars({ overlapClass = "-tw-space-x-2", onItemClick, }: OverlappingAvatarsProps) { + const baseId = useId(); + const isTouchDevice = useIsTouchDevice(); const slice = items.slice(0, maxCount); const sizeClass = SIZE_CLASS[size]; const avatarRing = @@ -68,6 +75,9 @@ export default function OverlappingAvatars({
); + const showTooltip = + !isTouchDevice && item.title !== undefined && item.title !== ""; + const tooltipId = showTooltip ? `${baseId}-${index}` : undefined; const wrapper = (
{content}
); - if (item.href) { + const anchor = item.href ? ( + onItemClick?.(e, item)} + > + {wrapper} + + ) : ( + wrapper + ); + + if (showTooltip && tooltipId !== undefined) { return ( - onItemClick?.(e, item)} - > - {wrapper} - + + {anchor} + + {item.title} + + ); } - return wrapper; + return anchor; })}
); From 877fba277a3a70cb9b2a95b907ffd51ebcf780f6 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 2 Feb 2026 12:54:19 +0200 Subject: [PATCH 8/9] WIP Signed-off-by: prxt6529 --- .../NotificationsFollowAllBtn.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/components/brain/notifications/NotificationsFollowAllBtn.tsx b/components/brain/notifications/NotificationsFollowAllBtn.tsx index f8c88e4a58..ac19e703e8 100644 --- a/components/brain/notifications/NotificationsFollowAllBtn.tsx +++ b/components/brain/notifications/NotificationsFollowAllBtn.tsx @@ -47,7 +47,7 @@ const NotificationsFollowAllBtn: FC = ({ return; } try { - await Promise.all( + const results = await Promise.allSettled( toFollow.map((profile) => commonApiPost< ApiIdentitySubscriptionActions, @@ -58,12 +58,20 @@ const NotificationsFollowAllBtn: FC = ({ }) ) ); - onIdentityFollowChange(); - } catch (error) { - setToast({ - message: error instanceof Error ? error.message : String(error), - type: "error", - }); + const hasFulfilled = results.some((r) => r.status === "fulfilled"); + const rejected = results.filter( + (r): r is PromiseRejectedResult => r.status === "rejected" + ); + if (hasFulfilled) onIdentityFollowChange(); + if (rejected.length > 0) { + const messages = rejected.map((r) => + r.reason instanceof Error ? r.reason.message : String(r.reason) + ); + setToast({ + message: messages.join("; "), + type: "error", + }); + } } finally { setMutating(false); } From 0c6fcfa89ec2d0928873340705ad924eca4dff68 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 2 Feb 2026 13:00:07 +0200 Subject: [PATCH 9/9] WIP Signed-off-by: prxt6529 --- .../drop-reacted/NotificationDropReactedGroup.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx index 8eaab2dd0c..df1a5dfaa1 100644 --- a/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx +++ b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx @@ -123,7 +123,9 @@ export default function NotificationDropReactedGroup({ const href = profile.handle ? `/${profile.handle}` : undefined; const displayName = profile.handle ?? - (profile.id != null ? String(profile.id) : undefined); + (profile.id === null || profile.id === undefined + ? undefined + : String(profile.id)); const title = displayName !== undefined && displayName !== "" ? displayName