Merge pull request #2761 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to d3f504245c
			
			
This commit is contained in:
		
						commit
						71375e984f
					
				
							
								
								
									
										12
									
								
								Gemfile.lock
								
								
								
								
							
							
						
						
									
										12
									
								
								Gemfile.lock
								
								
								
								
							|  | @ -100,8 +100,8 @@ GEM | ||||||
|     attr_required (1.0.2) |     attr_required (1.0.2) | ||||||
|     awrence (1.2.1) |     awrence (1.2.1) | ||||||
|     aws-eventstream (1.3.0) |     aws-eventstream (1.3.0) | ||||||
|     aws-partitions (1.947.0) |     aws-partitions (1.949.0) | ||||||
|     aws-sdk-core (3.199.0) |     aws-sdk-core (3.200.0) | ||||||
|       aws-eventstream (~> 1, >= 1.3.0) |       aws-eventstream (~> 1, >= 1.3.0) | ||||||
|       aws-partitions (~> 1, >= 1.651.0) |       aws-partitions (~> 1, >= 1.651.0) | ||||||
|       aws-sigv4 (~> 1.8) |       aws-sigv4 (~> 1.8) | ||||||
|  | @ -109,7 +109,7 @@ GEM | ||||||
|     aws-sdk-kms (1.87.0) |     aws-sdk-kms (1.87.0) | ||||||
|       aws-sdk-core (~> 3, >= 3.199.0) |       aws-sdk-core (~> 3, >= 3.199.0) | ||||||
|       aws-sigv4 (~> 1.1) |       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-core (~> 3, >= 3.199.0) | ||||||
|       aws-sdk-kms (~> 1) |       aws-sdk-kms (~> 1) | ||||||
|       aws-sigv4 (~> 1.8) |       aws-sigv4 (~> 1.8) | ||||||
|  | @ -696,7 +696,7 @@ GEM | ||||||
|     responders (3.1.1) |     responders (3.1.1) | ||||||
|       actionpack (>= 5.2) |       actionpack (>= 5.2) | ||||||
|       railties (>= 5.2) |       railties (>= 5.2) | ||||||
|     rexml (3.3.0) |     rexml (3.3.1) | ||||||
|       strscan |       strscan | ||||||
|     rotp (6.3.0) |     rotp (6.3.0) | ||||||
|     rouge (4.2.1) |     rouge (4.2.1) | ||||||
|  | @ -751,7 +751,7 @@ GEM | ||||||
|     rubocop-performance (1.21.1) |     rubocop-performance (1.21.1) | ||||||
|       rubocop (>= 1.48.1, < 2.0) |       rubocop (>= 1.48.1, < 2.0) | ||||||
|       rubocop-ast (>= 1.31.1, < 2.0) |       rubocop-ast (>= 1.31.1, < 2.0) | ||||||
|     rubocop-rails (2.25.0) |     rubocop-rails (2.25.1) | ||||||
|       activesupport (>= 4.2.0) |       activesupport (>= 4.2.0) | ||||||
|       rack (>= 1.1) |       rack (>= 1.1) | ||||||
|       rubocop (>= 1.33.0, < 2.0) |       rubocop (>= 1.33.0, < 2.0) | ||||||
|  | @ -833,7 +833,7 @@ GEM | ||||||
|       unicode-display_width (>= 1.1.1, < 3) |       unicode-display_width (>= 1.1.1, < 3) | ||||||
|     terrapin (1.0.1) |     terrapin (1.0.1) | ||||||
|       climate_control |       climate_control | ||||||
|     test-prof (1.3.3) |     test-prof (1.3.3.1) | ||||||
|     thor (1.3.1) |     thor (1.3.1) | ||||||
|     tilt (2.3.0) |     tilt (2.3.0) | ||||||
|     timeout (0.4.1) |     timeout (0.4.1) | ||||||
|  |  | ||||||
|  | @ -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 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 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 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) => { | export const expandHashtagTimeline         = (hashtag, { maxId, tags, local } = {}, done = noOp) => { | ||||||
|   return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { |   return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { | ||||||
|     max_id: maxId, |     max_id: maxId, | ||||||
|  |  | ||||||
|  | @ -44,6 +44,7 @@ export interface ApiPreviewCardJSON { | ||||||
|   type: string; |   type: string; | ||||||
|   author_name: string; |   author_name: string; | ||||||
|   author_url: string; |   author_url: string; | ||||||
|  |   author_account?: ApiAccountJSON; | ||||||
|   provider_name: string; |   provider_name: string; | ||||||
|   provider_url: string; |   provider_url: string; | ||||||
|   html: string; |   html: string; | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| import { useCallback, useEffect } from 'react'; | 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 { useIdentity } from '@/flavours/glitch/identity_context'; | ||||||
| import { | import { | ||||||
|  | @ -18,15 +18,11 @@ const messages = defineMessages({ | ||||||
|   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, |   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, | ||||||
|   follow: { id: 'account.follow', defaultMessage: 'Follow' }, |   follow: { id: 'account.follow', defaultMessage: 'Follow' }, | ||||||
|   followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, |   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' }, |   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export const FollowButton: React.FC<{ | export const FollowButton: React.FC<{ | ||||||
|   accountId: string; |   accountId?: string; | ||||||
| }> = ({ accountId }) => { | }> = ({ accountId }) => { | ||||||
|   const intl = useIntl(); |   const intl = useIntl(); | ||||||
|   const dispatch = useAppDispatch(); |   const dispatch = useAppDispatch(); | ||||||
|  | @ -35,7 +31,7 @@ export const FollowButton: React.FC<{ | ||||||
|     accountId ? state.accounts.get(accountId) : undefined, |     accountId ? state.accounts.get(accountId) : undefined, | ||||||
|   ); |   ); | ||||||
|   const relationship = useAppSelector((state) => |   const relationship = useAppSelector((state) => | ||||||
|     state.relationships.get(accountId), |     accountId ? state.relationships.get(accountId) : undefined, | ||||||
|   ); |   ); | ||||||
|   const following = relationship?.following || relationship?.requested; |   const following = relationship?.following || relationship?.requested; | ||||||
| 
 | 
 | ||||||
|  | @ -64,11 +60,28 @@ export const FollowButton: React.FC<{ | ||||||
|     if (accountId === me) { |     if (accountId === me) { | ||||||
|       return; |       return; | ||||||
|     } else if (relationship.following || relationship.requested) { |     } else if (relationship.following || relationship.requested) { | ||||||
|  |       dispatch( | ||||||
|  |         openModal({ | ||||||
|  |           modalType: 'CONFIRM', | ||||||
|  |           modalProps: { | ||||||
|  |             message: ( | ||||||
|  |               <FormattedMessage | ||||||
|  |                 id='confirmations.unfollow.message' | ||||||
|  |                 defaultMessage='Are you sure you want to unfollow {name}?' | ||||||
|  |                 values={{ name: <strong>@{account?.acct}</strong> }} | ||||||
|  |               /> | ||||||
|  |             ), | ||||||
|  |             confirm: intl.formatMessage(messages.unfollow), | ||||||
|  |             onConfirm: () => { | ||||||
|               dispatch(unfollowAccount(accountId)); |               dispatch(unfollowAccount(accountId)); | ||||||
|  |             }, | ||||||
|  |           }, | ||||||
|  |         }), | ||||||
|  |       ); | ||||||
|     } else { |     } else { | ||||||
|       dispatch(followAccount(accountId)); |       dispatch(followAccount(accountId)); | ||||||
|     } |     } | ||||||
|   }, [dispatch, accountId, relationship, account, signedIn]); |   }, [dispatch, intl, accountId, relationship, account, signedIn]); | ||||||
| 
 | 
 | ||||||
|   let label; |   let label; | ||||||
| 
 | 
 | ||||||
|  | @ -78,11 +91,9 @@ export const FollowButton: React.FC<{ | ||||||
|     label = intl.formatMessage(messages.edit_profile); |     label = intl.formatMessage(messages.edit_profile); | ||||||
|   } else if (!relationship) { |   } else if (!relationship) { | ||||||
|     label = <LoadingIndicator />; |     label = <LoadingIndicator />; | ||||||
|   } else if (relationship.requested) { |  | ||||||
|     label = intl.formatMessage(messages.cancel_follow_request); |  | ||||||
|   } else if (!relationship.following && relationship.followed_by) { |   } else if (!relationship.following && relationship.followed_by) { | ||||||
|     label = intl.formatMessage(messages.followBack); |     label = intl.formatMessage(messages.followBack); | ||||||
|   } else if (relationship.following) { |   } else if (relationship.following || relationship.requested) { | ||||||
|     label = intl.formatMessage(messages.unfollow); |     label = intl.formatMessage(messages.unfollow); | ||||||
|   } else { |   } else { | ||||||
|     label = intl.formatMessage(messages.follow); |     label = intl.formatMessage(messages.follow); | ||||||
|  |  | ||||||
|  | @ -17,7 +17,7 @@ import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; | ||||||
| 
 | 
 | ||||||
| export const HoverCardAccount = forwardRef< | export const HoverCardAccount = forwardRef< | ||||||
|   HTMLDivElement, |   HTMLDivElement, | ||||||
|   { accountId: string } |   { accountId?: string } | ||||||
| >(({ accountId }, ref) => { | >(({ accountId }, ref) => { | ||||||
|   const dispatch = useAppDispatch(); |   const dispatch = useAppDispatch(); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -12,8 +12,8 @@ import { HoverCardAccount } from 'flavours/glitch/components/hover_card_account' | ||||||
| import { useTimeout } from 'flavours/glitch/hooks/useTimeout'; | import { useTimeout } from 'flavours/glitch/hooks/useTimeout'; | ||||||
| 
 | 
 | ||||||
| const offset = [-12, 4] as OffsetValue; | const offset = [-12, 4] as OffsetValue; | ||||||
| const enterDelay = 650; | const enterDelay = 750; | ||||||
| const leaveDelay = 250; | const leaveDelay = 150; | ||||||
| const popperConfig = { strategy: 'fixed' } as UsePopperOptions; | const popperConfig = { strategy: 'fixed' } as UsePopperOptions; | ||||||
| 
 | 
 | ||||||
| const isHoverCardAnchor = (element: HTMLElement) => | const isHoverCardAnchor = (element: HTMLElement) => | ||||||
|  | @ -23,50 +23,12 @@ export const HoverCardController: React.FC = () => { | ||||||
|   const [open, setOpen] = useState(false); |   const [open, setOpen] = useState(false); | ||||||
|   const [accountId, setAccountId] = useState<string | undefined>(); |   const [accountId, setAccountId] = useState<string | undefined>(); | ||||||
|   const [anchor, setAnchor] = useState<HTMLElement | null>(null); |   const [anchor, setAnchor] = useState<HTMLElement | null>(null); | ||||||
|   const cardRef = useRef<HTMLDivElement>(null); |   const cardRef = useRef<HTMLDivElement | null>(null); | ||||||
|   const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); |   const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); | ||||||
|   const [setEnterTimeout, cancelEnterTimeout] = useTimeout(); |   const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout(); | ||||||
|  |   const [setScrollTimeout] = useTimeout(); | ||||||
|   const location = useLocation(); |   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(() => { |   const handleClose = useCallback(() => { | ||||||
|     cancelEnterTimeout(); |     cancelEnterTimeout(); | ||||||
|     cancelLeaveTimeout(); |     cancelLeaveTimeout(); | ||||||
|  | @ -79,22 +41,119 @@ export const HoverCardController: React.FC = () => { | ||||||
|   }, [handleClose, location]); |   }, [handleClose, location]); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   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, |       passive: true, | ||||||
|       capture: 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, |       passive: true, | ||||||
|       capture: true, |       capture: true, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     return () => { |     return () => { | ||||||
|       document.body.removeEventListener('mouseenter', handleAnchorMouseEnter); |       document.body.removeEventListener('mouseenter', handleMouseEnter); | ||||||
|       document.body.removeEventListener('mouseleave', handleAnchorMouseLeave); |       document.body.removeEventListener('mousemove', handleMouseMove); | ||||||
|  |       document.body.removeEventListener('mouseleave', handleMouseLeave); | ||||||
|  |       document.removeEventListener('scroll', handleScroll); | ||||||
|     }; |     }; | ||||||
|   }, [handleAnchorMouseEnter, handleAnchorMouseLeave]); |   }, [ | ||||||
| 
 |     setEnterTimeout, | ||||||
|   if (!accountId) return null; |     setLeaveTimeout, | ||||||
|  |     setScrollTimeout, | ||||||
|  |     cancelEnterTimeout, | ||||||
|  |     cancelLeaveTimeout, | ||||||
|  |     delayEnterTimeout, | ||||||
|  |     setOpen, | ||||||
|  |     setAccountId, | ||||||
|  |     setAnchor, | ||||||
|  |   ]); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Overlay |     <Overlay | ||||||
|  |  | ||||||
|  | @ -33,6 +33,7 @@ export default class StatusList extends ImmutablePureComponent { | ||||||
|     withCounters: PropTypes.bool, |     withCounters: PropTypes.bool, | ||||||
|     timelineId: PropTypes.string.isRequired, |     timelineId: PropTypes.string.isRequired, | ||||||
|     lastId: PropTypes.string, |     lastId: PropTypes.string, | ||||||
|  |     bindToDocument: PropTypes.bool, | ||||||
|     regex: PropTypes.string, |     regex: PropTypes.string, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -23,7 +23,6 @@ import { makeGetAccount, getAccountHidden } from '../../../selectors'; | ||||||
| import Header from '../components/header'; | import Header from '../components/header'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' }, |  | ||||||
|   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, |   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, | ||||||
|   blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' }, |   blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' }, | ||||||
| }); | }); | ||||||
|  | @ -43,7 +42,7 @@ const makeMapStateToProps = () => { | ||||||
| const mapDispatchToProps = (dispatch, { intl }) => ({ | const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||||
| 
 | 
 | ||||||
|   onFollow (account) { |   onFollow (account) { | ||||||
|     if (account.getIn(['relationship', 'following'])) { |     if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { | ||||||
|       dispatch(openModal({ |       dispatch(openModal({ | ||||||
|         modalType: 'CONFIRM', |         modalType: 'CONFIRM', | ||||||
|         modalProps: { |         modalProps: { | ||||||
|  | @ -52,15 +51,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||||
|           onConfirm: () => dispatch(unfollowAccount(account.get('id'))), |           onConfirm: () => dispatch(unfollowAccount(account.get('id'))), | ||||||
|         }, |         }, | ||||||
|       })); |       })); | ||||||
|     } else if (account.getIn(['relationship', 'requested'])) { |  | ||||||
|       dispatch(openModal({ |  | ||||||
|         modalType: 'CONFIRM', |  | ||||||
|         modalProps: { |  | ||||||
|           message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, |  | ||||||
|           confirm: intl.formatMessage(messages.cancelFollowRequestConfirm), |  | ||||||
|           onConfirm: () => dispatch(unfollowAccount(account.get('id'))), |  | ||||||
|         }, |  | ||||||
|       })); |  | ||||||
|     } else { |     } else { | ||||||
|       dispatch(followAccount(account.get('id'))); |       dispatch(followAccount(account.get('id'))); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -185,7 +185,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) | ||||||
|   menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete }); |   menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete }); | ||||||
| 
 | 
 | ||||||
|   const names = accounts.map(a => ( |   const names = accounts.map(a => ( | ||||||
|     <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}> |     <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} data-hover-card-account={a.get('id')}> | ||||||
|       <bdi> |       <bdi> | ||||||
|         <strong |         <strong | ||||||
|           className='display-name__html' |           className='display-name__html' | ||||||
|  |  | ||||||
|  | @ -4,6 +4,8 @@ import { useState, useCallback } from 'react'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| 
 | 
 | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| import { Blurhash } from 'flavours/glitch/components/blurhash'; | import { Blurhash } from 'flavours/glitch/components/blurhash'; | ||||||
|  | @ -57,7 +59,7 @@ export const Story = ({ | ||||||
| 
 | 
 | ||||||
|         <div className='story__details__shared'> |         <div className='story__details__shared'> | ||||||
|           {author ? <FormattedMessage id='link_preview.author' className='story__details__shared__author' defaultMessage='By {name}' values={{ name: authorAccount ? <AuthorLink accountId={authorAccount} /> : <strong>{author}</strong> }} /> : <span />} |           {author ? <FormattedMessage id='link_preview.author' className='story__details__shared__author' defaultMessage='By {name}' values={{ name: authorAccount ? <AuthorLink accountId={authorAccount} /> : <strong>{author}</strong> }} /> : <span />} | ||||||
|           {typeof sharedTimes === 'number' ? <span className='story__details__shared__pill'><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></span> : <Skeleton width='10ch' />} |           {typeof sharedTimes === 'number' ? <Link className='story__details__shared__pill' to={`/links/${encodeURIComponent(url)}`}><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></Link> : <Skeleton width='10ch' />} | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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<Column>(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 ( | ||||||
|  |     <Column bindToDocument={!multiColumn} ref={columnRef} label={story?.title}> | ||||||
|  |       <ColumnHeader | ||||||
|  |         icon='explore' | ||||||
|  |         iconComponent={ExploreIcon} | ||||||
|  |         title={story?.title} | ||||||
|  |         onClick={handleHeaderClick} | ||||||
|  |         multiColumn={multiColumn} | ||||||
|  |         showBackButton | ||||||
|  |       /> | ||||||
|  | 
 | ||||||
|  |       <StatusListContainer | ||||||
|  |         timelineId={`link:${decodedUrl}`} | ||||||
|  |         onLoadMore={handleLoadMore} | ||||||
|  |         trackScroll | ||||||
|  |         scrollKey={`link_timeline-${decodedUrl}`} | ||||||
|  |         bindToDocument={!multiColumn} | ||||||
|  |         regex={undefined} | ||||||
|  |       /> | ||||||
|  | 
 | ||||||
|  |       <Helmet> | ||||||
|  |         <title>{story?.title}</title> | ||||||
|  |         <meta name='robots' content='noindex' /> | ||||||
|  |       </Helmet> | ||||||
|  |     </Column> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | // eslint-disable-next-line import/no-default-export
 | ||||||
|  | export default LinkTimeline; | ||||||
|  | @ -58,6 +58,7 @@ import { | ||||||
|   FavouritedStatuses, |   FavouritedStatuses, | ||||||
|   BookmarkedStatuses, |   BookmarkedStatuses, | ||||||
|   FollowedTags, |   FollowedTags, | ||||||
|  |   LinkTimeline, | ||||||
|   ListTimeline, |   ListTimeline, | ||||||
|   Blocks, |   Blocks, | ||||||
|   DomainBlocks, |   DomainBlocks, | ||||||
|  | @ -211,6 +212,7 @@ class SwitchingColumnsArea extends PureComponent { | ||||||
|             <WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} /> |             <WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} /> | ||||||
|             <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} /> |             <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} /> | ||||||
|             <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> |             <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> | ||||||
|  |             <WrappedRoute path='/links/:url' component={LinkTimeline} content={children} /> | ||||||
|             <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} /> |             <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} /> | ||||||
|             <WrappedRoute path='/notifications' component={Notifications} content={children} exact /> |             <WrappedRoute path='/notifications' component={Notifications} content={children} exact /> | ||||||
|             <WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact /> |             <WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact /> | ||||||
|  | @ -649,7 +651,7 @@ class UI extends PureComponent { | ||||||
| 
 | 
 | ||||||
|           {layout !== 'mobile' && <PictureInPicture />} |           {layout !== 'mobile' && <PictureInPicture />} | ||||||
|           <NotificationsContainer /> |           <NotificationsContainer /> | ||||||
|           {/* Temporarily disabled while upstream improves the issue */ null && <HoverCardController />} |           <HoverCardController /> | ||||||
|           <LoadingBarContainer className='loading-bar' /> |           <LoadingBarContainer className='loading-bar' /> | ||||||
|           <ModalContainer /> |           <ModalContainer /> | ||||||
|           <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> |           <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> | ||||||
|  |  | ||||||
|  | @ -213,3 +213,7 @@ export function NotificationRequests () { | ||||||
| export function NotificationRequest () { | export function NotificationRequest () { | ||||||
|   return import(/*webpackChunkName: "features/glitch/notifications/request" */'../../notifications/request'); |   return import(/*webpackChunkName: "features/glitch/notifications/request" */'../../notifications/request'); | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export function LinkTimeline () { | ||||||
|  |   return import(/*webpackChunkName: "features/glitch/link_timeline" */'../../link_timeline'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -2,19 +2,34 @@ import { useRef, useCallback, useEffect } from 'react'; | ||||||
| 
 | 
 | ||||||
| export const useTimeout = () => { | export const useTimeout = () => { | ||||||
|   const timeoutRef = useRef<ReturnType<typeof setTimeout>>(); |   const timeoutRef = useRef<ReturnType<typeof setTimeout>>(); | ||||||
|  |   const callbackRef = useRef<() => void>(); | ||||||
| 
 | 
 | ||||||
|   const set = useCallback((callback: () => void, delay: number) => { |   const set = useCallback((callback: () => void, delay: number) => { | ||||||
|     if (timeoutRef.current) { |     if (timeoutRef.current) { | ||||||
|       clearTimeout(timeoutRef.current); |       clearTimeout(timeoutRef.current); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     callbackRef.current = callback; | ||||||
|     timeoutRef.current = setTimeout(callback, delay); |     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(() => { |   const cancel = useCallback(() => { | ||||||
|     if (timeoutRef.current) { |     if (timeoutRef.current) { | ||||||
|       clearTimeout(timeoutRef.current); |       clearTimeout(timeoutRef.current); | ||||||
|       timeoutRef.current = undefined; |       timeoutRef.current = undefined; | ||||||
|  |       callbackRef.current = undefined; | ||||||
|     } |     } | ||||||
|   }, []); |   }, []); | ||||||
| 
 | 
 | ||||||
|  | @ -25,5 +40,5 @@ export const useTimeout = () => { | ||||||
|     [cancel], |     [cancel], | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   return [set, cancel] as const; |   return [set, cancel, delay] as const; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -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'; | export type { StatusVisibility } from 'flavours/glitch/api_types/statuses'; | ||||||
| 
 | 
 | ||||||
| // Temporary until we type it correctly
 | // Temporary until we type it correctly
 | ||||||
| export type Status = Immutable.Map<string, unknown>; | export type Status = Immutable.Map<string, unknown>; | ||||||
|  | 
 | ||||||
|  | type CardShape = Required<ApiPreviewCardJSON>; | ||||||
|  | 
 | ||||||
|  | export type Card = RecordOf<CardShape>; | ||||||
|  |  | ||||||
|  | @ -11022,12 +11022,14 @@ noscript { | ||||||
|         overflow: hidden; |         overflow: hidden; | ||||||
|         white-space: nowrap; |         white-space: nowrap; | ||||||
|         text-overflow: ellipsis; |         text-overflow: ellipsis; | ||||||
|  |         text-align: end; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       &.verified { |       &.verified { | ||||||
|         dd { |         dd { | ||||||
|           display: flex; |           display: flex; | ||||||
|           align-items: center; |           align-items: center; | ||||||
|  |           justify-content: flex-end; | ||||||
|           gap: 4px; |           gap: 4px; | ||||||
|           overflow: hidden; |           overflow: hidden; | ||||||
|           white-space: nowrap; |           white-space: nowrap; | ||||||
|  |  | ||||||
|  | @ -2,19 +2,34 @@ import { useRef, useCallback, useEffect } from 'react'; | ||||||
| 
 | 
 | ||||||
| export const useTimeout = () => { | export const useTimeout = () => { | ||||||
|   const timeoutRef = useRef<ReturnType<typeof setTimeout>>(); |   const timeoutRef = useRef<ReturnType<typeof setTimeout>>(); | ||||||
|  |   const callbackRef = useRef<() => void>(); | ||||||
| 
 | 
 | ||||||
|   const set = useCallback((callback: () => void, delay: number) => { |   const set = useCallback((callback: () => void, delay: number) => { | ||||||
|     if (timeoutRef.current) { |     if (timeoutRef.current) { | ||||||
|       clearTimeout(timeoutRef.current); |       clearTimeout(timeoutRef.current); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     callbackRef.current = callback; | ||||||
|     timeoutRef.current = setTimeout(callback, delay); |     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(() => { |   const cancel = useCallback(() => { | ||||||
|     if (timeoutRef.current) { |     if (timeoutRef.current) { | ||||||
|       clearTimeout(timeoutRef.current); |       clearTimeout(timeoutRef.current); | ||||||
|       timeoutRef.current = undefined; |       timeoutRef.current = undefined; | ||||||
|  |       callbackRef.current = undefined; | ||||||
|     } |     } | ||||||
|   }, []); |   }, []); | ||||||
| 
 | 
 | ||||||
|  | @ -25,5 +40,5 @@ export const useTimeout = () => { | ||||||
|     [cancel], |     [cancel], | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   return [set, cancel] as const; |   return [set, cancel, delay] as const; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -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 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 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 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) => { | export const expandHashtagTimeline         = (hashtag, { maxId, tags, local } = {}, done = noOp) => { | ||||||
|   return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { |   return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { | ||||||
|     max_id: maxId, |     max_id: maxId, | ||||||
|  |  | ||||||
|  | @ -44,6 +44,7 @@ export interface ApiPreviewCardJSON { | ||||||
|   type: string; |   type: string; | ||||||
|   author_name: string; |   author_name: string; | ||||||
|   author_url: string; |   author_url: string; | ||||||
|  |   author_account?: ApiAccountJSON; | ||||||
|   provider_name: string; |   provider_name: string; | ||||||
|   provider_url: string; |   provider_url: string; | ||||||
|   html: string; |   html: string; | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| import { useCallback, useEffect } from 'react'; | 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 { useIdentity } from '@/mastodon/identity_context'; | ||||||
| import { | import { | ||||||
|  | @ -19,15 +19,11 @@ const messages = defineMessages({ | ||||||
|   follow: { id: 'account.follow', defaultMessage: 'Follow' }, |   follow: { id: 'account.follow', defaultMessage: 'Follow' }, | ||||||
|   followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, |   followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' }, | ||||||
|   mutual: { id: 'account.mutual', defaultMessage: 'Mutual' }, |   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' }, |   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export const FollowButton: React.FC<{ | export const FollowButton: React.FC<{ | ||||||
|   accountId: string; |   accountId?: string; | ||||||
| }> = ({ accountId }) => { | }> = ({ accountId }) => { | ||||||
|   const intl = useIntl(); |   const intl = useIntl(); | ||||||
|   const dispatch = useAppDispatch(); |   const dispatch = useAppDispatch(); | ||||||
|  | @ -36,7 +32,7 @@ export const FollowButton: React.FC<{ | ||||||
|     accountId ? state.accounts.get(accountId) : undefined, |     accountId ? state.accounts.get(accountId) : undefined, | ||||||
|   ); |   ); | ||||||
|   const relationship = useAppSelector((state) => |   const relationship = useAppSelector((state) => | ||||||
|     state.relationships.get(accountId), |     accountId ? state.relationships.get(accountId) : undefined, | ||||||
|   ); |   ); | ||||||
|   const following = relationship?.following || relationship?.requested; |   const following = relationship?.following || relationship?.requested; | ||||||
| 
 | 
 | ||||||
|  | @ -65,11 +61,28 @@ export const FollowButton: React.FC<{ | ||||||
|     if (accountId === me) { |     if (accountId === me) { | ||||||
|       return; |       return; | ||||||
|     } else if (relationship.following || relationship.requested) { |     } else if (relationship.following || relationship.requested) { | ||||||
|  |       dispatch( | ||||||
|  |         openModal({ | ||||||
|  |           modalType: 'CONFIRM', | ||||||
|  |           modalProps: { | ||||||
|  |             message: ( | ||||||
|  |               <FormattedMessage | ||||||
|  |                 id='confirmations.unfollow.message' | ||||||
|  |                 defaultMessage='Are you sure you want to unfollow {name}?' | ||||||
|  |                 values={{ name: <strong>@{account?.acct}</strong> }} | ||||||
|  |               /> | ||||||
|  |             ), | ||||||
|  |             confirm: intl.formatMessage(messages.unfollow), | ||||||
|  |             onConfirm: () => { | ||||||
|               dispatch(unfollowAccount(accountId)); |               dispatch(unfollowAccount(accountId)); | ||||||
|  |             }, | ||||||
|  |           }, | ||||||
|  |         }), | ||||||
|  |       ); | ||||||
|     } else { |     } else { | ||||||
|       dispatch(followAccount(accountId)); |       dispatch(followAccount(accountId)); | ||||||
|     } |     } | ||||||
|   }, [dispatch, accountId, relationship, account, signedIn]); |   }, [dispatch, intl, accountId, relationship, account, signedIn]); | ||||||
| 
 | 
 | ||||||
|   let label; |   let label; | ||||||
| 
 | 
 | ||||||
|  | @ -79,13 +92,11 @@ export const FollowButton: React.FC<{ | ||||||
|     label = intl.formatMessage(messages.edit_profile); |     label = intl.formatMessage(messages.edit_profile); | ||||||
|   } else if (!relationship) { |   } else if (!relationship) { | ||||||
|     label = <LoadingIndicator />; |     label = <LoadingIndicator />; | ||||||
|   } else if (relationship.requested) { |  | ||||||
|     label = intl.formatMessage(messages.cancel_follow_request); |  | ||||||
|   } else if (relationship.following && relationship.followed_by) { |   } else if (relationship.following && relationship.followed_by) { | ||||||
|     label = intl.formatMessage(messages.mutual); |     label = intl.formatMessage(messages.mutual); | ||||||
|   } else if (!relationship.following && relationship.followed_by) { |   } else if (!relationship.following && relationship.followed_by) { | ||||||
|     label = intl.formatMessage(messages.followBack); |     label = intl.formatMessage(messages.followBack); | ||||||
|   } else if (relationship.following) { |   } else if (relationship.following || relationship.requested) { | ||||||
|     label = intl.formatMessage(messages.unfollow); |     label = intl.formatMessage(messages.unfollow); | ||||||
|   } else { |   } else { | ||||||
|     label = intl.formatMessage(messages.follow); |     label = intl.formatMessage(messages.follow); | ||||||
|  |  | ||||||
|  | @ -17,7 +17,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store'; | ||||||
| 
 | 
 | ||||||
| export const HoverCardAccount = forwardRef< | export const HoverCardAccount = forwardRef< | ||||||
|   HTMLDivElement, |   HTMLDivElement, | ||||||
|   { accountId: string } |   { accountId?: string } | ||||||
| >(({ accountId }, ref) => { | >(({ accountId }, ref) => { | ||||||
|   const dispatch = useAppDispatch(); |   const dispatch = useAppDispatch(); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -12,8 +12,8 @@ import { useTimeout } from 'mastodon/../hooks/useTimeout'; | ||||||
| import { HoverCardAccount } from 'mastodon/components/hover_card_account'; | import { HoverCardAccount } from 'mastodon/components/hover_card_account'; | ||||||
| 
 | 
 | ||||||
| const offset = [-12, 4] as OffsetValue; | const offset = [-12, 4] as OffsetValue; | ||||||
| const enterDelay = 650; | const enterDelay = 750; | ||||||
| const leaveDelay = 250; | const leaveDelay = 150; | ||||||
| const popperConfig = { strategy: 'fixed' } as UsePopperOptions; | const popperConfig = { strategy: 'fixed' } as UsePopperOptions; | ||||||
| 
 | 
 | ||||||
| const isHoverCardAnchor = (element: HTMLElement) => | const isHoverCardAnchor = (element: HTMLElement) => | ||||||
|  | @ -23,50 +23,12 @@ export const HoverCardController: React.FC = () => { | ||||||
|   const [open, setOpen] = useState(false); |   const [open, setOpen] = useState(false); | ||||||
|   const [accountId, setAccountId] = useState<string | undefined>(); |   const [accountId, setAccountId] = useState<string | undefined>(); | ||||||
|   const [anchor, setAnchor] = useState<HTMLElement | null>(null); |   const [anchor, setAnchor] = useState<HTMLElement | null>(null); | ||||||
|   const cardRef = useRef<HTMLDivElement>(null); |   const cardRef = useRef<HTMLDivElement | null>(null); | ||||||
|   const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); |   const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); | ||||||
|   const [setEnterTimeout, cancelEnterTimeout] = useTimeout(); |   const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout(); | ||||||
|  |   const [setScrollTimeout] = useTimeout(); | ||||||
|   const location = useLocation(); |   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(() => { |   const handleClose = useCallback(() => { | ||||||
|     cancelEnterTimeout(); |     cancelEnterTimeout(); | ||||||
|     cancelLeaveTimeout(); |     cancelLeaveTimeout(); | ||||||
|  | @ -79,22 +41,119 @@ export const HoverCardController: React.FC = () => { | ||||||
|   }, [handleClose, location]); |   }, [handleClose, location]); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   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, |       passive: true, | ||||||
|       capture: 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, |       passive: true, | ||||||
|       capture: true, |       capture: true, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     return () => { |     return () => { | ||||||
|       document.body.removeEventListener('mouseenter', handleAnchorMouseEnter); |       document.body.removeEventListener('mouseenter', handleMouseEnter); | ||||||
|       document.body.removeEventListener('mouseleave', handleAnchorMouseLeave); |       document.body.removeEventListener('mousemove', handleMouseMove); | ||||||
|  |       document.body.removeEventListener('mouseleave', handleMouseLeave); | ||||||
|  |       document.removeEventListener('scroll', handleScroll); | ||||||
|     }; |     }; | ||||||
|   }, [handleAnchorMouseEnter, handleAnchorMouseLeave]); |   }, [ | ||||||
| 
 |     setEnterTimeout, | ||||||
|   if (!accountId) return null; |     setLeaveTimeout, | ||||||
|  |     setScrollTimeout, | ||||||
|  |     cancelEnterTimeout, | ||||||
|  |     cancelLeaveTimeout, | ||||||
|  |     delayEnterTimeout, | ||||||
|  |     setOpen, | ||||||
|  |     setAccountId, | ||||||
|  |     setAnchor, | ||||||
|  |   ]); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Overlay |     <Overlay | ||||||
|  |  | ||||||
|  | @ -33,6 +33,7 @@ export default class StatusList extends ImmutablePureComponent { | ||||||
|     withCounters: PropTypes.bool, |     withCounters: PropTypes.bool, | ||||||
|     timelineId: PropTypes.string, |     timelineId: PropTypes.string, | ||||||
|     lastId: PropTypes.string, |     lastId: PropTypes.string, | ||||||
|  |     bindToDocument: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   static defaultProps = { |   static defaultProps = { | ||||||
|  |  | ||||||
|  | @ -94,7 +94,7 @@ const messageForFollowButton = relationship => { | ||||||
|     return messages.mutual; |     return messages.mutual; | ||||||
|   } else if (!relationship.get('following') && relationship.get('followed_by')) { |   } else if (!relationship.get('following') && relationship.get('followed_by')) { | ||||||
|     return messages.followBack; |     return messages.followBack; | ||||||
|   } else if (relationship.get('following')) { |   } else if (relationship.get('following') || relationship.get('requested')) { | ||||||
|     return messages.unfollow; |     return messages.unfollow; | ||||||
|   } else { |   } else { | ||||||
|     return messages.follow; |     return messages.follow; | ||||||
|  | @ -291,10 +291,8 @@ class Header extends ImmutablePureComponent { | ||||||
|     if (me !== account.get('id')) { |     if (me !== account.get('id')) { | ||||||
|       if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded |       if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded | ||||||
|         actionBtn = <Button disabled><LoadingIndicator /></Button>; |         actionBtn = <Button disabled><LoadingIndicator /></Button>; | ||||||
|       } else if (account.getIn(['relationship', 'requested'])) { |  | ||||||
|         actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; |  | ||||||
|       } else if (!account.getIn(['relationship', 'blocking'])) { |       } else if (!account.getIn(['relationship', 'blocking'])) { | ||||||
|         actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(messageForFollowButton(account.get('relationship')))} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />; |         actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) })} text={intl.formatMessage(messageForFollowButton(account.get('relationship')))} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />; | ||||||
|       } else if (account.getIn(['relationship', 'blocking'])) { |       } else if (account.getIn(['relationship', 'blocking'])) { | ||||||
|         actionBtn = <Button text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; |         actionBtn = <Button text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|  | @ -25,7 +25,6 @@ import { makeGetAccount, getAccountHidden } from '../../../selectors'; | ||||||
| import Header from '../components/header'; | import Header from '../components/header'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' }, |  | ||||||
|   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, |   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, | ||||||
|   blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' }, |   blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' }, | ||||||
| }); | }); | ||||||
|  | @ -45,7 +44,7 @@ const makeMapStateToProps = () => { | ||||||
| const mapDispatchToProps = (dispatch, { intl }) => ({ | const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||||
| 
 | 
 | ||||||
|   onFollow (account) { |   onFollow (account) { | ||||||
|     if (account.getIn(['relationship', 'following'])) { |     if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { | ||||||
|       dispatch(openModal({ |       dispatch(openModal({ | ||||||
|         modalType: 'CONFIRM', |         modalType: 'CONFIRM', | ||||||
|         modalProps: { |         modalProps: { | ||||||
|  | @ -54,15 +53,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||||
|           onConfirm: () => dispatch(unfollowAccount(account.get('id'))), |           onConfirm: () => dispatch(unfollowAccount(account.get('id'))), | ||||||
|         }, |         }, | ||||||
|       })); |       })); | ||||||
|     } else if (account.getIn(['relationship', 'requested'])) { |  | ||||||
|       dispatch(openModal({ |  | ||||||
|         modalType: 'CONFIRM', |  | ||||||
|         modalProps: { |  | ||||||
|           message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, |  | ||||||
|           confirm: intl.formatMessage(messages.cancelFollowRequestConfirm), |  | ||||||
|           onConfirm: () => dispatch(unfollowAccount(account.get('id'))), |  | ||||||
|         }, |  | ||||||
|       })); |  | ||||||
|     } else { |     } else { | ||||||
|       dispatch(followAccount(account.get('id'))); |       dispatch(followAccount(account.get('id'))); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -163,7 +163,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) | ||||||
|   menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete }); |   menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete }); | ||||||
| 
 | 
 | ||||||
|   const names = accounts.map(a => ( |   const names = accounts.map(a => ( | ||||||
|     <Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}> |     <Link to={`/@${a.get('acct')}`} key={a.get('id')} data-hover-card-account={a.get('id')}> | ||||||
|       <bdi> |       <bdi> | ||||||
|         <strong |         <strong | ||||||
|           className='display-name__html' |           className='display-name__html' | ||||||
|  |  | ||||||
|  | @ -4,6 +4,8 @@ import { useState, useCallback } from 'react'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| 
 | 
 | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| import { Blurhash } from 'mastodon/components/blurhash'; | import { Blurhash } from 'mastodon/components/blurhash'; | ||||||
|  | @ -57,7 +59,7 @@ export const Story = ({ | ||||||
| 
 | 
 | ||||||
|         <div className='story__details__shared'> |         <div className='story__details__shared'> | ||||||
|           {author ? <FormattedMessage id='link_preview.author' className='story__details__shared__author' defaultMessage='By {name}' values={{ name: authorAccount ? <AuthorLink accountId={authorAccount} /> : <strong>{author}</strong> }} /> : <span />} |           {author ? <FormattedMessage id='link_preview.author' className='story__details__shared__author' defaultMessage='By {name}' values={{ name: authorAccount ? <AuthorLink accountId={authorAccount} /> : <strong>{author}</strong> }} /> : <span />} | ||||||
|           {typeof sharedTimes === 'number' ? <span className='story__details__shared__pill'><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></span> : <Skeleton width='10ch' />} |           {typeof sharedTimes === 'number' ? <Link className='story__details__shared__pill' to={`/links/${encodeURIComponent(url)}`}><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></Link> : <Skeleton width='10ch' />} | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,76 @@ | ||||||
|  | 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 'mastodon/actions/timelines'; | ||||||
|  | import Column from 'mastodon/components/column'; | ||||||
|  | import { ColumnHeader } from 'mastodon/components/column_header'; | ||||||
|  | import StatusListContainer from 'mastodon/features/ui/containers/status_list_container'; | ||||||
|  | import type { Card } from 'mastodon/models/status'; | ||||||
|  | import { useAppDispatch, useAppSelector } from 'mastodon/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<Column>(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 ( | ||||||
|  |     <Column bindToDocument={!multiColumn} ref={columnRef} label={story?.title}> | ||||||
|  |       <ColumnHeader | ||||||
|  |         icon='explore' | ||||||
|  |         iconComponent={ExploreIcon} | ||||||
|  |         title={story?.title} | ||||||
|  |         onClick={handleHeaderClick} | ||||||
|  |         multiColumn={multiColumn} | ||||||
|  |         showBackButton | ||||||
|  |       /> | ||||||
|  | 
 | ||||||
|  |       <StatusListContainer | ||||||
|  |         timelineId={`link:${decodedUrl}`} | ||||||
|  |         onLoadMore={handleLoadMore} | ||||||
|  |         trackScroll | ||||||
|  |         scrollKey={`link_timeline-${decodedUrl}`} | ||||||
|  |         bindToDocument={!multiColumn} | ||||||
|  |       /> | ||||||
|  | 
 | ||||||
|  |       <Helmet> | ||||||
|  |         <title>{story?.title}</title> | ||||||
|  |         <meta name='robots' content='noindex' /> | ||||||
|  |       </Helmet> | ||||||
|  |     </Column> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | // eslint-disable-next-line import/no-default-export
 | ||||||
|  | export default LinkTimeline; | ||||||
|  | @ -56,6 +56,7 @@ import { | ||||||
|   FavouritedStatuses, |   FavouritedStatuses, | ||||||
|   BookmarkedStatuses, |   BookmarkedStatuses, | ||||||
|   FollowedTags, |   FollowedTags, | ||||||
|  |   LinkTimeline, | ||||||
|   ListTimeline, |   ListTimeline, | ||||||
|   Blocks, |   Blocks, | ||||||
|   DomainBlocks, |   DomainBlocks, | ||||||
|  | @ -202,6 +203,7 @@ class SwitchingColumnsArea extends PureComponent { | ||||||
|             <WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} /> |             <WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} /> | ||||||
|             <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} /> |             <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} /> | ||||||
|             <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> |             <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> | ||||||
|  |             <WrappedRoute path='/links/:url' component={LinkTimeline} content={children} /> | ||||||
|             <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} /> |             <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} /> | ||||||
|             <WrappedRoute path='/notifications' component={Notifications} content={children} exact /> |             <WrappedRoute path='/notifications' component={Notifications} content={children} exact /> | ||||||
|             <WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact /> |             <WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact /> | ||||||
|  |  | ||||||
|  | @ -201,3 +201,7 @@ export function NotificationRequests () { | ||||||
| export function NotificationRequest () { | export function NotificationRequest () { | ||||||
|   return import(/*webpackChunkName: "features/notifications/request" */'../../notifications/request'); |   return import(/*webpackChunkName: "features/notifications/request" */'../../notifications/request'); | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export function LinkTimeline () { | ||||||
|  |   return import(/*webpackChunkName: "features/link_timeline" */'../../link_timeline'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -35,6 +35,7 @@ | ||||||
|   "account.follow_back": "Heuliañ d'ho tro", |   "account.follow_back": "Heuliañ d'ho tro", | ||||||
|   "account.followers": "Tud koumanantet", |   "account.followers": "Tud koumanantet", | ||||||
|   "account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.", |   "account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.", | ||||||
|  |   "account.followers_counter": "{count, plural, one {{counter} heulier} two {{counter} heulier} few {{counter} heulier} many {{counter} heulier} other {{counter} heulier}}", | ||||||
|   "account.following": "Koumanantoù", |   "account.following": "Koumanantoù", | ||||||
|   "account.follows.empty": "An implijer·ez-mañ na heul den ebet.", |   "account.follows.empty": "An implijer·ez-mañ na heul den ebet.", | ||||||
|   "account.go_to_profile": "Gwelet ar profil", |   "account.go_to_profile": "Gwelet ar profil", | ||||||
|  | @ -60,6 +61,7 @@ | ||||||
|   "account.requested_follow": "Gant {name} eo bet goulennet ho heuliañ", |   "account.requested_follow": "Gant {name} eo bet goulennet ho heuliañ", | ||||||
|   "account.share": "Skignañ profil @{name}", |   "account.share": "Skignañ profil @{name}", | ||||||
|   "account.show_reblogs": "Diskouez skignadennoù @{name}", |   "account.show_reblogs": "Diskouez skignadennoù @{name}", | ||||||
|  |   "account.statuses_counter": "{count, plural, one {{counter} embannadur} two {{counter} embannadur} few {{counter} embannadur} many {{counter} embannadur} other {{counter} embannadur}}", | ||||||
|   "account.unblock": "Diverzañ @{name}", |   "account.unblock": "Diverzañ @{name}", | ||||||
|   "account.unblock_domain": "Diverzañ an domani {domain}", |   "account.unblock_domain": "Diverzañ an domani {domain}", | ||||||
|   "account.unblock_short": "Distankañ", |   "account.unblock_short": "Distankañ", | ||||||
|  |  | ||||||
|  | @ -20,7 +20,7 @@ | ||||||
|   "account.block_short": "Zablokovat", |   "account.block_short": "Zablokovat", | ||||||
|   "account.blocked": "Blokovaný", |   "account.blocked": "Blokovaný", | ||||||
|   "account.browse_more_on_origin_server": "Více na původním profilu", |   "account.browse_more_on_origin_server": "Více na původním profilu", | ||||||
|   "account.cancel_follow_request": "Zrušit žádost o sledování", |   "account.cancel_follow_request": "Zrušit sledování", | ||||||
|   "account.copy": "Kopírovat odkaz na profil", |   "account.copy": "Kopírovat odkaz na profil", | ||||||
|   "account.direct": "Soukromě zmínit @{name}", |   "account.direct": "Soukromě zmínit @{name}", | ||||||
|   "account.disable_notifications": "Přestat mě upozorňovat, když @{name} zveřejní příspěvek", |   "account.disable_notifications": "Přestat mě upozorňovat, když @{name} zveřejní příspěvek", | ||||||
|  | @ -35,7 +35,9 @@ | ||||||
|   "account.follow_back": "Také sledovat", |   "account.follow_back": "Také sledovat", | ||||||
|   "account.followers": "Sledující", |   "account.followers": "Sledující", | ||||||
|   "account.followers.empty": "Tohoto uživatele zatím nikdo nesleduje.", |   "account.followers.empty": "Tohoto uživatele zatím nikdo nesleduje.", | ||||||
|  |   "account.followers_counter": "{count, plural, one {{counter} sledující} few {{counter} sledující} many {{counter} sledujících} other {{counter} sledujících}}", | ||||||
|   "account.following": "Sledujete", |   "account.following": "Sledujete", | ||||||
|  |   "account.following_counter": "{count, plural, one {{counter} sledovaný} few {{counter} sledovaní} many {{counter} sledovaných} other {{counter} sledovaných}}", | ||||||
|   "account.follows.empty": "Tento uživatel zatím nikoho nesleduje.", |   "account.follows.empty": "Tento uživatel zatím nikoho nesleduje.", | ||||||
|   "account.go_to_profile": "Přejít na profil", |   "account.go_to_profile": "Přejít na profil", | ||||||
|   "account.hide_reblogs": "Skrýt boosty od @{name}", |   "account.hide_reblogs": "Skrýt boosty od @{name}", | ||||||
|  | @ -61,6 +63,7 @@ | ||||||
|   "account.requested_follow": "{name} tě požádal o sledování", |   "account.requested_follow": "{name} tě požádal o sledování", | ||||||
|   "account.share": "Sdílet profil @{name}", |   "account.share": "Sdílet profil @{name}", | ||||||
|   "account.show_reblogs": "Zobrazit boosty od @{name}", |   "account.show_reblogs": "Zobrazit boosty od @{name}", | ||||||
|  |   "account.statuses_counter": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}", | ||||||
|   "account.unblock": "Odblokovat @{name}", |   "account.unblock": "Odblokovat @{name}", | ||||||
|   "account.unblock_domain": "Odblokovat doménu {domain}", |   "account.unblock_domain": "Odblokovat doménu {domain}", | ||||||
|   "account.unblock_short": "Odblokovat", |   "account.unblock_short": "Odblokovat", | ||||||
|  | @ -75,9 +78,9 @@ | ||||||
|   "admin.dashboard.retention.average": "Průměr", |   "admin.dashboard.retention.average": "Průměr", | ||||||
|   "admin.dashboard.retention.cohort": "Měsíc registrace", |   "admin.dashboard.retention.cohort": "Měsíc registrace", | ||||||
|   "admin.dashboard.retention.cohort_size": "Noví uživatelé", |   "admin.dashboard.retention.cohort_size": "Noví uživatelé", | ||||||
|   "admin.impact_report.instance_accounts": "Profily účtů, které by odstranily", |   "admin.impact_report.instance_accounts": "Profily účtů, které by byli odstaněny", | ||||||
|   "admin.impact_report.instance_followers": "Sledovatelé, o které by naši uživatelé přišli", |   "admin.impact_report.instance_followers": "Sledující, o které by naši uživatelé přišli", | ||||||
|   "admin.impact_report.instance_follows": "Následovníci jejich uživatelé by ztratili", |   "admin.impact_report.instance_follows": "Sledující, o které by naši uživatelé přišli", | ||||||
|   "admin.impact_report.title": "Shrnutí dopadu", |   "admin.impact_report.title": "Shrnutí dopadu", | ||||||
|   "alert.rate_limited.message": "Zkuste to prosím znovu po {retry_time, time, medium}.", |   "alert.rate_limited.message": "Zkuste to prosím znovu po {retry_time, time, medium}.", | ||||||
|   "alert.rate_limited.title": "Spojení omezena", |   "alert.rate_limited.title": "Spojení omezena", | ||||||
|  | @ -86,7 +89,7 @@ | ||||||
|   "announcement.announcement": "Oznámení", |   "announcement.announcement": "Oznámení", | ||||||
|   "attachments_list.unprocessed": "(nezpracováno)", |   "attachments_list.unprocessed": "(nezpracováno)", | ||||||
|   "audio.hide": "Skrýt zvuk", |   "audio.hide": "Skrýt zvuk", | ||||||
|   "block_modal.remote_users_caveat": "Požádáme server {domain}, aby respektoval vaše rozhodnutí. Úplné dodržování nastavení však není zaručeno, protože některé servery mohou řešit blokování různě. Veřejné příspěvky mohou být stále viditelné pro nepřihlášené uživatele.", |   "block_modal.remote_users_caveat": "Požádáme server {domain}, aby respektoval vaše rozhodnutí. Úplné dodržování nastavení však není zaručeno, protože některé servery mohou řešit blokování různě. Veřejné příspěvky mohou stále být viditelné pro nepřihlášené uživatele.", | ||||||
|   "block_modal.show_less": "Zobrazit méně", |   "block_modal.show_less": "Zobrazit méně", | ||||||
|   "block_modal.show_more": "Zobrazit více", |   "block_modal.show_more": "Zobrazit více", | ||||||
|   "block_modal.they_cant_mention": "Nemůže vás zmiňovat ani sledovat.", |   "block_modal.they_cant_mention": "Nemůže vás zmiňovat ani sledovat.", | ||||||
|  | @ -411,6 +414,8 @@ | ||||||
|   "limited_account_hint.action": "Přesto profil zobrazit", |   "limited_account_hint.action": "Přesto profil zobrazit", | ||||||
|   "limited_account_hint.title": "Tento profil byl skryt moderátory {domain}.", |   "limited_account_hint.title": "Tento profil byl skryt moderátory {domain}.", | ||||||
|   "link_preview.author": "Podle {name}", |   "link_preview.author": "Podle {name}", | ||||||
|  |   "link_preview.more_from_author": "Více od {name}", | ||||||
|  |   "link_preview.shares": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}", | ||||||
|   "lists.account.add": "Přidat do seznamu", |   "lists.account.add": "Přidat do seznamu", | ||||||
|   "lists.account.remove": "Odebrat ze seznamu", |   "lists.account.remove": "Odebrat ze seznamu", | ||||||
|   "lists.delete": "Smazat seznam", |   "lists.delete": "Smazat seznam", | ||||||
|  | @ -691,8 +696,11 @@ | ||||||
|   "server_banner.about_active_users": "Lidé používající tento server během posledních 30 dní (měsíční aktivní uživatelé)", |   "server_banner.about_active_users": "Lidé používající tento server během posledních 30 dní (měsíční aktivní uživatelé)", | ||||||
|   "server_banner.active_users": "aktivní uživatelé", |   "server_banner.active_users": "aktivní uživatelé", | ||||||
|   "server_banner.administered_by": "Spravováno:", |   "server_banner.administered_by": "Spravováno:", | ||||||
|  |   "server_banner.is_one_of_many": "{domain} je jedním z mnoha Mastodon serverů, které můžete použít k účasti na fediversu.", | ||||||
|   "server_banner.server_stats": "Statistiky serveru:", |   "server_banner.server_stats": "Statistiky serveru:", | ||||||
|   "sign_in_banner.create_account": "Vytvořit účet", |   "sign_in_banner.create_account": "Vytvořit účet", | ||||||
|  |   "sign_in_banner.follow_anyone": "Sledujte kohokoli napříč fediversem a uvidíte vše v chronologickém pořadí. Bez algoritmů, reklam a clickbaitu.", | ||||||
|  |   "sign_in_banner.mastodon_is": "Mastodon je ten nejlepší způsob, jak udržet krok s tím, co se právě děje.", | ||||||
|   "sign_in_banner.sign_in": "Přihlásit se", |   "sign_in_banner.sign_in": "Přihlásit se", | ||||||
|   "sign_in_banner.sso_redirect": "Přihlášení nebo Registrace", |   "sign_in_banner.sso_redirect": "Přihlášení nebo Registrace", | ||||||
|   "status.admin_account": "Otevřít moderátorské rozhraní pro @{name}", |   "status.admin_account": "Otevřít moderátorské rozhraní pro @{name}", | ||||||
|  |  | ||||||
|  | @ -37,7 +37,7 @@ | ||||||
|   "account.followers.empty": "Diesem Profil folgt noch niemand.", |   "account.followers.empty": "Diesem Profil folgt noch niemand.", | ||||||
|   "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}", |   "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}", | ||||||
|   "account.following": "Folge ich", |   "account.following": "Folge ich", | ||||||
|   "account.following_counter": "{count, plural, one {{counter} folge ich} other {{counter} folge ich}}", |   "account.following_counter": "{count, plural, one {{counter} Folge ich} other {{counter} Folge ich}}", | ||||||
|   "account.follows.empty": "Dieses Profil folgt noch niemandem.", |   "account.follows.empty": "Dieses Profil folgt noch niemandem.", | ||||||
|   "account.go_to_profile": "Profil aufrufen", |   "account.go_to_profile": "Profil aufrufen", | ||||||
|   "account.hide_reblogs": "Geteilte Beiträge von @{name} ausblenden", |   "account.hide_reblogs": "Geteilte Beiträge von @{name} ausblenden", | ||||||
|  |  | ||||||
|  | @ -35,7 +35,9 @@ | ||||||
|   "account.follow_back": "Fylg aftur", |   "account.follow_back": "Fylg aftur", | ||||||
|   "account.followers": "Fylgjarar", |   "account.followers": "Fylgjarar", | ||||||
|   "account.followers.empty": "Ongar fylgjarar enn.", |   "account.followers.empty": "Ongar fylgjarar enn.", | ||||||
|  |   "account.followers_counter": "{count, plural, one {{counter} fylgjari} other {{counter} fylgjarar}}", | ||||||
|   "account.following": "Fylgir", |   "account.following": "Fylgir", | ||||||
|  |   "account.following_counter": "{count, plural, one {{counter} fylgir} other {{counter} fylgja}}", | ||||||
|   "account.follows.empty": "Hesin brúkari fylgir ongum enn.", |   "account.follows.empty": "Hesin brúkari fylgir ongum enn.", | ||||||
|   "account.go_to_profile": "Far til vanga", |   "account.go_to_profile": "Far til vanga", | ||||||
|   "account.hide_reblogs": "Fjal lyft frá @{name}", |   "account.hide_reblogs": "Fjal lyft frá @{name}", | ||||||
|  | @ -61,6 +63,7 @@ | ||||||
|   "account.requested_follow": "{name} hevur biðið um at fylgja tær", |   "account.requested_follow": "{name} hevur biðið um at fylgja tær", | ||||||
|   "account.share": "Deil vanga @{name}'s", |   "account.share": "Deil vanga @{name}'s", | ||||||
|   "account.show_reblogs": "Vís lyft frá @{name}", |   "account.show_reblogs": "Vís lyft frá @{name}", | ||||||
|  |   "account.statuses_counter": "{count, plural, one {{counter} postur} other {{counter} postar}}", | ||||||
|   "account.unblock": "Banna ikki @{name}", |   "account.unblock": "Banna ikki @{name}", | ||||||
|   "account.unblock_domain": "Banna ikki økisnavnið {domain}", |   "account.unblock_domain": "Banna ikki økisnavnið {domain}", | ||||||
|   "account.unblock_short": "Banna ikki", |   "account.unblock_short": "Banna ikki", | ||||||
|  |  | ||||||
|  | @ -35,7 +35,9 @@ | ||||||
|   "account.follow_back": "맞팔로우 하기", |   "account.follow_back": "맞팔로우 하기", | ||||||
|   "account.followers": "팔로워", |   "account.followers": "팔로워", | ||||||
|   "account.followers.empty": "아직 아무도 이 사용자를 팔로우하고 있지 않습니다.", |   "account.followers.empty": "아직 아무도 이 사용자를 팔로우하고 있지 않습니다.", | ||||||
|  |   "account.followers_counter": "{count, plural, other {{counter} 팔로워}}", | ||||||
|   "account.following": "팔로잉", |   "account.following": "팔로잉", | ||||||
|  |   "account.following_counter": "{count, plural, other {{counter} 팔로잉}}", | ||||||
|   "account.follows.empty": "이 사용자는 아직 아무도 팔로우하고 있지 않습니다.", |   "account.follows.empty": "이 사용자는 아직 아무도 팔로우하고 있지 않습니다.", | ||||||
|   "account.go_to_profile": "프로필로 이동", |   "account.go_to_profile": "프로필로 이동", | ||||||
|   "account.hide_reblogs": "@{name}의 부스트를 숨기기", |   "account.hide_reblogs": "@{name}의 부스트를 숨기기", | ||||||
|  | @ -61,6 +63,7 @@ | ||||||
|   "account.requested_follow": "{name} 님이 팔로우 요청을 보냈습니다", |   "account.requested_follow": "{name} 님이 팔로우 요청을 보냈습니다", | ||||||
|   "account.share": "@{name}의 프로필 공유", |   "account.share": "@{name}의 프로필 공유", | ||||||
|   "account.show_reblogs": "@{name}의 부스트 보기", |   "account.show_reblogs": "@{name}의 부스트 보기", | ||||||
|  |   "account.statuses_counter": "{count, plural, other {{counter} 게시물}}", | ||||||
|   "account.unblock": "차단 해제", |   "account.unblock": "차단 해제", | ||||||
|   "account.unblock_domain": "도메인 {domain} 차단 해제", |   "account.unblock_domain": "도메인 {domain} 차단 해제", | ||||||
|   "account.unblock_short": "차단 해제", |   "account.unblock_short": "차단 해제", | ||||||
|  |  | ||||||
|  | @ -35,7 +35,9 @@ | ||||||
|   "account.follow_back": "ติดตามกลับ", |   "account.follow_back": "ติดตามกลับ", | ||||||
|   "account.followers": "ผู้ติดตาม", |   "account.followers": "ผู้ติดตาม", | ||||||
|   "account.followers.empty": "ยังไม่มีใครติดตามผู้ใช้นี้", |   "account.followers.empty": "ยังไม่มีใครติดตามผู้ใช้นี้", | ||||||
|  |   "account.followers_counter": "{count, plural, other {{counter} ผู้ติดตาม}}", | ||||||
|   "account.following": "กำลังติดตาม", |   "account.following": "กำลังติดตาม", | ||||||
|  |   "account.following_counter": "{count, plural, other {{counter} กำลังติดตาม}}", | ||||||
|   "account.follows.empty": "ผู้ใช้นี้ยังไม่ได้ติดตามใคร", |   "account.follows.empty": "ผู้ใช้นี้ยังไม่ได้ติดตามใคร", | ||||||
|   "account.go_to_profile": "ไปยังโปรไฟล์", |   "account.go_to_profile": "ไปยังโปรไฟล์", | ||||||
|   "account.hide_reblogs": "ซ่อนการดันจาก @{name}", |   "account.hide_reblogs": "ซ่อนการดันจาก @{name}", | ||||||
|  | @ -61,6 +63,7 @@ | ||||||
|   "account.requested_follow": "{name} ได้ขอติดตามคุณ", |   "account.requested_follow": "{name} ได้ขอติดตามคุณ", | ||||||
|   "account.share": "แชร์โปรไฟล์ของ @{name}", |   "account.share": "แชร์โปรไฟล์ของ @{name}", | ||||||
|   "account.show_reblogs": "แสดงการดันจาก @{name}", |   "account.show_reblogs": "แสดงการดันจาก @{name}", | ||||||
|  |   "account.statuses_counter": "{count, plural, other {{counter} โพสต์}}", | ||||||
|   "account.unblock": "เลิกปิดกั้น @{name}", |   "account.unblock": "เลิกปิดกั้น @{name}", | ||||||
|   "account.unblock_domain": "เลิกปิดกั้นโดเมน {domain}", |   "account.unblock_domain": "เลิกปิดกั้นโดเมน {domain}", | ||||||
|   "account.unblock_short": "เลิกปิดกั้น", |   "account.unblock_short": "เลิกปิดกั้น", | ||||||
|  |  | ||||||
|  | @ -35,7 +35,9 @@ | ||||||
|   "account.follow_back": "Theo dõi lại", |   "account.follow_back": "Theo dõi lại", | ||||||
|   "account.followers": "Người theo dõi", |   "account.followers": "Người theo dõi", | ||||||
|   "account.followers.empty": "Chưa có người theo dõi nào.", |   "account.followers.empty": "Chưa có người theo dõi nào.", | ||||||
|  |   "account.followers_counter": "{count, plural, other {{counter} người theo dõi}}", | ||||||
|   "account.following": "Đang theo dõi", |   "account.following": "Đang theo dõi", | ||||||
|  |   "account.following_counter": "{count, plural, other {{counter} đang theo dõi}}", | ||||||
|   "account.follows.empty": "Người này chưa theo dõi ai.", |   "account.follows.empty": "Người này chưa theo dõi ai.", | ||||||
|   "account.go_to_profile": "Xem hồ sơ", |   "account.go_to_profile": "Xem hồ sơ", | ||||||
|   "account.hide_reblogs": "Ẩn tút @{name} đăng lại", |   "account.hide_reblogs": "Ẩn tút @{name} đăng lại", | ||||||
|  | @ -61,6 +63,7 @@ | ||||||
|   "account.requested_follow": "{name} yêu cầu theo dõi bạn", |   "account.requested_follow": "{name} yêu cầu theo dõi bạn", | ||||||
|   "account.share": "Chia sẻ @{name}", |   "account.share": "Chia sẻ @{name}", | ||||||
|   "account.show_reblogs": "Hiện tút do @{name} đăng lại", |   "account.show_reblogs": "Hiện tút do @{name} đăng lại", | ||||||
|  |   "account.statuses_counter": "{count, plural, other {{counter} tút}}", | ||||||
|   "account.unblock": "Bỏ chặn @{name}", |   "account.unblock": "Bỏ chặn @{name}", | ||||||
|   "account.unblock_domain": "Bỏ ẩn {domain}", |   "account.unblock_domain": "Bỏ ẩn {domain}", | ||||||
|   "account.unblock_short": "Bỏ chặn", |   "account.unblock_short": "Bỏ chặn", | ||||||
|  |  | ||||||
|  | @ -1,4 +1,12 @@ | ||||||
|  | import type { RecordOf } from 'immutable'; | ||||||
|  | 
 | ||||||
|  | import type { ApiPreviewCardJSON } from 'mastodon/api_types/statuses'; | ||||||
|  | 
 | ||||||
| export type { StatusVisibility } from 'mastodon/api_types/statuses'; | export type { StatusVisibility } from 'mastodon/api_types/statuses'; | ||||||
| 
 | 
 | ||||||
| // Temporary until we type it correctly
 | // Temporary until we type it correctly
 | ||||||
| export type Status = Immutable.Map<string, unknown>; | export type Status = Immutable.Map<string, unknown>; | ||||||
|  | 
 | ||||||
|  | type CardShape = Required<ApiPreviewCardJSON>; | ||||||
|  | 
 | ||||||
|  | export type Card = RecordOf<CardShape>; | ||||||
|  |  | ||||||
|  | @ -10468,12 +10468,14 @@ noscript { | ||||||
|         overflow: hidden; |         overflow: hidden; | ||||||
|         white-space: nowrap; |         white-space: nowrap; | ||||||
|         text-overflow: ellipsis; |         text-overflow: ellipsis; | ||||||
|  |         text-align: end; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       &.verified { |       &.verified { | ||||||
|         dd { |         dd { | ||||||
|           display: flex; |           display: flex; | ||||||
|           align-items: center; |           align-items: center; | ||||||
|  |           justify-content: flex-end; | ||||||
|           gap: 4px; |           gap: 4px; | ||||||
|           overflow: hidden; |           overflow: hidden; | ||||||
|           white-space: nowrap; |           white-space: nowrap; | ||||||
|  |  | ||||||
|  | @ -291,6 +291,7 @@ cs: | ||||||
|         update_custom_emoji_html: Uživatel %{name} aktualizoval emoji %{target} |         update_custom_emoji_html: Uživatel %{name} aktualizoval emoji %{target} | ||||||
|         update_domain_block_html: "%{name} aktualizoval blokaci domény %{target}" |         update_domain_block_html: "%{name} aktualizoval blokaci domény %{target}" | ||||||
|         update_ip_block_html: "%{name} změnil pravidlo pro IP %{target}" |         update_ip_block_html: "%{name} změnil pravidlo pro IP %{target}" | ||||||
|  |         update_report_html: "%{name} aktualizoval hlášení %{target}" | ||||||
|         update_status_html: Uživatel %{name} aktualizoval příspěvek uživatele %{target} |         update_status_html: Uživatel %{name} aktualizoval příspěvek uživatele %{target} | ||||||
|         update_user_role_html: "%{name} změnil %{target} roli" |         update_user_role_html: "%{name} změnil %{target} roli" | ||||||
|       deleted_account: smazaný účet |       deleted_account: smazaný účet | ||||||
|  | @ -298,6 +299,7 @@ cs: | ||||||
|       filter_by_action: Filtrovat podle akce |       filter_by_action: Filtrovat podle akce | ||||||
|       filter_by_user: Filtrovat podle uživatele |       filter_by_user: Filtrovat podle uživatele | ||||||
|       title: Protokol auditu |       title: Protokol auditu | ||||||
|  |       unavailable_instance: "(doména není k dispozici)" | ||||||
|     announcements: |     announcements: | ||||||
|       destroyed_msg: Oznámení bylo úspěšně odstraněno! |       destroyed_msg: Oznámení bylo úspěšně odstraněno! | ||||||
|       edit: |       edit: | ||||||
|  | @ -984,6 +986,7 @@ cs: | ||||||
|       delete: Smazat |       delete: Smazat | ||||||
|       edit_preset: Upravit předlohu pro varování |       edit_preset: Upravit předlohu pro varování | ||||||
|       empty: Zatím jste nedefinovali žádné předlohy varování. |       empty: Zatím jste nedefinovali žádné předlohy varování. | ||||||
|  |       title: Předvolby varování | ||||||
|     webhooks: |     webhooks: | ||||||
|       add_new: Přidat koncový bod |       add_new: Přidat koncový bod | ||||||
|       delete: Smazat |       delete: Smazat | ||||||
|  |  | ||||||
|  | @ -166,6 +166,7 @@ cs: | ||||||
|       admin:write:reports: provádět moderátorské akce s hlášeními |       admin:write:reports: provádět moderátorské akce s hlášeními | ||||||
|       crypto: používat end-to-end šifrování |       crypto: používat end-to-end šifrování | ||||||
|       follow: upravovat vztahy mezi profily |       follow: upravovat vztahy mezi profily | ||||||
|  |       profile: číst pouze základní informace o vašem účtu | ||||||
|       push: přijímat vaše push oznámení |       push: přijímat vaše push oznámení | ||||||
|       read: vidět všechna data vašeho účtu |       read: vidět všechna data vašeho účtu | ||||||
|       read:accounts: vidět informace o účtech |       read:accounts: vidět informace o účtech | ||||||
|  |  | ||||||
|  | @ -28,6 +28,7 @@ Rails.application.routes.draw do | ||||||
|     /public/remote |     /public/remote | ||||||
|     /conversations |     /conversations | ||||||
|     /lists/(*any) |     /lists/(*any) | ||||||
|  |     /links/(*any) | ||||||
|     /notifications/(*any) |     /notifications/(*any) | ||||||
|     /favourites |     /favourites | ||||||
|     /bookmarks |     /bookmarks | ||||||
|  |  | ||||||
|  | @ -181,8 +181,8 @@ | ||||||
|     "eslint-plugin-formatjs": "^4.10.1", |     "eslint-plugin-formatjs": "^4.10.1", | ||||||
|     "eslint-plugin-import": "~2.29.0", |     "eslint-plugin-import": "~2.29.0", | ||||||
|     "eslint-plugin-jsdoc": "^48.0.0", |     "eslint-plugin-jsdoc": "^48.0.0", | ||||||
|     "eslint-plugin-jsx-a11y": "~6.8.0", |     "eslint-plugin-jsx-a11y": "~6.9.0", | ||||||
|     "eslint-plugin-promise": "~6.2.0", |     "eslint-plugin-promise": "~6.4.0", | ||||||
|     "eslint-plugin-react": "^7.33.2", |     "eslint-plugin-react": "^7.33.2", | ||||||
|     "eslint-plugin-react-hooks": "^4.6.0", |     "eslint-plugin-react-hooks": "^4.6.0", | ||||||
|     "husky": "^9.0.11", |     "husky": "^9.0.11", | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue