diff --git a/Gemfile b/Gemfile index 5dd5d5bf26..126d73f9ca 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ source 'https://rubygems.org' ruby '>= 3.2.0', '< 3.5.0' gem 'propshaft' -gem 'puma', '~> 6.3' +gem 'puma', '~> 7.0' gem 'rails', '~> 8.0' gem 'thor', '~> 1.2' diff --git a/Gemfile.lock b/Gemfile.lock index b3364ba38d..64ef3057d8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -637,7 +637,7 @@ GEM date stringio public_suffix (6.0.2) - puma (6.6.1) + puma (7.0.3) nio4r (~> 2.0) pundit (2.5.1) activesupport (>= 3.0.0) @@ -1052,7 +1052,7 @@ DEPENDENCIES prometheus_exporter (~> 2.2) propshaft public_suffix (~> 6.0) - puma (~> 6.3) + puma (~> 7.0) pundit (~> 2.3) rack-attack (~> 6.6) rack-cors diff --git a/app/controllers/api/v1/statuses/interaction_policies_controller.rb b/app/controllers/api/v1/statuses/interaction_policies_controller.rb index b8ec4fe140..6e2745806d 100644 --- a/app/controllers/api/v1/statuses/interaction_policies_controller.rb +++ b/app/controllers/api/v1/statuses/interaction_policies_controller.rb @@ -4,7 +4,6 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base include Api::InteractionPoliciesConcern before_action -> { doorkeeper_authorize! :write, :'write:statuses' } - before_action -> { check_feature_enabled } def update authorize @status, :update? @@ -22,10 +21,6 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base params.permit(:quote_approval_policy) end - def check_feature_enabled - raise ActionController::RoutingError unless Mastodon::Feature.outgoing_quotes_enabled? - end - def broadcast_updates! DistributionWorker.perform_async(@status.id, { 'update' => true }) ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 }) diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 6b5b397152..6619899041 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -159,8 +159,6 @@ class Api::V1::StatusesController < Api::BaseController end def set_quoted_status - return unless Mastodon::Feature.outgoing_quotes_enabled? - @quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present? authorize(@quoted_status, :quote?) if @quoted_status.present? rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError diff --git a/app/controllers/concerns/api/interaction_policies_concern.rb b/app/controllers/concerns/api/interaction_policies_concern.rb index 5b63705a9b..f1e1480c0c 100644 --- a/app/controllers/concerns/api/interaction_policies_concern.rb +++ b/app/controllers/concerns/api/interaction_policies_concern.rb @@ -4,8 +4,6 @@ module Api::InteractionPoliciesConcern extend ActiveSupport::Concern def quote_approval_policy - return nil unless Mastodon::Feature.outgoing_quotes_enabled? - case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy when 'public' Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16 diff --git a/app/javascript/flavours/glitch/components/alert/alert.stories.tsx b/app/javascript/flavours/glitch/components/alert/alert.stories.tsx index 4d5f8acb65..f12f06751d 100644 --- a/app/javascript/flavours/glitch/components/alert/alert.stories.tsx +++ b/app/javascript/flavours/glitch/components/alert/alert.stories.tsx @@ -8,6 +8,7 @@ const meta = { component: Alert, args: { isActive: true, + isLoading: false, animateFrom: 'side', title: '', message: '', @@ -20,6 +21,12 @@ const meta = { type: 'boolean', description: 'Animate to the active (displayed) state of the alert', }, + isLoading: { + control: 'boolean', + type: 'boolean', + description: + 'Display a loading indicator in the alert, replacing the dismiss button if present', + }, animateFrom: { control: 'radio', type: 'string', @@ -108,3 +115,11 @@ export const InSizedContainer: Story = { ), }; + +export const WithLoadingIndicator: Story = { + args: { + ...WithDismissButton.args, + isLoading: true, + }, + render: InSizedContainer.render, +}; diff --git a/app/javascript/flavours/glitch/components/alert/index.tsx b/app/javascript/flavours/glitch/components/alert/index.tsx index 1009e77524..eb0abcb518 100644 --- a/app/javascript/flavours/glitch/components/alert/index.tsx +++ b/app/javascript/flavours/glitch/components/alert/index.tsx @@ -3,6 +3,7 @@ import { useIntl } from 'react-intl'; import classNames from 'classnames'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { IconButton } from '../icon_button'; @@ -10,21 +11,23 @@ import { IconButton } from '../icon_button'; * Snackbar/Toast-style notification component. */ export const Alert: React.FC<{ - isActive?: boolean; - animateFrom?: 'side' | 'below'; title?: string; message: string; action?: string; onActionClick?: () => void; onDismiss?: () => void; + isActive?: boolean; + isLoading?: boolean; + animateFrom?: 'side' | 'below'; }> = ({ - isActive, - animateFrom = 'side', title, message, action, onActionClick, onDismiss, + isActive, + isLoading, + animateFrom = 'side', }) => { const intl = useIntl(); @@ -51,7 +54,13 @@ export const Alert: React.FC<{ )} - {onDismiss && ( + {isLoading && ( + + + + )} + + {onDismiss && !isLoading && ( > = ({ account, className, children, ...props }) => { return ( - + {account ? ( React.ReactNode; +}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { + const [delayedIsActive, setDelayedIsActive] = useState(false); + + useEffect(() => { + if (isActive && !withEntryDelay) { + setDelayedIsActive(true); + + return () => ''; + } else { + const timeout = setTimeout(() => { + setDelayedIsActive(isActive); + }, delayMs); + + return () => { + clearTimeout(timeout); + }; + } + }, [isActive, delayMs, withEntryDelay]); + + if (!isActive && !delayedIsActive) { + return null; + } + + return children(isActive && delayedIsActive); +}; diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 575eefc846..4362b70437 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -118,6 +118,7 @@ class Status extends ImmutablePureComponent { prepend: PropTypes.string, withDismiss: PropTypes.bool, isQuotedPost: PropTypes.bool, + shouldHighlightOnMount: PropTypes.bool, getScrollPosition: PropTypes.func, updateScrollBottom: PropTypes.func, expanded: PropTypes.bool, @@ -705,6 +706,7 @@ class Status extends ImmutablePureComponent { muted: this.props.muted, 'status--is-quote': isQuotedPost, 'status--has-quote': !!status.get('quote'), + 'status--highlighted-entry': this.props.shouldHighlightOnMount, }) } data-id={status.get('id')} diff --git a/app/javascript/flavours/glitch/components/status/boost_button.stories.tsx b/app/javascript/flavours/glitch/components/status/boost_button.stories.tsx index ba8736fe7f..0376518124 100644 --- a/app/javascript/flavours/glitch/components/status/boost_button.stories.tsx +++ b/app/javascript/flavours/glitch/components/status/boost_button.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses'; import { statusFactoryState } from '@/testing/factories'; -import { LegacyReblogButton, StatusBoostButton } from './boost_button'; +import { BoostButton } from './boost_button'; interface StoryProps { visibility: StatusVisibility; @@ -38,10 +38,7 @@ const meta = { }, }, render: (args) => ( - 0} - /> + 0} /> ), } satisfies Meta; @@ -78,12 +75,3 @@ export const Mine: Story = { }, }, }; - -export const Legacy: Story = { - render: (args) => ( - 0} - /> - ), -}; diff --git a/app/javascript/flavours/glitch/components/status/boost_button.tsx b/app/javascript/flavours/glitch/components/status/boost_button.tsx index 7d6580d52d..17ebcbc768 100644 --- a/app/javascript/flavours/glitch/components/status/boost_button.tsx +++ b/app/javascript/flavours/glitch/components/status/boost_button.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react'; +import type { FC, KeyboardEvent, MouseEvent } from 'react'; import { useIntl } from 'react-intl'; @@ -11,7 +11,6 @@ import { openModal } from '@/flavours/glitch/actions/modal'; import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu'; import type { Status } from '@/flavours/glitch/models/status'; import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; -import { isFeatureEnabled } from '@/flavours/glitch/utils/environment'; import type { SomeRequired } from '@/flavours/glitch/utils/types'; import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu'; @@ -47,10 +46,7 @@ interface ReblogButtonProps { type ActionMenuItemWithIcon = SomeRequired; -export const StatusBoostButton: FC = ({ - status, - counters, -}) => { +export const BoostButton: FC = ({ status, counters }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const statusState = useAppSelector((state) => @@ -192,65 +188,3 @@ const ReblogMenuItem: FC = ({ ); }; - -// Legacy helpers - -// Switch between the legacy and new reblog button based on feature flag. -export const BoostButton: FC = (props) => { - if (isFeatureEnabled('outgoing_quotes')) { - return ; - } - return ; -}; - -export const LegacyReblogButton: FC = ({ - status, - counters, -}) => { - const intl = useIntl(); - const statusState = useAppSelector((state) => - selectStatusState(state, status), - ); - - const { title, meta, iconComponent, disabled } = useMemo( - () => boostItemState(statusState), - [statusState], - ); - - const dispatch = useAppDispatch(); - const handleClick: MouseEventHandler = useCallback( - (event) => { - if (statusState.isLoggedIn) { - dispatch(toggleReblog(status.get('id') as string, event.shiftKey)); - } else { - dispatch( - openModal({ - modalType: 'INTERACTION', - modalProps: { - accountId: status.getIn(['account', 'id']), - url: status.get('uri'), - }, - }), - ); - } - }, - [dispatch, status, statusState.isLoggedIn], - ); - - return ( - - ); -}; diff --git a/app/javascript/flavours/glitch/components/status_action_bar/index.jsx b/app/javascript/flavours/glitch/components/status_action_bar/index.jsx index 04585cb835..9284fb17f0 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar/index.jsx +++ b/app/javascript/flavours/glitch/components/status_action_bar/index.jsx @@ -26,7 +26,6 @@ import { me } from '../../initial_state'; import { IconButton } from '../icon_button'; import { RelativeTimestamp } from '../relative_timestamp'; -import { isFeatureEnabled } from '../../utils/environment'; import { BoostButton } from '../status/boost_button'; import { RemoveQuoteHint } from './remove_quote_hint'; @@ -254,7 +253,7 @@ class StatusActionBar extends ImmutablePureComponent { if (writtenByMe || withDismiss) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); - if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) { + if (writtenByMe && !['private', 'direct'].includes(status.get('visibility'))) { menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange }); } menu.push(null); diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx index f1b8520122..fb52fd68cf 100644 --- a/app/javascript/flavours/glitch/components/status_content.jsx +++ b/app/javascript/flavours/glitch/components/status_content.jsx @@ -237,32 +237,6 @@ class StatusContent extends PureComponent { } } - handleMouseEnter = ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-original'); - } - }; - - handleMouseLeave = ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-static'); - } - }; - componentDidMount () { this._updateStatusLinks(); } @@ -354,7 +328,13 @@ class StatusContent extends PureComponent { if (this.props.onClick) { return ( <> -
+
+
({ }, onQuote (status) { - if (isFeatureEnabled('outgoing_quotes')) { - dispatch(quoteComposeById(status.get('id'))); - } + dispatch(quoteComposeById(status.get('id'))); }, onReblog (status, e) { diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx index 88a421bc61..163faf0a51 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx @@ -383,36 +383,6 @@ export const AccountHeader: React.FC<{ }); }, [account]); - const handleMouseEnter = useCallback( - ({ currentTarget }: React.MouseEvent) => { - if (autoPlayGif) { - return; - } - - currentTarget - .querySelectorAll('.custom-emoji') - .forEach((emoji) => { - emoji.src = emoji.getAttribute('data-original') ?? ''; - }); - }, - [], - ); - - const handleMouseLeave = useCallback( - ({ currentTarget }: React.MouseEvent) => { - if (autoPlayGif) { - return; - } - - currentTarget - .querySelectorAll('.custom-emoji') - .forEach((emoji) => { - emoji.src = emoji.getAttribute('data-static') ?? ''; - }); - }, - [], - ); - const suspended = account?.suspended; const isRemote = account?.acct !== account?.username; const remoteDomain = isRemote ? account?.acct.split('@')[1] : null; @@ -812,11 +782,9 @@ export const AccountHeader: React.FC<{ )}
{!(suspended || hidden || account.moved) && relationship?.requested_by && ( diff --git a/app/javascript/flavours/glitch/features/compose/components/visibility_button.tsx b/app/javascript/flavours/glitch/features/compose/components/visibility_button.tsx index 78d77ca536..1e6462ecd3 100644 --- a/app/javascript/flavours/glitch/features/compose/components/visibility_button.tsx +++ b/app/javascript/flavours/glitch/features/compose/components/visibility_button.tsx @@ -12,14 +12,12 @@ import type { ApiQuotePolicy } from '@/flavours/glitch/api_types/quotes'; import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses'; import { Icon } from '@/flavours/glitch/components/icon'; import { useAppSelector, useAppDispatch } from '@/flavours/glitch/store'; -import { isFeatureEnabled } from '@/flavours/glitch/utils/environment'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react'; import type { VisibilityModalCallback } from '../../ui/components/visibility_modal'; -import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import { messages as privacyMessages } from './privacy_dropdown'; @@ -43,9 +41,6 @@ interface PrivacyDropdownProps { } export const VisibilityButton: FC = (props) => { - if (!isFeatureEnabled('outgoing_quotes')) { - return ; - } return ; }; diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx index ff682c296d..cf35e28620 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx @@ -23,7 +23,6 @@ import { IconButton } from 'flavours/glitch/components/icon_button'; import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; import StatusContent from 'flavours/glitch/components/status_content'; import { Dropdown } from 'flavours/glitch/components/dropdown_menu'; -import { autoPlayGif } from 'flavours/glitch/initial_state'; import { makeGetStatus } from 'flavours/glitch/selectors'; import { LinkedDisplayName } from '@/flavours/glitch/components/display_name'; @@ -61,32 +60,6 @@ export const Conversation = ({ conversation, scrollKey }) => { const sharedCWState = useSelector(state => state.getIn(['state', 'content_warnings', 'shared_state'])); const [expanded, setExpanded] = useState(undefined); - const handleMouseEnter = useCallback(({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-original'); - } - }, []); - - const handleMouseLeave = useCallback(({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-static'); - } - }, []); - const handleClick = useCallback(() => { if (unread) { dispatch(markConversationRead(id)); @@ -171,7 +144,7 @@ export const Conversation = ({ conversation, scrollKey }) => { {unread && }
-
+
{names} }} />
diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.tsx b/app/javascript/flavours/glitch/features/directory/components/account_card.tsx index 8afb9f59d5..deb4f4832d 100644 --- a/app/javascript/flavours/glitch/features/directory/components/account_card.tsx +++ b/app/javascript/flavours/glitch/features/directory/components/account_card.tsx @@ -1,4 +1,3 @@ -import type { MouseEventHandler } from 'react'; import { useCallback } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; @@ -44,39 +43,6 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => { const account = useAppSelector((s) => getAccount(s, accountId)); const dispatch = useAppDispatch(); - const handleMouseEnter = useCallback( - ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - const emojis = - currentTarget.querySelectorAll('.custom-emoji'); - - emojis.forEach((emoji) => { - const original = emoji.getAttribute('data-original'); - if (original) emoji.src = original; - }); - }, - [], - ); - - const handleMouseLeave = useCallback( - ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = - currentTarget.querySelectorAll('.custom-emoji'); - - emojis.forEach((emoji) => { - const staticUrl = emoji.getAttribute('data-static'); - if (staticUrl) emoji.src = staticUrl; - }); - }, - [], - ); - const handleFollow = useCallback(() => { if (!account) return; @@ -189,9 +155,7 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => { {account.get('note').length > 0 && (
)} diff --git a/app/javascript/flavours/glitch/features/emoji/emoji_html.tsx b/app/javascript/flavours/glitch/features/emoji/emoji_html.tsx index f620193ab7..4fa671431c 100644 --- a/app/javascript/flavours/glitch/features/emoji/emoji_html.tsx +++ b/app/javascript/flavours/glitch/features/emoji/emoji_html.tsx @@ -1,5 +1,7 @@ import type { ComponentPropsWithoutRef, ElementType } from 'react'; +import classNames from 'classnames'; + import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; import { useEmojify } from './hooks'; @@ -7,12 +9,13 @@ import type { CustomEmojiMapArg } from './types'; type EmojiHTMLProps = Omit< ComponentPropsWithoutRef, - 'dangerouslySetInnerHTML' + 'dangerouslySetInnerHTML' | 'className' > & { htmlString: string; extraEmojis?: CustomEmojiMapArg; as?: Element; shallow?: boolean; + className?: string; }; export const ModernEmojiHTML = ({ @@ -20,6 +23,7 @@ export const ModernEmojiHTML = ({ htmlString, as: Wrapper = 'div', // Rename for syntax highlighting shallow, + className = '', ...props }: EmojiHTMLProps) => { const emojifiedHtml = useEmojify({ @@ -33,7 +37,11 @@ export const ModernEmojiHTML = ({ } return ( - + ); }; @@ -43,7 +51,13 @@ export const EmojiHTML = ( if (isModernEmojiEnabled()) { return ; } - const { as: asElement, htmlString, extraEmojis, ...rest } = props; + const { as: asElement, htmlString, extraEmojis, className, ...rest } = props; const Wrapper = asElement ?? 'div'; - return ; + return ( + + ); }; diff --git a/app/javascript/flavours/glitch/features/emoji/handlers.ts b/app/javascript/flavours/glitch/features/emoji/handlers.ts new file mode 100644 index 0000000000..dbfe194fa4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/emoji/handlers.ts @@ -0,0 +1,61 @@ +import { autoPlayGif } from '@/flavours/glitch/initial_state'; + +const PARENT_MAX_DEPTH = 10; + +export function handleAnimateGif(event: MouseEvent) { + // We already check this in ui/index.jsx, but just to be sure. + if (autoPlayGif) { + return; + } + + const { target, type } = event; + const animate = type === 'mouseover'; // Mouse over = animate, mouse out = don't animate. + + if (target instanceof HTMLImageElement) { + setAnimateGif(target, animate); + } else if (!(target instanceof HTMLElement) || target === document.body) { + return; + } + + let parent: HTMLElement | null = null; + let iter = 0; + + if (target.classList.contains('animate-parent')) { + parent = target; + } else { + // Iterate up to PARENT_MAX_DEPTH levels up the DOM tree to find a parent with the class 'animate-parent'. + let current: HTMLElement | null = target; + while (current) { + if (iter >= PARENT_MAX_DEPTH) { + return; // We can just exit right now. + } + current = current.parentElement; + if (current?.classList.contains('animate-parent')) { + parent = current; + break; + } + iter++; + } + } + + // Affect all animated children within the parent. + if (parent) { + const animatedChildren = + parent.querySelectorAll('img.custom-emoji'); + for (const child of animatedChildren) { + setAnimateGif(child, animate); + } + } +} + +function setAnimateGif(image: HTMLImageElement, animate: boolean) { + const { classList, dataset } = image; + if ( + !classList.contains('custom-emoji') || + !dataset.static || + !dataset.original + ) { + return; + } + image.src = animate ? dataset.original : dataset.static; +} diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.jsx b/app/javascript/flavours/glitch/features/getting_started/components/announcements.jsx index 66567fb709..5bfba51957 100644 --- a/app/javascript/flavours/glitch/features/getting_started/components/announcements.jsx +++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.jsx @@ -111,42 +111,14 @@ class ContentWithRouter extends ImmutablePureComponent { } }; - handleMouseEnter = ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-original'); - } - }; - - handleMouseLeave = ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-static'); - } - }; - render () { const { announcement } = this.props; return (
); } @@ -238,9 +210,21 @@ class Reaction extends ImmutablePureComponent { } return ( - - - + + + + + + + ); } diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx index 1794aa9a24..e424568586 100644 --- a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx +++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx @@ -9,7 +9,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import InfoIcon from '@/material-icons/400-24px/info.svg?react'; import Column from 'flavours/glitch/components/column'; import ColumnHeader from 'flavours/glitch/components/column_header'; -import { isFeatureEnabled } from 'flavours/glitch/utils/environment'; const messages = defineMessages({ heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' }, @@ -63,12 +62,10 @@ class KeyboardShortcuts extends ImmutablePureComponent { b - {isFeatureEnabled('outgoing_quotes') && ( - - q - - - )} + + q + + d diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status.tsx index 9a4a791692..edfb82403c 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status.tsx @@ -76,32 +76,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ [clickCoordinatesRef, statusId, account, history], ); - const handleMouseEnter = useCallback>( - ({ currentTarget }) => { - const emojis = - currentTarget.querySelectorAll('.custom-emoji'); - - for (const emoji of emojis) { - const newSrc = emoji.getAttribute('data-original'); - if (newSrc) emoji.src = newSrc; - } - }, - [], - ); - - const handleMouseLeave = useCallback>( - ({ currentTarget }) => { - const emojis = - currentTarget.querySelectorAll('.custom-emoji'); - - for (const emoji of emojis) { - const newSrc = emoji.getAttribute('data-static'); - if (newSrc) emoji.src = newSrc; - } - }, - [], - ); - const handleContentWarningClick = useCallback(() => { dispatch(toggleStatusSpoilers(statusId)); }, [dispatch, statusId]); @@ -123,13 +97,11 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ return (
diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx index 28af52cd00..488916dc1e 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx @@ -20,7 +20,6 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend import { IconButton } from '../../../components/icon_button'; import { Dropdown } from 'flavours/glitch/components/dropdown_menu'; import { me } from '../../../initial_state'; -import { isFeatureEnabled } from '@/flavours/glitch/utils/environment'; import { BoostButton } from '@/flavours/glitch/components/status/boost_button'; const messages = defineMessages({ @@ -199,7 +198,7 @@ class ActionBar extends PureComponent { } menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); - if (isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) { + if (!['private', 'direct'].includes(status.get('visibility'))) { menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange }); } menu.push(null); diff --git a/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx b/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx index 6af5428af0..1bf5b5b3ef 100644 --- a/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx +++ b/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from 'react'; -import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; +import { useIntl, defineMessages } from 'react-intl'; import { fetchContext, @@ -8,31 +8,80 @@ import { } from 'flavours/glitch/actions/statuses'; import type { AsyncRefreshHeader } from 'flavours/glitch/api'; import { apiGetAsyncRefresh } from 'flavours/glitch/api/async_refreshes'; +import { Alert } from 'flavours/glitch/components/alert'; +import { ExitAnimationWrapper } from 'flavours/glitch/components/exit_animation_wrapper'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; +const AnimatedAlert: React.FC< + React.ComponentPropsWithoutRef & { withEntryDelay?: boolean } +> = ({ isActive = false, withEntryDelay, ...props }) => ( + + {(delayedIsActive) => } + +); + const messages = defineMessages({ - loading: { + moreFound: { + id: 'status.context.more_replies_found', + defaultMessage: 'More replies found', + }, + show: { + id: 'status.context.show', + defaultMessage: 'Show', + }, + loadingInitial: { id: 'status.context.loading', - defaultMessage: 'Checking for more replies', + defaultMessage: 'Loading', + }, + loadingMore: { + id: 'status.context.loading_more', + defaultMessage: 'Loading more replies', + }, + success: { + id: 'status.context.loading_success', + defaultMessage: 'All replies loaded', + }, + error: { + id: 'status.context.loading_error', + defaultMessage: "Couldn't load new replies", + }, + retry: { + id: 'status.context.retry', + defaultMessage: 'Retry', }, }); +type LoadingState = + | 'idle' + | 'more-available' + | 'loading-initial' + | 'loading-more' + | 'success' + | 'error'; + export const RefreshController: React.FC<{ statusId: string; }> = ({ statusId }) => { const refresh = useAppSelector( (state) => state.contexts.refreshing[statusId], ); - const autoRefresh = useAppSelector( - (state) => - !state.contexts.replies[statusId] || - state.contexts.replies[statusId].length === 0, + const currentReplyCount = useAppSelector( + (state) => state.contexts.replies[statusId]?.length ?? 0, ); + const autoRefresh = !currentReplyCount; const dispatch = useAppDispatch(); const intl = useIntl(); - const [ready, setReady] = useState(false); - const [loading, setLoading] = useState(false); + + const [loadingState, setLoadingState] = useState( + refresh && autoRefresh ? 'loading-initial' : 'idle', + ); + + const [wasDismissed, setWasDismissed] = useState(false); + const dismissPrompt = useCallback(() => { + setWasDismissed(true); + setLoadingState('idle'); + }, []); useEffect(() => { let timeoutId: ReturnType; @@ -45,67 +94,104 @@ export const RefreshController: React.FC<{ if (result.async_refresh.result_count > 0) { if (autoRefresh) { - void dispatch(fetchContext({ statusId })); - return ''; + void dispatch(fetchContext({ statusId })).then(() => { + setLoadingState('idle'); + }); + } else { + setLoadingState('more-available'); } - - setReady(true); + } else { + setLoadingState('idle'); } } else { scheduleRefresh(refresh); } - - return ''; }); }, refresh.retry * 1000); }; - if (refresh) { + if (refresh && !wasDismissed) { scheduleRefresh(refresh); + setLoadingState('loading-initial'); } return () => { clearTimeout(timeoutId); }; - }, [dispatch, setReady, statusId, refresh, autoRefresh]); + }, [dispatch, statusId, refresh, autoRefresh, wasDismissed]); + + useEffect(() => { + // Hide success message after a short delay + if (loadingState === 'success') { + const timeoutId = setTimeout(() => { + setLoadingState('idle'); + }, 3000); + + return () => { + clearTimeout(timeoutId); + }; + } + return () => ''; + }, [loadingState]); const handleClick = useCallback(() => { - setLoading(true); - setReady(false); + setLoadingState('loading-more'); dispatch(fetchContext({ statusId })) .then(() => { - setLoading(false); + setLoadingState('success'); return ''; }) .catch(() => { - setLoading(false); + setLoadingState('error'); }); - }, [dispatch, setReady, statusId]); + }, [dispatch, statusId]); - if (ready && !loading) { + if (loadingState === 'loading-initial') { return ( - +
+ +
); } - if (!refresh && !loading) { - return null; - } - return ( -
- +
+ + + +
); }; diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index a5ef88057f..d196dff11a 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -5,6 +5,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import classNames from 'classnames'; import { Helmet } from 'react-helmet'; import { withRouter } from 'react-router-dom'; +import { difference } from 'lodash'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -148,9 +149,14 @@ class Status extends ImmutablePureComponent { isExpanded: undefined, threadExpanded: undefined, statusId: undefined, - loadedStatusId: undefined, showMedia: undefined, + loadedStatusId: undefined, revealBehindCW: undefined, + /** + * Holds the ids of newly added replies, excluding the initial load. + * Used to highlight newly added replies in the UI + */ + newRepliesIds: [], }; componentDidMount () { @@ -479,6 +485,7 @@ class Status extends ImmutablePureComponent { previousId={i > 0 ? list[i - 1] : undefined} nextId={list[i + 1] || (ancestors && statusId)} rootId={statusId} + shouldHighlightOnMount={this.state.newRepliesIds.includes(id)} /> )); } @@ -520,11 +527,20 @@ class Status extends ImmutablePureComponent { } componentDidUpdate (prevProps) { - const { status, ancestorsIds } = this.props; + const { status, ancestorsIds, descendantsIds } = this.props; if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) { this._scrollStatusIntoView(); } + + // Only highlight replies after the initial load + if (prevProps.descendantsIds.length) { + const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds); + + if (newRepliesIds.length) { + this.setState({newRepliesIds}); + } + } } componentWillUnmount () { @@ -662,8 +678,8 @@ class Status extends ImmutablePureComponent {
- {remoteHint} {descendants} + {remoteHint}
diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index ce48b2fd1b..d38e570d3c 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -25,11 +25,12 @@ import { layoutFromWindow } from 'flavours/glitch/is_mobile'; import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; +import { handleAnimateGif } from '../emoji/handlers'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { clearHeight } from '../../actions/height_cache'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { expandHomeTimeline } from '../../actions/timelines'; -import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state'; +import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state'; import BundleColumnError from './components/bundle_column_error'; import { NavigationBar } from './components/navigation_bar'; @@ -392,6 +393,11 @@ class UI extends PureComponent { window.addEventListener('beforeunload', this.handleBeforeUnload, false); window.addEventListener('resize', this.handleResize, { passive: true }); + if (!autoPlayGif) { + window.addEventListener('mouseover', handleAnimateGif, { passive: true }); + window.addEventListener('mouseout', handleAnimateGif, { passive: true }); + } + document.addEventListener('dragenter', this.handleDragEnter, false); document.addEventListener('dragover', this.handleDragOver, false); document.addEventListener('drop', this.handleDrop, false); @@ -450,6 +456,8 @@ class UI extends PureComponent { window.removeEventListener('beforeunload', this.handleBeforeUnload); window.removeEventListener('resize', this.handleResize); + window.removeEventListener('mouseover', handleAnimateGif); + window.removeEventListener('mouseout', handleAnimateGif); document.removeEventListener('dragenter', this.handleDragEnter); document.removeEventListener('dragover', this.handleDragOver); diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 12c7042a17..81e441a187 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -163,7 +163,7 @@ $content-width: 840px; flex: 1 1 auto; } - @media screen and (max-width: $content-width + $sidebar-width) { + @media screen and (max-width: ($content-width + $sidebar-width)) { .sidebar-wrapper--empty { display: none; } @@ -1086,6 +1086,17 @@ a.name-tag, } } + &__action-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + + &:not(.no-wrap) { + flex-wrap: wrap; + } + } + &__meta { padding: 0 15px; color: $dark-text-color; @@ -1102,10 +1113,8 @@ a.name-tag, } } - &__action-bar { - display: flex; - justify-content: space-between; - align-items: center; + &__actions { + margin-inline-start: auto; } &__permissions { diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 17c178850b..72c11300e6 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -1657,6 +1657,16 @@ } } } + + .no-reduce-motion &--highlighted-entry::before { + content: ''; + position: absolute; + inset: 0; + background: rgb(from $ui-highlight-color r g b / 20%); + opacity: 0; + animation: fade 0.7s reverse both 0.3s; + pointer-events: none; + } } .status__relative-time { @@ -3025,7 +3035,6 @@ a.account__display-name { flex: 1 1 auto; flex-direction: row; justify-content: flex-start; - overflow-x: auto; position: relative; &.unscrollable { @@ -3201,6 +3210,29 @@ a.account__display-name { } } +.column__alert { + position: sticky; + bottom: 1rem; + z-index: 10; + box-sizing: border-box; + display: grid; + width: 100%; + max-width: 360px; + padding-inline: 10px; + margin-top: 1rem; + margin-inline: auto; + + @media (max-width: #{$mobile-menu-breakpoint - 1}) { + bottom: 4rem; + } + + & > * { + // Make all nested alerts occupy the same space + // rather than stack + grid-area: 1 / 1; + } +} + .ui { --mobile-bottom-nav-height: 55px; --last-content-item-border-width: 2px; @@ -3241,7 +3273,6 @@ a.account__display-name { .column, .drawer { flex: 1 1 100%; - overflow: hidden; } @media screen and (width > $mobile-breakpoint) { @@ -10697,6 +10728,21 @@ noscript { } } +.notification-bar__loading-indicator { + --spinner-size: 22px; + + position: relative; + height: var(--spinner-size); + width: var(--spinner-size); + margin-inline-start: 2px; + + svg { + color: $white; + height: var(--spinner-size); + width: var(--spinner-size); + } +} + .hashtag-header { border-bottom: 1px solid var(--background-border-color); padding: 15px; diff --git a/app/javascript/flavours/glitch/utils/environment.ts b/app/javascript/flavours/glitch/utils/environment.ts index fc4448740f..2d544417e3 100644 --- a/app/javascript/flavours/glitch/utils/environment.ts +++ b/app/javascript/flavours/glitch/utils/environment.ts @@ -12,11 +12,7 @@ export function isProduction() { else return import.meta.env.PROD; } -export type Features = - | 'modern_emojis' - | 'outgoing_quotes' - | 'fasp' - | 'http_message_signatures'; +export type Features = 'modern_emojis' | 'fasp' | 'http_message_signatures'; export function isFeatureEnabled(feature: Features) { return initialState?.features.includes(feature) ?? false; diff --git a/app/javascript/mastodon/components/alert/alert.stories.tsx b/app/javascript/mastodon/components/alert/alert.stories.tsx index 4d5f8acb65..f12f06751d 100644 --- a/app/javascript/mastodon/components/alert/alert.stories.tsx +++ b/app/javascript/mastodon/components/alert/alert.stories.tsx @@ -8,6 +8,7 @@ const meta = { component: Alert, args: { isActive: true, + isLoading: false, animateFrom: 'side', title: '', message: '', @@ -20,6 +21,12 @@ const meta = { type: 'boolean', description: 'Animate to the active (displayed) state of the alert', }, + isLoading: { + control: 'boolean', + type: 'boolean', + description: + 'Display a loading indicator in the alert, replacing the dismiss button if present', + }, animateFrom: { control: 'radio', type: 'string', @@ -108,3 +115,11 @@ export const InSizedContainer: Story = {
), }; + +export const WithLoadingIndicator: Story = { + args: { + ...WithDismissButton.args, + isLoading: true, + }, + render: InSizedContainer.render, +}; diff --git a/app/javascript/mastodon/components/alert/index.tsx b/app/javascript/mastodon/components/alert/index.tsx index 1009e77524..72fee0a4a3 100644 --- a/app/javascript/mastodon/components/alert/index.tsx +++ b/app/javascript/mastodon/components/alert/index.tsx @@ -3,6 +3,7 @@ import { useIntl } from 'react-intl'; import classNames from 'classnames'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { IconButton } from '../icon_button'; @@ -10,21 +11,23 @@ import { IconButton } from '../icon_button'; * Snackbar/Toast-style notification component. */ export const Alert: React.FC<{ - isActive?: boolean; - animateFrom?: 'side' | 'below'; title?: string; message: string; action?: string; onActionClick?: () => void; onDismiss?: () => void; + isActive?: boolean; + isLoading?: boolean; + animateFrom?: 'side' | 'below'; }> = ({ - isActive, - animateFrom = 'side', title, message, action, onActionClick, onDismiss, + isActive, + isLoading, + animateFrom = 'side', }) => { const intl = useIntl(); @@ -51,7 +54,13 @@ export const Alert: React.FC<{ )} - {onDismiss && ( + {isLoading && ( + + + + )} + + {onDismiss && !isLoading && ( > = ({ account, className, children, ...props }) => { return ( - + {account ? ( React.ReactNode; +}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { + const [delayedIsActive, setDelayedIsActive] = useState(false); + + useEffect(() => { + if (isActive && !withEntryDelay) { + setDelayedIsActive(true); + + return () => ''; + } else { + const timeout = setTimeout(() => { + setDelayedIsActive(isActive); + }, delayMs); + + return () => { + clearTimeout(timeout); + }; + } + }, [isActive, delayMs, withEntryDelay]); + + if (!isActive && !delayedIsActive) { + return null; + } + + return children(isActive && delayedIsActive); +}; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 8664320abe..196da7c99a 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -118,6 +118,7 @@ class Status extends ImmutablePureComponent { unread: PropTypes.bool, showThread: PropTypes.bool, isQuotedPost: PropTypes.bool, + shouldHighlightOnMount: PropTypes.bool, getScrollPosition: PropTypes.func, updateScrollBottom: PropTypes.func, cacheMediaWidth: PropTypes.func, @@ -567,6 +568,7 @@ class Status extends ImmutablePureComponent { 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted, 'status--is-quote': isQuotedPost, 'status--has-quote': !!status.get('quote'), + 'status--highlighted-entry': this.props.shouldHighlightOnMount, }) } data-id={status.get('id')} diff --git a/app/javascript/mastodon/components/status/boost_button.stories.tsx b/app/javascript/mastodon/components/status/boost_button.stories.tsx index e81d334a93..402695a829 100644 --- a/app/javascript/mastodon/components/status/boost_button.stories.tsx +++ b/app/javascript/mastodon/components/status/boost_button.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import type { StatusVisibility } from '@/mastodon/api_types/statuses'; import { statusFactoryState } from '@/testing/factories'; -import { LegacyReblogButton, StatusBoostButton } from './boost_button'; +import { BoostButton } from './boost_button'; interface StoryProps { visibility: StatusVisibility; @@ -38,10 +38,7 @@ const meta = { }, }, render: (args) => ( - 0} - /> + 0} /> ), } satisfies Meta; @@ -78,12 +75,3 @@ export const Mine: Story = { }, }, }; - -export const Legacy: Story = { - render: (args) => ( - 0} - /> - ), -}; diff --git a/app/javascript/mastodon/components/status/boost_button.tsx b/app/javascript/mastodon/components/status/boost_button.tsx index 49bdc953e1..337eca5071 100644 --- a/app/javascript/mastodon/components/status/boost_button.tsx +++ b/app/javascript/mastodon/components/status/boost_button.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react'; +import type { FC, KeyboardEvent, MouseEvent } from 'react'; import { useIntl } from 'react-intl'; @@ -11,7 +11,6 @@ import { openModal } from '@/mastodon/actions/modal'; import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu'; import type { Status } from '@/mastodon/models/status'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; -import { isFeatureEnabled } from '@/mastodon/utils/environment'; import type { SomeRequired } from '@/mastodon/utils/types'; import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu'; @@ -47,10 +46,7 @@ interface ReblogButtonProps { type ActionMenuItemWithIcon = SomeRequired; -export const StatusBoostButton: FC = ({ - status, - counters, -}) => { +export const BoostButton: FC = ({ status, counters }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const statusState = useAppSelector((state) => @@ -192,65 +188,3 @@ const ReblogMenuItem: FC = ({ ); }; - -// Legacy helpers - -// Switch between the legacy and new reblog button based on feature flag. -export const BoostButton: FC = (props) => { - if (isFeatureEnabled('outgoing_quotes')) { - return ; - } - return ; -}; - -export const LegacyReblogButton: FC = ({ - status, - counters, -}) => { - const intl = useIntl(); - const statusState = useAppSelector((state) => - selectStatusState(state, status), - ); - - const { title, meta, iconComponent, disabled } = useMemo( - () => boostItemState(statusState), - [statusState], - ); - - const dispatch = useAppDispatch(); - const handleClick: MouseEventHandler = useCallback( - (event) => { - if (statusState.isLoggedIn) { - dispatch(toggleReblog(status.get('id') as string, event.shiftKey)); - } else { - dispatch( - openModal({ - modalType: 'INTERACTION', - modalProps: { - accountId: status.getIn(['account', 'id']), - url: status.get('uri'), - }, - }), - ); - } - }, - [dispatch, status, statusState.isLoggedIn], - ); - - return ( - - ); -}; diff --git a/app/javascript/mastodon/components/status_action_bar/index.jsx b/app/javascript/mastodon/components/status_action_bar/index.jsx index 0e72a8cefe..3e82912ab1 100644 --- a/app/javascript/mastodon/components/status_action_bar/index.jsx +++ b/app/javascript/mastodon/components/status_action_bar/index.jsx @@ -23,7 +23,6 @@ import { Dropdown } from 'mastodon/components/dropdown_menu'; import { me } from '../../initial_state'; import { IconButton } from '../icon_button'; -import { isFeatureEnabled } from '../../utils/environment'; import { BoostButton } from '../status/boost_button'; import { RemoveQuoteHint } from './remove_quote_hint'; @@ -281,7 +280,7 @@ class StatusActionBar extends ImmutablePureComponent { if (writtenByMe || withDismiss) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); - if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) { + if (writtenByMe && !['private', 'direct'].includes(status.get('visibility'))) { menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange }); } menu.push(null); diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 5f0f7079ae..af0059c7d6 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -140,32 +140,6 @@ class StatusContent extends PureComponent { } } - handleMouseEnter = ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-original'); - } - }; - - handleMouseLeave = ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-static'); - } - }; - componentDidMount () { this._updateStatusLinks(); } @@ -257,7 +231,13 @@ class StatusContent extends PureComponent { if (this.props.onClick) { return ( <> -
+
+
{ const getStatus = makeGetStatus(); const getPictureInPicture = makeGetPictureInPicture(); @@ -81,9 +79,7 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({ }, onQuote (status) { - if (isFeatureEnabled('outgoing_quotes')) { - dispatch(quoteComposeById(status.get('id'))); - } + dispatch(quoteComposeById(status.get('id'))); }, onFavourite (status) { diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index ed6d9cb83e..f58f1f4a8c 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -379,36 +379,6 @@ export const AccountHeader: React.FC<{ }); }, [account]); - const handleMouseEnter = useCallback( - ({ currentTarget }: React.MouseEvent) => { - if (autoPlayGif) { - return; - } - - currentTarget - .querySelectorAll('.custom-emoji') - .forEach((emoji) => { - emoji.src = emoji.getAttribute('data-original') ?? ''; - }); - }, - [], - ); - - const handleMouseLeave = useCallback( - ({ currentTarget }: React.MouseEvent) => { - if (autoPlayGif) { - return; - } - - currentTarget - .querySelectorAll('.custom-emoji') - .forEach((emoji) => { - emoji.src = emoji.getAttribute('data-static') ?? ''; - }); - }, - [], - ); - const suspended = account?.suspended; const isRemote = account?.acct !== account?.username; const remoteDomain = isRemote ? account?.acct.split('@')[1] : null; @@ -808,11 +778,9 @@ export const AccountHeader: React.FC<{ )}
{!(suspended || hidden || account.moved) && relationship?.requested_by && ( diff --git a/app/javascript/mastodon/features/compose/components/visibility_button.tsx b/app/javascript/mastodon/features/compose/components/visibility_button.tsx index fadb896b5e..1ea504ab1a 100644 --- a/app/javascript/mastodon/features/compose/components/visibility_button.tsx +++ b/app/javascript/mastodon/features/compose/components/visibility_button.tsx @@ -12,14 +12,12 @@ import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes'; import type { StatusVisibility } from '@/mastodon/api_types/statuses'; import { Icon } from '@/mastodon/components/icon'; import { useAppSelector, useAppDispatch } from '@/mastodon/store'; -import { isFeatureEnabled } from '@/mastodon/utils/environment'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react'; import type { VisibilityModalCallback } from '../../ui/components/visibility_modal'; -import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import { messages as privacyMessages } from './privacy_dropdown'; @@ -43,9 +41,6 @@ interface PrivacyDropdownProps { } export const VisibilityButton: FC = (props) => { - if (!isFeatureEnabled('outgoing_quotes')) { - return ; - } return ; }; diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx index 9aae588bcc..bb0815087b 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx @@ -23,7 +23,6 @@ import { IconButton } from 'mastodon/components/icon_button'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import StatusContent from 'mastodon/components/status_content'; import { Dropdown } from 'mastodon/components/dropdown_menu'; -import { autoPlayGif } from 'mastodon/initial_state'; import { makeGetStatus } from 'mastodon/selectors'; import { LinkedDisplayName } from '@/mastodon/components/display_name'; @@ -57,32 +56,6 @@ export const Conversation = ({ conversation, scrollKey }) => { const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId })); const accounts = useSelector(state => getAccounts(state, accountIds)); - const handleMouseEnter = useCallback(({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-original'); - } - }, []); - - const handleMouseLeave = useCallback(({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-static'); - } - }, []); - const handleClick = useCallback(() => { if (unread) { dispatch(markConversationRead(id)); @@ -163,7 +136,7 @@ export const Conversation = ({ conversation, scrollKey }) => { {unread && }
-
+
{names} }} />
diff --git a/app/javascript/mastodon/features/directory/components/account_card.tsx b/app/javascript/mastodon/features/directory/components/account_card.tsx index 2a0470bb72..9d317efd43 100644 --- a/app/javascript/mastodon/features/directory/components/account_card.tsx +++ b/app/javascript/mastodon/features/directory/components/account_card.tsx @@ -1,4 +1,3 @@ -import type { MouseEventHandler } from 'react'; import { useCallback } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; @@ -44,39 +43,6 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => { const account = useAppSelector((s) => getAccount(s, accountId)); const dispatch = useAppDispatch(); - const handleMouseEnter = useCallback( - ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - const emojis = - currentTarget.querySelectorAll('.custom-emoji'); - - emojis.forEach((emoji) => { - const original = emoji.getAttribute('data-original'); - if (original) emoji.src = original; - }); - }, - [], - ); - - const handleMouseLeave = useCallback( - ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = - currentTarget.querySelectorAll('.custom-emoji'); - - emojis.forEach((emoji) => { - const staticUrl = emoji.getAttribute('data-static'); - if (staticUrl) emoji.src = staticUrl; - }); - }, - [], - ); - const handleFollow = useCallback(() => { if (!account) return; @@ -185,9 +151,7 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => { {account.get('note').length > 0 && (
)} diff --git a/app/javascript/mastodon/features/emoji/emoji_html.tsx b/app/javascript/mastodon/features/emoji/emoji_html.tsx index e143c9fc16..08d62b2c37 100644 --- a/app/javascript/mastodon/features/emoji/emoji_html.tsx +++ b/app/javascript/mastodon/features/emoji/emoji_html.tsx @@ -1,5 +1,7 @@ import type { ComponentPropsWithoutRef, ElementType } from 'react'; +import classNames from 'classnames'; + import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { useEmojify } from './hooks'; @@ -7,12 +9,13 @@ import type { CustomEmojiMapArg } from './types'; type EmojiHTMLProps = Omit< ComponentPropsWithoutRef, - 'dangerouslySetInnerHTML' + 'dangerouslySetInnerHTML' | 'className' > & { htmlString: string; extraEmojis?: CustomEmojiMapArg; as?: Element; shallow?: boolean; + className?: string; }; export const ModernEmojiHTML = ({ @@ -20,6 +23,7 @@ export const ModernEmojiHTML = ({ htmlString, as: Wrapper = 'div', // Rename for syntax highlighting shallow, + className = '', ...props }: EmojiHTMLProps) => { const emojifiedHtml = useEmojify({ @@ -33,7 +37,11 @@ export const ModernEmojiHTML = ({ } return ( - + ); }; @@ -43,7 +51,13 @@ export const EmojiHTML = ( if (isModernEmojiEnabled()) { return ; } - const { as: asElement, htmlString, extraEmojis, ...rest } = props; + const { as: asElement, htmlString, extraEmojis, className, ...rest } = props; const Wrapper = asElement ?? 'div'; - return ; + return ( + + ); }; diff --git a/app/javascript/mastodon/features/emoji/handlers.ts b/app/javascript/mastodon/features/emoji/handlers.ts new file mode 100644 index 0000000000..3b02028f3c --- /dev/null +++ b/app/javascript/mastodon/features/emoji/handlers.ts @@ -0,0 +1,61 @@ +import { autoPlayGif } from '@/mastodon/initial_state'; + +const PARENT_MAX_DEPTH = 10; + +export function handleAnimateGif(event: MouseEvent) { + // We already check this in ui/index.jsx, but just to be sure. + if (autoPlayGif) { + return; + } + + const { target, type } = event; + const animate = type === 'mouseover'; // Mouse over = animate, mouse out = don't animate. + + if (target instanceof HTMLImageElement) { + setAnimateGif(target, animate); + } else if (!(target instanceof HTMLElement) || target === document.body) { + return; + } + + let parent: HTMLElement | null = null; + let iter = 0; + + if (target.classList.contains('animate-parent')) { + parent = target; + } else { + // Iterate up to PARENT_MAX_DEPTH levels up the DOM tree to find a parent with the class 'animate-parent'. + let current: HTMLElement | null = target; + while (current) { + if (iter >= PARENT_MAX_DEPTH) { + return; // We can just exit right now. + } + current = current.parentElement; + if (current?.classList.contains('animate-parent')) { + parent = current; + break; + } + iter++; + } + } + + // Affect all animated children within the parent. + if (parent) { + const animatedChildren = + parent.querySelectorAll('img.custom-emoji'); + for (const child of animatedChildren) { + setAnimateGif(child, animate); + } + } +} + +function setAnimateGif(image: HTMLImageElement, animate: boolean) { + const { classList, dataset } = image; + if ( + !classList.contains('custom-emoji') || + !dataset.static || + !dataset.original + ) { + return; + } + image.src = animate ? dataset.original : dataset.static; +} diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.jsx b/app/javascript/mastodon/features/getting_started/components/announcements.jsx index 87d7e2a3be..96bd995d2b 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.jsx +++ b/app/javascript/mastodon/features/getting_started/components/announcements.jsx @@ -111,42 +111,14 @@ class ContentWithRouter extends ImmutablePureComponent { } }; - handleMouseEnter = ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-original'); - } - }; - - handleMouseLeave = ({ currentTarget }) => { - if (autoPlayGif) { - return; - } - - const emojis = currentTarget.querySelectorAll('.custom-emoji'); - - for (var i = 0; i < emojis.length; i++) { - let emoji = emojis[i]; - emoji.src = emoji.getAttribute('data-static'); - } - }; - render () { const { announcement } = this.props; return (
); } @@ -238,9 +210,21 @@ class Reaction extends ImmutablePureComponent { } return ( - - - + + + + + + + ); } diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx index a1f14f14f6..01a4f0e1fd 100644 --- a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx +++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx @@ -9,7 +9,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import InfoIcon from '@/material-icons/400-24px/info.svg?react'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; -import { isFeatureEnabled } from 'mastodon/utils/environment'; const messages = defineMessages({ heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' }, @@ -63,12 +62,10 @@ class KeyboardShortcuts extends ImmutablePureComponent { b - {isFeatureEnabled('outgoing_quotes') && ( - - q - - - )} + + q + + enter, o diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx index f63d42f826..a17425169b 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx @@ -76,32 +76,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ [clickCoordinatesRef, statusId, account, history], ); - const handleMouseEnter = useCallback>( - ({ currentTarget }) => { - const emojis = - currentTarget.querySelectorAll('.custom-emoji'); - - for (const emoji of emojis) { - const newSrc = emoji.getAttribute('data-original'); - if (newSrc) emoji.src = newSrc; - } - }, - [], - ); - - const handleMouseLeave = useCallback>( - ({ currentTarget }) => { - const emojis = - currentTarget.querySelectorAll('.custom-emoji'); - - for (const emoji of emojis) { - const newSrc = emoji.getAttribute('data-static'); - if (newSrc) emoji.src = newSrc; - } - }, - [], - ); - const handleContentWarningClick = useCallback(() => { dispatch(toggleStatusSpoilers(statusId)); }, [dispatch, statusId]); @@ -123,13 +97,11 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ return (
diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index fa9d6497ae..6156cf1916 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -19,7 +19,6 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/ import { IconButton } from '../../../components/icon_button'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { me } from '../../../initial_state'; -import { isFeatureEnabled } from '@/mastodon/utils/environment'; import { BoostButton } from '@/mastodon/components/status/boost_button'; const messages = defineMessages({ @@ -237,7 +236,7 @@ class ActionBar extends PureComponent { } menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); - if (isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) { + if (!['private', 'direct'].includes(status.get('visibility'))) { menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange }); } menu.push(null); diff --git a/app/javascript/mastodon/features/status/components/refresh_controller.tsx b/app/javascript/mastodon/features/status/components/refresh_controller.tsx index 9788b2849f..34faaf1d5d 100644 --- a/app/javascript/mastodon/features/status/components/refresh_controller.tsx +++ b/app/javascript/mastodon/features/status/components/refresh_controller.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from 'react'; -import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; +import { useIntl, defineMessages } from 'react-intl'; import { fetchContext, @@ -8,31 +8,80 @@ import { } from 'mastodon/actions/statuses'; import type { AsyncRefreshHeader } from 'mastodon/api'; import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes'; +import { Alert } from 'mastodon/components/alert'; +import { ExitAnimationWrapper } from 'mastodon/components/exit_animation_wrapper'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; +const AnimatedAlert: React.FC< + React.ComponentPropsWithoutRef & { withEntryDelay?: boolean } +> = ({ isActive = false, withEntryDelay, ...props }) => ( + + {(delayedIsActive) => } + +); + const messages = defineMessages({ - loading: { + moreFound: { + id: 'status.context.more_replies_found', + defaultMessage: 'More replies found', + }, + show: { + id: 'status.context.show', + defaultMessage: 'Show', + }, + loadingInitial: { id: 'status.context.loading', - defaultMessage: 'Checking for more replies', + defaultMessage: 'Loading', + }, + loadingMore: { + id: 'status.context.loading_more', + defaultMessage: 'Loading more replies', + }, + success: { + id: 'status.context.loading_success', + defaultMessage: 'All replies loaded', + }, + error: { + id: 'status.context.loading_error', + defaultMessage: "Couldn't load new replies", + }, + retry: { + id: 'status.context.retry', + defaultMessage: 'Retry', }, }); +type LoadingState = + | 'idle' + | 'more-available' + | 'loading-initial' + | 'loading-more' + | 'success' + | 'error'; + export const RefreshController: React.FC<{ statusId: string; }> = ({ statusId }) => { const refresh = useAppSelector( (state) => state.contexts.refreshing[statusId], ); - const autoRefresh = useAppSelector( - (state) => - !state.contexts.replies[statusId] || - state.contexts.replies[statusId].length === 0, + const currentReplyCount = useAppSelector( + (state) => state.contexts.replies[statusId]?.length ?? 0, ); + const autoRefresh = !currentReplyCount; const dispatch = useAppDispatch(); const intl = useIntl(); - const [ready, setReady] = useState(false); - const [loading, setLoading] = useState(false); + + const [loadingState, setLoadingState] = useState( + refresh && autoRefresh ? 'loading-initial' : 'idle', + ); + + const [wasDismissed, setWasDismissed] = useState(false); + const dismissPrompt = useCallback(() => { + setWasDismissed(true); + setLoadingState('idle'); + }, []); useEffect(() => { let timeoutId: ReturnType; @@ -45,67 +94,104 @@ export const RefreshController: React.FC<{ if (result.async_refresh.result_count > 0) { if (autoRefresh) { - void dispatch(fetchContext({ statusId })); - return ''; + void dispatch(fetchContext({ statusId })).then(() => { + setLoadingState('idle'); + }); + } else { + setLoadingState('more-available'); } - - setReady(true); + } else { + setLoadingState('idle'); } } else { scheduleRefresh(refresh); } - - return ''; }); }, refresh.retry * 1000); }; - if (refresh) { + if (refresh && !wasDismissed) { scheduleRefresh(refresh); + setLoadingState('loading-initial'); } return () => { clearTimeout(timeoutId); }; - }, [dispatch, setReady, statusId, refresh, autoRefresh]); + }, [dispatch, statusId, refresh, autoRefresh, wasDismissed]); + + useEffect(() => { + // Hide success message after a short delay + if (loadingState === 'success') { + const timeoutId = setTimeout(() => { + setLoadingState('idle'); + }, 3000); + + return () => { + clearTimeout(timeoutId); + }; + } + return () => ''; + }, [loadingState]); const handleClick = useCallback(() => { - setLoading(true); - setReady(false); + setLoadingState('loading-more'); dispatch(fetchContext({ statusId })) .then(() => { - setLoading(false); + setLoadingState('success'); return ''; }) .catch(() => { - setLoading(false); + setLoadingState('error'); }); - }, [dispatch, setReady, statusId]); + }, [dispatch, statusId]); - if (ready && !loading) { + if (loadingState === 'loading-initial') { return ( - +
+ +
); } - if (!refresh && !loading) { - return null; - } - return ( -
- +
+ + + +
); }; diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 8bab174f67..404faf609e 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -5,6 +5,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import classNames from 'classnames'; import { Helmet } from 'react-helmet'; import { withRouter } from 'react-router-dom'; +import { difference } from 'lodash'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -150,6 +151,11 @@ class Status extends ImmutablePureComponent { fullscreen: false, showMedia: defaultMediaVisibility(this.props.status), loadedStatusId: undefined, + /** + * Holds the ids of newly added replies, excluding the initial load. + * Used to highlight newly added replies in the UI + */ + newRepliesIds: [], }; UNSAFE_componentWillMount () { @@ -462,6 +468,7 @@ class Status extends ImmutablePureComponent { previousId={i > 0 ? list[i - 1] : undefined} nextId={list[i + 1] || (ancestors && statusId)} rootId={statusId} + shouldHighlightOnMount={this.state.newRepliesIds.includes(id)} /> )); } @@ -495,11 +502,20 @@ class Status extends ImmutablePureComponent { } componentDidUpdate (prevProps) { - const { status, ancestorsIds } = this.props; + const { status, ancestorsIds, descendantsIds } = this.props; if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) { this._scrollStatusIntoView(); } + + // Only highlight replies after the initial load + if (prevProps.descendantsIds.length) { + const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds); + + if (newRepliesIds.length) { + this.setState({newRepliesIds}); + } + } } componentWillUnmount () { @@ -632,8 +648,8 @@ class Status extends ImmutablePureComponent {
- {remoteHint} {descendants} + {remoteHint}
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 0583bf99c5..efec38caf4 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -22,11 +22,12 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex import { layoutFromWindow } from 'mastodon/is_mobile'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; +import { handleAnimateGif } from '../emoji/handlers'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { clearHeight } from '../../actions/height_cache'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { expandHomeTimeline } from '../../actions/timelines'; -import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state'; +import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state'; import BundleColumnError from './components/bundle_column_error'; import { NavigationBar } from './components/navigation_bar'; @@ -379,6 +380,11 @@ class UI extends PureComponent { window.addEventListener('beforeunload', this.handleBeforeUnload, false); window.addEventListener('resize', this.handleResize, { passive: true }); + if (!autoPlayGif) { + window.addEventListener('mouseover', handleAnimateGif, { passive: true }); + window.addEventListener('mouseout', handleAnimateGif, { passive: true }); + } + document.addEventListener('dragenter', this.handleDragEnter, false); document.addEventListener('dragover', this.handleDragOver, false); document.addEventListener('drop', this.handleDrop, false); @@ -404,6 +410,8 @@ class UI extends PureComponent { window.removeEventListener('blur', this.handleWindowBlur); window.removeEventListener('beforeunload', this.handleBeforeUnload); window.removeEventListener('resize', this.handleResize); + window.removeEventListener('mouseover', handleAnimateGif); + window.removeEventListener('mouseout', handleAnimateGif); document.removeEventListener('dragenter', this.handleDragEnter); document.removeEventListener('dragover', this.handleDragOver); diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json index f08c0babbd..d1aaffe2f2 100644 --- a/app/javascript/mastodon/locales/cs.json +++ b/app/javascript/mastodon/locales/cs.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Zrušit boostnutí", "status.cannot_quote": "Nemáte oprávnění citovat tento příspěvek", "status.cannot_reblog": "Tento příspěvek nemůže být boostnutý", + "status.contains_quote": "Obsahuje citaci", "status.context.load_new_replies": "K dispozici jsou nové odpovědi", "status.context.loading": "Hledání dalších odpovědí", "status.continued_thread": "Pokračuje ve vlákně", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Příspěvek odstraněn autorem", "status.quote_followers_only": "Pouze moji sledující mohou citovat tento příspěvek", "status.quote_manual_review": "Autor provede manuální kontrolu", + "status.quote_noun": "Citace", "status.quote_policy_change": "Změňte, kdo může citovat", "status.quote_post_author": "Citovali příspěvek od @{name}", "status.quote_private": "Soukromé příspěvky nelze citovat", diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json index 4c24eaa387..6d8129e703 100644 --- a/app/javascript/mastodon/locales/cy.json +++ b/app/javascript/mastodon/locales/cy.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Dadhybu", "status.cannot_quote": "Does dim caniatâd i chi ddyfynnu'r postiad hwn", "status.cannot_reblog": "Does dim modd hybu'r postiad hwn", + "status.contains_quote": "Yn cynnwys dyfyniad", "status.context.load_new_replies": "Mae atebion newydd ar gael", "status.context.loading": "Yn chwilio am fwy o atebion", "status.continued_thread": "Edefyn parhaus", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Postiad wedi'i ddileu gan yr awdur", "status.quote_followers_only": "Dim ond dilynwyr all ddyfynnu'r postiad hwn", "status.quote_manual_review": "Bydd yr awdur yn ei adolygu ei hyn", + "status.quote_noun": "Dyfynnu", "status.quote_policy_change": "Newid pwy all ddyfynnu", "status.quote_post_author": "Wedi dyfynnu postiad gan @{name}", "status.quote_private": "Does dim modd dyfynnu postiadau preifat", diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json index 8fd1711358..9521401bca 100644 --- a/app/javascript/mastodon/locales/da.json +++ b/app/javascript/mastodon/locales/da.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Fjern fremhævelse", "status.cannot_quote": "Du har ikke tilladelse til at citere dette indlæg", "status.cannot_reblog": "Dette indlæg kan ikke fremhæves", + "status.contains_quote": "Indeholder citat", "status.context.load_new_replies": "Nye svar tilgængelige", "status.context.loading": "Tjekker for flere svar", "status.continued_thread": "Fortsat tråd", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Indlæg fjernet af forfatter", "status.quote_followers_only": "Kun følgere kan citere dette indlæg", "status.quote_manual_review": "Forfatter vil manuelt gennemgå", + "status.quote_noun": "Citat", "status.quote_policy_change": "Ændr hvem der kan citere", "status.quote_post_author": "Citerede et indlæg fra @{name}", "status.quote_private": "Private indlæg kan ikke citeres", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 0e5fcbd9ff..7ceb4509dc 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Beitrag nicht mehr teilen", "status.cannot_quote": "Dir ist es nicht gestattet, diesen Beitrag zu zitieren", "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden", + "status.contains_quote": "Enthält Zitat", "status.context.load_new_replies": "Neue Antworten verfügbar", "status.context.loading": "Weitere Antworten werden abgerufen", "status.continued_thread": "Fortgeführter Thread", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Beitrag durch Autor*in entfernt", "status.quote_followers_only": "Nur Follower können diesen Beitrag zitieren", "status.quote_manual_review": "Zitierte*r überprüft manuell", + "status.quote_noun": "Zitat", "status.quote_policy_change": "Ändern, wer zitieren darf", "status.quote_post_author": "Zitierte einen Beitrag von @{name}", "status.quote_private": "Private Beiträge können nicht zitiert werden", diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index e2bec9981f..feefaabeea 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -736,7 +736,7 @@ "privacy.private.long": "Μόνο οι ακόλουθοί σας", "privacy.private.short": "Ακόλουθοι", "privacy.public.long": "Όλοι εντός και εκτός του Mastodon", - "privacy.public.short": "Δημόσιο", + "privacy.public.short": "Δημόσια", "privacy.quote.anyone": "{visibility}, ο καθένας μπορεί να παραθέσει", "privacy.quote.disabled": "{visibility}, παραθέσεις απενεργοποιημένες", "privacy.quote.limited": "{visibility}, παραθέσεις περιορισμένες", @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Ακύρωση ενίσχυσης", "status.cannot_quote": "Δε σας επιτρέπετε να παραθέσετε αυτή την ανάρτηση", "status.cannot_reblog": "Αυτή η ανάρτηση δεν μπορεί να ενισχυθεί", + "status.contains_quote": "Περιέχει παράθεση", "status.context.load_new_replies": "Νέες απαντήσεις διαθέσιμες", "status.context.loading": "Γίνεται έλεγχος για περισσότερες απαντήσεις", "status.continued_thread": "Συνεχιζόμενο νήματος", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Η ανάρτηση αφαιρέθηκε από τον συντάκτη", "status.quote_followers_only": "Μόνο οι ακόλουθοι μπορούν να παραθέσουν αυτή την ανάρτηση", "status.quote_manual_review": "Ο συντάκτης θα επανεξετάσει χειροκίνητα", + "status.quote_noun": "Παράθεση", "status.quote_policy_change": "Αλλάξτε ποιός μπορεί να κάνει παράθεση", "status.quote_post_author": "Παρατίθεται μια ανάρτηση από @{name}", "status.quote_private": "Ιδιωτικές αναρτήσεις δεν μπορούν να παρατεθούν", diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 7721cc36d3..f949c30339 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -865,8 +865,13 @@ "status.cannot_quote": "You are not allowed to quote this post", "status.cannot_reblog": "This post cannot be boosted", "status.contains_quote": "Contains quote", - "status.context.load_new_replies": "New replies available", - "status.context.loading": "Checking for more replies", + "status.context.loading": "Loading more replies", + "status.context.loading_error": "Couldn't load new replies", + "status.context.loading_more": "Loading more replies", + "status.context.loading_success": "All replies loaded", + "status.context.more_replies_found": "More replies found", + "status.context.retry": "Retry", + "status.context.show": "Show", "status.continued_thread": "Continued thread", "status.copy": "Copy link to post", "status.delete": "Delete", diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json index 672cad81aa..3f582452d5 100644 --- a/app/javascript/mastodon/locales/es-AR.json +++ b/app/javascript/mastodon/locales/es-AR.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Quitar adhesión", "status.cannot_quote": "No te es permitido citar este mensaje", "status.cannot_reblog": "No se puede adherir a este mensaje", + "status.contains_quote": "Contiene cita", "status.context.load_new_replies": "Hay nuevas respuestas", "status.context.loading": "Buscando más respuestas", "status.continued_thread": "Continuación de hilo", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Mensaje eliminado por el autor", "status.quote_followers_only": "Solo los seguidores pueden citar este mensaje", "status.quote_manual_review": "El autor revisará manualmente", + "status.quote_noun": "Cita", "status.quote_policy_change": "Cambiá quién puede citar", "status.quote_post_author": "Se citó un mensaje de @{name}", "status.quote_private": "No se pueden citar los mensajes privados", diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json index ff1a75b4cc..6b48c21d36 100644 --- a/app/javascript/mastodon/locales/es-MX.json +++ b/app/javascript/mastodon/locales/es-MX.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Deshacer impulso", "status.cannot_quote": "No está permitido citar esta publicación", "status.cannot_reblog": "Esta publicación no puede ser impulsada", + "status.contains_quote": "Contiene cita", "status.context.load_new_replies": "Nuevas respuestas disponibles", "status.context.loading": "Comprobando si hay más respuestas", "status.continued_thread": "Hilo continuado", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Publicación eliminada por el autor", "status.quote_followers_only": "Solo los seguidores pueden citar esta publicación", "status.quote_manual_review": "El autor la revisará manualmente", + "status.quote_noun": "Cita", "status.quote_policy_change": "Cambia quién puede citarte", "status.quote_post_author": "Ha citado una publicación de @{name}", "status.quote_private": "Las publicaciones privadas no pueden citarse", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index e4f2bb0b44..f84cb41511 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Deshacer impulso", "status.cannot_quote": "No tienes permiso para citar esta publicación", "status.cannot_reblog": "Esta publicación no se puede impulsar", + "status.contains_quote": "Contiene cita", "status.context.load_new_replies": "Hay nuevas respuestas", "status.context.loading": "Buscando más respuestas", "status.continued_thread": "Continuó el hilo", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Publicación eliminada por el autor", "status.quote_followers_only": "Solo los seguidores pueden citar esta publicación", "status.quote_manual_review": "El autor revisará manualmente", + "status.quote_noun": "Cita", "status.quote_policy_change": "Cambia quién puede citarte", "status.quote_post_author": "Ha citado una publicación de @{name}", "status.quote_private": "Las publicaciones privadas no pueden ser citadas", diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json index 791736f131..4aaeb51421 100644 --- a/app/javascript/mastodon/locales/et.json +++ b/app/javascript/mastodon/locales/et.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Lõpeta jagamine", "status.cannot_quote": "Sul pole õigust seda postitust tsiteerida", "status.cannot_reblog": "Seda postitust ei saa jagada", + "status.contains_quote": "Sisaldab tsitaati", "status.context.load_new_replies": "Leidub uusi vastuseid", "status.context.loading": "Kontrollin täiendavate vastuste olemasolu", "status.continued_thread": "Jätkatud lõim", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Autor on postituse eemaldanud", "status.quote_followers_only": "Vaid jälgijad saavad seda postitust tsiteerida", "status.quote_manual_review": "Autor vaatab selle üle", + "status.quote_noun": "Tsitaat", "status.quote_policy_change": "Muuda neid, kes võivad tsiteerida", "status.quote_post_author": "Tsiteeris kasutaja @{name} postitust", "status.quote_private": "Otsepostituste tsiteerimine pole võimalik", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 2925044cc5..8bf69ab31d 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Peru tehostus", "status.cannot_quote": "Sinulla ei ole oikeutta lainata tätä julkaisua", "status.cannot_reblog": "Tätä julkaisua ei voi tehostaa", + "status.contains_quote": "Sisältää lainauksen", "status.context.load_new_replies": "Uusia vastauksia saatavilla", "status.context.loading": "Tarkistetaan lisävastauksia", "status.continued_thread": "Jatkoi ketjua", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Tekijä on poistanut julkaisun", "status.quote_followers_only": "Vain seuraajat voivat lainata tätä julkaisua", "status.quote_manual_review": "Tekijä arvioi pyynnön manuaalisesti", + "status.quote_noun": "Lainaus", "status.quote_policy_change": "Vaihda, kuka voi lainata", "status.quote_post_author": "Lainaa käyttäjän @{name} julkaisua", "status.quote_private": "Yksityisiä julkaisuja ei voi lainata", diff --git a/app/javascript/mastodon/locales/fo.json b/app/javascript/mastodon/locales/fo.json index dc4cbc694a..a8db9baeca 100644 --- a/app/javascript/mastodon/locales/fo.json +++ b/app/javascript/mastodon/locales/fo.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Strika stimbran", "status.cannot_quote": "Tú hevur ikki loyvi at sitera hendan postin", "status.cannot_reblog": "Tað ber ikki til at stimbra hendan postin", + "status.contains_quote": "Inniheldur sitat", "status.context.load_new_replies": "Nýggj svar tøk", "status.context.loading": "Kanni um tað eru fleiri svar", "status.continued_thread": "Framhaldandi tráður", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Posturin burturbeindur av høvundinum", "status.quote_followers_only": "Bara fylgjarar kunnu sitera hendan postin", "status.quote_manual_review": "Høvundurin fer at eftirkanna manuelt", + "status.quote_noun": "Sitat", "status.quote_policy_change": "Broyt hvør kann sitera", "status.quote_post_author": "Siteraði ein post hjá @{name}", "status.quote_private": "Privatir postar kunnu ikki siterast", diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json index f1641450df..4ede71c487 100644 --- a/app/javascript/mastodon/locales/ga.json +++ b/app/javascript/mastodon/locales/ga.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Dímhol", "status.cannot_quote": "Ní cheadaítear duit an post seo a lua", "status.cannot_reblog": "Ní féidir an phostáil seo a mholadh", + "status.contains_quote": "Tá luachan ann", "status.context.load_new_replies": "Freagraí nua ar fáil", "status.context.loading": "Ag seiceáil le haghaidh tuilleadh freagraí", "status.continued_thread": "Snáithe ar lean", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Post bainte ag an údar", "status.quote_followers_only": "Ní féidir ach le leantóirí an post seo a lua", "status.quote_manual_review": "Déanfaidh an t-údar athbhreithniú de láimh", + "status.quote_noun": "Luachan", "status.quote_policy_change": "Athraigh cé a fhéadann luachan a thabhairt", "status.quote_post_author": "Luaigh mé post le @{name}", "status.quote_private": "Ní féidir poist phríobháideacha a lua", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index 62b2a7389c..2d37337a0c 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Desfacer compartido", "status.cannot_quote": "Non tes permiso para citar esta publicación", "status.cannot_reblog": "Esta publicación non pode ser promovida", + "status.contains_quote": "Contén unha cita", "status.context.load_new_replies": "Non hai respostas dispoñibles", "status.context.loading": "Mirando se hai máis respostas", "status.continued_thread": "Continua co fío", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Publicación retirada pola autora", "status.quote_followers_only": "Só as seguidoras poden citar esta publicación", "status.quote_manual_review": "A autora revisará manualmente", + "status.quote_noun": "Cita", "status.quote_policy_change": "Cambia quen pode citarte", "status.quote_post_author": "Citou unha publicación de @{name}", "status.quote_private": "As publicacións privadas non se poden citar", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index e338305ab8..bcba5e0b7c 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "הסרת הדהוד", "status.cannot_quote": "אין לך הרשאה לצטט את ההודעה הזו", "status.cannot_reblog": "לא ניתן להדהד חצרוץ זה", + "status.contains_quote": "הודעה מכילה ציטוט", "status.context.load_new_replies": "הגיעו תגובות חדשות", "status.context.loading": "מחפש תגובות חדשות", "status.continued_thread": "שרשור מתמשך", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "ההודעה הוסרה על ידי המחבר.ת", "status.quote_followers_only": "רק עוקביך יוכלו לצטט את ההודעה", "status.quote_manual_review": "מחבר.ת ההודעה יחזרו אליך אחרי בדיקה", + "status.quote_noun": "ציטוט", "status.quote_policy_change": "הגדרת הרשאה לציטוט הודעותיך", "status.quote_post_author": "ההודעה צוטטה על ידי @{name}", "status.quote_private": "הודעות פרטיות לא ניתנות לציטוט", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 42fad56b6f..83fb3e60fe 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Megtolás visszavonása", "status.cannot_quote": "Nem idézheted ezt a bejegyzést", "status.cannot_reblog": "Ezt a bejegyzést nem lehet megtolni", + "status.contains_quote": "Idézést tartalmaz", "status.context.load_new_replies": "Új válaszok érhetőek el", "status.context.loading": "További válaszok keresése", "status.continued_thread": "Folytatott szál", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "A szerző eltávolítta a bejegyzést", "status.quote_followers_only": "Csak a követők idézhetik ezt a bejegyzést", "status.quote_manual_review": "A szerző kézileg fogja jóváhagyni", + "status.quote_noun": "Idézés", "status.quote_policy_change": "Módosítás, hogy kik idézhetnek", "status.quote_post_author": "Idézte @{name} bejegyzését", "status.quote_private": "A privát bejegyzések nem idézhetőek", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 8cd1bafa4a..ba42000ec7 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -642,21 +642,21 @@ "notifications.column_settings.alert": "デスクトップ通知", "notifications.column_settings.favourite": "お気に入り", "notifications.column_settings.filter_bar.advanced": "すべてのカテゴリを表示", - "notifications.column_settings.filter_bar.category": "クイックフィルターバー:", + "notifications.column_settings.filter_bar.category": "クイックフィルターバー", "notifications.column_settings.follow": "新しいフォロワー", - "notifications.column_settings.follow_request": "新しいフォローリクエスト:", + "notifications.column_settings.follow_request": "新しいフォローリクエスト", "notifications.column_settings.group": "グループ", "notifications.column_settings.mention": "返信", "notifications.column_settings.poll": "アンケート結果", "notifications.column_settings.push": "プッシュ通知", "notifications.column_settings.quote": "引用", - "notifications.column_settings.reblog": "ブースト:", + "notifications.column_settings.reblog": "ブースト", "notifications.column_settings.show": "カラムに表示", "notifications.column_settings.sound": "通知音を再生", "notifications.column_settings.status": "新しい投稿", - "notifications.column_settings.unread_notifications.category": "未読の通知:", + "notifications.column_settings.unread_notifications.category": "未読の通知", "notifications.column_settings.unread_notifications.highlight": "未読の通知を強調表示", - "notifications.column_settings.update": "編集:", + "notifications.column_settings.update": "編集", "notifications.filter.all": "すべて", "notifications.filter.boosts": "ブースト", "notifications.filter.favourites": "お気に入り", @@ -875,6 +875,7 @@ "status.quote.cancel": "引用をキャンセル", "status.quote_error.filtered": "あなたのフィルター設定によって非表示になっています", "status.quote_error.pending_approval": "承認待ちの投稿", + "status.quote_noun": "引用", "status.quotes": "{count, plural, other {引用}}", "status.read_more": "もっと見る", "status.reblog": "ブースト", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index c1a21ad6ba..f61f7469d5 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Niet langer boosten", "status.cannot_quote": "Je bent niet gemachtigd om dit bericht te citeren", "status.cannot_reblog": "Dit bericht kan niet geboost worden", + "status.contains_quote": "Bevat citaat", "status.context.load_new_replies": "Nieuwe reacties beschikbaar", "status.context.loading": "Op nieuwe reacties aan het controleren", "status.continued_thread": "Vervolg van gesprek", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Bericht verwijderd door auteur", "status.quote_followers_only": "Alleen volgers mogen dit bericht citeren", "status.quote_manual_review": "De auteur gaat het handmatig beoordelen", + "status.quote_noun": "Citaat", "status.quote_policy_change": "Wijzig wie jou mag citeren", "status.quote_post_author": "Citeerde een bericht van @{name}", "status.quote_private": "Citeren van berichten aan alleen volgers is niet mogelijk", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index d4f5993b85..9d61ed2053 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Yeniden paylaşımı geri al", "status.cannot_quote": "Bu gönderiyi alıntılamaya izniniz yok", "status.cannot_reblog": "Bu gönderi yeniden paylaşılamaz", + "status.contains_quote": "Alıntı içeriyor", "status.context.load_new_replies": "Yeni yanıtlar mevcut", "status.context.loading": "Daha fazla yanıt için kontrol ediliyor", "status.continued_thread": "Devam eden akış", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Gönderi yazarı tarafından kaldırıldı", "status.quote_followers_only": "Sadece takipçiler bu gönderiyi alıntılayabilir", "status.quote_manual_review": "Yazar manuel olarak gözden geçirecek", + "status.quote_noun": "Alıntı", "status.quote_policy_change": "Kimin alıntı yapabileceğini değiştirin", "status.quote_post_author": "@{name} adlı kullanıcının bir gönderisini alıntıladı", "status.quote_private": "Özel gönderiler alıntılanamaz", diff --git a/app/javascript/mastodon/locales/vi.json b/app/javascript/mastodon/locales/vi.json index 5ac881547d..99a5dc9051 100644 --- a/app/javascript/mastodon/locales/vi.json +++ b/app/javascript/mastodon/locales/vi.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "Bỏ đăng lại", "status.cannot_quote": "Bạn không được phép trích dẫn tút này", "status.cannot_reblog": "Không thể đăng lại tút này", + "status.contains_quote": "Chứa trích dẫn", "status.context.load_new_replies": "Có những trả lời mới", "status.context.loading": "Kiểm tra nhiều trả lời hơn", "status.continued_thread": "Tiếp tục chủ đề", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "Tút gốc đã bị tác giả gỡ", "status.quote_followers_only": "Chỉ người theo dõi tôi có thể trích dẫn tút này", "status.quote_manual_review": "Người đăng sẽ duyệt thủ công", + "status.quote_noun": "Trích dẫn", "status.quote_policy_change": "Thay đổi người có thể trích dẫn", "status.quote_post_author": "Trích dẫn từ tút của @{name}", "status.quote_private": "Không thể trích dẫn nhắn riêng", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 1f1f895ff7..c69e72eced 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "取消转嘟", "status.cannot_quote": "你无法引用此嘟文", "status.cannot_reblog": "不能转嘟这条嘟文", + "status.contains_quote": "包含引用", "status.context.load_new_replies": "有新回复", "status.context.loading": "正在检查更多回复", "status.continued_thread": "上接嘟文串", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "嘟文已被作者删除", "status.quote_followers_only": "只有关注者才能引用这篇嘟文", "status.quote_manual_review": "嘟文作者将人工审核", + "status.quote_noun": "引用", "status.quote_policy_change": "更改谁可以引用", "status.quote_post_author": "引用了 @{name} 的嘟文", "status.quote_private": "不能引用私人嘟文", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 5b70f9ffde..e4f9dcea29 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -864,6 +864,7 @@ "status.cancel_reblog_private": "取消轉嘟", "status.cannot_quote": "您不被允許引用此嘟文", "status.cannot_reblog": "這則嘟文無法被轉嘟", + "status.contains_quote": "包含引用嘟文", "status.context.load_new_replies": "有新回嘟", "status.context.loading": "正在檢查更多回嘟", "status.continued_thread": "接續討論串", @@ -903,6 +904,7 @@ "status.quote_error.revoked": "嘟文已被作者刪除", "status.quote_followers_only": "只有我的跟隨者能引用此嘟文", "status.quote_manual_review": "嘟文作者將人工審閱", + "status.quote_noun": "引用嘟文", "status.quote_policy_change": "變更可以引用的人", "status.quote_post_author": "已引用 @{name} 之嘟文", "status.quote_private": "無法引用私人嘟文", diff --git a/app/javascript/mastodon/utils/environment.ts b/app/javascript/mastodon/utils/environment.ts index fc4448740f..2d544417e3 100644 --- a/app/javascript/mastodon/utils/environment.ts +++ b/app/javascript/mastodon/utils/environment.ts @@ -12,11 +12,7 @@ export function isProduction() { else return import.meta.env.PROD; } -export type Features = - | 'modern_emojis' - | 'outgoing_quotes' - | 'fasp' - | 'http_message_signatures'; +export type Features = 'modern_emojis' | 'fasp' | 'http_message_signatures'; export function isFeatureEnabled(feature: Features) { return initialState?.features.includes(feature) ?? false; diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index c68bc6d679..781e8511f5 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -163,7 +163,7 @@ $content-width: 840px; flex: 1 1 auto; } - @media screen and (max-width: $content-width + $sidebar-width) { + @media screen and (max-width: ($content-width + $sidebar-width)) { .sidebar-wrapper--empty { display: none; } @@ -1081,6 +1081,17 @@ a.name-tag, } } + &__action-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + + &:not(.no-wrap) { + flex-wrap: wrap; + } + } + &__meta { padding: 0 15px; color: $dark-text-color; @@ -1097,10 +1108,8 @@ a.name-tag, } } - &__action-bar { - display: flex; - justify-content: space-between; - align-items: center; + &__actions { + margin-inline-start: auto; } &__permissions { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index acfc906dc6..b390a8a8e5 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1597,6 +1597,16 @@ } } } + + .no-reduce-motion &--highlighted-entry::before { + content: ''; + position: absolute; + inset: 0; + background: rgb(from $ui-highlight-color r g b / 20%); + opacity: 0; + animation: fade 0.7s reverse both 0.3s; + pointer-events: none; + } } .status__relative-time { @@ -2960,7 +2970,6 @@ a.account__display-name { flex: 1 1 auto; flex-direction: row; justify-content: flex-start; - overflow-x: auto; position: relative; &.unscrollable { @@ -3136,6 +3145,29 @@ a.account__display-name { } } +.column__alert { + position: sticky; + bottom: 1rem; + z-index: 10; + box-sizing: border-box; + display: grid; + width: 100%; + max-width: 360px; + padding-inline: 10px; + margin-top: 1rem; + margin-inline: auto; + + @media (max-width: #{$mobile-menu-breakpoint - 1}) { + bottom: 4rem; + } + + & > * { + // Make all nested alerts occupy the same space + // rather than stack + grid-area: 1 / 1; + } +} + .ui { --mobile-bottom-nav-height: 55px; --last-content-item-border-width: 2px; @@ -3176,7 +3208,6 @@ a.account__display-name { .column, .drawer { flex: 1 1 100%; - overflow: hidden; } @media screen and (width > $mobile-breakpoint) { @@ -10388,6 +10419,21 @@ noscript { } } +.notification-bar__loading-indicator { + --spinner-size: 22px; + + position: relative; + height: var(--spinner-size); + width: var(--spinner-size); + margin-inline-start: 2px; + + svg { + color: $white; + height: var(--spinner-size); + width: var(--spinner-size); + } +} + .hashtag-header { border-bottom: 1px solid var(--background-border-color); padding: 15px; diff --git a/app/lib/activitypub/activity/quote_request.rb b/app/lib/activitypub/activity/quote_request.rb index 7b49acd119..6d386f45dc 100644 --- a/app/lib/activitypub/activity/quote_request.rb +++ b/app/lib/activitypub/activity/quote_request.rb @@ -9,7 +9,7 @@ class ActivityPub::Activity::QuoteRequest < ActivityPub::Activity quoted_status = status_from_uri(object_uri) return if quoted_status.nil? || !quoted_status.account.local? || !quoted_status.distributable? - if Mastodon::Feature.outgoing_quotes_enabled? && StatusPolicy.new(@account, quoted_status).quote? + if StatusPolicy.new(@account, quoted_status).quote? accept_quote_request!(quoted_status) else reject_quote_request!(quoted_status) diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index aaa7b574ee..f99d15a6d9 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -38,7 +38,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer attribute :quote, key: :quote_uri, if: :quote? attribute :quote_authorization, if: :quote_authorization? - attribute :interaction_policy, if: -> { Mastodon::Feature.outgoing_quotes_enabled? } + attribute :interaction_policy def id raise Mastodon::NotPermittedError, 'Local-only statuses should not be serialized' if object.local_only? && !instance_options[:allow_local_only] diff --git a/app/serializers/rest/shallow_status_serializer.rb b/app/serializers/rest/shallow_status_serializer.rb index d82ac32621..0b951f6caa 100644 --- a/app/serializers/rest/shallow_status_serializer.rb +++ b/app/serializers/rest/shallow_status_serializer.rb @@ -6,5 +6,5 @@ class REST::ShallowStatusSerializer < REST::StatusSerializer # It looks like redefining one `has_one` requires redefining all inherited ones has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer - has_one :quote_approval, if: -> { Mastodon::Feature.outgoing_quotes_enabled? } + has_one :quote_approval end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 50b04e29f2..da4eb4b125 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -36,7 +36,7 @@ class REST::StatusSerializer < ActiveModel::Serializer has_one :quote, key: :quote, serializer: REST::QuoteSerializer has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer - has_one :quote_approval, if: -> { Mastodon::Feature.outgoing_quotes_enabled? } + has_one :quote_approval delegate :local?, to: :object diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 7e3733391f..5cbf990c4d 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -85,7 +85,7 @@ class PostStatusService < BaseService @sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present? @visibility = @options[:visibility] || @account.user&.setting_default_privacy @visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced? - @visibility = :private if @quoted_status&.private_visibility? + @visibility = :private if @quoted_status&.private_visibility? && %i(public unlisted).include?(@visibility&.to_sym) @scheduled_at = @options[:scheduled_at]&.to_datetime @scheduled_at = nil if scheduled_in_the_past? rescue ArgumentError diff --git a/app/views/admin/announcements/_announcement.html.haml b/app/views/admin/announcements/_announcement.html.haml index 87ae97cf48..5944b0b295 100644 --- a/app/views/admin/announcements/_announcement.html.haml +++ b/app/views/admin/announcements/_announcement.html.haml @@ -9,7 +9,7 @@ - else = l(announcement.created_at) - %div + .announcements-list__item__actions - if can?(:distribute, announcement) = table_link_to 'mail', t('admin.terms_of_service.notify_users'), admin_announcement_preview_path(announcement) - if can?(:update, announcement) diff --git a/app/views/admin/roles/_role.html.haml b/app/views/admin/roles/_role.html.haml index 085bdbd156..ddaca5d8a9 100644 --- a/app/views/admin/roles/_role.html.haml +++ b/app/views/admin/roles/_role.html.haml @@ -26,5 +26,5 @@ = link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id) · %abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size) - %div + .announcements-list__item__actions = table_link_to 'edit', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role) diff --git a/app/views/admin/rules/_rule.html.haml b/app/views/admin/rules/_rule.html.haml index 7d84534d59..d79c1dfa6c 100644 --- a/app/views/admin/rules/_rule.html.haml +++ b/app/views/admin/rules/_rule.html.haml @@ -3,7 +3,7 @@ #{rule_counter + 1}. = truncate(rule.text) - .announcements-list__item__action-bar + .announcements-list__item__action-bar.no-wrap .announcements-list__item__meta = rule.hint diff --git a/app/views/admin/warning_presets/_warning_preset.html.haml b/app/views/admin/warning_presets/_warning_preset.html.haml index 2cc056420f..6488c3a554 100644 --- a/app/views/admin/warning_presets/_warning_preset.html.haml +++ b/app/views/admin/warning_presets/_warning_preset.html.haml @@ -6,5 +6,5 @@ .announcements-list__item__meta = truncate(warning_preset.text) - %div + .announcements-list__item__actions = table_link_to 'delete', t('admin.warning_presets.delete'), admin_warning_preset_path(warning_preset), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, warning_preset) diff --git a/app/views/admin/webhooks/_webhook.html.haml b/app/views/admin/webhooks/_webhook.html.haml index dca5abeb77..6159d97820 100644 --- a/app/views/admin/webhooks/_webhook.html.haml +++ b/app/views/admin/webhooks/_webhook.html.haml @@ -14,6 +14,6 @@ %abbr{ title: webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: webhook.events.size) - %div + .announcements-list__item__actions = table_link_to 'edit', t('admin.webhooks.edit'), edit_admin_webhook_path(webhook) if can?(:update, webhook) = table_link_to 'delete', t('admin.webhooks.delete'), admin_webhook_path(webhook), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, webhook) diff --git a/app/views/filters/_filter.html.haml b/app/views/filters/_filter.html.haml index a544ac3a75..15326f3006 100644 --- a/app/views/filters/_filter.html.haml +++ b/app/views/filters/_filter.html.haml @@ -32,10 +32,10 @@ .permissions-list__item__text__type = t('filters.index.statuses_long', count: filter.statuses.size) - .announcements-list__item__action-bar - .announcements-list__item__meta + .filters-list__item__action-bar + .filters-list__item__meta = t('filters.index.contexts', contexts: filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', ')) - %div + .filters-list__item__actions = table_link_to 'edit', t('filters.edit.title'), edit_filter_path(filter) = table_link_to 'close', t('filters.index.delete'), filter_path(filter), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/oauth/authorized_applications/index.html.haml b/app/views/oauth/authorized_applications/index.html.haml index 3745ed219f..b28302a93f 100644 --- a/app/views/oauth/authorized_applications/index.html.haml +++ b/app/views/oauth/authorized_applications/index.html.haml @@ -28,7 +28,7 @@ = t('doorkeeper.authorized_applications.index.authorized_at', date: l(application.created_at.to_date)) - unless application.superapp? || current_account.unavailable? - %div + .announcements-list__item__actions = table_link_to 'close', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') } .announcements-list__item__permissions diff --git a/config/locales/el.yml b/config/locales/el.yml index 3243e9b761..a17e870075 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -114,7 +114,7 @@ el: other: Αυτός ο λογαριασμός έχει %{count} παραπτώματα. promote: Προαγωγή protocol: Πρωτόκολλο - public: Δημόσιο + public: Δημόσιος push_subscription_expires: Η εγγραφή PuSH λήγει redownload: Ανανέωση άβαταρ redownloaded_msg: Επιτυχής ανανέωση προφίλ του/της %{username} από την πηγή @@ -1919,7 +1919,7 @@ el: visibilities: direct: Ιδιωτική επισήμανση private: Μόνο ακόλουθοι - public: Δημόσιο + public: Δημόσια public_long: Όλοι εντός και εκτός του Mastodon unlisted: Ήσυχα δημόσια unlisted_long: Κρυμμένη από τα αποτελέσματα αναζήτησης Mastodon, τις τάσεις και τις δημόσιες ροές diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 28502bdd9c..184ab506d6 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -572,6 +572,7 @@ ja: title: モデレーション moderation_notes: create: モデレーションノートを追加 + title: モデレーションメモ private_comment: コメント (非公開) public_comment: コメント (公開) purge: パージ @@ -1067,14 +1068,18 @@ ja: trending: トレンド username_blocks: add_new: ルールを作成 + block_registrations: 登録拒否 comparison: contains: 含む equals: 一致 + contains_html: "%{string}を含む" delete: 削除 edit: title: ユーザー名ルールの編集 + matches_exactly_html: "%{string}に一致" new: create: ルールを作成 + title: ユーザー名ルール warning_presets: add_new: 追加 delete: 削除 @@ -1679,6 +1684,7 @@ ja: self_vote: 自分のアンケートには解答できません too_few_options: は複数必要です too_many_options: は%{max}個までです + vote: 投票 preferences: other: その他 posting_defaults: デフォルトの投稿設定 diff --git a/config/puma.rb b/config/puma.rb index 16c481a2ae..d34c14b425 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -27,7 +27,7 @@ if ENV['MASTODON_PROMETHEUS_EXPORTER_ENABLED'] == 'true' end end - on_worker_boot do + before_worker_boot do # Ruby process metrics (memory, GC, etc) PrometheusExporter::Instrumentation::Process.start(type: 'puma') @@ -44,7 +44,7 @@ if ENV['MASTODON_PROMETHEUS_EXPORTER_ENABLED'] == 'true' end end -on_worker_boot do +before_worker_boot do ActiveSupport.on_load(:active_record) do ActiveRecord::Base.establish_connection end diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index a6bbfcd24d..043f22b28e 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -45,7 +45,7 @@ module Mastodon def api_versions { - mastodon: Mastodon::Feature.outgoing_quotes_enabled? ? 7 : 6, + mastodon: 7, } end diff --git a/spec/lib/activitypub/activity/quote_request_spec.rb b/spec/lib/activitypub/activity/quote_request_spec.rb index 64627cbdfb..aae4ce0338 100644 --- a/spec/lib/activitypub/activity/quote_request_spec.rb +++ b/spec/lib/activitypub/activity/quote_request_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe ActivityPub::Activity::QuoteRequest, feature: :outgoing_quotes do +RSpec.describe ActivityPub::Activity::QuoteRequest do let(:sender) { Fabricate(:account, domain: 'example.com') } let(:recipient) { Fabricate(:account) } let(:quoted_post) { Fabricate(:status, account: recipient) } diff --git a/spec/lib/status_cache_hydrator_spec.rb b/spec/lib/status_cache_hydrator_spec.rb index f450997976..085866ef1d 100644 --- a/spec/lib/status_cache_hydrator_spec.rb +++ b/spec/lib/status_cache_hydrator_spec.rb @@ -28,7 +28,7 @@ RSpec.describe StatusCacheHydrator do end end - context 'when handling a status with a quote policy', feature: :outgoing_quotes do + context 'when handling a status with a quote policy' do let(:status) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) } before do diff --git a/spec/requests/api/v1/statuses/interaction_policies_spec.rb b/spec/requests/api/v1/statuses/interaction_policies_spec.rb index cdc33e40d7..aa447de17f 100644 --- a/spec/requests/api/v1/statuses/interaction_policies_spec.rb +++ b/spec/requests/api/v1/statuses/interaction_policies_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe 'Interaction policies', feature: :outgoing_quotes do +RSpec.describe 'Interaction policies' do let(:user) { Fabricate(:user) } let(:scopes) { 'write:statuses' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } diff --git a/spec/requests/api/v1/statuses_spec.rb b/spec/requests/api/v1/statuses_spec.rb index eb3e8aed5b..249abc2440 100644 --- a/spec/requests/api/v1/statuses_spec.rb +++ b/spec/requests/api/v1/statuses_spec.rb @@ -158,7 +158,7 @@ RSpec.describe '/api/v1/statuses' do end end - context 'without a quote policy', feature: :outgoing_quotes do + context 'without a quote policy' do let(:user) do Fabricate(:user, settings: { default_quote_policy: 'followers' }) end @@ -180,7 +180,7 @@ RSpec.describe '/api/v1/statuses' do end end - context 'without a quote policy and the user defaults to nobody', feature: :outgoing_quotes do + context 'without a quote policy and the user defaults to nobody' do let(:user) do Fabricate(:user, settings: { default_quote_policy: 'nobody' }) end @@ -202,7 +202,7 @@ RSpec.describe '/api/v1/statuses' do end end - context 'with a quote policy', feature: :outgoing_quotes do + context 'with a quote policy' do let(:quoted_status) { Fabricate(:status, account: user.account) } let(:params) do { @@ -227,7 +227,7 @@ RSpec.describe '/api/v1/statuses' do end end - context 'with a self-quote post', feature: :outgoing_quotes do + context 'with a self-quote post' do let(:quoted_status) { Fabricate(:status, account: user.account) } let(:params) do { @@ -248,7 +248,7 @@ RSpec.describe '/api/v1/statuses' do end end - context 'with a self-quote post and a CW but no text', feature: :outgoing_quotes do + context 'with a self-quote post and a CW but no text' do let(:quoted_status) { Fabricate(:status, account: user.account) } let(:params) do { @@ -420,7 +420,7 @@ RSpec.describe '/api/v1/statuses' do context 'when updating only the quote policy' do let(:params) { { status: status.text, quote_approval_policy: 'public' } } - it 'updates the status', :aggregate_failures, feature: :outgoing_quotes do + it 'updates the status', :aggregate_failures do expect { subject } .to change { status.reload.quote_approval_policy }.to(Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index 336f394337..04179e9bf4 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -58,7 +58,7 @@ RSpec.describe ActivityPub::NoteSerializer do end end - context 'with a quote policy', feature: :outgoing_quotes do + context 'with a quote policy' do let(:parent) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) } it 'has the expected shape' do diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index c434d0cb6e..96289cdeee 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -321,6 +321,14 @@ RSpec.describe PostStatusService do expect(status).to be_private_visibility end + it 'correctly preserves visibility for private mentions self-quoting private posts' do + account = Fabricate(:account) + quoted_status = Fabricate(:status, account: account, visibility: :private) + + status = subject.call(account, text: 'test', quoted_status: quoted_status, visibility: 'direct') + expect(status).to be_direct_visibility + end + it 'returns existing status when used twice with idempotency key' do account = Fabricate(:account) status1 = subject.call(account, text: 'test', idempotency: 'meepmeep')