From 94dbe6fea0fb5fd4920d1f4168d8564db7df216a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 12:28:34 +0300 Subject: [PATCH 1/9] feat(shared): inline post-hide feedback panel When a user picks Hide from the 3-dots menu, replace the card body in place with a "Post hidden in your feed" panel that surfaces follow-up signals (Unfollow source, Block topics, Report) plus Undo and Done. The hide mutation fires immediately so the recommendation engine still gets the negative signal even if the user does not pick a follow-up action. - New `useHidePost` hook composing `useReportPost` + `useBlockPostPanel` - New `PostHiddenPanel` component reusing existing block primitives - Extend `useBlockPostPanel` with a `mode: 'downvote' | 'hide'` discriminator so the same in-place panel slot can serve both flows - Render `PostHiddenPanel` from article / share / freeform / collection grid + list cards when the panel is in hide mode - Log new `HidePost{UnfollowSource,BlockTags,Report,Undo,Confirm}` events Co-authored-by: Cursor --- packages/shared/src/components/Feed.spec.tsx | 89 ++++++++- .../cards/Freeform/FreeformGrid.tsx | 10 + .../cards/Freeform/FreeformList.tsx | 10 + .../components/cards/article/ArticleGrid.tsx | 23 ++- .../components/cards/article/ArticleList.tsx | 10 + .../cards/collection/CollectionGrid.tsx | 10 + .../cards/collection/CollectionList.tsx | 11 ++ .../src/components/cards/share/ShareGrid.tsx | 10 + .../src/components/cards/share/ShareList.tsx | 9 + .../components/post/block/PostHiddenPanel.tsx | 175 ++++++++++++++++++ .../src/features/posts/PostOptionButton.tsx | 32 +--- .../src/hooks/post/useBlockPostPanel.ts | 15 +- packages/shared/src/hooks/post/useHidePost.ts | 94 ++++++++++ packages/shared/src/lib/log.ts | 5 + scripts/typecheck-strict-changed.js | 15 ++ 15 files changed, 479 insertions(+), 39 deletions(-) create mode 100644 packages/shared/src/components/post/block/PostHiddenPanel.tsx create mode 100644 packages/shared/src/hooks/post/useHidePost.ts diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 790c1b82862..0f359bc7a5b 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -20,6 +20,7 @@ import { POST_BY_ID_QUERY, REPORT_POST_MUTATION, PostType, + UNHIDE_POST_MUTATION, UserVote, REMOVE_BOOKMARK_MUTATION, } from '../graphql/posts'; @@ -809,8 +810,8 @@ describe('Feed logged in', () => { ); }); - it('should hide post', async () => { - let mutationCalled = false; + it('should hide post and replace card with the hidden feedback panel', async () => { + let hideCalled = false; renderComponent([ createFeedMock({ pageInfo: defaultFeedPage.pageInfo, @@ -822,7 +823,7 @@ describe('Feed logged in', () => { variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, }, result: () => { - mutationCalled = true; + hideCalled = true; return { data: { _: true } }; }, }, @@ -833,12 +834,90 @@ describe('Feed logged in', () => { }); const contextBtn = await screen.findByText('Hide'); contextBtn.click(); - await waitFor(() => expect(mutationCalled).toBeTruthy()); + await waitFor(() => expect(hideCalled).toBeTruthy()); + expect( + await screen.findByText('Post hidden in your feed'), + ).toBeInTheDocument(); + expect( + screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), + ).not.toBeInTheDocument(); + }); + + it('should restore the post when clicking Undo on the hidden feedback panel', async () => { + let unhideCalled = false; + renderComponent([ + createFeedMock({ + pageInfo: defaultFeedPage.pageInfo, + edges: [defaultFeedPage.edges[0]], + }), + { + request: { + query: HIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => ({ data: { _: true } }), + }, + { + request: { + query: UNHIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => { + unhideCalled = true; + return { data: { _: true } }; + }, + }, + ]); + + const [menuBtn] = await screen.findAllByLabelText('Options'); + fireEvent.keyDown(menuBtn, { key: ' ' }); + (await screen.findByText('Hide')).click(); + const undoBtn = await screen.findByRole('button', { name: 'Undo' }); + fireEvent.click(undoBtn); + + await waitFor(() => expect(unhideCalled).toBeTruthy()); await waitFor(() => expect( - screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), + screen.queryByText('Post hidden in your feed'), ).not.toBeInTheDocument(), ); + expect( + await screen.findByTitle( + 'Eminem Quotes Generator - Simple PHP RESTful API', + ), + ).toBeInTheDocument(); + }); + + it('should remove the post from the feed when clicking Done on the hidden feedback panel', async () => { + renderComponent([ + createFeedMock({ + pageInfo: defaultFeedPage.pageInfo, + edges: [defaultFeedPage.edges[0]], + }), + { + request: { + query: HIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => ({ data: { _: true } }), + }, + ]); + + const [menuBtn] = await screen.findAllByLabelText('Options'); + fireEvent.keyDown(menuBtn, { key: ' ' }); + (await screen.findByText('Hide')).click(); + + const doneBtn = await screen.findByRole('button', { name: 'Done' }); + fireEvent.click(doneBtn); + + await waitFor(() => + expect( + screen.queryByText('Post hidden in your feed'), + ).not.toBeInTheDocument(), + ); + expect( + screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), + ).not.toBeInTheDocument(); }); it('should block a source', async () => { diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx index 255d703b79e..5fecd925ba3 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx @@ -18,6 +18,8 @@ import { WelcomePostCardFooter } from '../common/WelcomePostCardFooter'; import ActionButtons from '../common/ActionButtons'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; +import { PostHiddenPanel } from '../../post/block/PostHiddenPanel'; export const FreeformGrid = forwardRef(function SharePostCard( { @@ -41,6 +43,14 @@ export const FreeformGrid = forwardRef(function SharePostCard( const containerRef = useRef(); const image = usePostImage(post); const { title } = useSmartTitle(post); + const { data: blockPanelData } = useBlockPostPanel(post); + + if ( + blockPanelData?.showTagsPanel === true && + blockPanelData?.mode === 'hide' + ) { + return ; + } return ( @@ -105,6 +108,13 @@ export const FreeformList = forwardRef(function SharePostCard( post?.source?.name, ]); + if ( + blockPanelData?.showTagsPanel === true && + blockPanelData?.mode === 'hide' + ) { + return ; + } + return ( 0) { - return ( - - ); + if (data?.showTagsPanel) { + if (data.mode === 'hide') { + return ; + } + + if (post.tags.length > 0) { + return ( + + ); + } } return ( diff --git a/packages/shared/src/components/cards/article/ArticleList.tsx b/packages/shared/src/components/cards/article/ArticleList.tsx index fa2329e7350..162e6292595 100644 --- a/packages/shared/src/components/cards/article/ArticleList.tsx +++ b/packages/shared/src/components/cards/article/ArticleList.tsx @@ -28,6 +28,8 @@ import { HIGH_PRIORITY_IMAGE_PROPS } from '../../image/Image'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { isSourceUserSource } from '../../../graphql/sources'; +import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; +import { PostHiddenPanel } from '../../post/block/PostHiddenPanel'; export const ArticleList = forwardRef(function ArticleList( { @@ -55,6 +57,7 @@ export const ArticleList = forwardRef(function ArticleList( onPostClick?.(post, event); const isMobile = useViewSize(ViewSize.MobileL); const { showFeedback } = usePostFeedback({ post }); + const { data: blockPanelData } = useBlockPostPanel(post); const isFeedPreview = useFeedPreviewMode(); const { title } = useSmartTitle(post); const { title: truncatedTitle } = useTruncatedSummary(title); @@ -100,6 +103,13 @@ export const ArticleList = forwardRef(function ArticleList( }; }, [isUserSource, post]); + if ( + blockPanelData?.showTagsPanel === true && + blockPanelData?.mode === 'hide' + ) { + return ; + } + return ( onPostClick?.(post); const onPostCardAuxClick = () => onPostAuxClick?.(post); + const { data: blockPanelData } = useBlockPostPanel(post); + + if ( + blockPanelData?.showTagsPanel === true && + blockPanelData?.mode === 'hide' + ) { + return ; + } return ( ); + if ( + blockPanelData?.showTagsPanel === true && + blockPanelData?.mode === 'hide' + ) { + return ; + } + return ( { if (isDeleted) { @@ -140,6 +143,13 @@ export const ShareGrid = forwardRef(function ShareGrid( sharedPost, ]); + if ( + blockPanelData?.showTagsPanel === true && + blockPanelData?.mode === 'hide' + ) { + return ; + } + return ( @@ -112,6 +117,10 @@ export const ShareList = forwardRef(function ShareList( sharedPost?.source?.handle, ]); + if (isHideMode) { + return ; + } + return ( id === source.id) ?? false; + + const [shouldBlockSource, setShouldBlockSource] = useState( + isSourceAlreadyBlocked, + ); + const [tags, setTags] = useState(() => + (post.tags ?? []).reduce( + (acc, tag) => ({ ...acc, [tag]: false }), + {}, + ), + ); + + const { onUnhide, onConfirmDismiss } = useHidePost({ post }); + const { onClose } = useBlockPostPanel(post); + const { onBlockTags, onBlockSource } = useTagAndSource({ + origin: Origin.PostContextMenu, + postId: post.id, + shouldInvalidateQueries: false, + feedId: customFeedId, + }); + const { openModal } = useLazyModal(); + + const selectedTags = Object.entries(tags) + .filter(([, selected]) => selected) + .map(([tag]) => tag); + const willBlockSource = shouldBlockSource && !isSourceAlreadyBlocked; + + const handleDone = async () => { + if (selectedTags.length > 0) { + await onBlockTags({ tags: selectedTags, requireLogin: true }); + } + + if (willBlockSource) { + await onBlockSource({ source, requireLogin: true }); + } + + if (willBlockSource) { + onConfirmDismiss('unfollow'); + return; + } + + if (selectedTags.length > 0) { + onConfirmDismiss('block'); + return; + } + + onConfirmDismiss('done'); + }; + + const handleReport = () => { + onClose(true); + openModal({ + type: LazyModal.ReportPost, + props: { + post, + origin: Origin.PostContextMenu, + onReported: () => { + onConfirmDismiss('report'); + }, + }, + }); + }; + + return ( +
+ onConfirmDismiss('done')} + size={ButtonSize.Small} + /> +

Post hidden in your feed

+

+ Help us improve. Tell us what didn't work for you (optional). +

+ + {!isSourceAlreadyBlocked && ( + + )} + {(post.tags ?? []).map((tag) => ( + setTags({ ...tags, [tag]: !tags[tag] })} + tagItem={tag} + data-testid="hideBlockTagButton" + /> + ))} + + + + + + +
+ ); +} diff --git a/packages/shared/src/features/posts/PostOptionButton.tsx b/packages/shared/src/features/posts/PostOptionButton.tsx index ecae3b87b12..75fbcab6a2c 100644 --- a/packages/shared/src/features/posts/PostOptionButton.tsx +++ b/packages/shared/src/features/posts/PostOptionButton.tsx @@ -58,7 +58,7 @@ import useFeedSettings from '../../hooks/useFeedSettings'; import { useLogContext } from '../../contexts/LogContext'; import { usePostLogEvent } from '../../lib/feed'; import { useFeedCardContext } from './FeedCardContext'; -import useReportPost from '../../hooks/useReportPost'; +import { useHidePost } from '../../hooks/post/useHidePost'; import { useSharePost } from '../../hooks/useSharePost'; import { useContentPreference } from '../../hooks/contentPreference/useContentPreference'; import { useLazyModal } from '../../hooks/useLazyModal'; @@ -187,7 +187,10 @@ const PostOptionButtonContent = ({ const { logEvent } = useLogContext(); const postLogEvent = usePostLogEvent(); const { boostedBy } = useFeedCardContext(); - const { hidePost, unhidePost } = useReportPost(); + const { onHide } = useHidePost({ + post, + origin: Origin.PostContextMenu, + }); const { openSharePost } = useSharePost(origin); const { follow, unfollow, unblock, block } = useContentPreference(); const { openModal } = useLazyModal(); @@ -436,27 +439,6 @@ const PostOptionButtonContent = ({ showMessageAndRemovePost(copy, postIndex, onUndo), }); - const onHidePost = async (): Promise => { - const { successful } = await hidePost(post.id); - - if (!successful) { - return; - } - - logEvent( - postLogEvent(LogEvent.HidePost, post, { - extra: { origin: Origin.PostContextMenu }, - ...logOpts, - }), - ); - - showMessageAndRemovePost( - "🙈 This post won't show up on your feed anymore", - postIndex, - () => unhidePost(post.id), - ); - }; - const postOptions: MenuItemProps[] = [ { icon: , @@ -483,7 +465,9 @@ const PostOptionButtonContent = ({ postOptions.push({ icon: , label: 'Hide', - action: onHidePost, + action: () => { + onHide(); + }, }); postOptions.push({ diff --git a/packages/shared/src/hooks/post/useBlockPostPanel.ts b/packages/shared/src/hooks/post/useBlockPostPanel.ts index 04a9fafc0ab..b99cb1d1740 100644 --- a/packages/shared/src/hooks/post/useBlockPostPanel.ts +++ b/packages/shared/src/hooks/post/useBlockPostPanel.ts @@ -20,16 +20,23 @@ import { disabledRefetch, isNullOrUndefined } from '../../lib/func'; import { useToastNotification } from '../useToastNotification'; import { generateQueryKey, RequestKey } from '../../lib/query'; +export type BlockPanelMode = 'downvote' | 'hide'; + interface BlockData { showTagsPanel?: boolean; blocked?: DownvoteBlocked; + mode?: BlockPanelMode; +} + +interface ShowPanelOptions { + mode?: BlockPanelMode; } interface UseBlockPost { data: BlockData; blockedTags: number; onClose(forceClose?: boolean): void; - onShowPanel(): void; + onShowPanel(options?: ShowPanelOptions): void; onDismissPermanently(): void; onReport(): void; onUndo(): void; @@ -172,7 +179,11 @@ export const useBlockPostPanel = ( ); const onShowPanel = useCallback( - () => setShowTagsPanel({ showTagsPanel: true }), + (options?: ShowPanelOptions) => + setShowTagsPanel({ + showTagsPanel: true, + mode: options?.mode ?? 'downvote', + }), [setShowTagsPanel], ); diff --git a/packages/shared/src/hooks/post/useHidePost.ts b/packages/shared/src/hooks/post/useHidePost.ts new file mode 100644 index 00000000000..d6b0076e0c1 --- /dev/null +++ b/packages/shared/src/hooks/post/useHidePost.ts @@ -0,0 +1,94 @@ +import { useCallback, useContext, useMemo } from 'react'; +import type { Post } from '../../graphql/posts'; +import useReportPost from '../useReportPost'; +import { useBlockPostPanel } from './useBlockPostPanel'; +import { ActiveFeedContext } from '../../contexts/ActiveFeedContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin } from '../../lib/log'; +import { usePostLogEvent } from '../../lib/feed'; + +interface UseHidePostProps { + post: Post; + origin?: Origin; +} + +interface UseHidePost { + onHide: (overrideOrigin?: Origin) => Promise; + onUnhide: () => Promise; + onConfirmDismiss: (reason?: 'done' | 'unfollow' | 'block' | 'report') => void; +} + +export const useHidePost = ({ + post, + origin = Origin.PostContextMenu, +}: UseHidePostProps): UseHidePost => { + const { hidePost, unhidePost } = useReportPost(); + const { onShowPanel, onClose } = useBlockPostPanel(post); + const { logEvent } = useLogContext(); + const postLogEvent = usePostLogEvent(); + const { items, onRemovePost, logOpts } = useContext(ActiveFeedContext); + + const postIndex = useMemo( + () => + items.findIndex( + (item) => item.type === 'post' && item.post.id === post.id, + ), + [items, post.id], + ); + + const onHide = useCallback( + async (overrideOrigin?: Origin) => { + const { successful } = await hidePost(post.id); + + if (!successful) { + return false; + } + + logEvent( + postLogEvent(LogEvent.HidePost, post, { + extra: { origin: overrideOrigin ?? origin }, + ...logOpts, + }), + ); + + onShowPanel({ mode: 'hide' }); + return true; + }, + [hidePost, post, logEvent, postLogEvent, origin, logOpts, onShowPanel], + ); + + const onUnhide = useCallback(async () => { + logEvent( + postLogEvent(LogEvent.HidePostUndo, post, { + ...logOpts, + }), + ); + await unhidePost(post.id); + onClose(true); + }, [unhidePost, post, onClose, logEvent, postLogEvent, logOpts]); + + const onConfirmDismiss = useCallback( + (reason: 'done' | 'unfollow' | 'block' | 'report' = 'done') => { + const eventByReason: Record = { + done: LogEvent.HidePostConfirm, + unfollow: LogEvent.HidePostUnfollowSource, + block: LogEvent.HidePostBlockTags, + report: LogEvent.HidePostReport, + }; + + logEvent( + postLogEvent(eventByReason[reason], post, { + ...logOpts, + }), + ); + + onClose(true); + if (postIndex >= 0) { + onRemovePost?.(postIndex); + } + }, + [onClose, onRemovePost, postIndex, logEvent, postLogEvent, post, logOpts], + ); + + return { onHide, onUnhide, onConfirmDismiss }; +}; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 67409532ad4..bba6df2b191 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -102,6 +102,11 @@ export enum Origin { export enum LogEvent { HidePost = 'hide post', + HidePostUnfollowSource = 'hide post unfollow source', + HidePostBlockTags = 'hide post block tags', + HidePostReport = 'hide post report', + HidePostUndo = 'hide post undo', + HidePostConfirm = 'hide post confirm', ReportSquad = 'report squad', Click = 'click', CommentPost = 'comment post', diff --git a/scripts/typecheck-strict-changed.js b/scripts/typecheck-strict-changed.js index 75389dae3d6..25561267307 100644 --- a/scripts/typecheck-strict-changed.js +++ b/scripts/typecheck-strict-changed.js @@ -80,6 +80,21 @@ const strictSkipList = new Set([ // null vs undefined) live on unrelated lines and should be addressed in // a dedicated cleanup PR. 'packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx', + // Inline-hide-feedback-panel branch — touched only to add a `mode` + // discriminator and route the hide flow through this hook. The + // surfaced strict errors (queryFn return type under tanstack-query v5 + // strict mode, `post.source` possibly undefined, optional accumulator + // chains) are pre-existing and should be addressed in a dedicated + // cleanup PR. + 'packages/shared/src/hooks/post/useBlockPostPanel.ts', + // Inline-hide-feedback-panel branch — touched only to early-return the + // hidden feedback panel when in `hide` mode. The remaining strict + // errors (`post.tags`, `post.source`, optional callback invocations, + // shared-post image typing, mutable ref typing) are pre-existing and + // should be addressed in a dedicated cleanup PR. + 'packages/shared/src/components/cards/article/ArticleGrid.tsx', + 'packages/shared/src/components/cards/Freeform/FreeformGrid.tsx', + 'packages/shared/src/components/cards/share/ShareGrid.tsx', ]); const changedFiles = getChangedTypescriptFiles().filter( From 7d0d50a41037063e0c14bba9e799d48333b323f9 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 13:02:07 +0300 Subject: [PATCH 2/9] feat(shared): explicit action rows in post-hide panel Reframe the hidden post panel around action taken instead of asking the user to fill out an optional form. The new title "Got it. You'll see less like this." reassures the user that the feed already improved, and the follow-up actions (Unfollow source, Block #tag, Report) are now explicit one-click rows mirroring the 3-dots menu instead of toggle pills + Done. Co-authored-by: Cursor --- packages/shared/src/components/Feed.spec.tsx | 12 +- .../components/post/block/PostHiddenPanel.tsx | 156 ++++++++---------- 2 files changed, 74 insertions(+), 94 deletions(-) diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 0f359bc7a5b..076a8ba73c2 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -836,7 +836,7 @@ describe('Feed logged in', () => { contextBtn.click(); await waitFor(() => expect(hideCalled).toBeTruthy()); expect( - await screen.findByText('Post hidden in your feed'), + await screen.findByText("Got it. You'll see less like this."), ).toBeInTheDocument(); expect( screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), @@ -878,7 +878,7 @@ describe('Feed logged in', () => { await waitFor(() => expect(unhideCalled).toBeTruthy()); await waitFor(() => expect( - screen.queryByText('Post hidden in your feed'), + screen.queryByText("Got it. You'll see less like this."), ).not.toBeInTheDocument(), ); expect( @@ -888,7 +888,7 @@ describe('Feed logged in', () => { ).toBeInTheDocument(); }); - it('should remove the post from the feed when clicking Done on the hidden feedback panel', async () => { + it('should remove the post from the feed when dismissing the hidden feedback panel', async () => { renderComponent([ createFeedMock({ pageInfo: defaultFeedPage.pageInfo, @@ -907,12 +907,12 @@ describe('Feed logged in', () => { fireEvent.keyDown(menuBtn, { key: ' ' }); (await screen.findByText('Hide')).click(); - const doneBtn = await screen.findByRole('button', { name: 'Done' }); - fireEvent.click(doneBtn); + const closeBtns = await screen.findAllByRole('button', { name: 'Close' }); + fireEvent.click(closeBtns[closeBtns.length - 1]); await waitFor(() => expect( - screen.queryByText('Post hidden in your feed'), + screen.queryByText("Got it. You'll see less like this."), ).not.toBeInTheDocument(), ); expect( diff --git a/packages/shared/src/components/post/block/PostHiddenPanel.tsx b/packages/shared/src/components/post/block/PostHiddenPanel.tsx index da7a34cae00..48f6fdb2242 100644 --- a/packages/shared/src/components/post/block/PostHiddenPanel.tsx +++ b/packages/shared/src/components/post/block/PostHiddenPanel.tsx @@ -1,5 +1,5 @@ -import type { ReactElement } from 'react'; -import React, { useState } from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { useHidePost } from '../../../hooks/post/useHidePost'; @@ -9,23 +9,41 @@ import useFeedSettings from '../../../hooks/useFeedSettings'; import { useCustomFeed } from '../../../hooks/feed/useCustomFeed'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; -import { - Button, - ButtonColor, - ButtonSize, - ButtonVariant, -} from '../../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import CloseButton from '../../CloseButton'; -import { SourceAvatar } from '../../profile/source'; -import { GenericTagButton } from '../../filters/TagButton'; +import { BlockIcon, FlagIcon } from '../../icons'; +import { IconSize } from '../../Icon'; import { Origin } from '../../../lib/log'; -import type { BlockTagSelection } from './common'; interface PostHiddenPanelProps { post: Post; className?: string; } +interface ActionRowProps { + icon: ReactNode; + label: ReactNode; + onClick: () => void; + ariaLabel?: string; +} + +const ActionRow = ({ + icon, + label, + onClick, + ariaLabel, +}: ActionRowProps): ReactElement => ( + +); + export function PostHiddenPanel({ post, className, @@ -39,16 +57,6 @@ export function PostHiddenPanel({ const isSourceAlreadyBlocked = feedSettings?.excludeSources?.some(({ id }) => id === source.id) ?? false; - const [shouldBlockSource, setShouldBlockSource] = useState( - isSourceAlreadyBlocked, - ); - const [tags, setTags] = useState(() => - (post.tags ?? []).reduce( - (acc, tag) => ({ ...acc, [tag]: false }), - {}, - ), - ); - const { onUnhide, onConfirmDismiss } = useHidePost({ post }); const { onClose } = useBlockPostPanel(post); const { onBlockTags, onBlockSource } = useTagAndSource({ @@ -59,31 +67,18 @@ export function PostHiddenPanel({ }); const { openModal } = useLazyModal(); - const selectedTags = Object.entries(tags) - .filter(([, selected]) => selected) - .map(([tag]) => tag); - const willBlockSource = shouldBlockSource && !isSourceAlreadyBlocked; - - const handleDone = async () => { - if (selectedTags.length > 0) { - await onBlockTags({ tags: selectedTags, requireLogin: true }); - } - - if (willBlockSource) { - await onBlockSource({ source, requireLogin: true }); - } - - if (willBlockSource) { - onConfirmDismiss('unfollow'); - return; - } + const blockableTags = (post.tags ?? []).filter( + (tag) => !(feedSettings?.blockedTags ?? []).includes(tag), + ); - if (selectedTags.length > 0) { - onConfirmDismiss('block'); - return; - } + const handleUnfollowSource = async () => { + await onBlockSource({ source, requireLogin: true }); + onConfirmDismiss('unfollow'); + }; - onConfirmDismiss('done'); + const handleBlockTag = async (tag: string) => { + await onBlockTags({ tags: [tag], requireLogin: true }); + onConfirmDismiss('block'); }; const handleReport = () => { @@ -100,10 +95,12 @@ export function PostHiddenPanel({ }); }; + const iconClassName = 'text-text-tertiary'; + return (
@@ -113,63 +110,46 @@ export function PostHiddenPanel({ onClick={() => onConfirmDismiss('done')} size={ButtonSize.Small} /> -

Post hidden in your feed

-

- Help us improve. Tell us what didn't work for you (optional). -

- +

+ Got it. You'll see less like this. +

+

Take it further:

+
{!isSourceAlreadyBlocked && ( - + } + label={<>Unfollow {source.name}} + onClick={handleUnfollowSource} + ariaLabel={`Unfollow ${source.name}`} + /> )} - {(post.tags ?? []).map((tag) => ( - ( + setTags({ ...tags, [tag]: !tags[tag] })} - tagItem={tag} - data-testid="hideBlockTagButton" + icon={} + label={`Block #${tag}`} + onClick={() => handleBlockTag(tag)} + ariaLabel={`Block ${tag}`} /> ))} - - - + ariaLabel="Report this post" + /> +
+
- - +
); } From cb39475ef1633058e484e29a11b70bc86b9d439c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 14:35:53 +0300 Subject: [PATCH 3/9] style(shared): match 3-dots dropdown styling in post-hide panel Tighten the inline hide panel so the action list mirrors the existing 3-dots dropdown menu (h-7 rows, rounded-10, typo-footnote, text-tertiary, hover:bg-surface-hover) and reduce outer padding so the rows sit flush. Use the source avatar for the unfollow row and a hashtag icon for tag rows so the leading visual matches what the user sees on chips and in the dropdown. Co-authored-by: Cursor --- .../components/post/block/PostHiddenPanel.tsx | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/shared/src/components/post/block/PostHiddenPanel.tsx b/packages/shared/src/components/post/block/PostHiddenPanel.tsx index 48f6fdb2242..cccf87670c7 100644 --- a/packages/shared/src/components/post/block/PostHiddenPanel.tsx +++ b/packages/shared/src/components/post/block/PostHiddenPanel.tsx @@ -11,8 +11,10 @@ import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import CloseButton from '../../CloseButton'; -import { BlockIcon, FlagIcon } from '../../icons'; -import { IconSize } from '../../Icon'; +import { FlagIcon, HashtagIcon } from '../../icons'; +import { MenuIcon } from '../../MenuIcon'; +import { SourceAvatar } from '../../profile/source'; +import { ProfileImageSize } from '../../ProfilePicture'; import { Origin } from '../../../lib/log'; interface PostHiddenPanelProps { @@ -35,11 +37,12 @@ const ActionRow = ({ }: ActionRowProps): ReactElement => ( ); @@ -95,51 +98,54 @@ export function PostHiddenPanel({ }); }; - const iconClassName = 'text-text-tertiary'; - return (
- onConfirmDismiss('done')} - size={ButtonSize.Small} - /> -

- Got it. You'll see less like this. -

-

Take it further:

-
+
+
+

+ Got it. You'll see less like this. +

+

Take it further:

+
+ onConfirmDismiss('done')} + size={ButtonSize.XSmall} + /> +
+
{!isSourceAlreadyBlocked && ( } - label={<>Unfollow {source.name}} + icon={ + + } + label={<>Don't show posts from {source.name}} onClick={handleUnfollowSource} - ariaLabel={`Unfollow ${source.name}`} + ariaLabel={`Don't show posts from ${source.name}`} /> )} {blockableTags.map((tag) => ( } + icon={} label={`Block #${tag}`} onClick={() => handleBlockTag(tag)} ariaLabel={`Block ${tag}`} /> ))} } - label="Report this post" + icon={} + label="Report" onClick={handleReport} - ariaLabel="Report this post" + ariaLabel="Report" />
-
+
); @@ -122,7 +124,7 @@ export function PostHiddenPanel({ {!isSourceAlreadyBlocked && ( + } label={<>Don't show posts from {source.name}} onClick={handleUnfollowSource} @@ -132,14 +134,14 @@ export function PostHiddenPanel({ {blockableTags.map((tag) => ( } + icon={} label={`Block #${tag}`} onClick={() => handleBlockTag(tag)} ariaLabel={`Block ${tag}`} /> ))} } + icon={} label="Report" onClick={handleReport} ariaLabel="Report" From 4a7d1bb6ad575f74988b21bdef8d618a353fed64 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 15:58:06 +0300 Subject: [PATCH 5/9] fix(shared): polish post-hide panel report flow and header alignment - Open the report modal without dismissing the panel first so the card no longer flashes back into the feed underneath the modal; only fire HidePostReport + remove the post on actual report submission. - Drop the stray pl-2 on the panel header so the title aligns to the same edge as the action-row container instead of sitting 4px in. - Await onHide() in the menu wiring so a failing HIDE_POST_MUTATION surfaces through the existing toast pipeline instead of being a silent floating promise. - Match the inline showTagsPanel/mode === 'hide' check used by the other card variants in ShareList for consistency. Co-authored-by: Cursor --- packages/shared/src/components/cards/share/ShareList.tsx | 7 ++++--- .../shared/src/components/post/block/PostHiddenPanel.tsx | 5 +---- packages/shared/src/features/posts/PostOptionButton.tsx | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/components/cards/share/ShareList.tsx b/packages/shared/src/components/cards/share/ShareList.tsx index a88388487d4..12de88c595d 100644 --- a/packages/shared/src/components/cards/share/ShareList.tsx +++ b/packages/shared/src/components/cards/share/ShareList.tsx @@ -63,8 +63,6 @@ export const ShareList = forwardRef(function ShareList( const { title: truncatedTitle } = useTruncatedSummary(title); const isUserSource = isSourceUserSource(post.source); const { data: blockPanelData } = useBlockPostPanel(post); - const isHideMode = - blockPanelData?.showTagsPanel === true && blockPanelData?.mode === 'hide'; const actionButtons = ( @@ -117,7 +115,10 @@ export const ShareList = forwardRef(function ShareList( sharedPost?.source?.handle, ]); - if (isHideMode) { + if ( + blockPanelData?.showTagsPanel === true && + blockPanelData?.mode === 'hide' + ) { return ; } diff --git a/packages/shared/src/components/post/block/PostHiddenPanel.tsx b/packages/shared/src/components/post/block/PostHiddenPanel.tsx index e0cbc1bbac2..e8325f0a646 100644 --- a/packages/shared/src/components/post/block/PostHiddenPanel.tsx +++ b/packages/shared/src/components/post/block/PostHiddenPanel.tsx @@ -3,7 +3,6 @@ import React from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { useHidePost } from '../../../hooks/post/useHidePost'; -import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; import useTagAndSource from '../../../hooks/useTagAndSource'; import useFeedSettings from '../../../hooks/useFeedSettings'; import { useCustomFeed } from '../../../hooks/feed/useCustomFeed'; @@ -63,7 +62,6 @@ export function PostHiddenPanel({ feedSettings?.excludeSources?.some(({ id }) => id === source.id) ?? false; const { onUnhide, onConfirmDismiss } = useHidePost({ post }); - const { onClose } = useBlockPostPanel(post); const { onBlockTags, onBlockSource } = useTagAndSource({ origin: Origin.PostContextMenu, postId: post.id, @@ -87,7 +85,6 @@ export function PostHiddenPanel({ }; const handleReport = () => { - onClose(true); openModal({ type: LazyModal.ReportPost, props: { @@ -107,7 +104,7 @@ export function PostHiddenPanel({ className, )} > -
+

Got it. You'll see less like this. diff --git a/packages/shared/src/features/posts/PostOptionButton.tsx b/packages/shared/src/features/posts/PostOptionButton.tsx index 75fbcab6a2c..e84309b084d 100644 --- a/packages/shared/src/features/posts/PostOptionButton.tsx +++ b/packages/shared/src/features/posts/PostOptionButton.tsx @@ -465,8 +465,8 @@ const PostOptionButtonContent = ({ postOptions.push({ icon: , label: 'Hide', - action: () => { - onHide(); + action: async () => { + await onHide(); }, }); From 26617d64de56c6e4f0bb0ac1cfac636360c07b3e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 16:44:36 +0300 Subject: [PATCH 6/9] fix(shared): center source avatar in post-hide action row The default mr-2 on SourceAvatar shifted the avatar off-center inside the size-6 flex slot, so the first row's leading edge no longer aligned with the hash and flag icons in the rows below. Co-authored-by: Cursor --- .../shared/src/components/post/block/PostHiddenPanel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/post/block/PostHiddenPanel.tsx b/packages/shared/src/components/post/block/PostHiddenPanel.tsx index e8325f0a646..9423efa8fc1 100644 --- a/packages/shared/src/components/post/block/PostHiddenPanel.tsx +++ b/packages/shared/src/components/post/block/PostHiddenPanel.tsx @@ -121,7 +121,11 @@ export function PostHiddenPanel({ {!isSourceAlreadyBlocked && ( + } label={<>Don't show posts from {source.name}} onClick={handleUnfollowSource} From 398bccb7de700f19ee1e6770b31c4c7585222a9b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 17:50:11 +0300 Subject: [PATCH 7/9] refactor(shared): tighten post-hide panel API and dedupe card wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review feedback: - Drop the unused `overrideOrigin` parameter on `useHidePost.onHide` (the hook prop already covers it) so the menu wiring in `PostOptionButton` collapses to `action: onHide`. - Rename `HidePostConfirm` to `HidePostDismiss` since the only caller is the panel close button — the previous name implied user confirmation of a follow-up action and would have skewed the analytics funnel. - Have `onUnhide` check the mutation result before logging and closing the panel so a failed unhide does not flip the UI back into the feed while the post is still server-side hidden. - Document why `onConfirmDismiss` tolerates `postIndex === -1` (panel rendered outside ActiveFeedContext, or post already removed). - Extract `useHiddenFeedbackPanel(post)` to collapse the eight identical early-return blocks across article / share / freeform / collection grid + list cards into a single hook + one-line check. - Reduce the panel's left padding (`p-3 pl-2`) so the action rows sit a few pixels closer to the card edge. - Add `data-testid="postHiddenPanelClose"` to the panel close button and scope the dismiss test to it instead of relying on DOM order across multiple Close buttons. Co-authored-by: Cursor --- packages/shared/src/components/Feed.spec.tsx | 4 +- .../cards/Freeform/FreeformGrid.tsx | 12 ++-- .../cards/Freeform/FreeformList.tsx | 12 ++-- .../components/cards/article/ArticleGrid.tsx | 27 ++++---- .../components/cards/article/ArticleList.tsx | 12 ++-- .../cards/collection/CollectionGrid.tsx | 12 ++-- .../cards/collection/CollectionList.tsx | 12 ++-- .../src/components/cards/share/ShareGrid.tsx | 12 ++-- .../src/components/cards/share/ShareList.tsx | 12 ++-- .../components/post/block/PostHiddenPanel.tsx | 5 +- .../src/features/posts/PostOptionButton.tsx | 4 +- .../src/hooks/post/useHiddenFeedbackPanel.tsx | 21 ++++++ packages/shared/src/hooks/post/useHidePost.ts | 64 +++++++++++-------- packages/shared/src/lib/log.ts | 2 +- 14 files changed, 105 insertions(+), 106 deletions(-) create mode 100644 packages/shared/src/hooks/post/useHiddenFeedbackPanel.tsx diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 076a8ba73c2..ea3828fda41 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -907,8 +907,8 @@ describe('Feed logged in', () => { fireEvent.keyDown(menuBtn, { key: ' ' }); (await screen.findByText('Hide')).click(); - const closeBtns = await screen.findAllByRole('button', { name: 'Close' }); - fireEvent.click(closeBtns[closeBtns.length - 1]); + const closeBtn = await screen.findByTestId('postHiddenPanelClose'); + fireEvent.click(closeBtn); await waitFor(() => expect( diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx index 5fecd925ba3..e43da749b0a 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx @@ -18,8 +18,7 @@ import { WelcomePostCardFooter } from '../common/WelcomePostCardFooter'; import ActionButtons from '../common/ActionButtons'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; -import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; -import { PostHiddenPanel } from '../../post/block/PostHiddenPanel'; +import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; export const FreeformGrid = forwardRef(function SharePostCard( { @@ -43,13 +42,10 @@ export const FreeformGrid = forwardRef(function SharePostCard( const containerRef = useRef(); const image = usePostImage(post); const { title } = useSmartTitle(post); - const { data: blockPanelData } = useBlockPostPanel(post); + const hiddenPanel = useHiddenFeedbackPanel(post); - if ( - blockPanelData?.showTagsPanel === true && - blockPanelData?.mode === 'hide' - ) { - return ; + if (hiddenPanel) { + return hiddenPanel; } return ( diff --git a/packages/shared/src/components/cards/Freeform/FreeformList.tsx b/packages/shared/src/components/cards/Freeform/FreeformList.tsx index bb743291c51..507f7b28613 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformList.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformList.tsx @@ -26,8 +26,7 @@ import { usePostActions } from '../../../hooks/post/usePostActions'; import { PostType } from '../../../graphql/posts'; import { sanitizeMessage } from '../../../features/onboarding/shared'; import { isSourceUserSource } from '../../../graphql/sources'; -import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; -import { PostHiddenPanel } from '../../post/block/PostHiddenPanel'; +import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; export const FreeformList = forwardRef(function SharePostCard( { @@ -62,7 +61,7 @@ export const FreeformList = forwardRef(function SharePostCard( const socialShare = interaction === 'copy' && post.type === PostType.Freeform; const { title: truncatedTitle } = useTruncatedSummary(title, content); const isUserSource = isSourceUserSource(post.source); - const { data: blockPanelData } = useBlockPostPanel(post); + const hiddenPanel = useHiddenFeedbackPanel(post); const actionButtons = ( @@ -108,11 +107,8 @@ export const FreeformList = forwardRef(function SharePostCard( post?.source?.name, ]); - if ( - blockPanelData?.showTagsPanel === true && - blockPanelData?.mode === 'hide' - ) { - return ; + if (hiddenPanel) { + return hiddenPanel; } return ( diff --git a/packages/shared/src/components/cards/article/ArticleGrid.tsx b/packages/shared/src/components/cards/article/ArticleGrid.tsx index bd74e61e5f7..5293334cad5 100644 --- a/packages/shared/src/components/cards/article/ArticleGrid.tsx +++ b/packages/shared/src/components/cards/article/ArticleGrid.tsx @@ -4,10 +4,10 @@ import classNames from 'classnames'; import type { PostCardProps } from '../common/common'; import { Container } from '../common/common'; import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; +import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; import { usePostFeedback } from '../../../hooks'; import { isVideoPost } from '../../../graphql/posts'; import { PostTagsPanel } from '../../post/block/PostTagsPanel'; -import { PostHiddenPanel } from '../../post/block/PostHiddenPanel'; import FeedItemContainer from '../common/FeedItemContainer'; import { CardSpace, @@ -47,6 +47,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( ref: Ref, ): ReactElement { const { className, style } = domProps; + const hiddenPanel = useHiddenFeedbackPanel(post); const { data } = useBlockPostPanel(post); const onPostCardClick = () => onPostClick(post); const onPostCardAuxClick = () => onPostAuxClick(post); @@ -55,20 +56,18 @@ export const ArticleGrid = forwardRef(function ArticleGrid( const { title } = useSmartTitle(post); const isVideoType = isVideoPost(post); - if (data?.showTagsPanel) { - if (data.mode === 'hide') { - return ; - } + if (hiddenPanel) { + return hiddenPanel; + } - if (post.tags.length > 0) { - return ( - - ); - } + if (data?.showTagsPanel && post.tags.length > 0) { + return ( + + ); } return ( diff --git a/packages/shared/src/components/cards/article/ArticleList.tsx b/packages/shared/src/components/cards/article/ArticleList.tsx index 162e6292595..15a921f7ba0 100644 --- a/packages/shared/src/components/cards/article/ArticleList.tsx +++ b/packages/shared/src/components/cards/article/ArticleList.tsx @@ -28,8 +28,7 @@ import { HIGH_PRIORITY_IMAGE_PROPS } from '../../image/Image'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { isSourceUserSource } from '../../../graphql/sources'; -import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; -import { PostHiddenPanel } from '../../post/block/PostHiddenPanel'; +import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; export const ArticleList = forwardRef(function ArticleList( { @@ -57,7 +56,7 @@ export const ArticleList = forwardRef(function ArticleList( onPostClick?.(post, event); const isMobile = useViewSize(ViewSize.MobileL); const { showFeedback } = usePostFeedback({ post }); - const { data: blockPanelData } = useBlockPostPanel(post); + const hiddenPanel = useHiddenFeedbackPanel(post); const isFeedPreview = useFeedPreviewMode(); const { title } = useSmartTitle(post); const { title: truncatedTitle } = useTruncatedSummary(title); @@ -103,11 +102,8 @@ export const ArticleList = forwardRef(function ArticleList( }; }, [isUserSource, post]); - if ( - blockPanelData?.showTagsPanel === true && - blockPanelData?.mode === 'hide' - ) { - return ; + if (hiddenPanel) { + return hiddenPanel; } return ( diff --git a/packages/shared/src/components/cards/collection/CollectionGrid.tsx b/packages/shared/src/components/cards/collection/CollectionGrid.tsx index 8dd8fbc5d9f..7089462c4e5 100644 --- a/packages/shared/src/components/cards/collection/CollectionGrid.tsx +++ b/packages/shared/src/components/cards/collection/CollectionGrid.tsx @@ -18,8 +18,7 @@ import CardOverlay from '../common/CardOverlay'; import PostTags from '../common/PostTags'; import { isPostUpdated } from '../../../graphql/posts'; import { TimeFormatType } from '../../../lib/dateFormat'; -import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; -import { PostHiddenPanel } from '../../post/block/PostHiddenPanel'; +import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; export const CollectionGrid = forwardRef(function CollectionCard( { @@ -42,13 +41,10 @@ export const CollectionGrid = forwardRef(function CollectionCard( const wasUpdated = isPostUpdated(post); const onPostCardClick = () => onPostClick?.(post); const onPostCardAuxClick = () => onPostAuxClick?.(post); - const { data: blockPanelData } = useBlockPostPanel(post); + const hiddenPanel = useHiddenFeedbackPanel(post); - if ( - blockPanelData?.showTagsPanel === true && - blockPanelData?.mode === 'hide' - ) { - return ; + if (hiddenPanel) { + return hiddenPanel; } return ( diff --git a/packages/shared/src/components/cards/collection/CollectionList.tsx b/packages/shared/src/components/cards/collection/CollectionList.tsx index 44db58ba4e1..21b9c4c099d 100644 --- a/packages/shared/src/components/cards/collection/CollectionList.tsx +++ b/packages/shared/src/components/cards/collection/CollectionList.tsx @@ -20,8 +20,7 @@ import { CardCoverList } from '../common/list/CardCover'; import { HIGH_PRIORITY_IMAGE_PROPS } from '../../image/Image'; import { isPostUpdated } from '../../../graphql/posts'; import { TimeFormatType } from '../../../lib/dateFormat'; -import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; -import { PostHiddenPanel } from '../../post/block/PostHiddenPanel'; +import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; export const CollectionList = forwardRef(function CollectionCard( { @@ -43,7 +42,7 @@ export const CollectionList = forwardRef(function CollectionCard( const image = usePostImage(post); const { title } = useTruncatedSummary(post?.title ?? ''); const wasUpdated = isPostUpdated(post); - const { data: blockPanelData } = useBlockPostPanel(post); + const hiddenPanel = useHiddenFeedbackPanel(post); const actionButtons = ( @@ -60,11 +59,8 @@ export const CollectionList = forwardRef(function CollectionCard( ); - if ( - blockPanelData?.showTagsPanel === true && - blockPanelData?.mode === 'hide' - ) { - return ; + if (hiddenPanel) { + return hiddenPanel; } return ( diff --git a/packages/shared/src/components/cards/share/ShareGrid.tsx b/packages/shared/src/components/cards/share/ShareGrid.tsx index 68308a6ed33..ac05e853303 100644 --- a/packages/shared/src/components/cards/share/ShareGrid.tsx +++ b/packages/shared/src/components/cards/share/ShareGrid.tsx @@ -28,8 +28,7 @@ import { IconSize } from '../../Icon'; import { useFeature } from '../../GrowthBookProvider'; import { sharedPostPreviewFeature } from '../../../lib/featureManagement'; import { SharedPostPreview } from './SharedPostPreview'; -import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; -import { PostHiddenPanel } from '../../post/block/PostHiddenPanel'; +import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; const EmptyStateContainer = classed( 'div', @@ -67,7 +66,7 @@ export const ShareGrid = forwardRef(function ShareGrid( const isVideoType = isVideoPost(post); const isSharedPostPreviewEnabled = useFeature(sharedPostPreviewFeature); const isSharedTweet = isSocialTwitterPost(sharedPost); - const { data: blockPanelData } = useBlockPostPanel(post); + const hiddenPanel = useHiddenFeedbackPanel(post); const footer = useMemo(() => { if (isDeleted) { @@ -143,11 +142,8 @@ export const ShareGrid = forwardRef(function ShareGrid( sharedPost, ]); - if ( - blockPanelData?.showTagsPanel === true && - blockPanelData?.mode === 'hide' - ) { - return ; + if (hiddenPanel) { + return hiddenPanel; } return ( diff --git a/packages/shared/src/components/cards/share/ShareList.tsx b/packages/shared/src/components/cards/share/ShareList.tsx index 12de88c595d..0d4f6bc482d 100644 --- a/packages/shared/src/components/cards/share/ShareList.tsx +++ b/packages/shared/src/components/cards/share/ShareList.tsx @@ -27,8 +27,7 @@ import { isSourceUserSource } from '../../../graphql/sources'; import { useFeature } from '../../GrowthBookProvider'; import { sharedPostPreviewFeature } from '../../../lib/featureManagement'; import { SharedPostPreview } from './SharedPostPreview'; -import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; -import { PostHiddenPanel } from '../../post/block/PostHiddenPanel'; +import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; export const ShareList = forwardRef(function ShareList( { @@ -62,7 +61,7 @@ export const ShareList = forwardRef(function ShareList( const { title } = useSmartTitle(post); const { title: truncatedTitle } = useTruncatedSummary(title); const isUserSource = isSourceUserSource(post.source); - const { data: blockPanelData } = useBlockPostPanel(post); + const hiddenPanel = useHiddenFeedbackPanel(post); const actionButtons = ( @@ -115,11 +114,8 @@ export const ShareList = forwardRef(function ShareList( sharedPost?.source?.handle, ]); - if ( - blockPanelData?.showTagsPanel === true && - blockPanelData?.mode === 'hide' - ) { - return ; + if (hiddenPanel) { + return hiddenPanel; } return ( diff --git a/packages/shared/src/components/post/block/PostHiddenPanel.tsx b/packages/shared/src/components/post/block/PostHiddenPanel.tsx index 9423efa8fc1..c539cd53f40 100644 --- a/packages/shared/src/components/post/block/PostHiddenPanel.tsx +++ b/packages/shared/src/components/post/block/PostHiddenPanel.tsx @@ -100,7 +100,7 @@ export function PostHiddenPanel({ return (
@@ -113,7 +113,8 @@ export function PostHiddenPanel({
onConfirmDismiss('done')} + data-testid="postHiddenPanelClose" + onClick={() => onConfirmDismiss('dismiss')} size={ButtonSize.XSmall} />

diff --git a/packages/shared/src/features/posts/PostOptionButton.tsx b/packages/shared/src/features/posts/PostOptionButton.tsx index e84309b084d..a0b95b9c6f1 100644 --- a/packages/shared/src/features/posts/PostOptionButton.tsx +++ b/packages/shared/src/features/posts/PostOptionButton.tsx @@ -465,9 +465,7 @@ const PostOptionButtonContent = ({ postOptions.push({ icon: , label: 'Hide', - action: async () => { - await onHide(); - }, + action: onHide, }); postOptions.push({ diff --git a/packages/shared/src/hooks/post/useHiddenFeedbackPanel.tsx b/packages/shared/src/hooks/post/useHiddenFeedbackPanel.tsx new file mode 100644 index 00000000000..bab90ca2cff --- /dev/null +++ b/packages/shared/src/hooks/post/useHiddenFeedbackPanel.tsx @@ -0,0 +1,21 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { Post } from '../../graphql/posts'; +import { useBlockPostPanel } from './useBlockPostPanel'; +import { PostHiddenPanel } from '../../components/post/block/PostHiddenPanel'; + +/** + * Returns the inline hide-feedback panel element when the cached block + * panel data for `post` is in `hide` mode, otherwise null. Card variants + * call this once and early-return the result so the card body is replaced + * in place with the panel. + */ +export const useHiddenFeedbackPanel = (post: Post): ReactElement | null => { + const { data } = useBlockPostPanel(post); + + if (!data?.showTagsPanel || data?.mode !== 'hide') { + return null; + } + + return ; +}; diff --git a/packages/shared/src/hooks/post/useHidePost.ts b/packages/shared/src/hooks/post/useHidePost.ts index d6b0076e0c1..b7dd4eba850 100644 --- a/packages/shared/src/hooks/post/useHidePost.ts +++ b/packages/shared/src/hooks/post/useHidePost.ts @@ -12,12 +12,21 @@ interface UseHidePostProps { origin?: Origin; } +type DismissReason = 'dismiss' | 'unfollow' | 'block' | 'report'; + interface UseHidePost { - onHide: (overrideOrigin?: Origin) => Promise; + onHide: () => Promise; onUnhide: () => Promise; - onConfirmDismiss: (reason?: 'done' | 'unfollow' | 'block' | 'report') => void; + onConfirmDismiss: (reason?: DismissReason) => void; } +const eventByReason: Record = { + dismiss: LogEvent.HidePostDismiss, + unfollow: LogEvent.HidePostUnfollowSource, + block: LogEvent.HidePostBlockTags, + report: LogEvent.HidePostReport, +}; + export const useHidePost = ({ post, origin = Origin.PostContextMenu, @@ -36,46 +45,41 @@ export const useHidePost = ({ [items, post.id], ); - const onHide = useCallback( - async (overrideOrigin?: Origin) => { - const { successful } = await hidePost(post.id); + const onHide = useCallback(async () => { + const { successful } = await hidePost(post.id); - if (!successful) { - return false; - } + if (!successful) { + return false; + } - logEvent( - postLogEvent(LogEvent.HidePost, post, { - extra: { origin: overrideOrigin ?? origin }, - ...logOpts, - }), - ); + logEvent( + postLogEvent(LogEvent.HidePost, post, { + extra: { origin }, + ...logOpts, + }), + ); - onShowPanel({ mode: 'hide' }); - return true; - }, - [hidePost, post, logEvent, postLogEvent, origin, logOpts, onShowPanel], - ); + onShowPanel({ mode: 'hide' }); + return true; + }, [hidePost, post, logEvent, postLogEvent, origin, logOpts, onShowPanel]); const onUnhide = useCallback(async () => { + const { successful } = await unhidePost(post.id); + + if (!successful) { + return; + } + logEvent( postLogEvent(LogEvent.HidePostUndo, post, { ...logOpts, }), ); - await unhidePost(post.id); onClose(true); }, [unhidePost, post, onClose, logEvent, postLogEvent, logOpts]); const onConfirmDismiss = useCallback( - (reason: 'done' | 'unfollow' | 'block' | 'report' = 'done') => { - const eventByReason: Record = { - done: LogEvent.HidePostConfirm, - unfollow: LogEvent.HidePostUnfollowSource, - block: LogEvent.HidePostBlockTags, - report: LogEvent.HidePostReport, - }; - + (reason: DismissReason = 'dismiss') => { logEvent( postLogEvent(eventByReason[reason], post, { ...logOpts, @@ -83,6 +87,10 @@ export const useHidePost = ({ ); onClose(true); + // postIndex can be -1 when the panel is rendered outside of an + // ActiveFeedContext (e.g. standalone post page) or when the post + // was already removed by another flow. Closing the panel is enough + // in those cases — no removal callback to invoke. if (postIndex >= 0) { onRemovePost?.(postIndex); } diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index bba6df2b191..26f858fbfa5 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -106,7 +106,7 @@ export enum LogEvent { HidePostBlockTags = 'hide post block tags', HidePostReport = 'hide post report', HidePostUndo = 'hide post undo', - HidePostConfirm = 'hide post confirm', + HidePostDismiss = 'hide post dismiss', ReportSquad = 'report squad', Click = 'click', CommentPost = 'comment post', From da355597d71d036512c977c4f839465d833f11ff Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 17:56:26 +0300 Subject: [PATCH 8/9] style(shared): tighten left padding inside post-hide action rows Move the per-option padding from symmetric `px-3` to `pl-2 pr-3` so the icon sits closer to the option's left edge, and revert the panel-level `pl-2` change so the title and surrounding chrome stay where they were. Co-authored-by: Cursor --- packages/shared/src/components/post/block/PostHiddenPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/post/block/PostHiddenPanel.tsx b/packages/shared/src/components/post/block/PostHiddenPanel.tsx index c539cd53f40..5858f540d76 100644 --- a/packages/shared/src/components/post/block/PostHiddenPanel.tsx +++ b/packages/shared/src/components/post/block/PostHiddenPanel.tsx @@ -39,7 +39,7 @@ const ActionRow = ({ role="menuitem" aria-label={ariaLabel} onClick={onClick} - className="flex h-9 w-full items-center gap-3 rounded-12 px-3 text-left text-text-tertiary typo-callout hover:bg-surface-hover" + className="flex h-9 w-full items-center gap-3 rounded-12 pl-2 pr-3 text-left text-text-tertiary typo-callout hover:bg-surface-hover" > {icon} @@ -100,7 +100,7 @@ export function PostHiddenPanel({ return (
From e3fbca4ae9930bff7c2f9031a1184d53468198b2 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 17 May 2026 09:03:28 +0300 Subject: [PATCH 9/9] feat(shared): multi-select hide feedback with batched submit and toast undo Rework the inline post-hide panel into a multi-select chip layout with a single batched submit, a persistent feedback toast with Undo, and a rename of the per-action log events into one feedback-submit event. Card variants now wrap the panel in FeedItemContainer so the card footprint stays stable while hidden. Co-authored-by: Cursor --- packages/shared/src/components/Feed.spec.tsx | 185 +++++++++++++++ .../cards/Freeform/FreeformGrid.tsx | 18 +- .../cards/Freeform/FreeformList.tsx | 18 +- .../components/cards/article/ArticleGrid.tsx | 19 +- .../components/cards/article/ArticleList.tsx | 19 +- .../cards/collection/CollectionGrid.tsx | 22 +- .../cards/collection/CollectionList.tsx | 22 +- .../src/components/cards/share/ShareGrid.tsx | 22 +- .../src/components/cards/share/ShareList.tsx | 18 +- .../components/post/block/PostHiddenPanel.tsx | 224 +++++++++++------- .../src/hooks/post/useHiddenFeedbackPanel.tsx | 26 +- packages/shared/src/hooks/post/useHidePost.ts | 195 +++++++++++++-- packages/shared/src/lib/log.ts | 3 +- 13 files changed, 645 insertions(+), 146 deletions(-) diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index ea3828fda41..c099a66458a 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -920,6 +920,191 @@ describe('Feed logged in', () => { ).not.toBeInTheDocument(); }); + it('should keep the Done button disabled until something is selected', async () => { + renderComponent([ + createFeedMock({ + pageInfo: defaultFeedPage.pageInfo, + edges: [defaultFeedPage.edges[0]], + }), + { + request: { + query: HIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => ({ data: { _: true } }), + }, + ]); + + const [menuBtn] = await screen.findAllByLabelText('Options'); + fireEvent.keyDown(menuBtn, { key: ' ' }); + (await screen.findByText('Hide')).click(); + + const doneBtn = await screen.findByTestId('postHiddenPanelDone'); + expect(doneBtn).toBeDisabled(); + + fireEvent.click(await screen.findByTestId('hideBlockSourceButton')); + expect(doneBtn).not.toBeDisabled(); + }); + + it('should batch source + tags into a single submit and show a toast with Undo', async () => { + let blockTagCalled = false; + let blockSourceCalled = false; + renderComponent([ + createFeedMock({ + pageInfo: defaultFeedPage.pageInfo, + edges: [defaultFeedPage.edges[0]], + }), + { + request: { + query: HIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => ({ data: { _: true } }), + }, + { + request: { + query: ADD_FILTERS_TO_FEED_MUTATION, + variables: { filters: { blockedTags: ['javascript'] } }, + }, + result: () => { + blockTagCalled = true; + return { data: { _: true } }; + }, + }, + { + request: { + query: ADD_FILTERS_TO_FEED_MUTATION, + variables: { filters: { excludeSources: ['echojs'] } }, + }, + result: () => { + blockSourceCalled = true; + return { data: { _: true } }; + }, + }, + ]); + + const [menuBtn] = await screen.findAllByLabelText('Options'); + fireEvent.keyDown(menuBtn, { key: ' ' }); + mockGraphQL(createTagsSettingsMock()); + await waitFor(async () => { + const data = await queryClient.getQueryData( + getFeedSettingsQueryKey(defaultUser), + ); + expect(data).toBeTruthy(); + }); + (await screen.findByText('Hide')).click(); + + fireEvent.click(await screen.findByTestId('hideBlockSourceButton')); + fireEvent.click(await screen.findByTestId('hideBlockTagButton')); + fireEvent.click(await screen.findByTestId('postHiddenPanelDone')); + + await waitFor(() => expect(blockTagCalled).toBeTruthy()); + await waitFor(() => expect(blockSourceCalled).toBeTruthy()); + + expect( + await screen.findByText('Unfollowed Echo JS and blocked #javascript'), + ).toBeInTheDocument(); + const toastUndo = await screen.findByRole('button', { name: 'Undo' }); + expect(toastUndo).toBeInTheDocument(); + expect( + screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), + ).not.toBeInTheDocument(); + }); + + it('should restore post and unblock when clicking Undo on the success toast', async () => { + let unhideCalled = false; + let unblockTagCalled = false; + let unblockSourceCalled = false; + renderComponent([ + createFeedMock({ + pageInfo: defaultFeedPage.pageInfo, + edges: [defaultFeedPage.edges[0]], + }), + { + request: { + query: HIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => ({ data: { _: true } }), + }, + { + request: { + query: ADD_FILTERS_TO_FEED_MUTATION, + variables: { filters: { blockedTags: ['javascript'] } }, + }, + result: () => ({ data: { _: true } }), + }, + { + request: { + query: ADD_FILTERS_TO_FEED_MUTATION, + variables: { filters: { excludeSources: ['echojs'] } }, + }, + result: () => ({ data: { _: true } }), + }, + { + request: { + query: UNHIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => { + unhideCalled = true; + return { data: { _: true } }; + }, + }, + { + request: { + query: REMOVE_FILTERS_FROM_FEED_MUTATION, + variables: { filters: { blockedTags: ['javascript'] } }, + }, + result: () => { + unblockTagCalled = true; + return { data: { _: true } }; + }, + }, + { + request: { + query: REMOVE_FILTERS_FROM_FEED_MUTATION, + variables: { filters: { excludeSources: ['echojs'] } }, + }, + result: () => { + unblockSourceCalled = true; + return { data: { _: true } }; + }, + }, + ]); + + const [menuBtn] = await screen.findAllByLabelText('Options'); + fireEvent.keyDown(menuBtn, { key: ' ' }); + mockGraphQL(createTagsSettingsMock()); + await waitFor(async () => { + const data = await queryClient.getQueryData( + getFeedSettingsQueryKey(defaultUser), + ); + expect(data).toBeTruthy(); + }); + (await screen.findByText('Hide')).click(); + + fireEvent.click(await screen.findByTestId('hideBlockSourceButton')); + fireEvent.click(await screen.findByTestId('hideBlockTagButton')); + fireEvent.click(await screen.findByTestId('postHiddenPanelDone')); + + await waitFor(() => + expect( + screen.queryByText("Got it. You'll see less like this."), + ).not.toBeInTheDocument(), + ); + + const toastAlert = await screen.findByRole('alert'); + const toastUndo = await within(toastAlert).findByRole('button', { + name: 'Undo', + }); + fireEvent.click(toastUndo); + + await waitFor(() => expect(unhideCalled).toBeTruthy()); + await waitFor(() => expect(unblockTagCalled).toBeTruthy()); + await waitFor(() => expect(unblockSourceCalled).toBeTruthy()); + }); + it('should block a source', async () => { let mutationCalled = false; renderComponent([ diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx index e43da749b0a..9ed4e2fb7b3 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx @@ -42,10 +42,22 @@ export const FreeformGrid = forwardRef(function SharePostCard( const containerRef = useRef(); const image = usePostImage(post); const { title } = useSmartTitle(post); - const hiddenPanel = useHiddenFeedbackPanel(post); + const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); - if (hiddenPanel) { - return hiddenPanel; + if (isHidden) { + return ( + + {hiddenPanel} + + ); } return ( diff --git a/packages/shared/src/components/cards/Freeform/FreeformList.tsx b/packages/shared/src/components/cards/Freeform/FreeformList.tsx index 507f7b28613..ce6bb285387 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformList.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformList.tsx @@ -61,7 +61,7 @@ export const FreeformList = forwardRef(function SharePostCard( const socialShare = interaction === 'copy' && post.type === PostType.Freeform; const { title: truncatedTitle } = useTruncatedSummary(title, content); const isUserSource = isSourceUserSource(post.source); - const hiddenPanel = useHiddenFeedbackPanel(post); + const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); const actionButtons = ( @@ -107,8 +107,20 @@ export const FreeformList = forwardRef(function SharePostCard( post?.source?.name, ]); - if (hiddenPanel) { - return hiddenPanel; + if (isHidden) { + return ( + + {hiddenPanel} + + ); } return ( diff --git a/packages/shared/src/components/cards/article/ArticleGrid.tsx b/packages/shared/src/components/cards/article/ArticleGrid.tsx index 5293334cad5..4abb8fcd9c2 100644 --- a/packages/shared/src/components/cards/article/ArticleGrid.tsx +++ b/packages/shared/src/components/cards/article/ArticleGrid.tsx @@ -47,7 +47,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( ref: Ref, ): ReactElement { const { className, style } = domProps; - const hiddenPanel = useHiddenFeedbackPanel(post); + const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); const { data } = useBlockPostPanel(post); const onPostCardClick = () => onPostClick(post); const onPostCardAuxClick = () => onPostAuxClick(post); @@ -56,8 +56,21 @@ export const ArticleGrid = forwardRef(function ArticleGrid( const { title } = useSmartTitle(post); const isVideoType = isVideoPost(post); - if (hiddenPanel) { - return hiddenPanel; + if (isHidden) { + return ( + + {hiddenPanel} + + ); } if (data?.showTagsPanel && post.tags.length > 0) { diff --git a/packages/shared/src/components/cards/article/ArticleList.tsx b/packages/shared/src/components/cards/article/ArticleList.tsx index 15a921f7ba0..afeaa5c459d 100644 --- a/packages/shared/src/components/cards/article/ArticleList.tsx +++ b/packages/shared/src/components/cards/article/ArticleList.tsx @@ -56,7 +56,7 @@ export const ArticleList = forwardRef(function ArticleList( onPostClick?.(post, event); const isMobile = useViewSize(ViewSize.MobileL); const { showFeedback } = usePostFeedback({ post }); - const hiddenPanel = useHiddenFeedbackPanel(post); + const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); const isFeedPreview = useFeedPreviewMode(); const { title } = useSmartTitle(post); const { title: truncatedTitle } = useTruncatedSummary(title); @@ -102,8 +102,21 @@ export const ArticleList = forwardRef(function ArticleList( }; }, [isUserSource, post]); - if (hiddenPanel) { - return hiddenPanel; + if (isHidden) { + return ( + + {hiddenPanel} + + ); } return ( diff --git a/packages/shared/src/components/cards/collection/CollectionGrid.tsx b/packages/shared/src/components/cards/collection/CollectionGrid.tsx index 7089462c4e5..fe03639b74b 100644 --- a/packages/shared/src/components/cards/collection/CollectionGrid.tsx +++ b/packages/shared/src/components/cards/collection/CollectionGrid.tsx @@ -41,10 +41,26 @@ export const CollectionGrid = forwardRef(function CollectionCard( const wasUpdated = isPostUpdated(post); const onPostCardClick = () => onPostClick?.(post); const onPostCardAuxClick = () => onPostAuxClick?.(post); - const hiddenPanel = useHiddenFeedbackPanel(post); + const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); - if (hiddenPanel) { - return hiddenPanel; + if (isHidden) { + return ( + + {hiddenPanel} + + ); } return ( diff --git a/packages/shared/src/components/cards/collection/CollectionList.tsx b/packages/shared/src/components/cards/collection/CollectionList.tsx index 21b9c4c099d..892e75dd07b 100644 --- a/packages/shared/src/components/cards/collection/CollectionList.tsx +++ b/packages/shared/src/components/cards/collection/CollectionList.tsx @@ -42,7 +42,7 @@ export const CollectionList = forwardRef(function CollectionCard( const image = usePostImage(post); const { title } = useTruncatedSummary(post?.title ?? ''); const wasUpdated = isPostUpdated(post); - const hiddenPanel = useHiddenFeedbackPanel(post); + const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); const actionButtons = ( @@ -59,8 +59,24 @@ export const CollectionList = forwardRef(function CollectionCard( ); - if (hiddenPanel) { - return hiddenPanel; + if (isHidden) { + return ( + + {hiddenPanel} + + ); } return ( diff --git a/packages/shared/src/components/cards/share/ShareGrid.tsx b/packages/shared/src/components/cards/share/ShareGrid.tsx index ac05e853303..5318356cb73 100644 --- a/packages/shared/src/components/cards/share/ShareGrid.tsx +++ b/packages/shared/src/components/cards/share/ShareGrid.tsx @@ -66,7 +66,7 @@ export const ShareGrid = forwardRef(function ShareGrid( const isVideoType = isVideoPost(post); const isSharedPostPreviewEnabled = useFeature(sharedPostPreviewFeature); const isSharedTweet = isSocialTwitterPost(sharedPost); - const hiddenPanel = useHiddenFeedbackPanel(post); + const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); const footer = useMemo(() => { if (isDeleted) { @@ -142,8 +142,24 @@ export const ShareGrid = forwardRef(function ShareGrid( sharedPost, ]); - if (hiddenPanel) { - return hiddenPanel; + if (isHidden) { + return ( + + {hiddenPanel} + + ); } return ( diff --git a/packages/shared/src/components/cards/share/ShareList.tsx b/packages/shared/src/components/cards/share/ShareList.tsx index 0d4f6bc482d..b9459124901 100644 --- a/packages/shared/src/components/cards/share/ShareList.tsx +++ b/packages/shared/src/components/cards/share/ShareList.tsx @@ -61,7 +61,7 @@ export const ShareList = forwardRef(function ShareList( const { title } = useSmartTitle(post); const { title: truncatedTitle } = useTruncatedSummary(title); const isUserSource = isSourceUserSource(post.source); - const hiddenPanel = useHiddenFeedbackPanel(post); + const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); const actionButtons = ( @@ -114,8 +114,20 @@ export const ShareList = forwardRef(function ShareList( sharedPost?.source?.handle, ]); - if (hiddenPanel) { - return hiddenPanel; + if (isHidden) { + return ( + + {hiddenPanel} + + ); } return ( diff --git a/packages/shared/src/components/post/block/PostHiddenPanel.tsx b/packages/shared/src/components/post/block/PostHiddenPanel.tsx index 5858f540d76..1f895d0cccf 100644 --- a/packages/shared/src/components/post/block/PostHiddenPanel.tsx +++ b/packages/shared/src/components/post/block/PostHiddenPanel.tsx @@ -1,19 +1,21 @@ -import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { useHidePost } from '../../../hooks/post/useHidePost'; -import useTagAndSource from '../../../hooks/useTagAndSource'; import useFeedSettings from '../../../hooks/useFeedSettings'; import { useCustomFeed } from '../../../hooks/feed/useCustomFeed'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; -import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../../buttons/Button'; import CloseButton from '../../CloseButton'; -import { FlagIcon, HashtagIcon } from '../../icons'; -import { IconSize } from '../../Icon'; import { SourceAvatar } from '../../profile/source'; -import { ProfileImageSize } from '../../ProfilePicture'; +import { GenericTagButton } from '../../filters/TagButton'; import { Origin } from '../../../lib/log'; interface PostHiddenPanelProps { @@ -21,33 +23,6 @@ interface PostHiddenPanelProps { className?: string; } -interface ActionRowProps { - icon: ReactNode; - label: ReactNode; - onClick: () => void; - ariaLabel?: string; -} - -const ActionRow = ({ - icon, - label, - onClick, - ariaLabel, -}: ActionRowProps): ReactElement => ( - -); - export function PostHiddenPanel({ post, className, @@ -55,33 +30,50 @@ export function PostHiddenPanel({ const { feedId: customFeedId } = useCustomFeed(); const { feedSettings } = useFeedSettings({ feedId: customFeedId }); const { source } = post; + if (!source) { throw new Error('PostHiddenPanel requires post.source'); } + const isSourceAlreadyBlocked = feedSettings?.excludeSources?.some(({ id }) => id === source.id) ?? false; - const { onUnhide, onConfirmDismiss } = useHidePost({ post }); - const { onBlockTags, onBlockSource } = useTagAndSource({ - origin: Origin.PostContextMenu, - postId: post.id, - shouldInvalidateQueries: false, - feedId: customFeedId, - }); - const { openModal } = useLazyModal(); + const blockableTags = useMemo( + () => + (post.tags ?? []).filter( + (tag) => !(feedSettings?.blockedTags ?? []).includes(tag), + ), + [post.tags, feedSettings?.blockedTags], + ); - const blockableTags = (post.tags ?? []).filter( - (tag) => !(feedSettings?.blockedTags ?? []).includes(tag), + const [shouldBlockSource, setShouldBlockSource] = useState(false); + const [tagSelection, setTagSelection] = useState>( + () => + blockableTags.reduce>( + (acc, tag) => ({ ...acc, [tag]: false }), + {}, + ), ); + const [isConfirmed, setIsConfirmed] = useState(false); - const handleUnfollowSource = async () => { - await onBlockSource({ source, requireLogin: true }); - onConfirmDismiss('unfollow'); - }; + const { onUnhide, onSubmitFeedback, onDismiss, onReportSubmitted } = + useHidePost({ post }); + const { openModal } = useLazyModal(); - const handleBlockTag = async (tag: string) => { - await onBlockTags({ tags: [tag], requireLogin: true }); - onConfirmDismiss('block'); + const selectedTags = Object.entries(tagSelection) + .filter(([, selected]) => selected) + .map(([tag]) => tag); + const hasSelection = shouldBlockSource || selectedTags.length > 0; + + const handleSubmit = async () => { + const { removed } = await onSubmitFeedback({ + tags: selectedTags, + blockSource: shouldBlockSource, + }); + + if (!removed) { + setIsConfirmed(true); + } }; const handleReport = () => { @@ -91,65 +83,102 @@ export function PostHiddenPanel({ post, origin: Origin.PostContextMenu, onReported: () => { - onConfirmDismiss('report'); + onReportSubmitted(); }, }, }); }; + if (isConfirmed) { + return ( +
+

+ Thanks for your feedback +

+

+ We'll show fewer posts like this. +

+ +
+ ); + } + return (
-
-
-

- Got it. You'll see less like this. -

-

Take it further:

-
- onConfirmDismiss('dismiss')} - size={ButtonSize.XSmall} - /> -
-
+ +

+ Got it. You'll see less like this. +

+

+ Pick anything else you want out of your feed (optional) +

+ {!isSourceAlreadyBlocked && ( - + )} {blockableTags.map((tag) => ( - } - label={`Block #${tag}`} - onClick={() => handleBlockTag(tag)} - ariaLabel={`Block ${tag}`} + role="listitem" + variant={ + tagSelection[tag] ? ButtonVariant.Primary : ButtonVariant.Float + } + action={() => + setTagSelection((prev) => ({ ...prev, [tag]: !prev[tag] })) + } + tagItem={tag} + data-testid="hideBlockTagButton" /> ))} - } - label="Report" + + +
-
+ > + Report + -
+ +
); } diff --git a/packages/shared/src/hooks/post/useHiddenFeedbackPanel.tsx b/packages/shared/src/hooks/post/useHiddenFeedbackPanel.tsx index bab90ca2cff..95216c110af 100644 --- a/packages/shared/src/hooks/post/useHiddenFeedbackPanel.tsx +++ b/packages/shared/src/hooks/post/useHiddenFeedbackPanel.tsx @@ -4,18 +4,24 @@ import type { Post } from '../../graphql/posts'; import { useBlockPostPanel } from './useBlockPostPanel'; import { PostHiddenPanel } from '../../components/post/block/PostHiddenPanel'; +interface UseHiddenFeedbackPanel { + isHidden: boolean; + content: ReactElement | null; +} + /** - * Returns the inline hide-feedback panel element when the cached block - * panel data for `post` is in `hide` mode, otherwise null. Card variants - * call this once and early-return the result so the card body is replaced - * in place with the panel. + * Returns a flag plus the inline hide-feedback panel content when the cached + * block panel state for `post` is in `hide` mode. Card variants check + * `isHidden` and render `content` inside their own `FeedItemContainer` so the + * outer card footprint (sizing, raised label, hover, bookmark border) stays + * identical to the regular post card and there is no layout shift. */ -export const useHiddenFeedbackPanel = (post: Post): ReactElement | null => { +export const useHiddenFeedbackPanel = (post: Post): UseHiddenFeedbackPanel => { const { data } = useBlockPostPanel(post); + const isHidden = !!(data?.showTagsPanel && data?.mode === 'hide'); - if (!data?.showTagsPanel || data?.mode !== 'hide') { - return null; - } - - return ; + return { + isHidden, + content: isHidden ? : null, + }; }; diff --git a/packages/shared/src/hooks/post/useHidePost.ts b/packages/shared/src/hooks/post/useHidePost.ts index b7dd4eba850..a30c1645f6a 100644 --- a/packages/shared/src/hooks/post/useHidePost.ts +++ b/packages/shared/src/hooks/post/useHidePost.ts @@ -1,30 +1,64 @@ import { useCallback, useContext, useMemo } from 'react'; import type { Post } from '../../graphql/posts'; +import type { Source } from '../../graphql/sources'; import useReportPost from '../useReportPost'; import { useBlockPostPanel } from './useBlockPostPanel'; +import useTagAndSource from '../useTagAndSource'; +import { useCustomFeed } from '../feed/useCustomFeed'; import { ActiveFeedContext } from '../../contexts/ActiveFeedContext'; import { useLogContext } from '../../contexts/LogContext'; +import { ToastSubject, useToastNotification } from '../useToastNotification'; import { LogEvent, Origin } from '../../lib/log'; import { usePostLogEvent } from '../../lib/feed'; +import { labels } from '../../lib/labels'; interface UseHidePostProps { post: Post; origin?: Origin; } -type DismissReason = 'dismiss' | 'unfollow' | 'block' | 'report'; +export interface HideFeedbackPayload { + tags: string[]; + blockSource: boolean; +} + +interface SubmitFeedbackResult { + removed: boolean; +} interface UseHidePost { onHide: () => Promise; onUnhide: () => Promise; - onConfirmDismiss: (reason?: DismissReason) => void; + onSubmitFeedback: ( + payload: HideFeedbackPayload, + ) => Promise; + onUndoFeedback: (payload: HideFeedbackPayload) => Promise; + onDismiss: () => void; + onReportSubmitted: () => void; } -const eventByReason: Record = { - dismiss: LogEvent.HidePostDismiss, - unfollow: LogEvent.HidePostUnfollowSource, - block: LogEvent.HidePostBlockTags, - report: LogEvent.HidePostReport, +const buildFeedbackMessage = ( + { tags, blockSource }: HideFeedbackPayload, + source?: Source, +): string => { + const sourceName = source?.name ?? 'this source'; + const tagCount = tags.length; + + if (blockSource && tagCount > 0) { + return tagCount === 1 + ? `Unfollowed ${sourceName} and blocked #${tags[0]}` + : `Unfollowed ${sourceName} and blocked ${tagCount} tags`; + } + + if (blockSource) { + return `You won't see posts from ${sourceName} anymore`; + } + + if (tagCount === 1) { + return `Blocked #${tags[0]}`; + } + + return `Blocked ${tagCount} tags`; }; export const useHidePost = ({ @@ -35,7 +69,16 @@ export const useHidePost = ({ const { onShowPanel, onClose } = useBlockPostPanel(post); const { logEvent } = useLogContext(); const postLogEvent = usePostLogEvent(); + const { displayToast } = useToastNotification(); const { items, onRemovePost, logOpts } = useContext(ActiveFeedContext); + const { feedId: customFeedId } = useCustomFeed(); + const { onBlockTags, onUnblockTags, onBlockSource, onUnblockSource } = + useTagAndSource({ + origin, + postId: post.id, + shouldInvalidateQueries: false, + feedId: customFeedId, + }); const postIndex = useMemo( () => @@ -78,25 +121,141 @@ export const useHidePost = ({ onClose(true); }, [unhidePost, post, onClose, logEvent, postLogEvent, logOpts]); - const onConfirmDismiss = useCallback( - (reason: DismissReason = 'dismiss') => { + const onUndoFeedback = useCallback( + async ({ tags, blockSource }: HideFeedbackPayload) => { + const { successful } = await unhidePost(post.id); + + if (!successful) { + return; + } + + await Promise.all([ + tags.length + ? onUnblockTags({ tags }) + : Promise.resolve({ successful: true }), + blockSource && post.source + ? onUnblockSource({ source: post.source }) + : Promise.resolve({ successful: true }), + ]); + logEvent( - postLogEvent(eventByReason[reason], post, { + postLogEvent(LogEvent.HidePostUndo, post, { ...logOpts, }), ); - onClose(true); - // postIndex can be -1 when the panel is rendered outside of an - // ActiveFeedContext (e.g. standalone post page) or when the post - // was already removed by another flow. Closing the panel is enough - // in those cases — no removal callback to invoke. - if (postIndex >= 0) { + }, + [ + unhidePost, + onUnblockTags, + onUnblockSource, + post, + logEvent, + postLogEvent, + logOpts, + onClose, + ], + ); + + const onSubmitFeedback = useCallback( + async ({ tags, blockSource }: HideFeedbackPayload) => { + await Promise.all([ + tags.length + ? onBlockTags({ tags, requireLogin: true }) + : Promise.resolve({ successful: true }), + blockSource && post.source + ? onBlockSource({ source: post.source, requireLogin: true }) + : Promise.resolve({ successful: true }), + ]); + + logEvent( + postLogEvent(LogEvent.HidePostFeedbackSubmit, post, { + extra: { blockedSource: blockSource, tagCount: tags.length, origin }, + ...logOpts, + }), + ); + + const removed = postIndex >= 0; + + // Remove from the feed BEFORE clearing the panel state so the original + // card never re-renders in place (avoids the "card flickers back" bug). + if (removed) { onRemovePost?.(postIndex); + onClose(true); } + + const showToast = blockSource || tags.length > 0; + if (showToast) { + displayToast(buildFeedbackMessage({ tags, blockSource }, post.source), { + subject: ToastSubject.Feed, + persistent: true, + action: { + copy: 'Undo', + onClick: () => onUndoFeedback({ tags, blockSource }), + }, + }); + } + + return { removed }; }, - [onClose, onRemovePost, postIndex, logEvent, postLogEvent, post, logOpts], + [ + onBlockTags, + onBlockSource, + post, + logEvent, + postLogEvent, + logOpts, + origin, + postIndex, + onRemovePost, + onClose, + displayToast, + onUndoFeedback, + ], ); - return { onHide, onUnhide, onConfirmDismiss }; + const onDismiss = useCallback(() => { + logEvent( + postLogEvent(LogEvent.HidePostDismiss, post, { + ...logOpts, + }), + ); + if (postIndex >= 0) { + onRemovePost?.(postIndex); + } + onClose(true); + }, [logEvent, postLogEvent, post, logOpts, postIndex, onRemovePost, onClose]); + + const onReportSubmitted = useCallback(() => { + logEvent( + postLogEvent(LogEvent.HidePostReport, post, { + ...logOpts, + }), + ); + if (postIndex >= 0) { + onRemovePost?.(postIndex); + } + onClose(true); + displayToast(labels.reporting.reportFeedbackText, { + subject: ToastSubject.Feed, + }); + }, [ + logEvent, + postLogEvent, + post, + logOpts, + postIndex, + onRemovePost, + onClose, + displayToast, + ]); + + return { + onHide, + onUnhide, + onSubmitFeedback, + onUndoFeedback, + onDismiss, + onReportSubmitted, + }; }; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 26f858fbfa5..7160b658cb6 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -102,8 +102,7 @@ export enum Origin { export enum LogEvent { HidePost = 'hide post', - HidePostUnfollowSource = 'hide post unfollow source', - HidePostBlockTags = 'hide post block tags', + HidePostFeedbackSubmit = 'hide post feedback submit', HidePostReport = 'hide post report', HidePostUndo = 'hide post undo', HidePostDismiss = 'hide post dismiss',