diff --git a/packages/shared/src/components/Markdown.tsx b/packages/shared/src/components/Markdown.tsx index 768a70b1c88..9a833b2275b 100644 --- a/packages/shared/src/components/Markdown.tsx +++ b/packages/shared/src/components/Markdown.tsx @@ -14,6 +14,10 @@ import useDebounceFn from '../hooks/useDebounceFn'; import { useDomPurify } from '../hooks/useDomPurify'; import { getUserShortInfo } from '../graphql/users'; import { generateQueryKey, RequestKey } from '../lib/query'; +import { useLazyModal } from '../hooks/useLazyModal'; +import { LazyModal } from './modals/common/types'; +import { getImageOriginRect } from './modals/ImageModal'; +import { useRequestProtocol } from '../hooks/useRequestProtocol'; function isImageElement( element: Element | EventTarget, @@ -21,10 +25,6 @@ function isImageElement( return element instanceof HTMLImageElement; } -function openImageInNewTab(src: string): void { - window.open(src, '_blank', 'noopener,noreferrer'); -} - const UserEntityCard = dynamic(() => import('./cards/entity/UserEntityCard'), { ssr: false, }); @@ -65,6 +65,8 @@ export default function Markdown({ openLinksInNewTab = false, }: MarkdownProps): ReactElement { const purify = useDomPurify(); + const { openModal } = useLazyModal(); + const { isCompanion } = useRequestProtocol(); const containerRef = useRef(null); const [userId, setUserId] = useState(''); const [offset, setOffset] = useState([0, 0]); @@ -91,7 +93,7 @@ export default function Markdown({ images.forEach((img) => { img.setAttribute('tabindex', '0'); img.setAttribute('role', 'button'); - img.setAttribute('aria-label', 'Open image in new tab'); + img.setAttribute('aria-label', 'Open image'); }); }); @@ -136,28 +138,54 @@ export default function Markdown({ [cancelUserClearing, userId, clearUser], ); - const onImageClick = useCallback((e: MouseEvent) => { - const element = e.target; + const openImage = useCallback( + (element: HTMLImageElement) => { + // The lazy-modal renderer isn't mounted in the extension companion, so + // fall back to opening the image in a new tab there. + if (isCompanion) { + window.open(element.src, '_blank', 'noopener,noreferrer'); + return; + } + openModal({ + type: LazyModal.ImageView, + props: { + src: element.src, + alt: element.alt || undefined, + originRect: getImageOriginRect(element), + }, + }); + }, + [isCompanion, openModal], + ); + + const onImageClick = useCallback( + (e: MouseEvent) => { + const element = e.target; - if (isImageElement(element) && element.src) { - e.stopPropagation(); - openImageInNewTab(element.src); - } - }, []); - - const onImageKeyDown = useCallback((e: KeyboardEvent) => { - const element = e.target; - - if ( - isImageElement(element) && - element.src && - (e.key === 'Enter' || e.key === ' ') - ) { - e.preventDefault(); - e.stopPropagation(); - openImageInNewTab(element.src); - } - }, []); + if (isImageElement(element) && element.src) { + e.stopPropagation(); + openImage(element); + } + }, + [openImage], + ); + + const onImageKeyDown = useCallback( + (e: KeyboardEvent) => { + const element = e.target; + + if ( + isImageElement(element) && + element.src && + (e.key === 'Enter' || e.key === ' ') + ) { + e.preventDefault(); + e.stopPropagation(); + openImage(element); + } + }, + [openImage], + ); return ( diff --git a/packages/shared/src/components/modals/CollectionPostModal.tsx b/packages/shared/src/components/modals/CollectionPostModal.tsx index 62e893b8231..e244a912d81 100644 --- a/packages/shared/src/components/modals/CollectionPostModal.tsx +++ b/packages/shared/src/components/modals/CollectionPostModal.tsx @@ -42,6 +42,7 @@ export default function CollectionPostModal({ onAfterOpen={onLoad} size={showRedesign ? Modal.Size.Large : Modal.Size.XLarge} className={showRedesign ? 'laptop:!overflow-clip' : undefined} + navigationRedesign={showRedesign} onRequestClose={onRequestClose} postType={PostType.Collection} source={post.source} diff --git a/packages/shared/src/components/modals/ImageModal.spec.tsx b/packages/shared/src/components/modals/ImageModal.spec.tsx new file mode 100644 index 00000000000..03354b6b198 --- /dev/null +++ b/packages/shared/src/components/modals/ImageModal.spec.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import ImageModal from './ImageModal'; + +const src = 'https://media.daily.dev/image.png'; + +const setup = (onRequestClose = jest.fn()) => { + render( + , + ); + return onRequestClose; +}; + +describe('ImageModal', () => { + it('renders the image full-screen with its alt and contained sizing', () => { + setup(); + const img = screen.getByAltText('Cover image'); + expect(img).toHaveAttribute('src', src); + expect(img).toHaveClass('object-contain', 'max-h-full', 'max-w-full'); + }); + + it('closes when the backdrop is clicked', () => { + const onRequestClose = setup(); + const [overlay] = screen.getAllByRole('button', { name: 'Close' }); + fireEvent.click(overlay); + expect(onRequestClose).toHaveBeenCalled(); + }); + + it('closes on Escape', () => { + const onRequestClose = setup(); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(onRequestClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/modals/ImageModal.tsx b/packages/shared/src/components/modals/ImageModal.tsx new file mode 100644 index 00000000000..1cfd41878fb --- /dev/null +++ b/packages/shared/src/components/modals/ImageModal.tsx @@ -0,0 +1,203 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useLayoutEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import classNames from 'classnames'; +import type ReactModal from 'react-modal'; +import { ButtonSize, ButtonVariant } from '../buttons/Button'; +import CloseButton from '../CloseButton'; +import { useEventListener } from '../../hooks'; + +export interface ImageOriginRect { + top: number; + left: number; + width: number; + height: number; +} + +/** + * Snapshot an element's viewport bounds for the lightbox FLIP entrance (see + * `ImageModal`). Shared by every surface that opens `LazyModal.ImageView` so + * the captured shape stays in one place. + */ +export const getImageOriginRect = (element: Element): ImageOriginRect => { + const { top, left, width, height } = element.getBoundingClientRect(); + return { top, left, width, height }; +}; + +interface ImageModalProps extends ReactModal.Props { + src: string; + alt?: string; + /** Bounds of the thumbnail that was clicked, used to animate the image + * expanding from it into the full view (a FLIP transition). */ + originRect?: ImageOriginRect; +} + +/** + * Full-screen image lightbox. Unlike the standard Modal card (fixed width + top + * margins), this fills the viewport and centers the image on both axes, sizing + * it with `max-h-full max-w-full object-contain` inside a padded container — so + * the image is always fully visible and scales down with the screen while + * preserving its aspect ratio. Mirrors the workspace-photos lightbox. + * + * Portaled into `document.body` so it stacks above the post modal (which lives + * in its own body-level ReactModal portal); rendering inline would leave it + * trapped under that portal at the same z-index. + */ +export default function ImageModal({ + onRequestClose, + src, + alt = 'Image', + originRect, +}: ImageModalProps): ReactElement | null { + const close = () => onRequestClose?.(undefined as never); + const imgRef = useRef(null); + const hasPlayedRef = useRef(false); + + // FLIP: render the image at its final centered size, but start it transformed + // to sit over the clicked thumbnail, then animate the transform away so it + // visually grows from the thumbnail into the full view. + const playFlip = () => { + const img = imgRef.current; + if (!originRect || !img || hasPlayedRef.current) { + return; + } + const final = img.getBoundingClientRect(); + if (!final.width || !final.height) { + // Not laid out yet (image still loading) — onLoad will retry. + return; + } + hasPlayedRef.current = true; + + // A single uniform scale (not independent sx/sy) so the image keeps its + // aspect ratio and never warps mid-flight. `min` contains the start frame + // within the thumbnail's footprint, so it reads as lifting off from there. + const scale = Math.min( + originRect.width / final.width, + originRect.height / final.height, + ); + // Translate centers together (transform-origin is the center too), so the + // image grows out of where the thumbnail sat rather than from a corner. + const dx = + originRect.left + originRect.width / 2 - (final.left + final.width / 2); + const dy = + originRect.top + originRect.height / 2 - (final.top + final.height / 2); + // Transform scales the corner radius along with everything else, so a fixed + // radius would render near-square at the small start scale and then "snap" + // round. Pre-divide by the scale so the rendered radius stays a constant + // ~16px (rounded-16) for the whole animation; read the resting value off the + // element so it tracks the class instead of a hardcoded number. + const restRadius = + parseFloat(getComputedStyle(img).borderTopLeftRadius) || 16; + + img.style.transformOrigin = 'center center'; + img.style.transition = 'none'; + img.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`; + img.style.borderRadius = `${restRadius / scale}px`; + img.style.opacity = '1'; + // Two frames: let the browser paint the start state first, then transition + // to identity — doing both in one frame can coalesce and snap. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + img.style.transition = + 'transform 300ms cubic-bezier(0.16, 1, 0.3, 1), border-radius 300ms cubic-bezier(0.16, 1, 0.3, 1)'; + img.style.transform = 'none'; + img.style.borderRadius = `${restRadius}px`; + }); + }); + }; + + useLayoutEffect(() => { + playFlip(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Close on Escape, but only this lightbox: capture the event and stop it + // before it reaches the underlying post modal's react-modal Esc handler — + // otherwise Esc would close both at once. + useEventListener( + globalThis as unknown as Window, + 'keydown', + (event) => { + if (event.key !== 'Escape') { + return; + } + event.stopImmediatePropagation(); + event.preventDefault(); + close(); + }, + true, + ); + + // Lock background scroll while open. Compensate for the removed scrollbar + // with matching padding so the page (and the FLIP's captured origin) doesn't + // shift sideways when the lightbox opens. Restores the previous values on + // close so a still-open underlying modal keeps its own (class-based) lock — + // where the scrollbar is already gone, so the width is 0 and we add nothing. + useEffect(() => { + const { style } = document.body; + const scrollbarWidth = + window.innerWidth - document.documentElement.clientWidth; + const previousOverflow = style.overflow; + const previousPaddingRight = style.paddingRight; + style.overflow = 'hidden'; + if (scrollbarWidth > 0) { + style.paddingRight = `${scrollbarWidth}px`; + } + return () => { + style.overflow = previousOverflow; + style.paddingRight = previousPaddingRight; + }; + }, []); + + const body = globalThis?.document?.body; + if (!body) { + return null; + } + + return createPortal( +
+
, + body, + ); +} diff --git a/packages/shared/src/components/modals/SharePostModal.tsx b/packages/shared/src/components/modals/SharePostModal.tsx index 4ae2b997800..86cff4932e1 100644 --- a/packages/shared/src/components/modals/SharePostModal.tsx +++ b/packages/shared/src/components/modals/SharePostModal.tsx @@ -42,6 +42,7 @@ export default function PostModal({ onAfterOpen={onLoad} size={showRedesign ? Modal.Size.Large : Modal.Size.XLarge} className={showRedesign ? 'laptop:!overflow-clip' : undefined} + navigationRedesign={showRedesign} onRequestClose={onRequestClose} postType={PostType.Share} source={post.source} diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index f4e7b4fdb85..199f5dfcce1 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -91,6 +91,10 @@ const VideoModal = dynamic( () => import(/* webpackChunkName: "videoModal" */ './VideoModal'), ); +const ImageModal = dynamic( + () => import(/* webpackChunkName: "imageModal" */ './ImageModal'), +); + const GenericReferralModal = dynamic( () => import( @@ -524,6 +528,7 @@ export const modals = { [LazyModal.VerifySession]: VerifySession, [LazyModal.GenericReferral]: GenericReferralModal, [LazyModal.Video]: VideoModal, + [LazyModal.ImageView]: ImageModal, [LazyModal.NewStreak]: NewStreakModal, [LazyModal.ReputationPrivileges]: ReputationPrivilegesModal, [LazyModal.MarketingCta]: MarketingCtaModal, diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index fdce8dbd699..dfda6a0d09c 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -41,6 +41,7 @@ export enum LazyModal { VerifySession = 'verifySession', GenericReferral = 'genericReferral', Video = 'video', + ImageView = 'imageView', NewStreak = 'newStreak', RecoverStreak = 'recoverStreak', ReputationPrivileges = 'reputationPrivileges', diff --git a/packages/shared/src/components/post/PostHeaderActions.tsx b/packages/shared/src/components/post/PostHeaderActions.tsx index 90feaad3d01..0a633c783c9 100644 --- a/packages/shared/src/components/post/PostHeaderActions.tsx +++ b/packages/shared/src/components/post/PostHeaderActions.tsx @@ -37,6 +37,7 @@ export function PostHeaderActions({ isFixedNavigation, buttonSize, hideSubscribeAction, + hideOptions, ...props }: PostHeaderActionsProps): ReactElement { const { openNewTab } = useContext(SettingsContext); @@ -126,11 +127,13 @@ export function PostHeaderActions({ {isCollection && !hideSubscribeAction && ( )} - + {!hideOptions && ( + + )} ); } diff --git a/packages/shared/src/components/post/common.tsx b/packages/shared/src/components/post/common.tsx index e631692ac7b..8f4fd8777da 100644 --- a/packages/shared/src/components/post/common.tsx +++ b/packages/shared/src/components/post/common.tsx @@ -31,6 +31,7 @@ type PostActions = Pick< | 'inlineActions' | 'isFixedNavigation' | 'hideSubscribeAction' + | 'hideOptions' >; export interface PostNavigationClassName { @@ -69,6 +70,8 @@ export interface PostHeaderActionsProps { isFixedNavigation?: boolean; buttonSize?: ButtonSize; hideSubscribeAction?: boolean; + /** Hide the "…" options menu (e.g. when it already lives elsewhere). */ + hideOptions?: boolean; } export interface PostContentProps diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index 3d6fd7b5e1d..11783726f2f 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { UserVote } from '../../../graphql/posts'; -import { useVotePost } from '../../../hooks'; +import { useViewSize, useVotePost, ViewSize } from '../../../hooks'; import { useBookmarkPost } from '../../../hooks/useBookmarkPost'; import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; import { useCanAwardUser } from '../../../hooks/useCoresFeature'; @@ -22,7 +22,6 @@ import CloseButton from '../../CloseButton'; import { UpvoteButtonIcon } from '../../cards/common/UpvoteButtonIcon'; import { IconSize } from '../../Icon'; import { - AnalyticsIcon, DiscussIcon as CommentIcon, DownvoteIcon, LinkIcon, @@ -30,10 +29,8 @@ import { } from '../../icons'; import { Tooltip } from '../../tooltip/Tooltip'; import type { LoggedUser } from '../../../lib/user'; -import { canViewPostAnalytics } from '../../../lib/user'; -import { webappUrl } from '../../../lib/constants'; -import { PostMenuOptions } from '../PostMenuOptions'; import { PostClickbaitShield } from '../common/PostClickbaitShield'; +import { PostMenuOptions } from '../PostMenuOptions'; interface FocusCardActionBarProps { post: Post; @@ -70,20 +67,28 @@ export const FocusCardActionBar = ({ receivingUser: post.author as LoggedUser | undefined, }); - // Detect when the sticky bar is pinned to the top so the X close button - // (modal only) appears just for the stuck state. + // Track whether the bar is pinned, and at which edge. The sentinel sits just + // above the bar: when it scrolls above the viewport top the bar is pinned at + // the TOP; when it's still below the viewport the bar is floating at the + // BOTTOM. The modal's X is only useful at the top (where the top nav strip + // has scrolled away) — at the bottom that strip is still on screen. const sentinelRef = useRef(null); const barRef = useRef(null); const copyLinkRef = useRef(null); - const analyticsRef = useRef(null); const [isStuck, setIsStuck] = useState(false); + const [isStuckTop, setIsStuckTop] = useState(false); useEffect(() => { const el = sentinelRef.current; if (!el || typeof IntersectionObserver === 'undefined') { return undefined; } const observer = new IntersectionObserver( - ([entry]) => setIsStuck(!entry.isIntersecting), + ([entry]) => { + const stuck = !entry.isIntersecting; + setIsStuck(stuck); + const rootTop = entry.rootBounds?.top ?? 0; + setIsStuckTop(stuck && entry.boundingClientRect.top < rootTop); + }, { threshold: 0 }, ); observer.observe(el); @@ -93,24 +98,41 @@ export const FocusCardActionBar = ({ const isUpvoteActive = post?.userState?.vote === UserVote.Up; const isDownvoteActive = post?.userState?.vote === UserVote.Down; const isAwarded = !!post?.userState?.awarded; + // Counts are hidden in the resting bar (the stats row sitting right above it + // already shows them) and surface only once the bar is pinned and that row + // has scrolled away. const upvotes = post.numUpvotes || 0; const comments = post.numComments || 0; const awards = post.numAwards || 0; - const canSeeAnalytics = canViewPostAnalytics({ user, post }); - // Sticky offset depends on the top chrome. The modal has no app header (pin - // to the very top). On the post page, the v2 rail layout hides the global - // header on laptop for logged-in users, so the bar sticks to the very top - // like the feed nav; the legacy/logged-out layout keeps a fixed 4rem header - // the bar must clear. `onClose` is only provided by the modal. + // The bar floats (sticky) from tablet up, so surface the metrics + menu + // whenever it's actually pinned there — including when a long post floats it + // at the bottom on load, where the stats row above has scrolled off. Below + // tablet the bar is plain in-flow, so keep it stable (no counts) — that's the + // width where toggling on scroll looked like flicker. + const barFloats = useViewSize(ViewSize.Tablet); + const isPinned = isStuck && barFloats; + // The X (modal close) only makes sense when pinned at the top; at the bottom + // the modal's top strip — and its own close — are still on screen. + const isPinnedTop = isStuckTop && barFloats; + // Sticky at BOTH edges (`top` + `bottom`), tablet and up only — on mobile the + // dedicated floating bottom bar already covers this, so the desktop treatment + // is excluded there. While its natural spot is still below the fold the bar + // pins near the bottom (always reachable), scrolls naturally through the + // viewport, then pins near the top once it scrolls above. `top-4`/`bottom-4` + // leave a gap from each edge so the pill reads as floating. The top offset + // also accounts for the top chrome — the modal has no app header; on the post + // page the v2 rail hides the global header on laptop for logged-in users, so + // the bar floats near the top, while the legacy/logged-out layout must clear + // a fixed 4rem header (4rem + 1rem gap = top-20). `onClose` is modal-only. const railOwnsHeader = isV2 && !!user; - const stickyTopClassName = - onClose || railOwnsHeader ? 'top-0' : 'top-0 laptop:top-16'; + const stickyOffsetClassName = + onClose || railOwnsHeader + ? 'tablet:top-4 tablet:bottom-4' + : 'tablet:top-4 tablet:bottom-4 laptop:top-20'; - // Dynamically fold the lowest-priority utilities into the "…" menu (which - // already lists Share and Post analytics) whenever the bar would overflow, - // and bring them back inline when there is room again. Measured against the - // real available width — not breakpoints — so it reacts to page/modal - // resizing. Priority (first to fold): analytics, then copy/share. + // Fold copy link out of the row when the bar would overflow, and bring it + // back inline when there is room again. Measured against the real available + // width — not breakpoints — so it reacts to page/modal resizing. useEffect(() => { const bar = barRef.current; if (!bar) { @@ -118,19 +140,12 @@ export const FocusCardActionBar = ({ } const fit = () => { const copyLink = copyLinkRef.current; - const analytics = analyticsRef.current; - // Show both first (inline display overrides the SSR fallback classes), - // then hide in priority order until the row stops overflowing. + // Show first (inline display overrides the SSR fallback classes), then + // hide it if the row still overflows. if (copyLink) { copyLink.style.display = 'flex'; } - if (analytics) { - analytics.style.display = 'flex'; - } const overflows = () => bar.scrollWidth > bar.clientWidth; - if (analytics && overflows()) { - analytics.style.display = 'none'; - } if (copyLink && overflows()) { copyLink.style.display = 'none'; } @@ -142,14 +157,15 @@ export const FocusCardActionBar = ({ const observer = new ResizeObserver(fit); observer.observe(bar); return () => observer.disconnect(); + // isPinned/counts change the row width (counts + "…" menu appear when pinned). }, [ - canSeeAnalytics, - upvotes, - comments, - awards, canAward, post.clickbaitTitleDetected, post.bookmarked, + isPinned, + upvotes, + comments, + awards, ]); const onToggleUpvote = async () => { @@ -200,13 +216,12 @@ export const FocusCardActionBar = ({
@@ -220,7 +235,7 @@ export const FocusCardActionBar = ({ color={ButtonColor.Avocado} icon={} iconPressed={} - count={upvotes} + count={isPinned ? upvotes : undefined} pressed={isUpvoteActive} onClick={onToggleUpvote} /> @@ -245,7 +260,7 @@ export const FocusCardActionBar = ({ color={ButtonColor.BlueCheese} icon={} iconPressed={} - count={comments} + count={isPinned ? comments : undefined} pressed={post.commented} onClick={onComment} /> @@ -260,7 +275,7 @@ export const FocusCardActionBar = ({ color={ButtonColor.Cabbage} icon={} iconPressed={} - count={awards} + count={isPinned ? awards : undefined} pressed={isAwarded} onClick={onGiveAward} /> @@ -279,11 +294,11 @@ export const FocusCardActionBar = ({ size: ButtonSize.Medium, }} /> - {/* Bookmark stays — it is the primary save action and is not in the - menu. Copy/share and analytics fold into the "…" menu when space - is tight (see the overflow effect); the `hidden tablet:flex` / - `hidden laptop:flex` classes are only the pre-measurement (SSR) - fallback — the effect overrides display once it measures. */} + {/* Bookmark stays — it is the primary save action. Copy link folds + out when space is tight (see the overflow effect); the + `hidden tablet:flex` classes are only the pre-measurement (SSR) + fallback — the effect overrides display once it measures. The "…" + menu and analytics now live in the card header / stats row. */}
)} - {canSeeAnalytics && ( -
- - } - href={`${webappUrl}posts/${post.id}/analytics`} - /> - -
+ {/* While pinned, the article header (which owns the "…" menu) has + scrolled away, so surface the menu here — to the left of the X. */} + {isPinned && ( + )} - - {isStuck && onClose && ( + {isPinnedTop && onClose && ( onClose()} /> )}
diff --git a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx index 2b29250d7c9..1df8d717ac9 100644 --- a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx +++ b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx @@ -20,19 +20,10 @@ import { } from '../../ProfilePicture'; import { Image } from '../../image/Image'; import { fallbackImages } from '../../../lib/config'; -import { - Button, - ButtonIconPosition, - ButtonSize, - ButtonVariant, -} from '../../buttons/Button'; +import { ClickableText } from '../../buttons/ClickableText'; +import { IconSize } from '../../Icon'; import { TimeSortIcon } from '../../icons/Sort/Time'; import { SortCommentsBy } from '../../../graphql/comments'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../../typography/Typography'; import { DiscussionMetaBar } from './DiscussionMetaBar'; import { DiscussionShareRow } from './DiscussionShareRow'; @@ -89,7 +80,6 @@ export const PostDiscussionPanel = ({ useSettingsContext(); const isNewestFirst = sortBy === SortCommentsBy.NewestFirst; const commentRef = useRef(null); - const rootRef = useRef(null); const [isComposerOpen, setIsComposerOpen] = useState(false); const { onShowUpvoted } = useUpvoteQuery(); const { openShareComment } = useShareComment(origin); @@ -114,7 +104,9 @@ export const PostDiscussionPanel = ({ return modalParentSelector(); } - return rootRef.current ?? document.body; + // Append to the document body, not the panel root: the redesign modal card + // clips overflow, which would cut off profile hover cards near its edges. + return document.body; }; const renderComposerTrigger = ({ @@ -125,7 +117,7 @@ export const PostDiscussionPanel = ({ type="button" aria-label="Add a comment" onClick={() => onCommentClick(Origin.StartDiscussion)} - className="group flex w-full items-center gap-3 rounded-16 border border-border-subtlest-tertiary bg-surface-float p-3 text-left transition-colors hover:border-border-subtlest-primary hover:bg-surface-hover" + className="group flex w-full items-center gap-3 rounded-16 border border-border-subtlest-tertiary p-3 text-left transition-colors hover:border-border-subtlest-primary" > {triggerUser ? ( @@ -178,36 +169,27 @@ export const PostDiscussionPanel = ({
{showSortHeader && ( - - - Sort: - - - + // A text link (not a button) so it aligns flush-left with the comments + // below it; `mb-2` adds breathing room before the first comment. + + setSortBy( + isNewestFirst + ? SortCommentsBy.OldestFirst + : SortCommentsBy.NewestFirst, + ) + } + > + {isNewestFirst ? 'Newest first' : 'Oldest first'} + + )}
window.removeEventListener('blur', onWindowBlur); }, [isVideoType, isVideoExpanded]); - // Shared by the cover image and the Read button so both honor the reader - // gate (open the reader inside daily.dev / install nudge) before falling back - // to opening the external article. + // Honors the reader gate (open the reader inside daily.dev / install nudge) + // before falling back to opening the external article. const handleReadClick = (event: React.MouseEvent) => { if (onReaderInstallGateClick(event)) { return; @@ -287,8 +291,9 @@ export const PostFocusCard = ({ focusCommentRef.current(); }; - // Rendered in the header on tablet+ (next to Follow) but moved below the - // title on mobile, where the header row is too tight to hold both. + // Rendered in the title column, directly under the title, so it stays close + // to the title regardless of the cover image height. The engagement bar lives + // further down by the comment composer where the reader's cursor rests. const renderReadButton = (className: string): ReactElement | null => readHref && !isInternalReadType(post) ? (
@@ -392,10 +403,11 @@ export const PostFocusCard = ({ {!isShared && isCollection && (

Collection

)} - {/* Title and image are top-aligned columns. The below-title read - button lives in the SAME column as the title (tight gap) so it - sits right under it regardless of the image height; from tablet - (656px) up the button moves to the top row and this one hides. */} + {/* Title and image are top-aligned columns. The cover image opens a + lightbox rather than navigating away. The read button lives in + the title column (right under the title) so it hugs the title + regardless of the image height — a short title next to a tall + image keeps the button close instead of dragging it down. */}
- - onShowUpvoted(post.id, upvotes)} onCommentsClick={scrollToComment} + // Spacing in this column is governed by its `gap-4`; drop the stats + // row's own bottom margin so the gap above the action bar matches + // the gap below it. + className="!mb-0" /> {isCollection && } @@ -531,6 +546,17 @@ export const PostFocusCard = ({ + +
{ container?: Omit< BaseTooltipContainerProps, diff --git a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.spec.tsx b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.spec.tsx index 13cf6632501..ed9d348b7dd 100644 --- a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.spec.tsx +++ b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.spec.tsx @@ -4,6 +4,7 @@ import type { PublicProfile } from '../../../../lib/user'; import { ProfileUserWorkspacePhotos } from './ProfileUserWorkspacePhotos'; import { useUserWorkspacePhotos } from '../../hooks/useUserWorkspacePhotos'; import { useGear } from '../../hooks/useGear'; +import { LazyModal } from '../../../../components/modals/common/types'; jest.mock('../../hooks/useUserWorkspacePhotos', () => ({ ...jest.requireActual('../../hooks/useUserWorkspacePhotos'), @@ -22,6 +23,11 @@ jest.mock('../../../../hooks/usePrompt', () => ({ usePrompt: () => ({ showPrompt: jest.fn() }), })); +const mockOpenModal = jest.fn(); +jest.mock('../../../../hooks/useLazyModal', () => ({ + useLazyModal: () => ({ openModal: mockOpenModal }), +})); + const mockUseUserWorkspacePhotos = useUserWorkspacePhotos as jest.MockedFunction; const mockUseGear = useGear as jest.MockedFunction; @@ -39,11 +45,6 @@ const baseUser: PublicProfile = { const photo = { id: 'p1', image: 'https://daily.dev/desk.png', position: 0 }; -const renderAndOpenLightbox = () => { - render(); - fireEvent.click(screen.getByRole('button', { name: 'View workspace photo' })); -}; - beforeEach(() => { jest.clearAllMocks(); mockUseUserWorkspacePhotos.mockReturnValue({ @@ -63,27 +64,28 @@ beforeEach(() => { }); describe('ProfileUserWorkspacePhotos lightbox', () => { - it('opens a dialog with a blurred backdrop when a photo is clicked', () => { - renderAndOpenLightbox(); - - expect( - screen.getByRole('dialog', { name: 'Workspace photo lightbox' }), - ).toBeInTheDocument(); - const backdrop = screen.getByRole('button', { name: 'Close lightbox' }); - expect(backdrop.className).toMatch(/backdrop-blur/); - }); - - it('closes the lightbox when the backdrop is clicked', () => { - renderAndOpenLightbox(); - - fireEvent.click(screen.getByRole('button', { name: 'Close lightbox' })); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); + it('opens the shared image lightbox with the photo when clicked', () => { + render(); - it('closes the lightbox when the close button is clicked', () => { - renderAndOpenLightbox(); + fireEvent.click( + screen.getByRole('button', { name: 'View workspace photo' }), + ); - fireEvent.click(screen.getByRole('button', { name: 'Close' })); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mockOpenModal).toHaveBeenCalledTimes(1); + expect(mockOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ + type: LazyModal.ImageView, + props: expect.objectContaining({ + src: photo.image, + alt: 'Workspace', + originRect: expect.objectContaining({ + top: expect.any(Number), + left: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }), + }), + }), + ); }); }); diff --git a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx index 22381f51518..cbe82924db8 100644 --- a/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx +++ b/packages/shared/src/features/profile/components/workspacePhotos/ProfileUserWorkspacePhotos.tsx @@ -15,7 +15,6 @@ import { sortableKeyboardCoordinates, rectSortingStrategy, } from '@dnd-kit/sortable'; -import { useEventListener } from '../../../../hooks/useEventListener'; import type { PublicProfile } from '../../../../lib/user'; import { useUserWorkspacePhotos, @@ -33,7 +32,9 @@ import { ButtonVariant, } from '../../../../components/buttons/Button'; import { CameraIcon, SettingsIcon } from '../../../../components/icons'; -import CloseButton from '../../../../components/CloseButton'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LazyModal } from '../../../../components/modals/common/types'; +import { getImageOriginRect } from '../../../../components/modals/ImageModal'; import { SortableWorkspacePhotoItem } from './WorkspacePhotoItem'; import { WorkspacePhotoUploadModal } from './WorkspacePhotoUploadModal'; import { GearModal } from '../gear/GearModal'; @@ -63,10 +64,10 @@ export function ProfileUserWorkspacePhotos({ const { displayToast } = useToastNotification(); const { showPrompt } = usePrompt(); const { logEvent } = useLogContext(); + const { openModal } = useLazyModal(); const [isPhotoModalOpen, setIsPhotoModalOpen] = useState(false); const [isGearModalOpen, setIsGearModalOpen] = useState(false); - const [selectedPhoto, setSelectedPhoto] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { @@ -197,20 +198,19 @@ export function ProfileUserWorkspacePhotos({ setIsGearModalOpen(false); }, []); - const handlePhotoClick = useCallback((photo: { image: string }) => { - setSelectedPhoto(photo.image); - }, []); - - const handleCloseLightbox = useCallback(() => { - setSelectedPhoto(null); - }, []); - - // Close lightbox on ESC key - useEventListener(globalThis as unknown as Window, 'keydown', (event) => { - if (event.key === 'Escape' && selectedPhoto) { - handleCloseLightbox(); - } - }); + const handlePhotoClick = useCallback( + (photo: { image: string }, event: React.MouseEvent) => { + openModal({ + type: LazyModal.ImageView, + props: { + src: photo.image, + alt: 'Workspace', + originRect: getImageOriginRect(event.currentTarget), + }, + }); + }, + [openModal], + ); const hasPhotos = photos.length > 0; const hasGear = gearItems.length > 0; @@ -355,33 +355,6 @@ export function ProfileUserWorkspacePhotos({ onSubmit={handleAddGear} /> )} - - {selectedPhoto && ( -
-
- )}
); } diff --git a/packages/shared/src/features/profile/components/workspacePhotos/WorkspacePhotoItem.tsx b/packages/shared/src/features/profile/components/workspacePhotos/WorkspacePhotoItem.tsx index 472f70e60f2..b548674bf5d 100644 --- a/packages/shared/src/features/profile/components/workspacePhotos/WorkspacePhotoItem.tsx +++ b/packages/shared/src/features/profile/components/workspacePhotos/WorkspacePhotoItem.tsx @@ -15,7 +15,10 @@ interface WorkspacePhotoItemProps { photo: UserWorkspacePhoto; isOwner: boolean; onDelete?: (photo: UserWorkspacePhoto) => void; - onClick?: (photo: UserWorkspacePhoto) => void; + onClick?: ( + photo: UserWorkspacePhoto, + event: React.MouseEvent, + ) => void; } export function WorkspacePhotoItem({ @@ -35,7 +38,7 @@ export function WorkspacePhotoItem({