Merge commit '811575a10903cada549580979cc809ca98ad570c' into glitch-soc/merge-upstream

This commit is contained in:
Claire
2026-03-11 18:50:59 +01:00
41 changed files with 690 additions and 266 deletions

View File

@@ -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];

View File

@@ -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<string | undefined>();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const isUsingTouchRef = useRef(false);
const cardRef = useRef<HTMLDivElement | null>(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 (

View File

@@ -205,24 +205,21 @@ export const AccountEdit: FC = () => {
showDescription={!hasFields}
buttons={
<>
{profile.fields.length > 1 && (
<Button
className={classes.editButton}
onClick={handleCustomFieldReorder}
>
<FormattedMessage
id='account_edit.custom_fields.reorder_button'
defaultMessage='Reorder fields'
/>
</Button>
)}
{hasFields && (
<EditButton
item={messages.customFieldsName}
onClick={handleCustomFieldAdd}
disabled={profile.fields.length >= maxFieldCount}
<Button
className={classes.editButton}
onClick={handleCustomFieldReorder}
disabled={profile.fields.length <= 1}
>
<FormattedMessage
id='account_edit.custom_fields.reorder_button'
defaultMessage='Reorder fields'
/>
)}
</Button>
<EditButton
item={messages.customFieldsName}
onClick={handleCustomFieldAdd}
disabled={profile.fields.length >= maxFieldCount}
/>
</>
}
>

View File

@@ -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<DialogModalProps & { fieldKey?: string }> = ({
);
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<DialogModalProps & { fieldKey?: string }> = ({
/>
</Callout>
)}
{hasLinkWithoutProtocol && (
<Callout variant='warning'>
<FormattedMessage
id='account_edit.field_edit_modal.url_warning'
defaultMessage='To add a link, please include {protocol} at the beginning.'
description='{protocol} is https://'
values={{
protocol: <code>https://</code>,
}}
/>
</Callout>
)}
</ConfirmationModal>
);
};

View File

@@ -212,11 +212,9 @@ export const ReorderFieldsModal: FC<DialogModalProps> = ({ 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);

View File

@@ -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 (
<div className={classes.header}>

View File

@@ -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 (
<ModalShell>

View File

@@ -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 && (
<IconButton
title={intl.formatMessage({
id: 'collections.remove_account',
defaultMessage: 'Remove this account',
})}
icon='remove'
iconComponent={CancelIcon}
onClick={handleRemoveAccount}
/>
)}
<IconButton
title={intl.formatMessage({
id: 'collections.remove_account',
defaultMessage: 'Remove this account',
})}
icon='remove'
iconComponent={CancelIcon}
onClick={handleRemoveAccount}
/>
</Account>
);
};
@@ -139,28 +136,25 @@ export const CollectionAccounts: React.FC<{
const intl = useIntl();
const dispatch = useAppDispatch();
const history = useHistory();
const location = useLocation<TempCollectionState>();
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<{
>
<AddedAccountItem
accountId={accountId}
isRemovable={!isEditMode}
onRemove={handleRemoveAccountItem}
/>
</Article>

View File

@@ -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<TempCollectionState>();
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<HTMLInputElement>) => {
setName(event.target.value);
dispatch(
updateCollectionEditorField({
field: 'name',
value: event.target.value,
}),
);
},
[],
[dispatch],
);
const handleDescriptionChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(event.target.value);
dispatch(
updateCollectionEditorField({
field: 'description',
value: event.target.value,
}),
);
},
[],
[dispatch],
);
const handleTopicChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setTopic(event.target.value);
dispatch(
updateCollectionEditorField({
field: 'topic',
value: inputToHashtag(event.target.value),
}),
);
},
[],
[dispatch],
);
const handleDiscoverableChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setDiscoverable(event.target.value === 'public');
dispatch(
updateCollectionEditorField({
field: 'discoverable',
value: event.target.value === 'public',
}),
);
},
[],
[dispatch],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
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}
/>

View File

@@ -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={() => <CollectionDetails collection={collection} />}
render={() => <CollectionDetails />}
/>
</Switch>
)}

View File

@@ -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<ApiCreateCollectionPayload>
| 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;

View File

@@ -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.",

View File

@@ -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 ?",

View File

@@ -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 ?",

View File

@@ -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.",

View File

@@ -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}",

View File

@@ -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?",

View File

@@ -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<K extends keyof EditorState> {
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<ApiCollectionJSON | null>) {
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<K extends keyof EditorState>(
state: CollectionState,
action: PayloadAction<UpdateEditorFieldPayload<K>>,
) {
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) ?? [];

View File

@@ -221,7 +221,12 @@ export const patchProfile = createDataLoadingThunk(
`${profileEditSlice.name}/patchProfile`,
(params: Partial<ApiProfileUpdateParams>) => apiPatchProfile(params),
transformProfile,
{ useLoadingBar: false },
{
useLoadingBar: false,
condition(_, { getState }) {
return !getState().profileEdit.isPending;
},
},
);
export const selectFieldById = createAppSelector(

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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}`;
};

View File

@@ -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

View File

@@ -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',

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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