diff --git a/components/brain/notifications/subcomponents/CollapsibleDropPreview.tsx b/components/brain/notifications/subcomponents/CollapsibleDropPreview.tsx new file mode 100644 index 0000000000..d8b40b0a32 --- /dev/null +++ b/components/brain/notifications/subcomponents/CollapsibleDropPreview.tsx @@ -0,0 +1,388 @@ +"use client"; + +import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + +const TOP_HEIGHT_PX = 180; +const BOTTOM_HEIGHT_PX = 160; + +const EXPAND_FADE_PX = 40; +const EXPAND_SOLID_PX = 40; +const EXPAND_SECTION_HEIGHT_PX = + EXPAND_FADE_PX + EXPAND_SOLID_PX + EXPAND_FADE_PX; + +const EXPAND_ANIMATION_MS = 250; + +const COLLAPSED_TOTAL_PX = TOP_HEIGHT_PX + BOTTOM_HEIGHT_PX; + +interface CollapsibleDropPreviewProps { + readonly children: React.ReactElement; +} + +export default function CollapsibleDropPreview({ + children, +}: CollapsibleDropPreviewProps) { + const hostRef = useRef(null); + const measureRef = useRef(null); + const bottomSliceContentRef = useRef(null); + const expandedRef = useRef(null); + + const [hostWidth, setHostWidth] = useState(0); + const [hasMeasured, setHasMeasured] = useState(false); + const [measuredHeight, setMeasuredHeight] = useState(0); + const [bottomSliceHeight, setBottomSliceHeight] = useState(0); + + const [isExpanded, setIsExpanded] = useState(false); + const [animateMaxHeight, setAnimateMaxHeight] = useState(null); + + const prefersReducedMotion = useMemo(() => { + if (globalThis.window === undefined) return false; + return globalThis.window.matchMedia("(prefers-reduced-motion: reduce)") + .matches; + }, []); + + const isOverflowing = useMemo(() => { + if (!hasMeasured) return false; + return measuredHeight > COLLAPSED_TOTAL_PX; + }, [hasMeasured, measuredHeight]); + + // 1) Track the actual rendered width of the host container + useEffect(() => { + const el = hostRef.current; + if (!el) return; + + const apply = () => { + const w = Math.max(el.getBoundingClientRect().width, 0); + if (w > 0) setHostWidth(w); + }; + + apply(); + + const ro = new ResizeObserver(() => apply()); + ro.observe(el); + + const raf = requestAnimationFrame(apply); + const t = setTimeout(apply, 150); + + return () => { + ro.disconnect(); + cancelAnimationFrame(raf); + clearTimeout(t); + }; + }, []); + + // 2) Measure full height in a fixed-position, invisible box with exact width + const measureNow = useCallback(() => { + const el = measureRef.current; + if (!el) return; + + // Use max of scroll/offset/rect — different layouts behave differently + const rectH = el.getBoundingClientRect().height; + const h = Math.max(el.scrollHeight, el.offsetHeight, rectH, 0); + + if (h > 0) { + setMeasuredHeight(h); + setHasMeasured(true); + } + }, []); + + useLayoutEffect(() => { + if (hasMeasured) return; + measureNow(); + }, [hasMeasured, measureNow]); + + useEffect(() => { + if (hostWidth <= 0) return; + + measureNow(); + + const el = measureRef.current; + if (!el) return; + + const ro = new ResizeObserver(() => measureNow()); + ro.observe(el); + + const raf = requestAnimationFrame(measureNow); + const t = setTimeout(measureNow, 150); + + return () => { + ro.disconnect(); + cancelAnimationFrame(raf); + clearTimeout(t); + }; + }, [hostWidth, measureNow]); + + const onExpand = useCallback(() => { + if (!prefersReducedMotion) { + setAnimateMaxHeight(COLLAPSED_TOTAL_PX); + } + setIsExpanded(true); + }, [prefersReducedMotion]); + + useEffect(() => { + if (!isExpanded) return; + if (measuredHeight <= 0) return; + + if (prefersReducedMotion) { + setAnimateMaxHeight(measuredHeight); + expandedRef.current?.scrollIntoView({ behavior: "auto", block: "start" }); + return; + } + + const id = requestAnimationFrame(() => { + requestAnimationFrame(() => setAnimateMaxHeight(measuredHeight)); + }); + + const t = setTimeout(() => { + expandedRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }, EXPAND_ANIMATION_MS); + + return () => { + cancelAnimationFrame(id); + clearTimeout(t); + }; + }, [isExpanded, measuredHeight, prefersReducedMotion]); + + const measureBottomSlice = useCallback(() => { + const el = bottomSliceContentRef.current; + if (!el) return; + const h = Math.max( + el.scrollHeight, + el.offsetHeight, + el.getBoundingClientRect().height, + 0 + ); + if (h > 0) setBottomSliceHeight(h); + }, []); + + useLayoutEffect(() => { + if (!hasMeasured || !isOverflowing) return; + measureBottomSlice(); + const el = bottomSliceContentRef.current; + if (!el) return; + const ro = new ResizeObserver(measureBottomSlice); + ro.observe(el); + const raf = requestAnimationFrame(measureBottomSlice); + const t = setTimeout(measureBottomSlice, 150); + return () => { + ro.disconnect(); + cancelAnimationFrame(raf); + clearTimeout(t); + }; + }, [hasMeasured, isOverflowing, measureBottomSlice]); + + if (hasMeasured && !isOverflowing) { + return ( +
+ {children} + {hostWidth > 0 && ( +
+
+ {children} +
+
+ )} +
+ ); + } + + // Expanded render + if (hasMeasured && isOverflowing && isExpanded) { + const maxH = animateMaxHeight ?? measuredHeight; + + return ( +
+ {hostWidth > 0 && ( +
+
+ {children} +
+
+ )} + +
+ {children} +
+
+ ); + } + + const bottomContentHeight = + bottomSliceHeight > 0 ? bottomSliceHeight : measuredHeight; + const minBottomOffset = TOP_HEIGHT_PX + EXPAND_SECTION_HEIGHT_PX; + const bottomOffset = Math.max( + bottomContentHeight - BOTTOM_HEIGHT_PX, + minBottomOffset + ); + + return ( +
+ {hostWidth > 0 && hasMeasured && ( +
+
+ {children} +
+
+ )} + + {hasMeasured ? ( +
+
+
+ {React.cloneElement(children, { key: "drop-top" })} +
+
+ +
+
+ {React.cloneElement(children, { key: "drop-bottom" })} +
+ +
+
+ +
+
+ {React.cloneElement(children, { key: "drop-expand-underlay" })} +
+
+
+
+ +
+
+ ) : ( +
+ {children} +
+ )} +
+ ); +} diff --git a/components/brain/notifications/subcomponents/NotificationDrop.tsx b/components/brain/notifications/subcomponents/NotificationDrop.tsx index de0d9ce664..bdeecc966d 100644 --- a/components/brain/notifications/subcomponents/NotificationDrop.tsx +++ b/components/brain/notifications/subcomponents/NotificationDrop.tsx @@ -1,17 +1,21 @@ "use client"; -import type { - DropInteractionParams} from "@/components/waves/drops/Drop"; -import Drop, { - DropLocation, -} from "@/components/waves/drops/Drop"; +import type { DropInteractionParams } from "@/components/waves/drops/Drop"; +import Drop, { DropLocation } from "@/components/waves/drops/Drop"; import type { ApiDrop } from "@/generated/models/ApiDrop"; -import type { - ExtendedDrop} from "@/helpers/waves/drop.helpers"; -import { - convertApiDropToExtendedDrop -} from "@/helpers/waves/drop.helpers"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { convertApiDropToExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; +import type { ReactNode } from "react"; +import CollapsibleDropPreview from "./CollapsibleDropPreview"; + +function wrapDropContentInCollapsible(content: ReactNode) { + return ( + +
{content}
+
+ ); +} interface NotificationDropProps { readonly drop: ApiDrop; @@ -49,6 +53,7 @@ export default function NotificationDrop({ onReplyClick={onReplyClick} onQuoteClick={onQuoteClick} onDropContentClick={onDropContentClick} + wrapContentOnly={wrapDropContentInCollapsible} /> ); } diff --git a/components/drops/view/UnreadDivider.tsx b/components/drops/view/UnreadDivider.tsx index 181a4e1384..c8837b5998 100644 --- a/components/drops/view/UnreadDivider.tsx +++ b/components/drops/view/UnreadDivider.tsx @@ -10,12 +10,12 @@ const UnreadDivider = memo(function UnreadDivider({ label = "New Messages", }: UnreadDividerProps) { return ( -
-
- +
+
+ {label} -
+
); }); diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index 94d8caf45a..15baa6fac2 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -64,7 +64,7 @@ const InlineCodeRenderer = ({ {...props} style={{ ...style, textOverflow: "unset" }} className={mergeClassNames( - "tw-text-iron-200 tw-whitespace-pre-wrap tw-break-words", + "tw-whitespace-pre-wrap tw-break-words tw-text-iron-200", className )} > @@ -126,7 +126,7 @@ const CodeBlockRenderer = ({ ref={codeRef} style={{ ...style, textOverflow: "unset" }} className={mergeClassNames( - "tw-text-iron-200 tw-whitespace-pre-wrap tw-break-words", + "tw-whitespace-pre-wrap tw-break-words tw-text-iron-200", className )} > @@ -173,12 +173,13 @@ const createMarkdownComponents = ({ const ListItemRenderer = ({ children, className, + node: _node, ...props }: MarkdownRendererProps<"li">) => (
  • @@ -194,7 +195,7 @@ const createMarkdownComponents = ({
    diff --git a/components/user/collected/UserPageCollected.tsx b/components/user/collected/UserPageCollected.tsx index 36e7991ce0..5e23387bc9 100644 --- a/components/user/collected/UserPageCollected.tsx +++ b/components/user/collected/UserPageCollected.tsx @@ -464,7 +464,7 @@ export default function UserPageCollected({ queryKey: [QueryKey.PROFILE_COLLECTED_TRANSFER, filters, connectedAddress], queryFn: async () => { try { - const allPagesUrl = `${publicEnv.API_ENDPOINT}/api/profiles/${connectedAddress}/collected?page_size=200&seized=${CollectionSeized.SEIZED}`; + const allPagesUrl = `${publicEnv.API_ENDPOINT}/api/profiles/${connectedAddress}/collected?page_size=200&seized=${CollectionSeized.SEIZED}&account_for_consolidations=false`; return await fetchAllPages(allPagesUrl); } catch (error) { console.error( diff --git a/components/waves/drops/Drop.tsx b/components/waves/drops/Drop.tsx index 5aee444815..0c980cf29d 100644 --- a/components/waves/drops/Drop.tsx +++ b/components/waves/drops/Drop.tsx @@ -1,19 +1,18 @@ "use client"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { ApiDropType } from "@/generated/models/ApiDropType"; import type { Drop as DropType, - ExtendedDrop} from "@/helpers/waves/drop.helpers"; -import { - DropSize + ExtendedDrop, } from "@/helpers/waves/drop.helpers"; +import { DropSize } from "@/helpers/waves/drop.helpers"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; -import WaveDrop from "./WaveDrop"; -import type { ApiDrop } from "@/generated/models/ApiDrop"; -import { ApiDropType } from "@/generated/models/ApiDropType"; +import { useMemo } from "react"; +import DropContext from "./DropContext"; import ParticipationDrop from "./participation/ParticipationDrop"; +import WaveDrop from "./WaveDrop"; import WinnerDrop from "./winner/WinnerDrop"; -import DropContext from "./DropContext"; -import { useMemo } from "react"; export interface DropInteractionParams { drop: ExtendedDrop; @@ -41,6 +40,7 @@ interface DropProps { readonly onQuoteClick: (drop: ApiDrop) => void; readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; readonly parentContainerRef?: React.RefObject | undefined; + readonly wrapContentOnly?: (content: React.ReactNode) => React.ReactNode; } export default function Drop({ @@ -58,6 +58,7 @@ export default function Drop({ onDropContentClick, showReplyAndQuote, parentContainerRef, + wrapContentOnly, }: DropProps) { const components: Record = { [ApiDropType.Participatory]: ( @@ -109,6 +110,7 @@ export default function Drop({ onQuoteClick={onQuoteClick} onDropContentClick={onDropContentClick} showReplyAndQuote={showReplyAndQuote} + wrapContentOnly={wrapContentOnly} /> ), }; diff --git a/components/waves/drops/WaveDrop.tsx b/components/waves/drops/WaveDrop.tsx index 1a230d602d..de1615f57e 100644 --- a/components/waves/drops/WaveDrop.tsx +++ b/components/waves/drops/WaveDrop.tsx @@ -12,8 +12,8 @@ import useHasTouchInput from "@/hooks/useHasTouchInput"; import { selectEditingDropId, setEditingDropId } from "@/store/editSlice"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; import { memo, useCallback, useEffect, useRef, useState } from "react"; -import { createBreakpoint } from "react-use"; import { useDispatch, useSelector } from "react-redux"; +import { createBreakpoint } from "react-use"; import type { DropInteractionParams } from "./Drop"; import { DropLocation } from "./Drop"; import type { BoostAnimationState } from "./DropBoostAnimation"; @@ -135,6 +135,9 @@ interface WaveDropProps { readonly onReplyClick: (serialNo: number) => void; readonly onQuoteClick: (drop: ApiDrop) => void; readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; + readonly wrapContentOnly?: + | ((content: React.ReactNode) => React.ReactNode) + | undefined; } const WaveDrop = ({ @@ -151,6 +154,7 @@ const WaveDrop = ({ onQuoteClick, onDropContentClick, showReplyAndQuote, + wrapContentOnly, }: WaveDropProps) => { const [activePartIndex, setActivePartIndex] = useState(0); const [isSlideUp, setIsSlideUp] = useState(false); @@ -365,6 +369,94 @@ const WaveDrop = ({ isDrop ); + const contentBlock = ( + <> + {drop.reply_to && + (drop.reply_to.drop_id !== previousDrop?.reply_to?.drop_id || + drop.author.handle !== previousDrop?.author?.handle) && + drop.reply_to.drop_id !== dropViewDropId && ( + + )} +
    + {showAuthorInfo && } +
    + {showAuthorInfo && ( + + )} +
    + +
    +
    +
    + {!isMobile && showReplyAndQuote && !isEditing && ( + + )} + + ); + + const reactionsRow = ( +
    + {drop.metadata.length > 0 && ( + + )} + {!!drop.raters_count && } + +
    + ); + return (
    - {drop.reply_to && - (drop.reply_to.drop_id !== previousDrop?.reply_to?.drop_id || - drop.author.handle !== previousDrop.author.handle) && - drop.reply_to.drop_id !== dropViewDropId && ( - - )} -
    - {showAuthorInfo && } -
    - {showAuthorInfo && ( - - )} -
    - -
    -
    -
    - {!isMobile && showReplyAndQuote && !isEditing && ( - - )} -
    - {drop.metadata.length > 0 && ( - - )} - {!!drop.raters_count && } - -
    + {wrapContentOnly ? wrapContentOnly(contentBlock) : contentBlock} + {reactionsRow} ("hidden"); const [hasSeenDivider, setHasSeenDivider] = useState(false); + const [userDismissed, setUserDismissed] = useState(false); const [swipeOffset, setSwipeOffset] = useState(0); const swipeOffsetRef = useRef(0); const touchStartXRef = useRef(null); @@ -35,6 +36,10 @@ export const WaveDropsScrollToUnreadButton: FC< const hasAutoDismissedRef = useRef(false); const lastSerialNoRef = useRef(null); + useEffect(() => { + setUserDismissed(false); + }, [unreadDividerSerialNo]); + const checkUnreadVisibility = useCallback(() => { if (lastSerialNoRef.current !== unreadDividerSerialNo) { lastSerialNoRef.current = unreadDividerSerialNo; @@ -122,6 +127,25 @@ export const WaveDropsScrollToUnreadButton: FC< checkUnreadVisibility(); }, [unreadDividerSerialNo, checkUnreadVisibility]); + const isButtonVisible = + unreadPosition !== "hidden" && + unreadDividerSerialNo !== null && + !hasSeenDivider && + !userDismissed; + + useEffect(() => { + if (!isButtonVisible) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + const active = document.activeElement; + if (active?.closest('[role="dialog"]')) return; + setUserDismissed(true); + onDismiss(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isButtonVisible, onDismiss, userDismissed]); + const handleDismiss = useCallback( (e: React.MouseEvent) => { if (suppressClickRef.current) { @@ -131,6 +155,7 @@ export const WaveDropsScrollToUnreadButton: FC< return; } e.stopPropagation(); + setUserDismissed(true); onDismiss(); }, [onDismiss] @@ -151,6 +176,7 @@ export const WaveDropsScrollToUnreadButton: FC< const handleTouchEnd = useCallback(() => { if (swipeOffsetRef.current >= SWIPE_THRESHOLD) { suppressClickRef.current = true; + setUserDismissed(true); onDismiss(); } swipeOffsetRef.current = 0; @@ -174,6 +200,7 @@ export const WaveDropsScrollToUnreadButton: FC< if (!isDraggingRef.current) return; if (swipeOffsetRef.current >= SWIPE_THRESHOLD) { suppressClickRef.current = true; + setUserDismissed(true); onDismiss(); } swipeOffsetRef.current = 0; @@ -186,6 +213,7 @@ export const WaveDropsScrollToUnreadButton: FC< if (isDraggingRef.current) { if (swipeOffsetRef.current >= SWIPE_THRESHOLD) { suppressClickRef.current = true; + setUserDismissed(true); onDismiss(); } swipeOffsetRef.current = 0; @@ -198,7 +226,8 @@ export const WaveDropsScrollToUnreadButton: FC< if ( unreadPosition === "hidden" || unreadDividerSerialNo === null || - hasSeenDivider + hasSeenDivider || + userDismissed ) { return null; } @@ -228,7 +257,7 @@ export const WaveDropsScrollToUnreadButton: FC<
    diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsSerialScroll.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsSerialScroll.ts index 883d17f97a..9cd40269d5 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsSerialScroll.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsSerialScroll.ts @@ -1,3 +1,5 @@ +import { DropSize } from "@/helpers/waves/drop.helpers"; +import type { useVirtualizedWaveDrops } from "@/hooks/useVirtualizedWaveDrops"; import { useCallback, useEffect, @@ -5,8 +7,6 @@ import { useState, type MutableRefObject, } from "react"; -import type { useVirtualizedWaveDrops } from "@/hooks/useVirtualizedWaveDrops"; -import { DropSize } from "@/helpers/waves/drop.helpers"; import { delay } from "../utils/delay"; type VirtualizedWaveDropsResult = ReturnType; @@ -64,6 +64,7 @@ export const useWaveDropsSerialScroll = ({ const latestWaveMessagesRef = useRef(waveMessages); const smallestSerialNo = useRef(null); + const lastScrolledToSerialRef = useRef(null); const [init, setInit] = useState(false); const queueSerialTarget = useCallback((serialNo: number) => { @@ -205,6 +206,7 @@ export const useWaveDropsSerialScroll = ({ scrollOperationAbortController.current = new AbortController(); const signal = scrollOperationAbortController.current.signal; + let didSucceed = false; try { if (signal.aborted) { scrollOperationLockRef.current = false; @@ -236,19 +238,24 @@ export const useWaveDropsSerialScroll = ({ } const success = await smoothScrollWithRetries(); + didSucceed = success; if (!signal.aborted) { setTimeout(() => { if (success && !signal.aborted) { setSerialTarget(null); + lastScrolledToSerialRef.current = null; } - }, 500); + }, 600); } } catch (error) { if (!signal.aborted) { console.warn("Scroll operation failed:", error); } } finally { + if (!didSucceed || signal.aborted) { + lastScrolledToSerialRef.current = null; + } scrollOperationLockRef.current = false; setIsScrolling(false); } @@ -263,18 +270,24 @@ export const useWaveDropsSerialScroll = ({ ]); useEffect(() => { - if (init && serialTarget && !isScrolling) { - const currentSmallestSerial = smallestSerialNo.current; - if (currentSmallestSerial && currentSmallestSerial <= serialTarget) { - const success = scrollToSerialNo("smooth"); - if (success) { + if (!init || !serialTarget || isScrolling) return; + if (lastScrolledToSerialRef.current === serialTarget) return; + const currentSmallestSerial = smallestSerialNo.current; + if (currentSmallestSerial && currentSmallestSerial <= serialTarget) { + lastScrolledToSerialRef.current = serialTarget; + const success = scrollToSerialNo("smooth"); + if (success) { + setTimeout(() => { setSerialTarget(null); - } else { - fetchAndScrollToDrop(); - } + lastScrolledToSerialRef.current = null; + }, 600); } else { + lastScrolledToSerialRef.current = null; fetchAndScrollToDrop(); } + } else { + lastScrolledToSerialRef.current = serialTarget; + fetchAndScrollToDrop(); } }, [init, serialTarget, fetchAndScrollToDrop, scrollToSerialNo, isScrolling]); diff --git a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx index e60b8f14d8..10ffadfc3b 100644 --- a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx +++ b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx @@ -76,7 +76,8 @@ export const WaveDropsContent: React.FC = ({ onBoostedDropClick, onScrollToUnread, }) => { - const { unreadDividerSerialNo, setUnreadDividerSerialNo } = useUnreadDivider(); + const { unreadDividerSerialNo, setUnreadDividerSerialNo } = + useUnreadDivider(); const handleDismissUnread = () => { setUnreadDividerSerialNo(null);