diff --git a/Gemfile.lock b/Gemfile.lock index 02437eab6b..42cc0e1986 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,8 +100,8 @@ GEM attr_required (1.0.2) awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.947.0) - aws-sdk-core (3.199.0) + aws-partitions (1.949.0) + aws-sdk-core (3.200.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.8) @@ -109,7 +109,7 @@ GEM aws-sdk-kms (1.87.0) aws-sdk-core (~> 3, >= 3.199.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.154.0) + aws-sdk-s3 (1.155.0) aws-sdk-core (~> 3, >= 3.199.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.8) @@ -696,7 +696,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.0) + rexml (3.3.1) strscan rotp (6.3.0) rouge (4.2.1) @@ -751,7 +751,7 @@ GEM rubocop-performance (1.21.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.25.0) + rubocop-rails (2.25.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) @@ -833,7 +833,7 @@ GEM unicode-display_width (>= 1.1.1, < 3) terrapin (1.0.1) climate_control - test-prof (1.3.3) + test-prof (1.3.3.1) thor (1.3.1) tilt (2.3.0) timeout (0.4.1) diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index d3ce4c575c..eb5050f152 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -170,6 +170,7 @@ export const expandAccountTimeline = (accountId, { maxId, withReplies, t export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); +export const expandLinkTimeline = (url, { maxId } = {}, done = noOp) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }, done); export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, diff --git a/app/javascript/flavours/glitch/api_types/statuses.ts b/app/javascript/flavours/glitch/api_types/statuses.ts index 261d600305..9de86e7fa6 100644 --- a/app/javascript/flavours/glitch/api_types/statuses.ts +++ b/app/javascript/flavours/glitch/api_types/statuses.ts @@ -44,6 +44,7 @@ export interface ApiPreviewCardJSON { type: string; author_name: string; author_url: string; + author_account?: ApiAccountJSON; provider_name: string; provider_url: string; html: string; diff --git a/app/javascript/flavours/glitch/components/follow_button.tsx b/app/javascript/flavours/glitch/components/follow_button.tsx index 5c9cd2c067..a6b2064703 100644 --- a/app/javascript/flavours/glitch/components/follow_button.tsx +++ b/app/javascript/flavours/glitch/components/follow_button.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect } from 'react'; -import { useIntl, defineMessages } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { useIdentity } from '@/flavours/glitch/identity_context'; import { @@ -18,15 +18,11 @@ const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, - cancel_follow_request: { - id: 'account.cancel_follow_request', - defaultMessage: 'Withdraw follow request', - }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, }); export const FollowButton: React.FC<{ - accountId: string; + accountId?: string; }> = ({ accountId }) => { const intl = useIntl(); const dispatch = useAppDispatch(); @@ -35,7 +31,7 @@ export const FollowButton: React.FC<{ accountId ? state.accounts.get(accountId) : undefined, ); const relationship = useAppSelector((state) => - state.relationships.get(accountId), + accountId ? state.relationships.get(accountId) : undefined, ); const following = relationship?.following || relationship?.requested; @@ -64,11 +60,28 @@ export const FollowButton: React.FC<{ if (accountId === me) { return; } else if (relationship.following || relationship.requested) { - dispatch(unfollowAccount(accountId)); + dispatch( + openModal({ + modalType: 'CONFIRM', + modalProps: { + message: ( + @{account?.acct} }} + /> + ), + confirm: intl.formatMessage(messages.unfollow), + onConfirm: () => { + dispatch(unfollowAccount(accountId)); + }, + }, + }), + ); } else { dispatch(followAccount(accountId)); } - }, [dispatch, accountId, relationship, account, signedIn]); + }, [dispatch, intl, accountId, relationship, account, signedIn]); let label; @@ -78,11 +91,9 @@ export const FollowButton: React.FC<{ label = intl.formatMessage(messages.edit_profile); } else if (!relationship) { label = ; - } else if (relationship.requested) { - label = intl.formatMessage(messages.cancel_follow_request); } else if (!relationship.following && relationship.followed_by) { label = intl.formatMessage(messages.followBack); - } else if (relationship.following) { + } else if (relationship.following || relationship.requested) { label = intl.formatMessage(messages.unfollow); } else { label = intl.formatMessage(messages.follow); diff --git a/app/javascript/flavours/glitch/components/hover_card_account.tsx b/app/javascript/flavours/glitch/components/hover_card_account.tsx index a62128e17b..56f431a786 100644 --- a/app/javascript/flavours/glitch/components/hover_card_account.tsx +++ b/app/javascript/flavours/glitch/components/hover_card_account.tsx @@ -17,7 +17,7 @@ import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; export const HoverCardAccount = forwardRef< HTMLDivElement, - { accountId: string } + { accountId?: string } >(({ accountId }, ref) => { const dispatch = useAppDispatch(); diff --git a/app/javascript/flavours/glitch/components/hover_card_controller.tsx b/app/javascript/flavours/glitch/components/hover_card_controller.tsx index 6e11d28381..347dcd4f2f 100644 --- a/app/javascript/flavours/glitch/components/hover_card_controller.tsx +++ b/app/javascript/flavours/glitch/components/hover_card_controller.tsx @@ -12,8 +12,8 @@ import { HoverCardAccount } from 'flavours/glitch/components/hover_card_account' import { useTimeout } from 'flavours/glitch/hooks/useTimeout'; const offset = [-12, 4] as OffsetValue; -const enterDelay = 650; -const leaveDelay = 250; +const enterDelay = 750; +const leaveDelay = 150; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; const isHoverCardAnchor = (element: HTMLElement) => @@ -23,50 +23,12 @@ export const HoverCardController: React.FC = () => { const [open, setOpen] = useState(false); const [accountId, setAccountId] = useState(); const [anchor, setAnchor] = useState(null); - const cardRef = useRef(null); + const cardRef = useRef(null); const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); - const [setEnterTimeout, cancelEnterTimeout] = useTimeout(); + const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout(); + const [setScrollTimeout] = useTimeout(); const location = useLocation(); - const handleAnchorMouseEnter = useCallback( - (e: MouseEvent) => { - const { target } = e; - - if (target instanceof HTMLElement && isHoverCardAnchor(target)) { - cancelLeaveTimeout(); - - setEnterTimeout(() => { - target.setAttribute('aria-describedby', 'hover-card'); - setAnchor(target); - setOpen(true); - setAccountId( - target.getAttribute('data-hover-card-account') ?? undefined, - ); - }, enterDelay); - } - - if (target === cardRef.current?.parentNode) { - cancelLeaveTimeout(); - } - }, - [cancelLeaveTimeout, setEnterTimeout, setOpen, setAccountId, setAnchor], - ); - - const handleAnchorMouseLeave = useCallback( - (e: MouseEvent) => { - if (e.target === anchor || e.target === cardRef.current?.parentNode) { - cancelEnterTimeout(); - - setLeaveTimeout(() => { - anchor?.removeAttribute('aria-describedby'); - setOpen(false); - setAnchor(null); - }, leaveDelay); - } - }, - [cancelEnterTimeout, setLeaveTimeout, setOpen, setAnchor, anchor], - ); - const handleClose = useCallback(() => { cancelEnterTimeout(); cancelLeaveTimeout(); @@ -79,22 +41,119 @@ export const HoverCardController: React.FC = () => { }, [handleClose, location]); useEffect(() => { - document.body.addEventListener('mouseenter', handleAnchorMouseEnter, { + let isScrolling = false; + let currentAnchor: HTMLElement | null = null; + + const open = (target: HTMLElement) => { + target.setAttribute('aria-describedby', 'hover-card'); + setOpen(true); + setAnchor(target); + setAccountId(target.getAttribute('data-hover-card-account') ?? undefined); + }; + + const close = () => { + currentAnchor?.removeAttribute('aria-describedby'); + currentAnchor = null; + setOpen(false); + setAnchor(null); + setAccountId(undefined); + }; + + const handleMouseEnter = (e: MouseEvent) => { + const { target } = e; + + // We've exited the window + if (!(target instanceof HTMLElement)) { + close(); + return; + } + + // We've entered an anchor + if (!isScrolling && isHoverCardAnchor(target)) { + cancelLeaveTimeout(); + + currentAnchor?.removeAttribute('aria-describedby'); + currentAnchor = target; + + setEnterTimeout(() => { + open(target); + }, enterDelay); + } + + // We've entered the hover card + if ( + !isScrolling && + (target === currentAnchor || target === cardRef.current) + ) { + cancelLeaveTimeout(); + } + }; + + const handleMouseLeave = (e: MouseEvent) => { + if (!currentAnchor) { + return; + } + + if (e.target === currentAnchor || e.target === cardRef.current) { + cancelEnterTimeout(); + + setLeaveTimeout(() => { + close(); + }, leaveDelay); + } + }; + + const handleScrollEnd = () => { + isScrolling = false; + }; + + const handleScroll = () => { + isScrolling = true; + cancelEnterTimeout(); + setScrollTimeout(handleScrollEnd, 100); + }; + + const handleMouseMove = () => { + delayEnterTimeout(enterDelay); + }; + + document.body.addEventListener('mouseenter', handleMouseEnter, { passive: true, capture: true, }); - document.body.addEventListener('mouseleave', handleAnchorMouseLeave, { + + document.body.addEventListener('mousemove', handleMouseMove, { + passive: true, + capture: false, + }); + + document.body.addEventListener('mouseleave', handleMouseLeave, { + passive: true, + capture: true, + }); + + document.addEventListener('scroll', handleScroll, { passive: true, capture: true, }); return () => { - document.body.removeEventListener('mouseenter', handleAnchorMouseEnter); - document.body.removeEventListener('mouseleave', handleAnchorMouseLeave); + document.body.removeEventListener('mouseenter', handleMouseEnter); + document.body.removeEventListener('mousemove', handleMouseMove); + document.body.removeEventListener('mouseleave', handleMouseLeave); + document.removeEventListener('scroll', handleScroll); }; - }, [handleAnchorMouseEnter, handleAnchorMouseLeave]); - - if (!accountId) return null; + }, [ + setEnterTimeout, + setLeaveTimeout, + setScrollTimeout, + cancelEnterTimeout, + cancelLeaveTimeout, + delayEnterTimeout, + setOpen, + setAccountId, + setAnchor, + ]); return ( { const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { - if (account.getIn(['relationship', 'following'])) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { dispatch(openModal({ modalType: 'CONFIRM', modalProps: { @@ -52,15 +51,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onConfirm: () => dispatch(unfollowAccount(account.get('id'))), }, })); - } else if (account.getIn(['relationship', 'requested'])) { - dispatch(openModal({ - modalType: 'CONFIRM', - modalProps: { - message: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.cancelFollowRequestConfirm), - onConfirm: () => dispatch(unfollowAccount(account.get('id'))), - }, - })); } else { dispatch(followAccount(account.get('id'))); } diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx index 458a547d02..7071c8719d 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx @@ -185,7 +185,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete }); const names = accounts.map(a => ( - + {author ? : {author} }} /> : } - {typeof sharedTimes === 'number' ? : } + {typeof sharedTimes === 'number' ? : } diff --git a/app/javascript/flavours/glitch/features/link_timeline/index.tsx b/app/javascript/flavours/glitch/features/link_timeline/index.tsx new file mode 100644 index 0000000000..bbe295d474 --- /dev/null +++ b/app/javascript/flavours/glitch/features/link_timeline/index.tsx @@ -0,0 +1,77 @@ +import { useRef, useEffect, useCallback } from 'react'; + +import { Helmet } from 'react-helmet'; +import { useParams } from 'react-router-dom'; + +import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; +import { expandLinkTimeline } from 'flavours/glitch/actions/timelines'; +import Column from 'flavours/glitch/components/column'; +import { ColumnHeader } from 'flavours/glitch/components/column_header'; +import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import type { Card } from 'flavours/glitch/models/status'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; + +export const LinkTimeline: React.FC<{ + multiColumn: boolean; +}> = ({ multiColumn }) => { + const { url } = useParams<{ url: string }>(); + const decodedUrl = url ? decodeURIComponent(url) : undefined; + const dispatch = useAppDispatch(); + const columnRef = useRef(null); + const firstStatusId = useAppSelector((state) => + decodedUrl + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + (state.timelines.getIn([`link:${decodedUrl}`, 'items', 0]) as string) + : undefined, + ); + const story = useAppSelector((state) => + firstStatusId + ? (state.statuses.getIn([firstStatusId, 'card']) as Card) + : undefined, + ); + + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + const handleLoadMore = useCallback( + (maxId: string) => { + dispatch(expandLinkTimeline(decodedUrl, { maxId })); + }, + [dispatch, decodedUrl], + ); + + useEffect(() => { + dispatch(expandLinkTimeline(decodedUrl)); + }, [dispatch, decodedUrl]); + + return ( + + + + + + + {story?.title} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default LinkTimeline; diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index 9fb4a784b3..747922f23e 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -58,6 +58,7 @@ import { FavouritedStatuses, BookmarkedStatuses, FollowedTags, + LinkTimeline, ListTimeline, Blocks, DomainBlocks, @@ -211,6 +212,7 @@ class SwitchingColumnsArea extends PureComponent { + @@ -649,7 +651,7 @@ class UI extends PureComponent { {layout !== 'mobile' && } - {/* Temporarily disabled while upstream improves the issue */ null && } + diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js index 6a140e3fd7..a312cefff7 100644 --- a/app/javascript/flavours/glitch/features/ui/util/async-components.js +++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js @@ -213,3 +213,7 @@ export function NotificationRequests () { export function NotificationRequest () { return import(/*webpackChunkName: "features/glitch/notifications/request" */'../../notifications/request'); } + +export function LinkTimeline () { + return import(/*webpackChunkName: "features/glitch/link_timeline" */'../../link_timeline'); +} diff --git a/app/javascript/flavours/glitch/hooks/useTimeout.ts b/app/javascript/flavours/glitch/hooks/useTimeout.ts index f1814ae8e3..bb1e8848dd 100644 --- a/app/javascript/flavours/glitch/hooks/useTimeout.ts +++ b/app/javascript/flavours/glitch/hooks/useTimeout.ts @@ -2,19 +2,34 @@ import { useRef, useCallback, useEffect } from 'react'; export const useTimeout = () => { const timeoutRef = useRef>(); + const callbackRef = useRef<() => void>(); const set = useCallback((callback: () => void, delay: number) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } + callbackRef.current = callback; timeoutRef.current = setTimeout(callback, delay); }, []); + const delay = useCallback((delay: number) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + if (!callbackRef.current) { + return; + } + + timeoutRef.current = setTimeout(callbackRef.current, delay); + }, []); + const cancel = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = undefined; + callbackRef.current = undefined; } }, []); @@ -25,5 +40,5 @@ export const useTimeout = () => { [cancel], ); - return [set, cancel] as const; + return [set, cancel, delay] as const; }; diff --git a/app/javascript/flavours/glitch/models/status.ts b/app/javascript/flavours/glitch/models/status.ts index d9566daf39..bf1784bc61 100644 --- a/app/javascript/flavours/glitch/models/status.ts +++ b/app/javascript/flavours/glitch/models/status.ts @@ -1,4 +1,12 @@ +import type { RecordOf } from 'immutable'; + +import type { ApiPreviewCardJSON } from 'flavours/glitch/api_types/statuses'; + export type { StatusVisibility } from 'flavours/glitch/api_types/statuses'; // Temporary until we type it correctly export type Status = Immutable.Map; + +type CardShape = Required; + +export type Card = RecordOf; diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 2760259346..5c2a130cb9 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -11022,12 +11022,14 @@ noscript { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + text-align: end; } &.verified { dd { display: flex; align-items: center; + justify-content: flex-end; gap: 4px; overflow: hidden; white-space: nowrap; diff --git a/app/javascript/hooks/useTimeout.ts b/app/javascript/hooks/useTimeout.ts index f1814ae8e3..bb1e8848dd 100644 --- a/app/javascript/hooks/useTimeout.ts +++ b/app/javascript/hooks/useTimeout.ts @@ -2,19 +2,34 @@ import { useRef, useCallback, useEffect } from 'react'; export const useTimeout = () => { const timeoutRef = useRef>(); + const callbackRef = useRef<() => void>(); const set = useCallback((callback: () => void, delay: number) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } + callbackRef.current = callback; timeoutRef.current = setTimeout(callback, delay); }, []); + const delay = useCallback((delay: number) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + if (!callbackRef.current) { + return; + } + + timeoutRef.current = setTimeout(callbackRef.current, delay); + }, []); + const cancel = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = undefined; + callbackRef.current = undefined; } }, []); @@ -25,5 +40,5 @@ export const useTimeout = () => { [cancel], ); - return [set, cancel] as const; + return [set, cancel, delay] as const; }; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 4ca3c3a151..f0ea46118e 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -158,6 +158,7 @@ export const expandAccountTimeline = (accountId, { maxId, withReplies, t export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); +export const expandLinkTimeline = (url, { maxId } = {}, done = noOp) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }, done); export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index db4e20506f..a934faeb7a 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -44,6 +44,7 @@ export interface ApiPreviewCardJSON { type: string; author_name: string; author_url: string; + author_account?: ApiAccountJSON; provider_name: string; provider_url: string; html: string; diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index db59942882..ecc4e1ee17 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect } from 'react'; -import { useIntl, defineMessages } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { useIdentity } from '@/mastodon/identity_context'; import { @@ -19,15 +19,11 @@ const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, mutual: { id: 'account.mutual', defaultMessage: 'Mutual' }, - cancel_follow_request: { - id: 'account.cancel_follow_request', - defaultMessage: 'Withdraw follow request', - }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, }); export const FollowButton: React.FC<{ - accountId: string; + accountId?: string; }> = ({ accountId }) => { const intl = useIntl(); const dispatch = useAppDispatch(); @@ -36,7 +32,7 @@ export const FollowButton: React.FC<{ accountId ? state.accounts.get(accountId) : undefined, ); const relationship = useAppSelector((state) => - state.relationships.get(accountId), + accountId ? state.relationships.get(accountId) : undefined, ); const following = relationship?.following || relationship?.requested; @@ -65,11 +61,28 @@ export const FollowButton: React.FC<{ if (accountId === me) { return; } else if (relationship.following || relationship.requested) { - dispatch(unfollowAccount(accountId)); + dispatch( + openModal({ + modalType: 'CONFIRM', + modalProps: { + message: ( + @{account?.acct} }} + /> + ), + confirm: intl.formatMessage(messages.unfollow), + onConfirm: () => { + dispatch(unfollowAccount(accountId)); + }, + }, + }), + ); } else { dispatch(followAccount(accountId)); } - }, [dispatch, accountId, relationship, account, signedIn]); + }, [dispatch, intl, accountId, relationship, account, signedIn]); let label; @@ -79,13 +92,11 @@ export const FollowButton: React.FC<{ label = intl.formatMessage(messages.edit_profile); } else if (!relationship) { label = ; - } else if (relationship.requested) { - label = intl.formatMessage(messages.cancel_follow_request); } else if (relationship.following && relationship.followed_by) { label = intl.formatMessage(messages.mutual); } else if (!relationship.following && relationship.followed_by) { label = intl.formatMessage(messages.followBack); - } else if (relationship.following) { + } else if (relationship.following || relationship.requested) { label = intl.formatMessage(messages.unfollow); } else { label = intl.formatMessage(messages.follow); diff --git a/app/javascript/mastodon/components/hover_card_account.tsx b/app/javascript/mastodon/components/hover_card_account.tsx index 59f9577838..8933e14a98 100644 --- a/app/javascript/mastodon/components/hover_card_account.tsx +++ b/app/javascript/mastodon/components/hover_card_account.tsx @@ -17,7 +17,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store'; export const HoverCardAccount = forwardRef< HTMLDivElement, - { accountId: string } + { accountId?: string } >(({ accountId }, ref) => { const dispatch = useAppDispatch(); diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx index 0130390ef8..5ca55ebde9 100644 --- a/app/javascript/mastodon/components/hover_card_controller.tsx +++ b/app/javascript/mastodon/components/hover_card_controller.tsx @@ -12,8 +12,8 @@ import { useTimeout } from 'mastodon/../hooks/useTimeout'; import { HoverCardAccount } from 'mastodon/components/hover_card_account'; const offset = [-12, 4] as OffsetValue; -const enterDelay = 650; -const leaveDelay = 250; +const enterDelay = 750; +const leaveDelay = 150; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; const isHoverCardAnchor = (element: HTMLElement) => @@ -23,50 +23,12 @@ export const HoverCardController: React.FC = () => { const [open, setOpen] = useState(false); const [accountId, setAccountId] = useState(); const [anchor, setAnchor] = useState(null); - const cardRef = useRef(null); + const cardRef = useRef(null); const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); - const [setEnterTimeout, cancelEnterTimeout] = useTimeout(); + const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout(); + const [setScrollTimeout] = useTimeout(); const location = useLocation(); - const handleAnchorMouseEnter = useCallback( - (e: MouseEvent) => { - const { target } = e; - - if (target instanceof HTMLElement && isHoverCardAnchor(target)) { - cancelLeaveTimeout(); - - setEnterTimeout(() => { - target.setAttribute('aria-describedby', 'hover-card'); - setAnchor(target); - setOpen(true); - setAccountId( - target.getAttribute('data-hover-card-account') ?? undefined, - ); - }, enterDelay); - } - - if (target === cardRef.current?.parentNode) { - cancelLeaveTimeout(); - } - }, - [cancelLeaveTimeout, setEnterTimeout, setOpen, setAccountId, setAnchor], - ); - - const handleAnchorMouseLeave = useCallback( - (e: MouseEvent) => { - if (e.target === anchor || e.target === cardRef.current?.parentNode) { - cancelEnterTimeout(); - - setLeaveTimeout(() => { - anchor?.removeAttribute('aria-describedby'); - setOpen(false); - setAnchor(null); - }, leaveDelay); - } - }, - [cancelEnterTimeout, setLeaveTimeout, setOpen, setAnchor, anchor], - ); - const handleClose = useCallback(() => { cancelEnterTimeout(); cancelLeaveTimeout(); @@ -79,22 +41,119 @@ export const HoverCardController: React.FC = () => { }, [handleClose, location]); useEffect(() => { - document.body.addEventListener('mouseenter', handleAnchorMouseEnter, { + let isScrolling = false; + let currentAnchor: HTMLElement | null = null; + + const open = (target: HTMLElement) => { + target.setAttribute('aria-describedby', 'hover-card'); + setOpen(true); + setAnchor(target); + setAccountId(target.getAttribute('data-hover-card-account') ?? undefined); + }; + + const close = () => { + currentAnchor?.removeAttribute('aria-describedby'); + currentAnchor = null; + setOpen(false); + setAnchor(null); + setAccountId(undefined); + }; + + const handleMouseEnter = (e: MouseEvent) => { + const { target } = e; + + // We've exited the window + if (!(target instanceof HTMLElement)) { + close(); + return; + } + + // We've entered an anchor + if (!isScrolling && isHoverCardAnchor(target)) { + cancelLeaveTimeout(); + + currentAnchor?.removeAttribute('aria-describedby'); + currentAnchor = target; + + setEnterTimeout(() => { + open(target); + }, enterDelay); + } + + // We've entered the hover card + if ( + !isScrolling && + (target === currentAnchor || target === cardRef.current) + ) { + cancelLeaveTimeout(); + } + }; + + const handleMouseLeave = (e: MouseEvent) => { + if (!currentAnchor) { + return; + } + + if (e.target === currentAnchor || e.target === cardRef.current) { + cancelEnterTimeout(); + + setLeaveTimeout(() => { + close(); + }, leaveDelay); + } + }; + + const handleScrollEnd = () => { + isScrolling = false; + }; + + const handleScroll = () => { + isScrolling = true; + cancelEnterTimeout(); + setScrollTimeout(handleScrollEnd, 100); + }; + + const handleMouseMove = () => { + delayEnterTimeout(enterDelay); + }; + + document.body.addEventListener('mouseenter', handleMouseEnter, { passive: true, capture: true, }); - document.body.addEventListener('mouseleave', handleAnchorMouseLeave, { + + document.body.addEventListener('mousemove', handleMouseMove, { + passive: true, + capture: false, + }); + + document.body.addEventListener('mouseleave', handleMouseLeave, { + passive: true, + capture: true, + }); + + document.addEventListener('scroll', handleScroll, { passive: true, capture: true, }); return () => { - document.body.removeEventListener('mouseenter', handleAnchorMouseEnter); - document.body.removeEventListener('mouseleave', handleAnchorMouseLeave); + document.body.removeEventListener('mouseenter', handleMouseEnter); + document.body.removeEventListener('mousemove', handleMouseMove); + document.body.removeEventListener('mouseleave', handleMouseLeave); + document.removeEventListener('scroll', handleScroll); }; - }, [handleAnchorMouseEnter, handleAnchorMouseLeave]); - - if (!accountId) return null; + }, [ + setEnterTimeout, + setLeaveTimeout, + setScrollTimeout, + cancelEnterTimeout, + cancelLeaveTimeout, + delayEnterTimeout, + setOpen, + setAccountId, + setAnchor, + ]); return ( { return messages.mutual; } else if (!relationship.get('following') && relationship.get('followed_by')) { return messages.followBack; - } else if (relationship.get('following')) { + } else if (relationship.get('following') || relationship.get('requested')) { return messages.unfollow; } else { return messages.follow; @@ -291,10 +291,8 @@ class Header extends ImmutablePureComponent { if (me !== account.get('id')) { if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded actionBtn = ; - } else if (account.getIn(['relationship', 'requested'])) { - actionBtn =