mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Merge commit '811575a10903cada549580979cc809ca98ad570c' into glitch-soc/merge-upstream
This commit is contained in:
@@ -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 |
|
||||
|
||||
42
Gemfile.lock
42
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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
21
app/javascript/mastodon/utils/checks.test.ts
Normal file
21
app/javascript/mastodon/utils/checks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
28
app/javascript/mastodon/utils/hashtags.test.ts
Normal file
28
app/javascript/mastodon/utils/hashtags.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
16
app/workers/activitypub/process_featured_item_worker.rb
Normal file
16
app/workers/activitypub/process_featured_item_worker.rb
Normal 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
|
||||
@@ -92,3 +92,6 @@ end
|
||||
Sidekiq.strict_args!
|
||||
|
||||
Redis.raise_deprecations = true
|
||||
|
||||
# Silence deprecation warning from json-schema
|
||||
JSON::Validator.use_multi_json = false
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,5 +51,14 @@ RSpec.describe REST::CollectionSerializer do
|
||||
expect(subject)
|
||||
.to include('description' => '<p>remote</p>')
|
||||
end
|
||||
|
||||
context 'when the description contains unwanted HTML' do
|
||||
let(:description_html) { '<script>alert("hi!");</script><p>Nice people</p>' }
|
||||
let(:collection) { Fabricate(:remote_collection, description_html:) }
|
||||
|
||||
it 'scrubs the HTML' do
|
||||
expect(subject).to include('description' => '<p>Nice people</p>')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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) { '<p>A list of remote actors you should follow.</p>' }
|
||||
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 '<p>A list of remote actors you should follow.</p>'
|
||||
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 '<p>A list of remote actors you should follow.</p>'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user