diff --git a/FEDERATION.md b/FEDERATION.md index 0ac44afc3c..7593d6d953 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -13,7 +13,8 @@ - [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md) - [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) - [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) -- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md): partial support for incoming quote-posts +- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) +- [FEP-3b86: Activity Intents](https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md): offer handlers for `Object` and `Create` (with support for the `content` parameter only), has support for the `Follow`, `Announce`, `Like` and `Object` intents ## ActivityPub in Mastodon @@ -68,3 +69,5 @@ The following table summarizes those limits. | Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated | | Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected | | Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated | +| Collection name (`FeaturedCollection` `name`) | 256 | Name will be truncated | +| Collection description (`FeaturedCollection` `summary`) | 2048 | Description will be truncated | diff --git a/Gemfile.lock b/Gemfile.lock index 120b28f757..5ab80abb8c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -99,7 +99,7 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1222.0) + aws-partitions (1.1223.0) aws-sdk-core (3.243.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -157,7 +157,7 @@ GEM case_transform (0.2) activesupport cbor (0.5.10.1) - cgi (0.4.2) + cgi (0.5.1) charlock_holmes (0.7.9) chewy (7.6.0) activesupport (>= 5.2) @@ -209,7 +209,7 @@ GEM activerecord (>= 4.2, < 9.0) docile (1.4.1) domain_name (0.6.20240107) - doorkeeper (5.8.2) + doorkeeper (5.9.0) railties (>= 5) dotenv (3.2.0) drb (2.2.3) @@ -230,7 +230,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - excon (1.3.2) + excon (1.4.0) logger fabrication (3.0.0) faker (3.6.1) @@ -246,7 +246,7 @@ GEM faraday-net_http (3.4.2) net-http (~> 0.5) fast_blank (1.0.1) - fastimage (2.4.0) + fastimage (2.4.1) ffi (1.17.3) ffi-compiler (1.3.2) ffi (>= 1.15.5) @@ -276,9 +276,9 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) - google-protobuf (4.33.5) + google-protobuf (4.34.0) bigdecimal - rake (>= 13) + rake (~> 13.3) googleapis-common-protos-types (1.22.0) google-protobuf (~> 4.26) haml (7.2.0) @@ -352,7 +352,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.18.1) + json (2.19.1) json-canonicalization (1.0.0) json-jwt (1.17.0) activesupport (>= 4.2) @@ -446,7 +446,7 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0224) + mime-types-data (3.2026.0303) mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (6.0.2) @@ -507,7 +507,7 @@ GEM tzinfo validate_url webfinger (~> 2.0) - openssl (3.3.2) + openssl (4.0.1) openssl-signature_algorithm (1.3.0) openssl (> 2.0) opentelemetry-api (1.7.0) @@ -738,17 +738,17 @@ GEM rspec-support (~> 3.13.0) rspec-github (3.0.0) rspec-core (~> 3.0) - rspec-mocks (3.13.7) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.3) + rspec-rails (8.0.4) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) rspec-sidekiq (5.3.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) @@ -766,7 +766,7 @@ GEM rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-capybara (2.22.1) @@ -792,7 +792,7 @@ GEM lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-rspec (~> 3.5) - ruby-prof (2.0.2) + ruby-prof (2.0.4) base64 ostruct ruby-progressbar (1.13.0) @@ -847,7 +847,7 @@ GEM stackprof (0.2.28) starry (0.2.0) base64 - stoplight (5.7.0) + stoplight (5.8.0) concurrent-ruby zeitwerk stringio (3.2.0) @@ -867,7 +867,7 @@ GEM test-prof (1.5.2) thor (1.5.0) tilt (2.7.0) - timeout (0.6.0) + timeout (0.6.1) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) @@ -1100,4 +1100,4 @@ RUBY VERSION ruby 3.4.8 BUNDLED WITH - 4.0.7 + 4.0.8 diff --git a/app/javascript/entrypoints/remote_interaction_helper.ts b/app/javascript/entrypoints/remote_interaction_helper.ts index 26f9e1f4e0..093f6a7ec2 100644 --- a/app/javascript/entrypoints/remote_interaction_helper.ts +++ b/app/javascript/entrypoints/remote_interaction_helper.ts @@ -39,26 +39,27 @@ const findLink = (rel: string, data: unknown): JRDLink | undefined => { } }; -const intentParams = (intent: string) => { +const intentParams = (intent: string): [string, string] | null => { switch (intent) { case 'follow': - return ['https://w3id.org/fep/3b86/Follow', 'object'] as [string, string]; + return ['https://w3id.org/fep/3b86/Follow', 'object']; case 'reblog': - return ['https://w3id.org/fep/3b86/Announce', 'object'] as [ - string, - string, - ]; + return ['https://w3id.org/fep/3b86/Announce', 'object']; case 'favourite': - return ['https://w3id.org/fep/3b86/Like', 'object'] as [string, string]; + return ['https://w3id.org/fep/3b86/Like', 'object']; case 'vote': case 'reply': - return ['https://w3id.org/fep/3b86/Object', 'object'] as [string, string]; + return ['https://w3id.org/fep/3b86/Object', 'object']; default: return null; } }; -const findTemplateLink = (data: unknown, intent: string) => { +const findTemplateLink = ( + data: unknown, + intent: string, +): [string, string] | [null, null] => { + // Find the FEP-3b86 handler for the specific intent const [needle, param] = intentParams(intent) ?? [ 'http://ostatus.org/schema/1.0/subscribe', 'uri', @@ -66,14 +67,21 @@ const findTemplateLink = (data: unknown, intent: string) => { const match = findLink(needle, data); - if (match) { - return [match.template, param] as [string, string]; + if (match?.template) { + return [match.template, param]; } - const fallback = findLink('http://ostatus.org/schema/1.0/subscribe', data); + // If the specific intent wasn't found, try the FEP-3b86 handler for the `Object` intent + let fallback = findLink('https://w3id.org/fep/3b86/Object', data); + if (fallback?.template) { + return [fallback.template, 'object']; + } - if (fallback) { - return [fallback.template, 'uri'] as [string, string]; + // If it's still not found, try the legacy OStatus subscribe handler + fallback = findLink('http://ostatus.org/schema/1.0/subscribe', data); + + if (fallback?.template) { + return [fallback.template, 'uri']; } return [null, null]; diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx index d9352018bb..a0c704a4e7 100644 --- a/app/javascript/mastodon/components/hover_card_controller.tsx +++ b/app/javascript/mastodon/components/hover_card_controller.tsx @@ -14,6 +14,10 @@ import { useTimeout } from 'mastodon/hooks/useTimeout'; const offset = [-12, 4] as OffsetValue; const enterDelay = 750; const leaveDelay = 150; +// Only open the card if the mouse was moved within this time, +// to avoid triggering the card without intentional mouse movement +// (e.g. when content changed underneath the mouse cursor) +const activeMovementThreshold = 150; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; const isHoverCardAnchor = (element: HTMLElement) => @@ -23,10 +27,10 @@ export const HoverCardController: React.FC = () => { const [open, setOpen] = useState(false); const [accountId, setAccountId] = useState(); const [anchor, setAnchor] = useState(null); - const isUsingTouchRef = useRef(false); const cardRef = useRef(null); const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout(); + const [setMoveTimeout, cancelMoveTimeout] = useTimeout(); const [setScrollTimeout] = useTimeout(); const handleClose = useCallback(() => { @@ -45,6 +49,8 @@ export const HoverCardController: React.FC = () => { useEffect(() => { let isScrolling = false; + let isUsingTouch = false; + let isActiveMouseMovement = false; let currentAnchor: HTMLElement | null = null; let currentTitle: string | null = null; @@ -66,7 +72,7 @@ export const HoverCardController: React.FC = () => { const handleTouchStart = () => { // Keeping track of touch events to prevent the // hover card from being displayed on touch devices - isUsingTouchRef.current = true; + isUsingTouch = true; }; const handleMouseEnter = (e: MouseEvent) => { @@ -78,13 +84,14 @@ export const HoverCardController: React.FC = () => { return; } - // Bail out if a touch is active - if (isUsingTouchRef.current) { + // Bail out if we're scrolling, a touch is active, + // or if there was no active mouse movement + if (isScrolling || !isActiveMouseMovement || isUsingTouch) { return; } // We've entered an anchor - if (!isScrolling && isHoverCardAnchor(target)) { + if (isHoverCardAnchor(target)) { cancelLeaveTimeout(); currentAnchor?.removeAttribute('aria-describedby'); @@ -99,10 +106,7 @@ export const HoverCardController: React.FC = () => { } // We've entered the hover card - if ( - !isScrolling && - (target === currentAnchor || target === cardRef.current) - ) { + if (target === currentAnchor || target === cardRef.current) { cancelLeaveTimeout(); } }; @@ -141,10 +145,17 @@ export const HoverCardController: React.FC = () => { }; const handleMouseMove = () => { - if (isUsingTouchRef.current) { - isUsingTouchRef.current = false; + if (isUsingTouch) { + isUsingTouch = false; } + delayEnterTimeout(enterDelay); + + cancelMoveTimeout(); + isActiveMouseMovement = true; + setMoveTimeout(() => { + isActiveMouseMovement = false; + }, activeMovementThreshold); }; document.body.addEventListener('touchstart', handleTouchStart, { @@ -188,6 +199,8 @@ export const HoverCardController: React.FC = () => { setOpen, setAccountId, setAnchor, + setMoveTimeout, + cancelMoveTimeout, ]); return ( diff --git a/app/javascript/mastodon/features/account_edit/index.tsx b/app/javascript/mastodon/features/account_edit/index.tsx index 43a13f612a..7dc2397f8b 100644 --- a/app/javascript/mastodon/features/account_edit/index.tsx +++ b/app/javascript/mastodon/features/account_edit/index.tsx @@ -205,24 +205,21 @@ export const AccountEdit: FC = () => { showDescription={!hasFields} buttons={ <> - {profile.fields.length > 1 && ( - - )} - {hasFields && ( - = maxFieldCount} + + = maxFieldCount} + /> } > diff --git a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx index c7b3b6ebc5..b5a095cf68 100644 --- a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx @@ -18,6 +18,7 @@ import { useAppDispatch, useAppSelector, } from '@/mastodon/store'; +import { isUrlWithoutProtocol } from '@/mastodon/utils/checks'; import { ConfirmationModal } from '../../ui/components/confirmation_modals'; import type { DialogModalProps } from '../../ui/components/dialog_modal'; @@ -48,7 +49,7 @@ const messages = defineMessages({ }, editValueHint: { id: 'account_edit.field_edit_modal.value_hint', - defaultMessage: 'E.g. “example.me”', + defaultMessage: 'E.g. “https://example.me”', }, limitHeader: { id: 'account_edit.field_edit_modal.limit_header', @@ -109,6 +110,10 @@ export const EditFieldModal: FC = ({ ); return hasLink && hasEmoji; }, [customEmojiCodes, newLabel, newValue]); + const hasLinkWithoutProtocol = useMemo( + () => isUrlWithoutProtocol(newValue), + [newValue], + ); const dispatch = useAppDispatch(); const handleSave = useCallback(() => { @@ -175,6 +180,19 @@ export const EditFieldModal: FC = ({ /> )} + + {hasLinkWithoutProtocol && ( + + https://, + }} + /> + + )} ); }; diff --git a/app/javascript/mastodon/features/account_edit/modals/fields_reorder_modal.tsx b/app/javascript/mastodon/features/account_edit/modals/fields_reorder_modal.tsx index 5eee431a27..8a94c99ac2 100644 --- a/app/javascript/mastodon/features/account_edit/modals/fields_reorder_modal.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/fields_reorder_modal.tsx @@ -212,11 +212,9 @@ export const ReorderFieldsModal: FC = ({ onClose }) => { return; } newFields.push({ name: field.name, value: field.value }); - - void dispatch(patchProfile({ fields_attributes: newFields })).then( - onClose, - ); } + + void dispatch(patchProfile({ fields_attributes: newFields })).then(onClose); }, [dispatch, fieldKeys, fields, onClose]); const emojis = useAppSelector((state) => state.custom_emojis); diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index 9870e44bc6..8db00e73d3 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Helmet } from 'react-helmet'; -import { useLocation, useParams } from 'react-router'; +import { useHistory, useLocation, useParams } from 'react-router'; import { openModal } from '@/mastodon/actions/modal'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; @@ -84,6 +84,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ const intl = useIntl(); const { name, description, tag, account_id } = collection; const dispatch = useAppDispatch(); + const history = useHistory(); const handleShare = useCallback(() => { dispatch( @@ -97,12 +98,14 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ }, [collection, dispatch]); const location = useLocation<{ newCollection?: boolean } | undefined>(); - const wasJustCreated = location.state?.newCollection; + const isNewCollection = location.state?.newCollection; useEffect(() => { - if (wasJustCreated) { + if (isNewCollection) { + // Replace with current pathname to clear `newCollection` state + history.replace(location.pathname); handleShare(); } - }, [handleShare, wasJustCreated]); + }, [history, handleShare, isNewCollection, location.pathname]); return (
diff --git a/app/javascript/mastodon/features/collections/detail/share_modal.tsx b/app/javascript/mastodon/features/collections/detail/share_modal.tsx index 0f4681d077..26bab6abe0 100644 --- a/app/javascript/mastodon/features/collections/detail/share_modal.tsx +++ b/app/javascript/mastodon/features/collections/detail/share_modal.tsx @@ -64,7 +64,7 @@ export const CollectionShareModal: React.FC<{ onClose(); dispatch(changeCompose(shareMessage)); dispatch(focusCompose()); - }, [collectionLink, dispatch, intl, isOwnCollection, onClose]); + }, [onClose, collectionLink, dispatch, intl, isOwnCollection]); return ( diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index 47af9e211c..423b72e628 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -2,7 +2,7 @@ import { useCallback, useId, useMemo, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; -import { useHistory, useLocation } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import CancelIcon from '@/material-icons/400-24px/cancel.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; @@ -30,12 +30,12 @@ import { useAccount } from 'mastodon/hooks/useAccount'; import { me } from 'mastodon/initial_state'; import { addCollectionItem, + getCollectionItemIds, removeCollectionItem, + updateCollectionEditorField, } from 'mastodon/reducers/slices/collections'; import { store, useAppDispatch, useAppSelector } from 'mastodon/store'; -import type { TempCollectionState } from './state'; -import { getCollectionEditorState } from './state'; import classes from './styles.module.scss'; import { WizardStepHeader } from './wizard_step_header'; @@ -52,9 +52,8 @@ function isOlderThanAWeek(date?: string): boolean { const AddedAccountItem: React.FC<{ accountId: string; - isRemovable: boolean; onRemove: (id: string) => void; -}> = ({ accountId, isRemovable, onRemove }) => { +}> = ({ accountId, onRemove }) => { const intl = useIntl(); const account = useAccount(accountId); @@ -86,17 +85,15 @@ const AddedAccountItem: React.FC<{ id={accountId} extraAccountInfo={lastPostHint} > - {isRemovable && ( - - )} + ); }; @@ -139,28 +136,25 @@ export const CollectionAccounts: React.FC<{ const intl = useIntl(); const dispatch = useAppDispatch(); const history = useHistory(); - const location = useLocation(); - const { id, initialItemIds } = getCollectionEditorState( - collection, - location.state, - ); - const isEditMode = !!id; - const collectionItems = collection?.items; - const [searchValue, setSearchValue] = useState(''); - // This state is only used when creating a new collection. - // In edit mode, the collection will be updated instantly - const [addedAccountIds, setAccountIds] = useState(initialItemIds); + const { id, items } = collection ?? {}; + const isEditMode = !!id; + const collectionItems = items; + + const addedAccountIds = useAppSelector( + (state) => state.collections.editor.accountIds, + ); + + // In edit mode, we're bypassing state and just return collection items directly, + // since they're edited "live", saving after each addition/deletion const accountIds = useMemo( () => - isEditMode - ? (collectionItems - ?.map((item) => item.account_id) - .filter((id): id is string => !!id) ?? []) - : addedAccountIds, + isEditMode ? getCollectionItemIds(collectionItems) : addedAccountIds, [isEditMode, collectionItems, addedAccountIds], ); + const [searchValue, setSearchValue] = useState(''); + const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT; const { @@ -233,28 +227,41 @@ export const CollectionAccounts: React.FC<{ [dispatch, relationships], ); - const removeAccountItem = useCallback((accountId: string) => { - setAccountIds((ids) => ids.filter((id) => id !== accountId)); - }, []); + const removeAccountItem = useCallback( + (accountId: string) => { + dispatch( + updateCollectionEditorField({ + field: 'accountIds', + value: accountIds.filter((id) => id !== accountId), + }), + ); + }, + [accountIds, dispatch], + ); const addAccountItem = useCallback( (accountId: string) => { confirmFollowStatus(accountId, () => { - setAccountIds((ids) => [...ids, accountId]); + dispatch( + updateCollectionEditorField({ + field: 'accountIds', + value: [...accountIds, accountId], + }), + ); }); }, - [confirmFollowStatus], + [accountIds, confirmFollowStatus, dispatch], ); const toggleAccountItem = useCallback( (item: SuggestionItem) => { - if (addedAccountIds.includes(item.id)) { + if (accountIds.includes(item.id)) { removeAccountItem(item.id); } else { addAccountItem(item.id); } }, - [addAccountItem, addedAccountIds, removeAccountItem], + [accountIds, addAccountItem, removeAccountItem], ); const instantRemoveAccountItem = useCallback( @@ -406,7 +413,6 @@ export const CollectionAccounts: React.FC<{ > diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index 6234bca514..f59bd4de51 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -1,13 +1,13 @@ -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useHistory, useLocation } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { isFulfilled } from '@reduxjs/toolkit'; +import { inputToHashtag } from '@/mastodon/utils/hashtags'; import type { - ApiCollectionJSON, ApiCreateCollectionPayload, ApiUpdateCollectionPayload, } from 'mastodon/api_types/collections'; @@ -23,70 +23,77 @@ import { TextInputField } from 'mastodon/components/form_fields/text_input_field import { createCollection, updateCollection, + updateCollectionEditorField, } from 'mastodon/reducers/slices/collections'; -import { useAppDispatch } from 'mastodon/store'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; -import type { TempCollectionState } from './state'; -import { getCollectionEditorState } from './state'; import classes from './styles.module.scss'; import { WizardStepHeader } from './wizard_step_header'; -export const CollectionDetails: React.FC<{ - collection?: ApiCollectionJSON | null; -}> = ({ collection }) => { +export const CollectionDetails: React.FC = () => { const dispatch = useAppDispatch(); const history = useHistory(); - const location = useLocation(); - - const { - id, - initialName, - initialDescription, - initialTopic, - initialItemIds, - initialDiscoverable, - initialSensitive, - } = getCollectionEditorState(collection, location.state); - - const [name, setName] = useState(initialName); - const [description, setDescription] = useState(initialDescription); - const [topic, setTopic] = useState(initialTopic); - const [discoverable, setDiscoverable] = useState(initialDiscoverable); - const [sensitive, setSensitive] = useState(initialSensitive); + const { id, name, description, topic, discoverable, sensitive, accountIds } = + useAppSelector((state) => state.collections.editor); const handleNameChange = useCallback( (event: React.ChangeEvent) => { - setName(event.target.value); + dispatch( + updateCollectionEditorField({ + field: 'name', + value: event.target.value, + }), + ); }, - [], + [dispatch], ); const handleDescriptionChange = useCallback( (event: React.ChangeEvent) => { - setDescription(event.target.value); + dispatch( + updateCollectionEditorField({ + field: 'description', + value: event.target.value, + }), + ); }, - [], + [dispatch], ); const handleTopicChange = useCallback( (event: React.ChangeEvent) => { - setTopic(event.target.value); + dispatch( + updateCollectionEditorField({ + field: 'topic', + value: inputToHashtag(event.target.value), + }), + ); }, - [], + [dispatch], ); const handleDiscoverableChange = useCallback( (event: React.ChangeEvent) => { - setDiscoverable(event.target.value === 'public'); + dispatch( + updateCollectionEditorField({ + field: 'discoverable', + value: event.target.value === 'public', + }), + ); }, - [], + [dispatch], ); const handleSensitiveChange = useCallback( (event: React.ChangeEvent) => { - setSensitive(event.target.checked); + dispatch( + updateCollectionEditorField({ + field: 'sensitive', + value: event.target.checked, + }), + ); }, - [], + [dispatch], ); const handleSubmit = useCallback( @@ -112,7 +119,7 @@ export const CollectionDetails: React.FC<{ description, discoverable, sensitive, - account_ids: initialItemIds, + account_ids: accountIds, }; if (topic) { payload.tag_name = topic; @@ -124,9 +131,7 @@ export const CollectionDetails: React.FC<{ }), ).then((result) => { if (isFulfilled(result)) { - history.replace( - `/collections/${result.payload.collection.id}/edit/details`, - ); + history.replace(`/collections`); history.push(`/collections/${result.payload.collection.id}`, { newCollection: true, }); @@ -143,7 +148,7 @@ export const CollectionDetails: React.FC<{ sensitive, dispatch, history, - initialItemIds, + accountIds, ], ); @@ -215,6 +220,9 @@ export const CollectionDetails: React.FC<{ } value={topic} onChange={handleTopicChange} + autoCapitalize='off' + autoCorrect='off' + spellCheck='false' maxLength={40} /> diff --git a/app/javascript/mastodon/features/collections/editor/index.tsx b/app/javascript/mastodon/features/collections/editor/index.tsx index 2200bccb17..ff1549b942 100644 --- a/app/javascript/mastodon/features/collections/editor/index.tsx +++ b/app/javascript/mastodon/features/collections/editor/index.tsx @@ -16,7 +16,10 @@ import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { fetchCollection } from 'mastodon/reducers/slices/collections'; +import { + collectionEditorActions, + fetchCollection, +} from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { CollectionAccounts } from './accounts'; @@ -68,6 +71,7 @@ export const CollectionEditorPage: React.FC<{ const collection = useAppSelector((state) => id ? state.collections.collections[id] : undefined, ); + const editorStateId = useAppSelector((state) => state.collections.editor.id); const isEditMode = !!id; const isLoading = isEditMode && !collection; @@ -77,6 +81,18 @@ export const CollectionEditorPage: React.FC<{ } }, [dispatch, id]); + useEffect(() => { + if (id !== editorStateId) { + void dispatch(collectionEditorActions.reset()); + } + }, [dispatch, editorStateId, id]); + + useEffect(() => { + if (collection) { + void dispatch(collectionEditorActions.init(collection)); + } + }, [dispatch, collection]); + const pageTitle = intl.formatMessage(usePageTitle(id)); return ( @@ -104,7 +120,7 @@ export const CollectionEditorPage: React.FC<{ exact path={`${path}/details`} // eslint-disable-next-line react/jsx-no-bind - render={() => } + render={() => } /> )} diff --git a/app/javascript/mastodon/features/collections/editor/state.ts b/app/javascript/mastodon/features/collections/editor/state.ts deleted file mode 100644 index abac0b94b5..0000000000 --- a/app/javascript/mastodon/features/collections/editor/state.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { - ApiCollectionJSON, - ApiCreateCollectionPayload, -} from '@/mastodon/api_types/collections'; - -/** - * Temporary editor state across creation steps, - * kept in location state - */ -export type TempCollectionState = - | Partial - | undefined; - -/** - * Resolve initial editor state. Temporary location state - * trumps stored data, otherwise initial values are returned. - */ -export function getCollectionEditorState( - collection: ApiCollectionJSON | null | undefined, - locationState: TempCollectionState, -) { - const { - id, - name = '', - description = '', - tag, - language = '', - discoverable = true, - sensitive = false, - items, - } = collection ?? {}; - - const collectionItemIds = - items?.map((item) => item.account_id).filter(onlyExistingIds) ?? []; - - const initialItemIds = ( - locationState?.account_ids ?? collectionItemIds - ).filter(onlyExistingIds); - - return { - id, - initialItemIds, - initialName: locationState?.name ?? name, - initialDescription: locationState?.description ?? description, - initialTopic: locationState?.tag_name ?? tag?.name ?? '', - initialLanguage: locationState?.language ?? language, - initialDiscoverable: locationState?.discoverable ?? discoverable, - initialSensitive: locationState?.sensitive ?? sensitive, - }; -} - -const onlyExistingIds = (id?: string): id is string => !!id; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 57b868c848..2ede5449d1 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -173,7 +173,8 @@ "account_edit.field_edit_modal.link_emoji_warning": "We recommend against the use of custom emoji in combination with urls. Custom fields containing both will display as text only instead of as a link, in order to prevent user confusion.", "account_edit.field_edit_modal.name_hint": "E.g. “Personal website”", "account_edit.field_edit_modal.name_label": "Label", - "account_edit.field_edit_modal.value_hint": "E.g. “example.me”", + "account_edit.field_edit_modal.url_warning": "To add a link, please include {protocol} at the beginning.", + "account_edit.field_edit_modal.value_hint": "E.g. “https://example.me”", "account_edit.field_edit_modal.value_label": "Value", "account_edit.field_reorder_modal.drag_cancel": "Dragging was cancelled. Field \"{item}\" was dropped.", "account_edit.field_reorder_modal.drag_end": "Field \"{item}\" was dropped.", diff --git a/app/javascript/mastodon/locales/fr-CA.json b/app/javascript/mastodon/locales/fr-CA.json index a27b980d3e..6360c49eeb 100644 --- a/app/javascript/mastodon/locales/fr-CA.json +++ b/app/javascript/mastodon/locales/fr-CA.json @@ -338,12 +338,14 @@ "collections.create_collection": "Créer une collection", "collections.delete_collection": "Supprimer la collection", "collections.description_length_hint": "Maximum 100 caractères", + "collections.detail.accept_inclusion": "D'accord", "collections.detail.accounts_heading": "Comptes", "collections.detail.author_added_you": "{author} vous a ajouté·e à cette collection", "collections.detail.curated_by_author": "Organisée par {author}", "collections.detail.curated_by_you": "Organisée par vous", "collections.detail.loading": "Chargement de la collection…", "collections.detail.other_accounts_in_collection": "Autres comptes dans cette collection :", + "collections.detail.revoke_inclusion": "Me retirer", "collections.detail.sensitive_note": "Cette collection contient des comptes et du contenu qui peut être sensibles.", "collections.detail.share": "Partager la collection", "collections.edit_details": "Modifier les détails", @@ -359,6 +361,9 @@ "collections.old_last_post_note": "Dernière publication il y a plus d'une semaine", "collections.remove_account": "Supprimer ce compte", "collections.report_collection": "Signaler cette collection", + "collections.revoke_collection_inclusion": "Me retirer de cette collection", + "collections.revoke_inclusion.confirmation": "Vous avez été retiré·e de « {collection} »", + "collections.revoke_inclusion.error": "Une erreur s'est produite, veuillez réessayer plus tard.", "collections.search_accounts_label": "Chercher des comptes à ajouter…", "collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes", "collections.sensitive": "Sensible", @@ -482,6 +487,9 @@ "confirmations.remove_from_followers.confirm": "Supprimer l'abonné·e", "confirmations.remove_from_followers.message": "{name} cessera de vous suivre. Voulez-vous vraiment continuer ?", "confirmations.remove_from_followers.title": "Supprimer l'abonné·e ?", + "confirmations.revoke_collection_inclusion.confirm": "Me retirer", + "confirmations.revoke_collection_inclusion.message": "Cette action est permanente, la personne qui gère la collection ne pourra plus vous y rajouter plus tard.", + "confirmations.revoke_collection_inclusion.title": "Vous retirer de cette collection ?", "confirmations.revoke_quote.confirm": "Retirer le message", "confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.", "confirmations.revoke_quote.title": "Retirer le message ?", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 1bf320aa01..acde4d4a92 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -338,12 +338,14 @@ "collections.create_collection": "Créer une collection", "collections.delete_collection": "Supprimer la collection", "collections.description_length_hint": "Maximum 100 caractères", + "collections.detail.accept_inclusion": "D'accord", "collections.detail.accounts_heading": "Comptes", "collections.detail.author_added_you": "{author} vous a ajouté·e à cette collection", "collections.detail.curated_by_author": "Organisée par {author}", "collections.detail.curated_by_you": "Organisée par vous", "collections.detail.loading": "Chargement de la collection…", "collections.detail.other_accounts_in_collection": "Autres comptes dans cette collection :", + "collections.detail.revoke_inclusion": "Me retirer", "collections.detail.sensitive_note": "Cette collection contient des comptes et du contenu qui peut être sensibles.", "collections.detail.share": "Partager la collection", "collections.edit_details": "Modifier les détails", @@ -359,6 +361,9 @@ "collections.old_last_post_note": "Dernière publication il y a plus d'une semaine", "collections.remove_account": "Supprimer ce compte", "collections.report_collection": "Signaler cette collection", + "collections.revoke_collection_inclusion": "Me retirer de cette collection", + "collections.revoke_inclusion.confirmation": "Vous avez été retiré·e de « {collection} »", + "collections.revoke_inclusion.error": "Une erreur s'est produite, veuillez réessayer plus tard.", "collections.search_accounts_label": "Chercher des comptes à ajouter…", "collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes", "collections.sensitive": "Sensible", @@ -482,6 +487,9 @@ "confirmations.remove_from_followers.confirm": "Supprimer l'abonné·e", "confirmations.remove_from_followers.message": "{name} cessera de vous suivre. Voulez-vous vraiment continuer ?", "confirmations.remove_from_followers.title": "Supprimer l'abonné·e ?", + "confirmations.revoke_collection_inclusion.confirm": "Me retirer", + "confirmations.revoke_collection_inclusion.message": "Cette action est permanente, la personne qui gère la collection ne pourra plus vous y rajouter plus tard.", + "confirmations.revoke_collection_inclusion.title": "Vous retirer de cette collection ?", "confirmations.revoke_quote.confirm": "Retirer le message", "confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.", "confirmations.revoke_quote.title": "Retirer le message ?", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 665cd22df8..4e3c3fb7df 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -177,7 +177,10 @@ "account_edit.field_edit_modal.value_label": "Érték", "account_edit.field_reorder_modal.drag_cancel": "Az áthúzás megszakítva. A(z) „{item}” mező el lett dobva.", "account_edit.field_reorder_modal.drag_end": "A(z) „{item}” mező el lett dobva.", + "account_edit.field_reorder_modal.drag_instructions": "Az egyéni mezők átrendezéséhez nyomj Szóközt vagy Entert. Húzás közben használd a nyílgombokat a mező felfelé vagy lefelé mozgatásához. A mező új pozícióba helyezéséhez nyomd meg a Szóközt vagy az Entert, vagy a megszakításhoz nyomd meg az Esc gombot.", "account_edit.field_reorder_modal.drag_move": "A(z) „{item}” mező át lett helyezve.", + "account_edit.field_reorder_modal.drag_over": "A(z) „{item}” mező át lett helyezve ennek a helyére: „{over}”.", + "account_edit.field_reorder_modal.drag_start": "A(z) „{item}” mező áthelyezéshez felvéve.", "account_edit.field_reorder_modal.handle_label": "A(z) „{item}” mező húzása", "account_edit.field_reorder_modal.title": "Mezők átrendezése", "account_edit.name_modal.add_title": "Megjelenítendő név hozzáadása", @@ -194,6 +197,8 @@ "account_edit.profile_tab.subtitle": "Szabd testre a profilodon látható lapokat, és a megjelenített tartalmukat.", "account_edit.profile_tab.title": "Profil lap beállításai", "account_edit.save": "Mentés", + "account_edit.verified_modal.details": "Növeld a Mastodon-profilod hitelességét a személyes webhelyekre mutató hivatkozások ellenőrzésével. Így működik:", + "account_edit.verified_modal.invisible_link.details": "A hivatkozás hozzáadása a fejlécedhez. A fontos rész a rel=\"me\", mely megakadályozza, hogy mások a nevedben lépjenek fel olyan oldalakon, ahol van felhasználók által előállított tartalom. A(z) {tag} helyett a „link” címkét is használhatod az oldal fejlécében, de a HTML-nek elérhetőnek kell lennie JavaScript futtatása nélkül is.", "account_edit.verified_modal.invisible_link.summary": "Hogyan lehet egy hivatkozás láthatatlanná tenni?", "account_edit.verified_modal.step1.header": "Másold a lenti HTML-kódot és illeszd be a webhelyed fejlécébe", "account_edit.verified_modal.step2.details": "Ha már egyéni mezőként hozzáadtad a webhelyedet, akkor törölnöd kell, újból hozzá kell adnod, hogy újra ellenőrizve legyen.", diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json index bdfbb605d4..fd7dfcc569 100644 --- a/app/javascript/mastodon/locales/ro.json +++ b/app/javascript/mastodon/locales/ro.json @@ -1,6 +1,7 @@ { "about.blocks": "Servere moderate", "about.contact": "Contact:", + "about.default_locale": "Standard", "about.disclaimer": "Mastodon este o aplicație gratuită, cu sursă deschisă și o marcă înregistrată a Mastodon gGmbH.", "about.domain_blocks.no_reason_available": "Motivul nu este disponibil", "about.domain_blocks.preamble": "Mastodon îți permite în general să vezi conținut de la și să interacționezi cu utilizatori de pe oricare server în fediverse. Acestea sunt excepțiile care au fost făcute pe acest server.", @@ -8,22 +9,33 @@ "about.domain_blocks.silenced.title": "Limitat", "about.domain_blocks.suspended.explanation": "Nicio informație de la acest server nu va fi procesată, stocată sau trimisă, făcând imposibilă orice interacțiune sau comunicare cu utilizatorii de pe acest server.", "about.domain_blocks.suspended.title": "Suspendat", + "about.language_label": "Limbă", "about.not_available": "Această informație nu a fost pusă la dispoziție pe acest server.", "about.powered_by": "Media socială descentralizată furnizată de {mastodon}", "about.rules": "Reguli server", "account.account_note_header": "Notă personală", + "account.activity": "Activități", + "account.add_note": "Adaugă o notă personală", "account.add_or_remove_from_list": "Adaugă sau elimină din liste", + "account.badges.admin": "Admin", + "account.badges.blocked": "Blocat", "account.badges.bot": "Robot", + "account.badges.domain_blocked": "Domeniu blocat", "account.badges.group": "Grup", + "account.badges.muted": "Silențios", + "account.badges.muted_until": "Silențios până la {until}", "account.block": "Blochează pe @{name}", "account.block_domain": "Blochează domeniul {domain}", "account.block_short": "Blochează", "account.blocked": "Blocat", + "account.blocking": "Blocarea", "account.cancel_follow_request": "Retrage cererea de urmărire", "account.copy": "Copiază link-ul profilului", "account.direct": "Menționează pe @{name} în privat", "account.disable_notifications": "Nu îmi mai trimite notificări când postează @{name}", + "account.edit_note": "Editare notă personală", "account.edit_profile": "Modifică profilul", + "account.edit_profile_short": "Editare", "account.enable_notifications": "Trimite-mi o notificare când postează @{name}", "account.endorse": "Promovează pe profil", "account.featured_tags.last_status_at": "Ultima postare pe {date}", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index a6d249925a..40ac1f4dee 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -338,12 +338,14 @@ "collections.create_collection": "Koleksiyon oluştur", "collections.delete_collection": "Koleksiyonu sil", "collections.description_length_hint": "100 karakterle sınırlı", + "collections.detail.accept_inclusion": "Tamam", "collections.detail.accounts_heading": "Hesaplar", "collections.detail.author_added_you": "{author} sizi koleksiyonuna ekledi", "collections.detail.curated_by_author": "{author} tarafından derlenen", "collections.detail.curated_by_you": "Sizin derledikleriniz", "collections.detail.loading": "Koleksiyon yükleniyor…", "collections.detail.other_accounts_in_collection": "Bu koleksiyondaki diğer kişiler:", + "collections.detail.revoke_inclusion": "Beni çıkar", "collections.detail.sensitive_note": "Bu koleksiyon bazı kullanıcılar için hassas olabilecek hesap ve içerik içerebilir.", "collections.detail.share": "Bu koleksiyonu paylaş", "collections.edit_details": "Ayrıntıları düzenle", @@ -359,6 +361,9 @@ "collections.old_last_post_note": "Son gönderi bir haftadan önce", "collections.remove_account": "Bu hesabı çıkar", "collections.report_collection": "Bu koleksiyonu bildir", + "collections.revoke_collection_inclusion": "Beni bu koleksiyondan çıkar", + "collections.revoke_inclusion.confirmation": "\"{collection}\" koleksiyonundan çıkarıldınız", + "collections.revoke_inclusion.error": "Bir hata oluştu, lütfen daha sonra tekrar deneyin.", "collections.search_accounts_label": "Eklemek için hesap arayın…", "collections.search_accounts_max_reached": "Maksimum hesabı eklediniz", "collections.sensitive": "Hassas", @@ -482,6 +487,9 @@ "confirmations.remove_from_followers.confirm": "Takipçi kaldır", "confirmations.remove_from_followers.message": "{name} sizi takip etmeyi bırakacaktır. Devam etmek istediğinize emin misiniz?", "confirmations.remove_from_followers.title": "Takipçiyi kaldır?", + "confirmations.revoke_collection_inclusion.confirm": "Beni çıkar", + "confirmations.revoke_collection_inclusion.message": "Bu eylem kalıcıdır ve koleksiyonu derleyen kişi daha sonra sizi koleksiyona tekrar ekleyemeyecektir.", + "confirmations.revoke_collection_inclusion.title": "Kendini bu koleksiyondan çıkar?", "confirmations.revoke_quote.confirm": "Gönderiyi kaldır", "confirmations.revoke_quote.message": "Bu işlem geri alınamaz.", "confirmations.revoke_quote.title": "Gönderiyi silmek ister misiniz?", diff --git a/app/javascript/mastodon/reducers/slices/collections.ts b/app/javascript/mastodon/reducers/slices/collections.ts index 127794b478..a534a13440 100644 --- a/app/javascript/mastodon/reducers/slices/collections.ts +++ b/app/javascript/mastodon/reducers/slices/collections.ts @@ -1,3 +1,4 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import { importFetchedAccounts } from '@/mastodon/actions/importer'; @@ -36,17 +37,69 @@ interface CollectionState { status: QueryStatus; } >; + editor: EditorState; +} + +interface EditorState { + id: string | null; + name: string; + description: string; + topic: string; + language: string | null; + discoverable: boolean; + sensitive: boolean; + accountIds: string[]; +} + +interface UpdateEditorFieldPayload { + field: K; + value: EditorState[K]; } const initialState: CollectionState = { collections: {}, accountCollections: {}, + editor: { + id: null, + name: '', + description: '', + topic: '', + language: null, + discoverable: true, + sensitive: false, + accountIds: [], + }, }; const collectionSlice = createSlice({ name: 'collections', initialState, - reducers: {}, + reducers: { + init(state, action: PayloadAction) { + const collection = action.payload; + + state.editor = { + id: collection?.id ?? null, + name: collection?.name ?? '', + description: collection?.description ?? '', + topic: collection?.tag?.name ?? '', + language: collection?.language ?? '', + discoverable: collection?.discoverable ?? true, + sensitive: collection?.sensitive ?? false, + accountIds: getCollectionItemIds(collection?.items ?? []), + }; + }, + reset(state) { + state.editor = initialState.editor; + }, + updateEditorField( + state: CollectionState, + action: PayloadAction>, + ) { + const { field, value } = action.payload; + state.editor[field] = value; + }, + }, extraReducers(builder) { /** * Fetching account collections @@ -104,6 +157,7 @@ const collectionSlice = createSlice({ builder.addCase(updateCollection.fulfilled, (state, action) => { const { collection } = action.payload; state.collections[collection.id] = collection; + state.editor = initialState.editor; }); /** @@ -132,6 +186,7 @@ const collectionSlice = createSlice({ const { collection } = actions.payload; state.collections[collection.id] = collection; + state.editor = initialState.editor; if (state.accountCollections[collection.account_id]) { state.accountCollections[collection.account_id]?.collectionIds.unshift( @@ -240,6 +295,9 @@ export const revokeCollectionInclusion = createAppAsyncThunk( ); export const collections = collectionSlice.reducer; +export const collectionEditorActions = collectionSlice.actions; +export const updateCollectionEditorField = + collectionSlice.actions.updateEditorField; /** * Selectors @@ -278,3 +336,8 @@ export const selectAccountCollections = createAppSelector( } satisfies AccountCollectionQuery; }, ); + +const onlyExistingIds = (id?: string): id is string => !!id; + +export const getCollectionItemIds = (items?: ApiCollectionJSON['items']) => + items?.map((item) => item.account_id).filter(onlyExistingIds) ?? []; diff --git a/app/javascript/mastodon/reducers/slices/profile_edit.ts b/app/javascript/mastodon/reducers/slices/profile_edit.ts index e4840c642d..62a908e5b1 100644 --- a/app/javascript/mastodon/reducers/slices/profile_edit.ts +++ b/app/javascript/mastodon/reducers/slices/profile_edit.ts @@ -221,7 +221,12 @@ export const patchProfile = createDataLoadingThunk( `${profileEditSlice.name}/patchProfile`, (params: Partial) => apiPatchProfile(params), transformProfile, - { useLoadingBar: false }, + { + useLoadingBar: false, + condition(_, { getState }) { + return !getState().profileEdit.isPending; + }, + }, ); export const selectFieldById = createAppSelector( diff --git a/app/javascript/mastodon/utils/checks.test.ts b/app/javascript/mastodon/utils/checks.test.ts new file mode 100644 index 0000000000..862a2a0abf --- /dev/null +++ b/app/javascript/mastodon/utils/checks.test.ts @@ -0,0 +1,21 @@ +import { isUrlWithoutProtocol } from './checks'; + +describe('isUrlWithoutProtocol', () => { + test.concurrent.each([ + ['example.com', true], + ['sub.domain.co.uk', true], + ['example', false], // No dot + ['example..com', false], // Consecutive dots + ['example.com.', false], // Trailing dot + ['example.c', false], // TLD too short + ['example.123', false], // Numeric TLDs are not valid + ['example.com/path', true], // Paths are allowed + ['example.com?query=string', true], // Query strings are allowed + ['example.com#fragment', true], // Fragments are allowed + ['example .com', false], // Spaces are not allowed + ['example://com', false], // Protocol inside the string is not allowed + ['example.com^', false], // Invalid characters not allowed + ])('should return %s for input "%s"', (input, expected) => { + expect(isUrlWithoutProtocol(input)).toBe(expected); + }); +}); diff --git a/app/javascript/mastodon/utils/checks.ts b/app/javascript/mastodon/utils/checks.ts index 8b05ac24a7..d5d528bdc6 100644 --- a/app/javascript/mastodon/utils/checks.ts +++ b/app/javascript/mastodon/utils/checks.ts @@ -9,3 +9,29 @@ export function isValidUrl( return false; } } + +/** + * Checks if the input string is probably a URL without a protocol. Note this is not full URL validation, + * and is mostly used to detect link-like inputs. + * @see https://www.xjavascript.com/blog/check-if-a-javascript-string-is-a-url/ + * @param input The input string to check + */ +export function isUrlWithoutProtocol(input: string): boolean { + if (!input.length || input.includes(' ') || input.includes('://')) { + return false; + } + + try { + const url = new URL(`http://${input}`); + const { host } = url; + return ( + host !== '' && // Host is not empty + host.includes('.') && // Host contains at least one dot + !host.endsWith('.') && // No trailing dot + !host.includes('..') && // No consecutive dots + /\.[\w]{2,}$/.test(host) // TLD is at least 2 characters + ); + } catch {} + + return false; +} diff --git a/app/javascript/mastodon/utils/hashtags.test.ts b/app/javascript/mastodon/utils/hashtags.test.ts new file mode 100644 index 0000000000..05b79b1d52 --- /dev/null +++ b/app/javascript/mastodon/utils/hashtags.test.ts @@ -0,0 +1,28 @@ +import { inputToHashtag } from './hashtags'; + +describe('inputToHashtag', () => { + test.concurrent.each([ + ['', ''], + // Prepend or keep hashtag + ['mastodon', '#mastodon'], + ['#mastodon', '#mastodon'], + // Preserve trailing whitespace + ['mastodon ', '#mastodon '], + [' ', '# '], + // Collapse whitespace & capitalise first character + ['cats of mastodon', '#catsOfMastodon'], + ['x y z', '#xYZ'], + [' mastodon', '#mastodon'], + // Preserve initial casing + ['Log in', '#LogIn'], + ['#NaturePhotography', '#NaturePhotography'], + // Normalise hash symbol variant + ['#nature', '#nature'], + ['#Nature Photography', '#NaturePhotography'], + // Allow special characters + ['hello-world', '#hello-world'], + ['hello,world', '#hello,world'], + ])('for input "%s", return "%s"', (input, expected) => { + expect(inputToHashtag(input)).toBe(expected); + }); +}); diff --git a/app/javascript/mastodon/utils/hashtags.ts b/app/javascript/mastodon/utils/hashtags.ts index 0c5505c6c9..d14efe5db3 100644 --- a/app/javascript/mastodon/utils/hashtags.ts +++ b/app/javascript/mastodon/utils/hashtags.ts @@ -27,3 +27,35 @@ const buildHashtagRegex = () => { export const HASHTAG_PATTERN_REGEX = buildHashtagPatternRegex(); export const HASHTAG_REGEX = buildHashtagRegex(); + +/** + * Formats an input string as a hashtag: + * - Prepends `#` unless present + * - Strips spaces (except at the end, to allow typing it) + * - Capitalises first character after stripped space + */ +export const inputToHashtag = (input: string): string => { + if (!input) { + return ''; + } + + const trailingSpace = /\s+$/.exec(input)?.[0] ?? ''; + const trimmedInput = input.trimEnd(); + + const withoutHash = + trimmedInput.startsWith('#') || trimmedInput.startsWith('#') + ? trimmedInput.slice(1) + : trimmedInput; + + // Split by space, filter empty strings, and capitalise the start of each word but the first + const words = withoutHash + .split(/\s+/) + .filter((word) => word.length > 0) + .map((word, index) => + index === 0 + ? word + : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ); + + return `#${words.join('')}${trailingSpace}`; +}; diff --git a/app/lib/request.rb b/app/lib/request.rb index cc741f212d..66d7ece70f 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -208,7 +208,7 @@ class Request return end - signature_value = @signing.sign(signed_headers.without('User-Agent', 'Accept-Encoding'), @verb, Addressable::URI.parse(request.uri)) + signature_value = @signing.sign(signed_headers.without('User-Agent', 'Accept-Encoding', 'Accept'), @verb, Addressable::URI.parse(request.uri)) request.headers['Signature'] = signature_value end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index cc2a537b3c..fe2325b6f3 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -11,30 +11,26 @@ class AdminMailer < ApplicationMailer after_action :set_important_headers!, only: :new_critical_software_updates + around_action :set_locale + default to: -> { @me.user_email } def new_report(report) @report = report - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance, id: @report.id) - end + mail subject: default_i18n_subject(instance: @instance, id: @report.id) end def new_appeal(appeal) @appeal = appeal - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance, username: @appeal.account.username) - end + mail subject: default_i18n_subject(instance: @instance, username: @appeal.account.username) end def new_pending_account(user) @account = user.account - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance, username: @account.username) - end + mail subject: default_i18n_subject(instance: @instance, username: @account.username) end def new_trends(links, tags, statuses) @@ -42,31 +38,23 @@ class AdminMailer < ApplicationMailer @tags = tags @statuses = statuses - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance) - end + mail subject: default_i18n_subject(instance: @instance) end def new_software_updates @software_updates = SoftwareUpdate.by_version - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance) - end + mail subject: default_i18n_subject(instance: @instance) end def new_critical_software_updates @software_updates = SoftwareUpdate.urgent.by_version - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance) - end + mail subject: default_i18n_subject(instance: @instance) end def auto_close_registrations - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance) - end + mail subject: default_i18n_subject(instance: @instance) end private @@ -79,6 +67,10 @@ class AdminMailer < ApplicationMailer @instance = Rails.configuration.x.local_domain end + def set_locale(&block) + locale_for_account(@me, &block) + end + def set_important_headers! headers( 'Importance' => 'high', diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 54dde1bb0d..ecb3750968 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -15,6 +15,8 @@ class NotificationMailer < ApplicationMailer before_deliver :verify_functional_user + around_action :set_locale + default to: -> { email_address_with_name(@user.email, @me.username) } layout 'mailer' @@ -22,45 +24,33 @@ class NotificationMailer < ApplicationMailer def mention return if @status.blank? - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @status.account.acct) - end + mail subject: default_i18n_subject(name: @status.account.acct) end def quote return if @status.blank? - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @status.account.acct) - end + mail subject: default_i18n_subject(name: @status.account.acct) end def follow - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @account.acct) - end + mail subject: default_i18n_subject(name: @account.acct) end def favourite return if @status.blank? - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @account.acct) - end + mail subject: default_i18n_subject(name: @account.acct) end def reblog return if @status.blank? - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @account.acct) - end + mail subject: default_i18n_subject(name: @account.acct) end def follow_request - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @account.acct) - end + mail subject: default_i18n_subject(name: @account.acct) end private @@ -81,6 +71,10 @@ class NotificationMailer < ApplicationMailer @account = @notification.from_account end + def set_locale(&block) + locale_for_account(@me, &block) + end + def verify_functional_user throw(:abort) unless @user.functional? end diff --git a/app/models/collection.rb b/app/models/collection.rb index 0061e7ff5c..3be633bbf1 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -22,6 +22,8 @@ # class Collection < ApplicationRecord MAX_ITEMS = 25 + NAME_LENGTH_HARD_LIMIT = 256 + DESCRIPTION_LENGTH_HARD_LIMIT = 2048 belongs_to :account belongs_to :tag, optional: true @@ -31,10 +33,16 @@ class Collection < ApplicationRecord has_many :collection_reports, dependent: :delete_all validates :name, presence: true - validates :description, presence: true, - if: :local? - validates :description_html, presence: true, - if: :remote? + validates :name, length: { maximum: 40 }, if: :local? + validates :name, length: { maximum: NAME_LENGTH_HARD_LIMIT }, if: :remote? + validates :description, + presence: true, + length: { maximum: 100 }, + if: :local? + validates :description_html, + presence: true, + length: { maximum: DESCRIPTION_LENGTH_HARD_LIMIT }, + if: :remote? validates :local, inclusion: [true, false] validates :sensitive, inclusion: [true, false] validates :discoverable, inclusion: [true, false] diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb index 583d2e6c1b..44d6bc6987 100644 --- a/app/models/email_domain_block.rb +++ b/app/models/email_domain_block.rb @@ -59,7 +59,7 @@ class EmailDomainBlock < ApplicationRecord def blocking?(allow_with_approval: false) blocks = EmailDomainBlock.where(domain: domains_with_variants, allow_with_approval: allow_with_approval).by_domain_length - blocks.each { |block| block.history.add(@attempt_ip) } if @attempt_ip.present? + blocks.each { |block| block.history.add(@attempt_ip.to_s) } if @attempt_ip.present? blocks.any? end diff --git a/app/models/trends/history.rb b/app/models/trends/history.rb index 21331f00dc..9e4d173475 100644 --- a/app/models/trends/history.rb +++ b/app/models/trends/history.rb @@ -40,11 +40,11 @@ class Trends::History with_redis { |redis| redis.get(key_for(:uses)).to_i } end - def add(account_id) + def add(value) with_redis do |redis| redis.pipelined do |pipeline| pipeline.incrby(key_for(:uses), 1) - pipeline.pfadd(key_for(:accounts), account_id) + pipeline.pfadd(key_for(:accounts), value) pipeline.expire(key_for(:uses), EXPIRE_AFTER) pipeline.expire(key_for(:accounts), EXPIRE_AFTER) end diff --git a/app/serializers/rest/collection_serializer.rb b/app/serializers/rest/collection_serializer.rb index 370384c220..ac7c8ad026 100644 --- a/app/serializers/rest/collection_serializer.rb +++ b/app/serializers/rest/collection_serializer.rb @@ -14,7 +14,9 @@ class REST::CollectionSerializer < ActiveModel::Serializer end def description - object.local? ? object.description : object.description_html + return object.description if object.local? + + Sanitize.fragment(object.description_html, Sanitize::Config::MASTODON_STRICT) end def items diff --git a/app/services/activitypub/process_featured_collection_service.rb b/app/services/activitypub/process_featured_collection_service.rb new file mode 100644 index 0000000000..edbb50c533 --- /dev/null +++ b/app/services/activitypub/process_featured_collection_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessFeaturedCollectionService + include JsonLdHelper + include Lockable + include Redisable + + ITEMS_LIMIT = 150 + + def call(account, json) + @account = account + @json = json + return if non_matching_uri_hosts?(@account.uri, @json['id']) + + with_redis_lock("collection:#{@json['id']}") do + return if @account.collections.exists?(uri: @json['id']) + + @collection = @account.collections.create!( + local: false, + uri: @json['id'], + name: (@json['name'] || '')[0, Collection::NAME_LENGTH_HARD_LIMIT], + description_html: truncated_summary, + language:, + sensitive: @json['sensitive'], + discoverable: @json['discoverable'], + original_number_of_items: @json['totalItems'] || 0, + tag_name: @json.dig('topic', 'name') + ) + + process_items! + + @collection + end + end + + private + + def truncated_summary + text = @json['summaryMap']&.values&.first || @json['summary'] || '' + text[0, Collection::DESCRIPTION_LENGTH_HARD_LIMIT] + end + + def language + @json['summaryMap']&.keys&.first + end + + def process_items! + @json['orderedItems'].take(ITEMS_LIMIT).each do |item_json| + ActivityPub::ProcessFeaturedItemWorker.perform_async(@collection.id, item_json) + end + end +end diff --git a/app/workers/activitypub/process_featured_item_worker.rb b/app/workers/activitypub/process_featured_item_worker.rb new file mode 100644 index 0000000000..dd765e7df6 --- /dev/null +++ b/app/workers/activitypub/process_featured_item_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessFeaturedItemWorker + include Sidekiq::Worker + include ExponentialBackoff + + sidekiq_options queue: 'pull', retry: 3 + + def perform(collection_id, id_or_json) + collection = Collection.find(collection_id) + + ActivityPub::ProcessFeaturedItemService.new.call(collection, id_or_json) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/config/environments/test.rb b/config/environments/test.rb index 0c4f1de41e..12709d5f0b 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -92,3 +92,6 @@ end Sidekiq.strict_args! Redis.raise_deprecations = true + +# Silence deprecation warning from json-schema +JSON::Validator.use_multi_json = false diff --git a/lib/action_dispatch/remote_ip_extensions.rb b/lib/action_dispatch/remote_ip_extensions.rb index e5c48bf3c5..bf78c69439 100644 --- a/lib/action_dispatch/remote_ip_extensions.rb +++ b/lib/action_dispatch/remote_ip_extensions.rb @@ -17,11 +17,11 @@ module ActionDispatch module GetIpExtensions def calculate_ip # Set by the Rack web server, this is a single value. - remote_addr = ips_from(@req.remote_addr).last + remote_addr = sanitize_ips(ips_from(@req.remote_addr)).last # Could be a CSV list and/or repeated headers that were concatenated. - client_ips = ips_from(@req.client_ip).reverse! - forwarded_ips = ips_from(@req.x_forwarded_for).reverse! + client_ips = sanitize_ips(ips_from(@req.client_ip)).reverse! + forwarded_ips = sanitize_ips(@req.forwarded_for || []).reverse! # `Client-Ip` and `X-Forwarded-For` should not, generally, both be set. If they # are both set, it means that either: diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb index fc833d354b..6937829ebb 100644 --- a/spec/models/collection_spec.rb +++ b/spec/models/collection_spec.rb @@ -8,8 +8,12 @@ RSpec.describe Collection do it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_length_of(:name).is_at_most(40) } + it { is_expected.to validate_presence_of(:description) } + it { is_expected.to validate_length_of(:description).is_at_most(100) } + it { is_expected.to_not allow_value(nil).for(:local) } it { is_expected.to_not allow_value(nil).for(:sensitive) } @@ -23,10 +27,14 @@ RSpec.describe Collection do context 'when collection is remote' do subject { Fabricate.build :collection, local: false } + it { is_expected.to validate_length_of(:name).is_at_most(Collection::NAME_LENGTH_HARD_LIMIT) } + it { is_expected.to_not validate_presence_of(:description) } it { is_expected.to validate_presence_of(:description_html) } + it { is_expected.to validate_length_of(:description_html).is_at_most(Collection::DESCRIPTION_LENGTH_HARD_LIMIT) } + it { is_expected.to validate_presence_of(:uri) } it { is_expected.to validate_presence_of(:original_number_of_items) } diff --git a/spec/models/email_domain_block_spec.rb b/spec/models/email_domain_block_spec.rb index c3662b2d6c..5dbc4a5aff 100644 --- a/spec/models/email_domain_block_spec.rb +++ b/spec/models/email_domain_block_spec.rb @@ -56,16 +56,20 @@ RSpec.describe EmailDomainBlock do end describe '.requires_approval?' do - subject { described_class.requires_approval?(input) } + subject { described_class.requires_approval?(input, attempt_ip: IPAddr.new('100.100.100.100')) } let(:input) { nil } context 'with a matching block requiring approval' do - before { Fabricate :email_domain_block, domain: input, allow_with_approval: true } + let!(:email_domain_block) { Fabricate :email_domain_block, domain: input, allow_with_approval: true } let(:input) { 'host.example' } - it { is_expected.to be true } + it 'returns true and records attempt' do + expect do + expect(subject).to be(true) + end.to change { email_domain_block.history.get(Date.current).accounts }.by(1) + end end context 'with a matching block not requiring approval' do diff --git a/spec/serializers/rest/collection_serializer_spec.rb b/spec/serializers/rest/collection_serializer_spec.rb index 816b1873f6..67ff464d18 100644 --- a/spec/serializers/rest/collection_serializer_spec.rb +++ b/spec/serializers/rest/collection_serializer_spec.rb @@ -51,5 +51,14 @@ RSpec.describe REST::CollectionSerializer do expect(subject) .to include('description' => '

remote

') end + + context 'when the description contains unwanted HTML' do + let(:description_html) { '

Nice people

' } + let(:collection) { Fabricate(:remote_collection, description_html:) } + + it 'scrubs the HTML' do + expect(subject).to include('description' => '

Nice people

') + end + end end end diff --git a/spec/services/activitypub/process_featured_collection_service_spec.rb b/spec/services/activitypub/process_featured_collection_service_spec.rb new file mode 100644 index 0000000000..3a0fdd82f1 --- /dev/null +++ b/spec/services/activitypub/process_featured_collection_service_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessFeaturedCollectionService do + subject { described_class.new } + + let(:account) { Fabricate(:remote_account) } + let(:summary) { '

A list of remote actors you should follow.

' } + let(:base_json) do + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'https://example.com/featured_collections/1', + 'type' => 'FeaturedCollection', + 'attributedTo' => account.uri, + 'name' => 'Good people from other servers', + 'sensitive' => false, + 'discoverable' => true, + 'topic' => { + 'type' => 'Hashtag', + 'name' => '#people', + }, + 'published' => '2026-03-09T15:19:25Z', + 'totalItems' => 2, + 'orderedItems' => [ + 'https://example.com/featured_items/1', + 'https://example.com/featured_items/2', + ], + } + end + let(:featured_collection_json) { base_json.merge('summary' => summary) } + + context "when the collection's URI does not match the account's" do + let(:non_matching_account) { Fabricate(:remote_account, domain: 'other.example.com') } + + it 'does not create a collection and returns `nil`' do + expect do + expect(subject.call(non_matching_account, featured_collection_json)).to be_nil + end.to_not change(Collection, :count) + end + end + + context 'when URIs match up' do + it 'creates a collection and queues jobs to handle its items' do + expect { subject.call(account, featured_collection_json) }.to change(account.collections, :count).by(1) + + new_collection = account.collections.last + expect(new_collection.uri).to eq 'https://example.com/featured_collections/1' + expect(new_collection.name).to eq 'Good people from other servers' + expect(new_collection.description_html).to eq '

A list of remote actors you should follow.

' + expect(new_collection.sensitive).to be false + expect(new_collection.discoverable).to be true + expect(new_collection.tag.formatted_name).to eq '#people' + + expect(ActivityPub::ProcessFeaturedItemWorker).to have_enqueued_sidekiq_job.exactly(2).times + end + end + + context 'when the json includes a summary map' do + let(:featured_collection_json) do + base_json.merge({ + 'summaryMap' => { + 'en' => summary, + }, + }) + end + + it 'sets language and summary correctly' do + expect { subject.call(account, featured_collection_json) }.to change(account.collections, :count).by(1) + + new_collection = account.collections.last + expect(new_collection.language).to eq 'en' + expect(new_collection.description_html).to eq '

A list of remote actors you should follow.

' + end + end +end diff --git a/spec/workers/activitypub/process_featured_item_worker_spec.rb b/spec/workers/activitypub/process_featured_item_worker_spec.rb new file mode 100644 index 0000000000..f27ec21c35 --- /dev/null +++ b/spec/workers/activitypub/process_featured_item_worker_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessFeaturedItemWorker do + subject { described_class.new } + + let(:collection) { Fabricate(:remote_collection) } + let(:object) { 'https://example.com/featured_items/1' } + let(:stubbed_service) do + instance_double(ActivityPub::ProcessFeaturedItemService, call: true) + end + + before do + allow(ActivityPub::ProcessFeaturedItemService).to receive(:new).and_return(stubbed_service) + end + + describe 'perform' do + it 'calls the service to process the item' do + subject.perform(collection.id, object) + + expect(stubbed_service).to have_received(:call).with(collection, object) + end + end +end