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..ac19e703e8 --- /dev/null +++ b/components/brain/notifications/NotificationsFollowAllBtn.tsx @@ -0,0 +1,104 @@ +"use client"; + +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, + UserFollowBtnSize, +} from "@/components/user/utils/UserFollowBtn"; +import type { ApiIdentitySubscriptionActions } from "@/generated/models/ApiIdentitySubscriptionActions"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import { commonApiPost } from "@/services/api/common-api"; +import type { FC } from "react"; +import { useContext, useState } from "react"; +import { + DEFAULT_SUBSCRIPTION_BODY, + FollowBtnCheckIcon, + FollowBtnPlusIcon, +} from "./notificationsFollowShared"; + +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 { + const results = await Promise.allSettled( + toFollow.map((profile) => + commonApiPost< + ApiIdentitySubscriptionActions, + ApiIdentitySubscriptionActions + >({ + endpoint: `identities/${profile.handle}/subscriptions`, + body: DEFAULT_SUBSCRIPTION_BODY, + }) + ) + ); + 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); + } + }; + + return ( +
+ +
+ ); +}; + +export default NotificationsFollowAllBtn; diff --git a/components/brain/notifications/NotificationsFollowBtn.tsx b/components/brain/notifications/NotificationsFollowBtn.tsx index e9b82e15b2..2c9eee7077 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; @@ -43,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: () => { @@ -71,11 +70,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 +109,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/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..df1a5dfaa1 --- /dev/null +++ b/components/brain/notifications/drop-reacted/NotificationDropReactedGroup.tsx @@ -0,0 +1,188 @@ +"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 { Fragment, useEffect, 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"; + +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; +} + +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; + 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 idsKey = ids.join(","); + const latestPerUser = notificationsLatestPerUser(notifications); + const fullReactors = latestPerUser.map((n) => n.related_identity); + const reactionGroups = groupLatestByReaction(latestPerUser); + + useEffect(() => { + hasMarkedRef.current = false; + }, [idsKey]); + + const handleDropContentClick = (clickedDrop: ExtendedDrop) => { + if (!hasMarkedRef.current && onMarkAsRead) { + hasMarkedRef.current = true; + onMarkAsRead(ids); + } + onDropContentClick?.(clickedDrop); + }; + + return ( +
+
+
+
+ + New reactions + + {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 || profile.id === undefined + ? undefined + : String(profile.id)); + 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/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/notificationsFollowShared.tsx b/components/brain/notifications/notificationsFollowShared.tsx new file mode 100644 index 0000000000..9269ad3283 --- /dev/null +++ b/components/brain/notifications/notificationsFollowShared.tsx @@ -0,0 +1,55 @@ +import type { UserFollowBtnSize } from "@/components/user/utils/UserFollowBtn"; +import { FOLLOW_BTN_SVG_CLASSES } from "@/components/user/utils/UserFollowBtn"; +import type { ApiIdentitySubscriptionActions } from "@/generated/models/ApiIdentitySubscriptionActions"; +import { ApiIdentitySubscriptionTargetAction } from "@/generated/models/ApiIdentitySubscriptionTargetAction"; + +export const DEFAULT_SUBSCRIPTION_BODY: ApiIdentitySubscriptionActions = { + actions: Object.values(ApiIdentitySubscriptionTargetAction).filter( + (i) => i !== ApiIdentitySubscriptionTargetAction.DropVoted + ), +}; + +export function FollowBtnCheckIcon() { + return ( + + ); +} + +export function FollowBtnPlusIcon({ + size, +}: { + readonly size: UserFollowBtnSize; +}) { + return ( + + ); +} diff --git a/components/brain/notifications/subcomponents/NotificationsContent.tsx b/components/brain/notifications/subcomponents/NotificationsContent.tsx index caa839b9aa..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 { TypedNotification } from "@/types/feed.types"; -import type { ActiveDropState } from "@/types/dropInteractionTypes"; import NotificationsStateMessage from "./NotificationsStateMessage"; interface NotificationsContentProps { @@ -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,10 +43,11 @@ export default function NotificationsContent({ loadingOlder, activeDrop, setActiveDrop, + markNotificationIdsAsRead, }: NotificationsContentProps): ReactNode { if (isLoadingProfile) { return ( -
+
); @@ -73,7 +75,10 @@ export default function NotificationsContent({ return ( ); } @@ -89,7 +94,7 @@ export default function NotificationsContent({ if (showLoader) { return ( -
+
); @@ -97,7 +102,7 @@ export default function NotificationsContent({ if (showNoItems) { return ( -
+
); @@ -109,6 +114,9 @@ export default function NotificationsContent({ loadingOlder={loadingOlder} activeDrop={activeDrop} setActiveDrop={setActiveDrop} + {...(markNotificationIdsAsRead !== undefined && { + markNotificationIdsAsRead, + })} /> ); } diff --git a/components/brain/notifications/utils/groupReactionNotifications.ts b/components/brain/notifications/utils/groupReactionNotifications.ts new file mode 100644 index 0000000000..a3fe52f98e --- /dev/null +++ b/components/brain/notifications/utils/groupReactionNotifications.ts @@ -0,0 +1,87 @@ +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; + anchorCreatedAt: 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, + anchorCreatedAt: item.created_at, + notifications: [item], + }); + return; + } + existing.notifications.push(item); + if (item.created_at >= existing.anchorCreatedAt) { + existing.anchorIndex = index; + existing.anchorCreatedAt = item.created_at; + } + }); + + 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), + notifications[0]! + ); + 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..8aa90f8211 --- /dev/null +++ b/components/common/OverlappingAvatars.tsx @@ -0,0 +1,128 @@ +"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; + readonly pfpUrl: string | null; + readonly href?: string; + readonly ariaLabel?: string; + readonly fallback?: string; + readonly title?: 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 baseId = useId(); + const isTouchDevice = useIsTouchDevice(); + 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) => { + 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 ? ( + {item.ariaLabel + ) : ( +
+ + {item.fallback ?? "?"} + +
+ ); + + const showTooltip = + !isTouchDevice && item.title !== undefined && item.title !== ""; + const tooltipId = showTooltip ? `${baseId}-${index}` : undefined; + const wrapper = ( +
+ {content} +
+ ); + + const anchor = item.href ? ( + onItemClick?.(e, item)} + > + {wrapper} + + ) : ( + wrapper + ); + + if (showTooltip && tooltipId !== undefined) { + return ( + + {anchor} + + {item.title} + + + ); + } + return anchor; + })} +
+ ); +} diff --git a/components/waves/list/WaveItemDropped.tsx b/components/waves/list/WaveItemDropped.tsx index 26598656a5..4005ac5329 100644 --- a/components/waves/list/WaveItemDropped.tsx +++ b/components/waves/list/WaveItemDropped.tsx @@ -1,110 +1,42 @@ "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 - )} -
- ); - })} +
+
+ { + const href = c.contributor_identity + ? `/${c.contributor_identity}` + : 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" + 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..ee31e63687 100644 --- a/hooks/useNotificationsQuery.tsx +++ b/hooks/useNotificationsQuery.tsx @@ -1,10 +1,18 @@ "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"; -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"; 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,30 @@ 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(() => { + 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; - // 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..81d24e4f10 100644 --- a/types/feed.types.ts +++ b/types/feed.types.ts @@ -151,6 +151,27 @@ 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.type === "grouped_reactions" + ); +} + /** * Fallback type for unknown/unsupported notification causes. * Used to render generic notifications that don't match known causes.