diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e975479e..cfbc450d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [4.5.6] - 2026-02-03 + +### Security + +- Fix ActivityPub collection caching logic for pinned posts and featured tags not checking blocked accounts ([GHSA-ccpr-m53r-mfwr](https://github.com/mastodon/mastodon/security/advisories/GHSA-ccpr-m53r-mfwr)) + +### Changed + +- Shorten caching of quote posts pending approval (#37570 and #37592 by @ClearlyClaire) + +### Fixed + +- Fix relationship cache not being cleared when handling account migrations (#37664 by @ClearlyClaire) +- Fix quote cancel button not appearing after edit then delete-and-redraft (#37066 by @PGrayCS) +- Fix followers with profile subscription (bell icon) being notified of post edits (#37646 by @ClearlyClaire) +- Fix error when encountering invalid tag in updated object (#37635 by @ClearlyClaire) +- Fix cross-server conversation tracking (#37559 by @ClearlyClaire) +- Fix recycled connections not being immediately closed (#37335 and #37674 by @ClearlyClaire and @shleeable) + ## [4.5.5] - 2026-01-20 ### Security diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index 553a43dad2..6647d09997 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -6,17 +6,31 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :check_authorization before_action :set_items before_action :set_size before_action :set_type def show expires_in 3.minutes, public: public_fetch_mode? - render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + + if @unauthorized + render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + else + render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end end private + def check_authorization + # Because in public fetch mode we cache the response, there would be no + # benefit from performing the check below, since a blocked account or domain + # would likely be served the cache from the reverse proxy anyway + + @unauthorized = authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + end + def set_items case params[:id] when 'featured' @@ -59,11 +73,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController end def for_signed_account - # Because in public fetch mode we cache the response, there would be no - # benefit from performing the check below, since a blocked account or domain - # would likely be served the cache from the reverse proxy anyway - - if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + if @unauthorized [] else yield diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index bf9f813ef0..07f6683b43 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -153,7 +153,8 @@ export function fetchAccountFail(id, error) { */ export function followAccount(id, options = { reblogs: true }) { return (dispatch, getState) => { - const alreadyFollowing = getState().getIn(['relationships', id, 'following']); + const relationship = getState().getIn(['relationships', id]); + const alreadyFollowing = relationship?.following || relationship?.requested; const locked = getState().getIn(['accounts', id, 'locked'], false); dispatch(followAccountRequest({ id, locked })); diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.tsx b/app/javascript/flavours/glitch/components/dropdown_menu.tsx index fa196a8bdb..1284d6ee13 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.tsx +++ b/app/javascript/flavours/glitch/components/dropdown_menu.tsx @@ -71,10 +71,15 @@ export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({ return null; } - const { text, description, icon } = item; + const { text, description, icon, iconId } = item; return ( <> - {icon && } + {icon && ( + + )} {text} {Boolean(description) && ( diff --git a/app/javascript/flavours/glitch/components/follow_button.tsx b/app/javascript/flavours/glitch/components/follow_button.tsx index c06bfe4c77..6b5bffbd6f 100644 --- a/app/javascript/flavours/glitch/components/follow_button.tsx +++ b/app/javascript/flavours/glitch/components/follow_button.tsx @@ -5,6 +5,7 @@ import { useIntl, defineMessages } from 'react-intl'; import classNames from 'classnames'; import { useIdentity } from '@/flavours/glitch/identity_context'; +import { isClientFeatureEnabled } from '@/flavours/glitch/utils/environment'; import { fetchRelationships, followAccount, @@ -94,7 +95,17 @@ export const FollowButton: React.FC<{ if (accountId === me) { return; - } else if (relationship.muting) { + } else if (relationship.blocking) { + dispatch( + openModal({ + modalType: 'CONFIRM_UNBLOCK', + modalProps: { account }, + }), + ); + } else if ( + relationship.muting && + !isClientFeatureEnabled('profile_redesign') + ) { dispatch(unmuteAccount(accountId)); } else if (account && relationship.following) { dispatch( @@ -107,13 +118,6 @@ export const FollowButton: React.FC<{ modalProps: { account }, }), ); - } else if (relationship.blocking) { - dispatch( - openModal({ - modalType: 'CONFIRM_UNBLOCK', - modalProps: { account }, - }), - ); } else { dispatch(followAccount(accountId)); } @@ -136,7 +140,10 @@ export const FollowButton: React.FC<{ label = intl.formatMessage(messages.edit_profile); } else if (!relationship) { label = ; - } else if (relationship.muting) { + } else if ( + relationship.muting && + !isClientFeatureEnabled('profile_redesign') + ) { label = intl.formatMessage(messages.unmute); } else if (relationship.following) { label = intl.formatMessage(messages.unfollow); @@ -173,7 +180,7 @@ export const FollowButton: React.FC<{ (!(relationship?.following || relationship?.requested) && (account?.suspended || !!account?.moved)) } - secondary={following} + secondary={following || relationship?.blocking} compact={compact} className={classNames(className, { 'button--destructive': following })} > diff --git a/app/javascript/flavours/glitch/components/form_fields/index.ts b/app/javascript/flavours/glitch/components/form_fields/index.ts index 8dd693d51e..76137dd37a 100644 --- a/app/javascript/flavours/glitch/components/form_fields/index.ts +++ b/app/javascript/flavours/glitch/components/form_fields/index.ts @@ -2,4 +2,4 @@ export { TextInputField } from './text_input_field'; export { TextAreaField } from './text_area_field'; export { CheckboxField, Checkbox } from './checkbox_field'; export { ToggleField, Toggle } from './toggle_field'; -export { SelectField } from './select_field'; +export { SelectField, Select } from './select_field'; diff --git a/app/javascript/flavours/glitch/components/form_fields/select.module.scss b/app/javascript/flavours/glitch/components/form_fields/select.module.scss new file mode 100644 index 0000000000..e68e248fec --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/select.module.scss @@ -0,0 +1,66 @@ +.wrapper { + position: relative; + width: 100%; + + /* Dropdown indicator icon */ + &::after { + --icon-size: 11px; + + content: ''; + position: absolute; + top: 0; + bottom: 0; + inset-inline-end: 9px; + width: var(--icon-size); + background-color: var(--color-text-tertiary); + pointer-events: none; + mask-image: url("data:image/svg+xml;utf8,"); + mask-position: right center; + mask-size: var(--icon-size); + mask-repeat: no-repeat; + } + + &:has(.select:focus-visible)::after { + background-color: var(--color-text-secondary); + } + + &:has(.select:disabled)::after { + background-color: var(--color-text-disabled); + } +} + +.select { + appearance: none; + box-sizing: border-box; + display: block; + width: 100%; + height: 41px; + padding-inline-start: 10px; + padding-inline-end: 30px; + font-family: inherit; + font-size: 14px; + color: var(--color-text-primary); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border-primary); + border-radius: 4px; + outline: 0; + + @media screen and (width <= 600px) { + font-size: 16px; + } + + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: -1px; + } + + &:disabled { + color: var(--color-text-disabled); + border-color: transparent; + cursor: not-allowed; + } + + [data-has-error='true'] & { + border-color: var(--color-text-error); + } +} diff --git a/app/javascript/flavours/glitch/components/form_fields/select_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/select_field.stories.tsx index 762436fe28..469238dd44 100644 --- a/app/javascript/flavours/glitch/components/form_fields/select_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/select_field.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { SelectField } from './select_field'; +import { SelectField, Select } from './select_field'; const meta = { title: 'Components/Form Fields/SelectField', @@ -8,24 +8,19 @@ const meta = { args: { label: 'Fruit preference', hint: 'Select your favourite fruit or not. Up to you.', - }, - render(args) { - // Component styles require a wrapper class at the moment - return ( -
- - - - - - - - - - - -
- ); + children: ( + <> + + + + + + + + + + + ), }, } satisfies Meta; @@ -59,3 +54,16 @@ export const WithError: Story = { hasError: true, }, }; + +export const Plain: Story = { + render(args) { + return - {children} - - + )} ), ); SelectField.displayName = 'SelectField'; + +export const Select = forwardRef< + HTMLSelectElement, + ComponentPropsWithoutRef<'select'> +>(({ className, size, ...otherProps }, ref) => ( +
+ + +
)} diff --git a/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx b/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx index d479c48707..8d878c0ce8 100644 --- a/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx +++ b/app/javascript/flavours/glitch/features/account/components/follow_request_note.jsx @@ -24,12 +24,12 @@ export default class FollowRequestNote extends ImmutablePureComponent {
- - 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 a19a1d3550..dc57350e7b 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 @@ -1,4 +1,5 @@ -import { useCallback } from 'react'; +import type { RefCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import classNames from 'classnames'; import { Helmet } from 'react-helmet'; @@ -81,6 +82,40 @@ export const AccountHeader: React.FC<{ [dispatch, account], ); + const [isFooterIntersecting, setIsIntersecting] = useState(false); + const handleIntersect: IntersectionObserverCallback = useCallback( + (entries) => { + const entry = entries.at(0); + if (!entry) { + return; + } + + setIsIntersecting(entry.isIntersecting); + }, + [], + ); + const [observer] = useState( + () => + new IntersectionObserver(handleIntersect, { + rootMargin: '0px 0px -55px 0px', // Height of bottom nav bar. + }), + ); + + const handleObserverRef: RefCallback = useCallback( + (node) => { + if (node) { + observer.observe(node); + } + }, + [observer], + ); + + useEffect(() => { + return () => { + observer.disconnect(); + }; + }, [observer]); + if (!account) { return null; } @@ -118,7 +153,12 @@ export const AccountHeader: React.FC<{ )}
- {!hideTabs && !hidden && } +
{titleFromAccount(account)} diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/badges.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/badges.tsx index 6d0793bd25..de54dfe473 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/badges.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/components/badges.tsx @@ -108,48 +108,6 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => { className={classNames(className, classes.badgeMuted)} />, ); - } else if ( - relationship.followed_by && - (relationship.following || relationship.requested) - ) { - badges.push( - - } - className={className} - />, - ); - } else if (relationship.followed_by) { - badges.push( - - } - className={className} - />, - ); - } else if (relationship.requested_by) { - badges.push( - - } - className={className} - />, - ); } } diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/fields.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/fields.tsx index 5b51fb11d3..c1f49c2db8 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/fields.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/components/fields.tsx @@ -1,21 +1,23 @@ -import { useCallback, useMemo } from 'react'; import type { FC } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import classNames from 'classnames'; -import { openModal } from '@/flavours/glitch/actions/modal'; import { AccountFields } from '@/flavours/glitch/components/account_fields'; import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; import { FormattedDateWrapper } from '@/flavours/glitch/components/formatted_date'; -import { Icon } from '@/flavours/glitch/components/icon'; -import { MiniCardList } from '@/flavours/glitch/components/mini_card/list'; +import { IconButton } from '@/flavours/glitch/components/icon_button'; +import { MiniCard } from '@/flavours/glitch/components/mini_card'; import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link'; import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import { useOverflowScroll } from '@/flavours/glitch/hooks/useOverflow'; import type { Account } from '@/flavours/glitch/models/account'; -import { useAppDispatch } from '@/flavours/glitch/store'; +import { isValidUrl } from '@/flavours/glitch/utils/checks'; import IconVerified from '@/images/icons/icon_verified.svg?react'; +import IconLeftArrow from '@/material-icons/400-24px/chevron_left.svg?react'; +import IconRightArrow from '@/material-icons/400-24px/chevron_right.svg?react'; +import IconLink from '@/material-icons/400-24px/link_2.svg?react'; import { isRedesignEnabled } from '../common'; @@ -57,61 +59,94 @@ export const AccountHeaderFields: FC<{ accountId: string }> = ({ const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => { const htmlHandlers = useElementHandledLink(); - const cards = useMemo( - () => - account.fields - .toArray() - .map(({ value_emojified, name_emojified, verified_at }) => ({ - label: ( - <> - - {!!verified_at && ( - - )} - - ), - value: ( - - ), - className: classNames( - classes.fieldCard, - !!verified_at && classes.fieldCardVerified, - ), - })), - [account.emojis, account.fields, htmlHandlers], - ); + const intl = useIntl(); - const dispatch = useAppDispatch(); - const handleOverflowClick = useCallback(() => { - dispatch( - openModal({ - modalType: 'ACCOUNT_FIELDS', - modalProps: { accountId: account.id }, - }), - ); - }, [account.id, dispatch]); + const { + bodyRef, + canScrollLeft, + canScrollRight, + handleLeftNav, + handleRightNav, + handleScroll, + } = useOverflowScroll(); return ( - +
+ {canScrollLeft && ( + + )} +
+ {account.fields.map( + ( + { name, name_emojified, value_emojified, value_plain, verified_at }, + key, + ) => ( + + } + value={ + + } + icon={fieldIcon(verified_at, value_plain)} + className={classNames( + classes.fieldCard, + verified_at && classes.fieldCardVerified, + )} + /> + ), + )} +
+ {canScrollRight && ( + + )} +
); }; + +function fieldIcon(verified_at: string | null, value_plain: string | null) { + if (verified_at) { + return IconVerified; + } else if (value_plain && isValidUrl(value_plain)) { + return IconLink; + } + return undefined; +} diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/fields_modal.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/fields_modal.tsx deleted file mode 100644 index c47af8549d..0000000000 --- a/app/javascript/flavours/glitch/features/account_timeline/components/fields_modal.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { FC } from 'react'; - -import { FormattedMessage, useIntl } from 'react-intl'; - -import { DisplayName } from '@/flavours/glitch/components/display_name'; -import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context'; -import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; -import { Icon } from '@/flavours/glitch/components/icon'; -import { IconButton } from '@/flavours/glitch/components/icon_button'; -import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator'; -import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link'; -import { useAccount } from '@/flavours/glitch/hooks/useAccount'; -import IconVerified from '@/images/icons/icon_verified.svg?react'; -import CloseIcon from '@/material-icons/400-24px/close.svg?react'; - -import classes from './redesign.module.scss'; - -export const AccountFieldsModal: FC<{ - accountId: string; - onClose: () => void; -}> = ({ accountId, onClose }) => { - const intl = useIntl(); - const account = useAccount(accountId); - const htmlHandlers = useElementHandledLink(); - - if (!account) { - return ( -
- -
- ); - } - - return ( -
-
- - - , - }} - /> - -
-
- -
- {account.fields.map((field, index) => ( -
- -
- - {!!field.verified_at && ( - - )} -
-
- ))} -
-
-
-
- ); -}; diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/menu.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/menu.tsx index 5f76a944fe..590f5d1fa6 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/menu.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/components/menu.tsx @@ -12,6 +12,7 @@ import { unpinAccount, } from '@/flavours/glitch/actions/accounts'; import { removeAccountFromFollowers } from '@/flavours/glitch/actions/accounts_typed'; +import { showAlert } from '@/flavours/glitch/actions/alerts'; import { directCompose, mentionCompose, @@ -26,16 +27,78 @@ import { initReport } from '@/flavours/glitch/actions/reports'; import { Dropdown } from '@/flavours/glitch/components/dropdown_menu'; import { useAccount } from '@/flavours/glitch/hooks/useAccount'; import { useIdentity } from '@/flavours/glitch/identity_context'; +import type { Account } from '@/flavours/glitch/models/account'; import type { MenuItem } from '@/flavours/glitch/models/dropdown_menu'; +import type { Relationship } from '@/flavours/glitch/models/relationship'; import { PERMISSION_MANAGE_FEDERATION, PERMISSION_MANAGE_USERS, } from '@/flavours/glitch/permissions'; +import type { AppDispatch } from '@/flavours/glitch/store'; import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; +import BlockIcon from '@/material-icons/400-24px/block.svg?react'; +import EditIcon from '@/material-icons/400-24px/edit_square.svg?react'; +import LinkIcon from '@/material-icons/400-24px/link_2.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; +import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react'; +import ReportIcon from '@/material-icons/400-24px/report.svg?react'; +import ShareIcon from '@/material-icons/400-24px/share.svg?react'; import { isRedesignEnabled } from '../common'; +export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => { + const intl = useIntl(); + const { signedIn, permissions } = useIdentity(); + + const account = useAccount(accountId); + const relationship = useAppSelector((state) => + state.relationships.get(accountId), + ); + + const dispatch = useAppDispatch(); + const menuItems = useMemo(() => { + if (!account) { + return []; + } + + if (isRedesignEnabled()) { + return redesignMenuItems({ + account, + signedIn, + permissions, + intl, + relationship, + dispatch, + }); + } + return currentMenuItems({ + account, + signedIn, + permissions, + intl, + relationship, + dispatch, + }); + }, [account, signedIn, permissions, intl, relationship, dispatch]); + return ( + + ); +}; + +interface MenuItemsParams { + account: Account; + signedIn: boolean; + permissions: number; + intl: ReturnType; + relationship?: Relationship; + dispatch: AppDispatch; +} + const messages = defineMessages({ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, @@ -112,80 +175,78 @@ const messages = defineMessages({ }, }); -export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => { - const intl = useIntl(); - const { signedIn, permissions } = useIdentity(); +function currentMenuItems({ + account, + signedIn, + permissions, + intl, + relationship, + dispatch, +}: MenuItemsParams): MenuItem[] { + const items: MenuItem[] = []; + const isRemote = account.acct !== account.username; - const account = useAccount(accountId); - const relationship = useAppSelector((state) => - state.relationships.get(accountId), - ); - - const dispatch = useAppDispatch(); - const menuItems = useMemo(() => { - const arr: MenuItem[] = []; - - if (!account) { - return arr; - } - - const isRemote = account.acct !== account.username; - - if (signedIn && !account.suspended) { - arr.push({ + if (signedIn && !account.suspended) { + items.push( + { text: intl.formatMessage(messages.mention, { name: account.username, }), action: () => { dispatch(mentionCompose(account)); }, - }); - arr.push({ + }, + { text: intl.formatMessage(messages.direct, { name: account.username, }), action: () => { dispatch(directCompose(account)); }, - }); - arr.push(null); - } + }, + null, + ); + } - if (isRemote) { - arr.push({ + if (isRemote) { + items.push( + { text: intl.formatMessage(messages.openOriginalPage), href: account.url, - }); - arr.push(null); - } + }, + null, + ); + } - if (!signedIn) { - return arr; - } + if (!signedIn) { + return items; + } - if (relationship?.following) { - if (!relationship.muting) { - if (relationship.showing_reblogs) { - arr.push({ - text: intl.formatMessage(messages.hideReblogs, { - name: account.username, - }), - action: () => { - dispatch(followAccount(account.id, { reblogs: false })); - }, - }); - } else { - arr.push({ - text: intl.formatMessage(messages.showReblogs, { - name: account.username, - }), - action: () => { - dispatch(followAccount(account.id, { reblogs: true })); - }, - }); - } + if (relationship?.following) { + // Timeline options + if (!relationship.muting) { + if (relationship.showing_reblogs) { + items.push({ + text: intl.formatMessage(messages.hideReblogs, { + name: account.username, + }), + action: () => { + dispatch(followAccount(account.id, { reblogs: false })); + }, + }); + } else { + items.push({ + text: intl.formatMessage(messages.showReblogs, { + name: account.username, + }), + action: () => { + dispatch(followAccount(account.id, { reblogs: true })); + }, + }); + } - arr.push({ + items.push( + { text: intl.formatMessage(messages.languages), action: () => { dispatch( @@ -197,13 +258,295 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => { }), ); }, - }); - arr.push(null); - } + }, + null, + ); } - if (isRedesignEnabled()) { - arr.push({ + items.push( + { + text: intl.formatMessage( + relationship.endorsed ? messages.unendorse : messages.endorse, + ), + action: () => { + if (relationship.endorsed) { + dispatch(unpinAccount(account.id)); + } else { + dispatch(pinAccount(account.id)); + } + }, + }, + { + text: intl.formatMessage(messages.add_or_remove_from_list), + action: () => { + dispatch( + openModal({ + modalType: 'LIST_ADDER', + modalProps: { + accountId: account.id, + }, + }), + ); + }, + }, + null, + ); + } + + if (relationship?.followed_by) { + const handleRemoveFromFollowers = () => { + dispatch( + openModal({ + modalType: 'CONFIRM', + modalProps: { + title: intl.formatMessage(messages.confirmRemoveFromFollowersTitle), + message: intl.formatMessage( + messages.confirmRemoveFromFollowersMessage, + { name: {account.acct} }, + ), + confirm: intl.formatMessage( + messages.confirmRemoveFromFollowersButton, + ), + onConfirm: () => { + void dispatch( + removeAccountFromFollowers({ accountId: account.id }), + ); + }, + }, + }), + ); + }; + + items.push({ + text: intl.formatMessage(messages.removeFromFollowers, { + name: account.username, + }), + action: handleRemoveFromFollowers, + dangerous: true, + }); + } + + if (relationship?.muting) { + items.push({ + text: intl.formatMessage(messages.unmute, { + name: account.username, + }), + action: () => { + dispatch(unmuteAccount(account.id)); + }, + }); + } else { + items.push({ + text: intl.formatMessage(messages.mute, { + name: account.username, + }), + action: () => { + dispatch(initMuteModal(account)); + }, + dangerous: true, + }); + } + + if (relationship?.blocking) { + items.push({ + text: intl.formatMessage(messages.unblock, { + name: account.username, + }), + action: () => { + dispatch(unblockAccount(account.id)); + }, + }); + } else { + items.push({ + text: intl.formatMessage(messages.block, { + name: account.username, + }), + action: () => { + dispatch(blockAccount(account.id)); + }, + dangerous: true, + }); + } + + if (!account.suspended) { + items.push({ + text: intl.formatMessage(messages.report, { + name: account.username, + }), + action: () => { + dispatch(initReport(account)); + }, + dangerous: true, + }); + } + + const remoteDomain = isRemote ? account.acct.split('@')[1] : null; + if (remoteDomain) { + items.push(null); + + if (relationship?.domain_blocking) { + items.push({ + text: intl.formatMessage(messages.unblockDomain, { + domain: remoteDomain, + }), + action: () => { + dispatch(unblockDomain(remoteDomain)); + }, + }); + } else { + items.push({ + text: intl.formatMessage(messages.blockDomain, { + domain: remoteDomain, + }), + action: () => { + dispatch(initDomainBlockModal(account)); + }, + dangerous: true, + }); + } + } + + if ( + (permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || + (isRemote && + (permissions & PERMISSION_MANAGE_FEDERATION) === + PERMISSION_MANAGE_FEDERATION) + ) { + items.push(null); + if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { + items.push({ + text: intl.formatMessage(messages.admin_account, { + name: account.username, + }), + href: `/admin/accounts/${account.id}`, + }); + } + if ( + isRemote && + (permissions & PERMISSION_MANAGE_FEDERATION) === + PERMISSION_MANAGE_FEDERATION + ) { + items.push({ + text: intl.formatMessage(messages.admin_domain, { + domain: remoteDomain, + }), + href: `/admin/instances/${remoteDomain}`, + }); + } + } + + return items; +} + +const redesignMessages = defineMessages({ + share: { id: 'account.menu.share', defaultMessage: 'Share…' }, + copy: { id: 'account.menu.copy', defaultMessage: 'Copy link' }, + copied: { + id: 'account.menu.copied', + defaultMessage: 'Copied account link to clipboard', + }, + mention: { id: 'account.menu.mention', defaultMessage: 'Mention' }, + direct: { + id: 'account.menu.direct', + defaultMessage: 'Privately mention', + }, + mute: { id: 'account.menu.mute', defaultMessage: 'Mute account' }, + unmute: { + id: 'account.menu.unmute', + defaultMessage: 'Unmute account', + }, + block: { id: 'account.menu.block', defaultMessage: 'Block account' }, + unblock: { + id: 'account.menu.unblock', + defaultMessage: 'Unblock account', + }, + domainBlock: { + id: 'account.menu.block_domain', + defaultMessage: 'Block {domain}', + }, + domainUnblock: { + id: 'account.menu.unblock_domain', + defaultMessage: 'Unblock {domain}', + }, + report: { id: 'account.menu.report', defaultMessage: 'Report account' }, + hideReblogs: { + id: 'account.menu.hide_reblogs', + defaultMessage: 'Hide boosts in timeline', + }, + showReblogs: { + id: 'account.menu.show_reblogs', + defaultMessage: 'Show boosts in timeline', + }, + addToList: { + id: 'account.menu.add_to_list', + defaultMessage: 'Add to list…', + }, + openOriginalPage: { + id: 'account.menu.open_original_page', + defaultMessage: 'View on {domain}', + }, + removeFollower: { + id: 'account.menu.remove_follower', + defaultMessage: 'Remove follower', + }, +}); + +function redesignMenuItems({ + account, + signedIn, + permissions, + intl, + relationship, + dispatch, +}: MenuItemsParams): MenuItem[] { + const items: MenuItem[] = []; + const isRemote = account.acct !== account.username; + const remoteDomain = isRemote ? account.acct.split('@')[1] : null; + + // Share and copy link options + if (account.url) { + if ('share' in navigator) { + items.push({ + text: intl.formatMessage(redesignMessages.share), + action: () => { + void navigator.share({ + url: account.url, + }); + }, + icon: ShareIcon, + }); + } + items.push( + { + text: intl.formatMessage(redesignMessages.copy), + action: () => { + void navigator.clipboard.writeText(account.url); + dispatch(showAlert({ message: redesignMessages.copied })); + }, + icon: LinkIcon, + }, + null, + ); + } + + // Mention and direct message options + if (signedIn && !account.suspended) { + items.push( + { + text: intl.formatMessage(redesignMessages.mention), + action: () => { + dispatch(mentionCompose(account)); + }, + }, + + { + text: intl.formatMessage(redesignMessages.direct), + action: () => { + dispatch(directCompose(account)); + }, + }, + null, + { text: intl.formatMessage( relationship?.note ? messages.editNote : messages.addNote, ), @@ -217,27 +560,34 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => { }), ); }, - }); - if (!relationship?.following) { - arr.push(null); - } - } + icon: EditIcon, + }, + null, + ); + } - if (relationship?.following) { - arr.push({ - text: intl.formatMessage( - relationship.endorsed ? messages.unendorse : messages.endorse, - ), - action: () => { - if (relationship.endorsed) { - dispatch(unpinAccount(account.id)); - } else { - dispatch(pinAccount(account.id)); - } - }, - }); - arr.push({ - text: intl.formatMessage(messages.add_or_remove_from_list), + // Open on remote page. + if (isRemote) { + items.push( + { + text: intl.formatMessage(redesignMessages.openOriginalPage, { + domain: remoteDomain, + }), + href: account.url, + }, + null, + ); + } + + if (!signedIn) { + return items; + } + + // List and featuring options + if (relationship?.following) { + items.push( + { + text: intl.formatMessage(redesignMessages.addToList), action: () => { dispatch( openModal({ @@ -248,12 +598,76 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => { }), ); }, - }); - arr.push(null); + }, + { + text: intl.formatMessage( + relationship.endorsed ? messages.unendorse : messages.endorse, + ), + action: () => { + if (relationship.endorsed) { + dispatch(unpinAccount(account.id)); + } else { + dispatch(pinAccount(account.id)); + } + }, + }, + null, + ); + + // Timeline options + if (!relationship.muting) { + items.push( + { + text: intl.formatMessage( + relationship.showing_reblogs + ? redesignMessages.hideReblogs + : redesignMessages.showReblogs, + ), + action: () => { + dispatch( + followAccount(account.id, { + reblogs: !relationship.showing_reblogs, + }), + ); + }, + }, + { + text: intl.formatMessage(messages.languages), + action: () => { + dispatch( + openModal({ + modalType: 'SUBSCRIBED_LANGUAGES', + modalProps: { + accountId: account.id, + }, + }), + ); + }, + }, + ); } - if (relationship?.followed_by) { - const handleRemoveFromFollowers = () => { + items.push( + { + text: intl.formatMessage( + relationship.muting ? redesignMessages.unmute : redesignMessages.mute, + ), + action: () => { + if (relationship.muting) { + dispatch(unmuteAccount(account.id)); + } else { + dispatch(initMuteModal(account)); + } + }, + }, + null, + ); + } + + if (relationship?.followed_by) { + items.push({ + text: intl.formatMessage(redesignMessages.removeFollower), + action: () => { dispatch( openModal({ modalType: 'CONFIRM', @@ -276,134 +690,91 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => { }, }), ); - }; + }, + dangerous: true, + icon: PersonRemoveIcon, + }); + } - arr.push({ - text: intl.formatMessage(messages.removeFromFollowers, { - name: account.username, - }), - action: handleRemoveFromFollowers, - dangerous: true, - }); - } - - if (relationship?.muting) { - arr.push({ - text: intl.formatMessage(messages.unmute, { - name: account.username, - }), - action: () => { - dispatch(unmuteAccount(account.id)); - }, - }); - } else { - arr.push({ - text: intl.formatMessage(messages.mute, { - name: account.username, - }), - action: () => { - dispatch(initMuteModal(account)); - }, - dangerous: true, - }); - } - - if (relationship?.blocking) { - arr.push({ - text: intl.formatMessage(messages.unblock, { - name: account.username, - }), - action: () => { - dispatch(unblockAccount(account.id)); - }, - }); - } else { - arr.push({ - text: intl.formatMessage(messages.block, { - name: account.username, - }), - action: () => { - dispatch(blockAccount(account.id)); - }, - dangerous: true, - }); - } - - if (!account.suspended) { - arr.push({ - text: intl.formatMessage(messages.report, { - name: account.username, - }), - action: () => { - dispatch(initReport(account)); - }, - dangerous: true, - }); - } - - const remoteDomain = isRemote ? account.acct.split('@')[1] : null; - if (remoteDomain) { - arr.push(null); - - if (relationship?.domain_blocking) { - arr.push({ - text: intl.formatMessage(messages.unblockDomain, { - domain: remoteDomain, - }), - action: () => { - dispatch(unblockDomain(remoteDomain)); - }, - }); + items.push({ + text: intl.formatMessage( + relationship?.blocking + ? redesignMessages.unblock + : redesignMessages.block, + ), + action: () => { + if (relationship?.blocking) { + dispatch(unblockAccount(account.id)); } else { - arr.push({ - text: intl.formatMessage(messages.blockDomain, { - domain: remoteDomain, - }), - action: () => { - dispatch(initDomainBlockModal(account)); - }, - dangerous: true, - }); + dispatch(blockAccount(account.id)); } - } + }, + dangerous: true, + icon: BlockIcon, + }); + if (!account.suspended) { + items.push({ + text: intl.formatMessage(redesignMessages.report), + action: () => { + dispatch(initReport(account)); + }, + dangerous: true, + icon: ReportIcon, + }); + } + + if (remoteDomain) { + items.push({ + text: intl.formatMessage( + relationship?.domain_blocking + ? redesignMessages.domainUnblock + : redesignMessages.domainBlock, + { + domain: remoteDomain, + }, + ), + action: () => { + if (relationship?.domain_blocking) { + dispatch(unblockDomain(remoteDomain)); + } else { + dispatch(initDomainBlockModal(account)); + } + }, + dangerous: true, + icon: BlockIcon, + iconId: 'domain-block', + }); + } + + if ( + (permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || + (isRemote && + (permissions & PERMISSION_MANAGE_FEDERATION) === + PERMISSION_MANAGE_FEDERATION) + ) { + items.push(null); + if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { + items.push({ + text: intl.formatMessage(messages.admin_account, { + name: account.username, + }), + href: `/admin/accounts/${account.id}`, + }); + } if ( - (permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || - (isRemote && - (permissions & PERMISSION_MANAGE_FEDERATION) === - PERMISSION_MANAGE_FEDERATION) + remoteDomain && + (permissions & PERMISSION_MANAGE_FEDERATION) === + PERMISSION_MANAGE_FEDERATION ) { - arr.push(null); - if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { - arr.push({ - text: intl.formatMessage(messages.admin_account, { - name: account.username, - }), - href: `/admin/accounts/${account.id}`, - }); - } - if ( - isRemote && - (permissions & PERMISSION_MANAGE_FEDERATION) === - PERMISSION_MANAGE_FEDERATION - ) { - arr.push({ - text: intl.formatMessage(messages.admin_domain, { - domain: remoteDomain, - }), - href: `/admin/instances/${remoteDomain}`, - }); - } + items.push({ + text: intl.formatMessage(messages.admin_domain, { + domain: remoteDomain, + }), + href: `/admin/instances/${remoteDomain}`, + }); } + } - return arr; - }, [account, signedIn, permissions, intl, relationship, dispatch]); - return ( - - ); -}; + return items; +} diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/redesign.module.scss b/app/javascript/flavours/glitch/features/account_timeline/components/redesign.module.scss index 80195a7a82..944f6f7a7a 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/flavours/glitch/features/account_timeline/components/redesign.module.scss @@ -1,3 +1,7 @@ +.barWrapper { + border-bottom: none; +} + .nameWrapper { display: flex; gap: 16px; @@ -45,6 +49,43 @@ } } +$button-breakpoint: 420px; +$button-fallback-breakpoint: #{$button-breakpoint} + 55px; + +.buttonsDesktop { + @container (width < #{$button-breakpoint}) { + display: none; + } + + @supports (not (container-type: inline-size)) { + @media (max-width: #{$button-fallback-breakpoint}) { + display: none; + } + } +} + +.buttonsMobile { + position: sticky; + bottom: 55px; // Height of bottom nav bar. + + @container (width >= #{$button-breakpoint}) { + display: none; + } + + @supports (not (container-type: inline-size)) { + @media (min-width: (#{$button-fallback-breakpoint} + 1px)) { + display: none; + } + } +} + +.buttonsMobileIsStuck { + padding: 12px 16px; + background-color: var(--color-bg-primary); + border-top: 1px solid var(--color-border-primary); + margin: 0 -20px; +} + .badge { background-color: var(--color-bg-secondary); border: none; @@ -84,36 +125,112 @@ svg.badgeIcon { } } -.fieldList { +.fieldWrapper { margin-top: 16px; + width: 100%; + position: relative; +} + +.fieldWrapper::before, +.fieldWrapper::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 40px; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-in-out; + z-index: 1; +} + +.fieldWrapper::before { + left: 0; + background: linear-gradient( + to left, + transparent 0%, + var(--color-bg-primary) 100% + ); +} + +.fieldWrapper::after { + right: 0; + background: linear-gradient( + to right, + transparent 0%, + var(--color-bg-primary) 100% + ); +} + +.fieldWrapperLeft::before { + opacity: 1; +} + +.fieldWrapperRight::after { + opacity: 1; +} + +.fieldList { + display: flex; + flex-wrap: nowrap; + gap: 4px; + scroll-snap-type: x mandatory; + scroll-padding-left: 40px; + scroll-padding-right: 40px; + scroll-behavior: smooth; + overflow-x: scroll; + scrollbar-width: none; + overflow-y: visible; } .fieldCard { - position: relative; + scroll-snap-align: start; - a { - color: var(--color-text-brand); - text-decoration: none; + &:focus-visible, + &:focus-within { + outline: var(--outline-focus-default); + outline-offset: -2px; + } + + :is(dt, dd) { + max-width: 200px; } } .fieldCardVerified { background-color: var(--color-bg-brand-softer); - - dt { - padding-right: 1rem; - } - - .fieldIconVerified { - position: absolute; - top: 4px; - right: 4px; - } } -.fieldIconVerified { - width: 1rem; - height: 1rem; +.fieldArrowButton { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: var(--color-bg-elevated); + box-shadow: 0 1px 4px 0 var(--color-shadow-primary); + border-radius: 9999px; + transition: + color 0.2s ease-in-out, + background-color 0.2s ease-in-out; + outline-offset: 2px; + z-index: 2; + + &:first-child { + left: 4px; + } + + &:last-child { + right: 4px; + } + + &:hover, + &:focus, + &:focus-visible { + background-color: color-mix( + in oklab, + var(--color-bg-brand-base) var(--overlay-strength-brand), + var(--color-bg-elevated) + ); + } } .fieldNumbersWrapper { diff --git a/app/javascript/flavours/glitch/features/account_timeline/v2/featured_tags.tsx b/app/javascript/flavours/glitch/features/account_timeline/v2/featured_tags.tsx index 637c18c2aa..8a804d7809 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/v2/featured_tags.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/v2/featured_tags.tsx @@ -9,7 +9,7 @@ import { useParams } from 'react-router'; import { fetchFeaturedTags } from '@/flavours/glitch/actions/featured_tags'; import { useAppHistory } from '@/flavours/glitch/components/router'; import { Tag } from '@/flavours/glitch/components/tags/tag'; -import { useOverflow } from '@/flavours/glitch/hooks/useOverflow'; +import { useOverflowButton } from '@/flavours/glitch/hooks/useOverflow'; import { selectAccountFeaturedTags } from '@/flavours/glitch/selectors/accounts'; import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; @@ -30,7 +30,7 @@ export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => { // Get list of tags with overflow handling. const [showOverflow, setShowOverflow] = useState(false); const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } = - useOverflow(); + useOverflowButton(); // Handle whether to show all tags. const handleOverflowClick: MouseEventHandler = useCallback(() => { diff --git a/app/javascript/flavours/glitch/features/compose/index.tsx b/app/javascript/flavours/glitch/features/compose/index.tsx index 3eb689dd8a..56e91b7f48 100644 --- a/app/javascript/flavours/glitch/features/compose/index.tsx +++ b/app/javascript/flavours/glitch/features/compose/index.tsx @@ -220,7 +220,7 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => { {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- this is not a feature but a visual easter egg */}
diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.tsx b/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.tsx index 677e8edf05..14e5b5d71c 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.tsx +++ b/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.tsx @@ -1,9 +1,10 @@ -import { useEffect, useCallback, useRef, useState, useId } from 'react'; +import { useEffect, useCallback, useId } from 'react'; import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; import { Link } from 'react-router-dom'; +import { useOverflowScroll } from '@/flavours/glitch/hooks/useOverflow'; import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; @@ -178,74 +179,24 @@ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean, ); - const bodyRef = useRef(null); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(true); useEffect(() => { void dispatch(fetchSuggestions()); }, [dispatch]); - useEffect(() => { - if (!bodyRef.current) { - return; - } - - if (getComputedStyle(bodyRef.current).direction === 'rtl') { - setCanScrollLeft( - bodyRef.current.clientWidth - bodyRef.current.scrollLeft < - bodyRef.current.scrollWidth, - ); - setCanScrollRight(bodyRef.current.scrollLeft < 0); - } else { - setCanScrollLeft(bodyRef.current.scrollLeft > 0); - setCanScrollRight( - bodyRef.current.scrollLeft + bodyRef.current.clientWidth < - bodyRef.current.scrollWidth, - ); - } - }, [setCanScrollRight, setCanScrollLeft, suggestions]); - - const handleLeftNav = useCallback(() => { - if (!bodyRef.current) { - return; - } - - bodyRef.current.scrollLeft -= 200; - }, []); - - const handleRightNav = useCallback(() => { - if (!bodyRef.current) { - return; - } - - bodyRef.current.scrollLeft += 200; - }, []); - - const handleScroll = useCallback(() => { - if (!bodyRef.current) { - return; - } - - if (getComputedStyle(bodyRef.current).direction === 'rtl') { - setCanScrollLeft( - bodyRef.current.clientWidth - bodyRef.current.scrollLeft < - bodyRef.current.scrollWidth, - ); - setCanScrollRight(bodyRef.current.scrollLeft < 0); - } else { - setCanScrollLeft(bodyRef.current.scrollLeft > 0); - setCanScrollRight( - bodyRef.current.scrollLeft + bodyRef.current.clientWidth < - bodyRef.current.scrollWidth, - ); - } - }, [setCanScrollRight, setCanScrollLeft]); - const handleDismiss = useCallback(() => { dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true)); }, [dispatch]); + const { + bodyRef, + handleScroll, + canScrollLeft, + canScrollRight, + handleLeftNav, + handleRightNav, + } = useOverflowScroll({ absoluteDistance: true }); + if (dismissed || (!isLoading && suggestions.length === 0)) { return null; } diff --git a/app/javascript/flavours/glitch/features/navigation_panel/components/disabled_account_banner.tsx b/app/javascript/flavours/glitch/features/navigation_panel/components/disabled_account_banner.tsx index fd93aa0bf6..587ca963e0 100644 --- a/app/javascript/flavours/glitch/features/navigation_panel/components/disabled_account_banner.tsx +++ b/app/javascript/flavours/glitch/features/navigation_panel/components/disabled_account_banner.tsx @@ -75,7 +75,7 @@ export const DisabledAccountBanner: React.FC = () => {
diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.jsx b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.jsx index 8578cb299b..b41a152792 100644 --- a/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.jsx @@ -98,7 +98,7 @@ class BundleColumnError extends PureComponent {
{errorType === 'network' && } {errorType === 'error' && } - +
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_placeholder.tsx b/app/javascript/flavours/glitch/features/ui/components/modal_placeholder.tsx index 826c3007e6..9c4c03f290 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_placeholder.tsx +++ b/app/javascript/flavours/glitch/features/ui/components/modal_placeholder.tsx @@ -46,7 +46,7 @@ export const ModalPlaceholder: React.FC<{ defaultMessage='Try again' /> -