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-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-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-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
|
## ActivityPub in Mastodon
|
||||||
|
|
||||||
@@ -68,3 +69,5 @@ The following table summarizes those limits.
|
|||||||
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
|
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
|
||||||
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
|
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
|
||||||
| Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated |
|
| 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)
|
ast (2.4.3)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1222.0)
|
aws-partitions (1.1223.0)
|
||||||
aws-sdk-core (3.243.0)
|
aws-sdk-core (3.243.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
@@ -157,7 +157,7 @@ GEM
|
|||||||
case_transform (0.2)
|
case_transform (0.2)
|
||||||
activesupport
|
activesupport
|
||||||
cbor (0.5.10.1)
|
cbor (0.5.10.1)
|
||||||
cgi (0.4.2)
|
cgi (0.5.1)
|
||||||
charlock_holmes (0.7.9)
|
charlock_holmes (0.7.9)
|
||||||
chewy (7.6.0)
|
chewy (7.6.0)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
@@ -209,7 +209,7 @@ GEM
|
|||||||
activerecord (>= 4.2, < 9.0)
|
activerecord (>= 4.2, < 9.0)
|
||||||
docile (1.4.1)
|
docile (1.4.1)
|
||||||
domain_name (0.6.20240107)
|
domain_name (0.6.20240107)
|
||||||
doorkeeper (5.8.2)
|
doorkeeper (5.9.0)
|
||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
dotenv (3.2.0)
|
dotenv (3.2.0)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
@@ -230,7 +230,7 @@ GEM
|
|||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (1.3.2)
|
excon (1.4.0)
|
||||||
logger
|
logger
|
||||||
fabrication (3.0.0)
|
fabrication (3.0.0)
|
||||||
faker (3.6.1)
|
faker (3.6.1)
|
||||||
@@ -246,7 +246,7 @@ GEM
|
|||||||
faraday-net_http (3.4.2)
|
faraday-net_http (3.4.2)
|
||||||
net-http (~> 0.5)
|
net-http (~> 0.5)
|
||||||
fast_blank (1.0.1)
|
fast_blank (1.0.1)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.1)
|
||||||
ffi (1.17.3)
|
ffi (1.17.3)
|
||||||
ffi-compiler (1.3.2)
|
ffi-compiler (1.3.2)
|
||||||
ffi (>= 1.15.5)
|
ffi (>= 1.15.5)
|
||||||
@@ -276,9 +276,9 @@ GEM
|
|||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
google-protobuf (4.33.5)
|
google-protobuf (4.34.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rake (>= 13)
|
rake (~> 13.3)
|
||||||
googleapis-common-protos-types (1.22.0)
|
googleapis-common-protos-types (1.22.0)
|
||||||
google-protobuf (~> 4.26)
|
google-protobuf (~> 4.26)
|
||||||
haml (7.2.0)
|
haml (7.2.0)
|
||||||
@@ -352,7 +352,7 @@ GEM
|
|||||||
azure-blob (~> 0.5.2)
|
azure-blob (~> 0.5.2)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.18.1)
|
json (2.19.1)
|
||||||
json-canonicalization (1.0.0)
|
json-canonicalization (1.0.0)
|
||||||
json-jwt (1.17.0)
|
json-jwt (1.17.0)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
@@ -446,7 +446,7 @@ GEM
|
|||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
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_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (6.0.2)
|
minitest (6.0.2)
|
||||||
@@ -507,7 +507,7 @@ GEM
|
|||||||
tzinfo
|
tzinfo
|
||||||
validate_url
|
validate_url
|
||||||
webfinger (~> 2.0)
|
webfinger (~> 2.0)
|
||||||
openssl (3.3.2)
|
openssl (4.0.1)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
opentelemetry-api (1.7.0)
|
opentelemetry-api (1.7.0)
|
||||||
@@ -738,17 +738,17 @@ GEM
|
|||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-github (3.0.0)
|
rspec-github (3.0.0)
|
||||||
rspec-core (~> 3.0)
|
rspec-core (~> 3.0)
|
||||||
rspec-mocks (3.13.7)
|
rspec-mocks (3.13.8)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-rails (8.0.3)
|
rspec-rails (8.0.4)
|
||||||
actionpack (>= 7.2)
|
actionpack (>= 7.2)
|
||||||
activesupport (>= 7.2)
|
activesupport (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
rspec-core (~> 3.13)
|
rspec-core (>= 3.13.0, < 5.0.0)
|
||||||
rspec-expectations (~> 3.13)
|
rspec-expectations (>= 3.13.0, < 5.0.0)
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (>= 3.13.0, < 5.0.0)
|
||||||
rspec-support (~> 3.13)
|
rspec-support (>= 3.13.0, < 5.0.0)
|
||||||
rspec-sidekiq (5.3.0)
|
rspec-sidekiq (5.3.0)
|
||||||
rspec-core (~> 3.0)
|
rspec-core (~> 3.0)
|
||||||
rspec-expectations (~> 3.0)
|
rspec-expectations (~> 3.0)
|
||||||
@@ -766,7 +766,7 @@ GEM
|
|||||||
rubocop-ast (>= 1.49.0, < 2.0)
|
rubocop-ast (>= 1.49.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.49.0)
|
rubocop-ast (1.49.1)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.7)
|
prism (~> 1.7)
|
||||||
rubocop-capybara (2.22.1)
|
rubocop-capybara (2.22.1)
|
||||||
@@ -792,7 +792,7 @@ GEM
|
|||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (~> 1.72, >= 1.72.1)
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
rubocop-rspec (~> 3.5)
|
rubocop-rspec (~> 3.5)
|
||||||
ruby-prof (2.0.2)
|
ruby-prof (2.0.4)
|
||||||
base64
|
base64
|
||||||
ostruct
|
ostruct
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
@@ -847,7 +847,7 @@ GEM
|
|||||||
stackprof (0.2.28)
|
stackprof (0.2.28)
|
||||||
starry (0.2.0)
|
starry (0.2.0)
|
||||||
base64
|
base64
|
||||||
stoplight (5.7.0)
|
stoplight (5.8.0)
|
||||||
concurrent-ruby
|
concurrent-ruby
|
||||||
zeitwerk
|
zeitwerk
|
||||||
stringio (3.2.0)
|
stringio (3.2.0)
|
||||||
@@ -867,7 +867,7 @@ GEM
|
|||||||
test-prof (1.5.2)
|
test-prof (1.5.2)
|
||||||
thor (1.5.0)
|
thor (1.5.0)
|
||||||
tilt (2.7.0)
|
tilt (2.7.0)
|
||||||
timeout (0.6.0)
|
timeout (0.6.1)
|
||||||
tpm-key_attestation (0.14.1)
|
tpm-key_attestation (0.14.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
@@ -1100,4 +1100,4 @@ RUBY VERSION
|
|||||||
ruby 3.4.8
|
ruby 3.4.8
|
||||||
|
|
||||||
BUNDLED WITH
|
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) {
|
switch (intent) {
|
||||||
case 'follow':
|
case 'follow':
|
||||||
return ['https://w3id.org/fep/3b86/Follow', 'object'] as [string, string];
|
return ['https://w3id.org/fep/3b86/Follow', 'object'];
|
||||||
case 'reblog':
|
case 'reblog':
|
||||||
return ['https://w3id.org/fep/3b86/Announce', 'object'] as [
|
return ['https://w3id.org/fep/3b86/Announce', 'object'];
|
||||||
string,
|
|
||||||
string,
|
|
||||||
];
|
|
||||||
case 'favourite':
|
case 'favourite':
|
||||||
return ['https://w3id.org/fep/3b86/Like', 'object'] as [string, string];
|
return ['https://w3id.org/fep/3b86/Like', 'object'];
|
||||||
case 'vote':
|
case 'vote':
|
||||||
case 'reply':
|
case 'reply':
|
||||||
return ['https://w3id.org/fep/3b86/Object', 'object'] as [string, string];
|
return ['https://w3id.org/fep/3b86/Object', 'object'];
|
||||||
default:
|
default:
|
||||||
return null;
|
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) ?? [
|
const [needle, param] = intentParams(intent) ?? [
|
||||||
'http://ostatus.org/schema/1.0/subscribe',
|
'http://ostatus.org/schema/1.0/subscribe',
|
||||||
'uri',
|
'uri',
|
||||||
@@ -66,14 +67,21 @@ const findTemplateLink = (data: unknown, intent: string) => {
|
|||||||
|
|
||||||
const match = findLink(needle, data);
|
const match = findLink(needle, data);
|
||||||
|
|
||||||
if (match) {
|
if (match?.template) {
|
||||||
return [match.template, param] as [string, string];
|
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) {
|
// If it's still not found, try the legacy OStatus subscribe handler
|
||||||
return [fallback.template, 'uri'] as [string, string];
|
fallback = findLink('http://ostatus.org/schema/1.0/subscribe', data);
|
||||||
|
|
||||||
|
if (fallback?.template) {
|
||||||
|
return [fallback.template, 'uri'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [null, null];
|
return [null, null];
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import { useTimeout } from 'mastodon/hooks/useTimeout';
|
|||||||
const offset = [-12, 4] as OffsetValue;
|
const offset = [-12, 4] as OffsetValue;
|
||||||
const enterDelay = 750;
|
const enterDelay = 750;
|
||||||
const leaveDelay = 150;
|
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 popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||||
|
|
||||||
const isHoverCardAnchor = (element: HTMLElement) =>
|
const isHoverCardAnchor = (element: HTMLElement) =>
|
||||||
@@ -23,10 +27,10 @@ export const HoverCardController: React.FC = () => {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [accountId, setAccountId] = useState<string | undefined>();
|
const [accountId, setAccountId] = useState<string | undefined>();
|
||||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||||
const isUsingTouchRef = useRef(false);
|
|
||||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
||||||
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
|
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
|
||||||
|
const [setMoveTimeout, cancelMoveTimeout] = useTimeout();
|
||||||
const [setScrollTimeout] = useTimeout();
|
const [setScrollTimeout] = useTimeout();
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -45,6 +49,8 @@ export const HoverCardController: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isScrolling = false;
|
let isScrolling = false;
|
||||||
|
let isUsingTouch = false;
|
||||||
|
let isActiveMouseMovement = false;
|
||||||
let currentAnchor: HTMLElement | null = null;
|
let currentAnchor: HTMLElement | null = null;
|
||||||
let currentTitle: string | null = null;
|
let currentTitle: string | null = null;
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ export const HoverCardController: React.FC = () => {
|
|||||||
const handleTouchStart = () => {
|
const handleTouchStart = () => {
|
||||||
// Keeping track of touch events to prevent the
|
// Keeping track of touch events to prevent the
|
||||||
// hover card from being displayed on touch devices
|
// hover card from being displayed on touch devices
|
||||||
isUsingTouchRef.current = true;
|
isUsingTouch = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseEnter = (e: MouseEvent) => {
|
const handleMouseEnter = (e: MouseEvent) => {
|
||||||
@@ -78,13 +84,14 @@ export const HoverCardController: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bail out if a touch is active
|
// Bail out if we're scrolling, a touch is active,
|
||||||
if (isUsingTouchRef.current) {
|
// or if there was no active mouse movement
|
||||||
|
if (isScrolling || !isActiveMouseMovement || isUsingTouch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We've entered an anchor
|
// We've entered an anchor
|
||||||
if (!isScrolling && isHoverCardAnchor(target)) {
|
if (isHoverCardAnchor(target)) {
|
||||||
cancelLeaveTimeout();
|
cancelLeaveTimeout();
|
||||||
|
|
||||||
currentAnchor?.removeAttribute('aria-describedby');
|
currentAnchor?.removeAttribute('aria-describedby');
|
||||||
@@ -99,10 +106,7 @@ export const HoverCardController: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We've entered the hover card
|
// We've entered the hover card
|
||||||
if (
|
if (target === currentAnchor || target === cardRef.current) {
|
||||||
!isScrolling &&
|
|
||||||
(target === currentAnchor || target === cardRef.current)
|
|
||||||
) {
|
|
||||||
cancelLeaveTimeout();
|
cancelLeaveTimeout();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -141,10 +145,17 @@ export const HoverCardController: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseMove = () => {
|
const handleMouseMove = () => {
|
||||||
if (isUsingTouchRef.current) {
|
if (isUsingTouch) {
|
||||||
isUsingTouchRef.current = false;
|
isUsingTouch = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
delayEnterTimeout(enterDelay);
|
delayEnterTimeout(enterDelay);
|
||||||
|
|
||||||
|
cancelMoveTimeout();
|
||||||
|
isActiveMouseMovement = true;
|
||||||
|
setMoveTimeout(() => {
|
||||||
|
isActiveMouseMovement = false;
|
||||||
|
}, activeMovementThreshold);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.body.addEventListener('touchstart', handleTouchStart, {
|
document.body.addEventListener('touchstart', handleTouchStart, {
|
||||||
@@ -188,6 +199,8 @@ export const HoverCardController: React.FC = () => {
|
|||||||
setOpen,
|
setOpen,
|
||||||
setAccountId,
|
setAccountId,
|
||||||
setAnchor,
|
setAnchor,
|
||||||
|
setMoveTimeout,
|
||||||
|
cancelMoveTimeout,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -205,24 +205,21 @@ export const AccountEdit: FC = () => {
|
|||||||
showDescription={!hasFields}
|
showDescription={!hasFields}
|
||||||
buttons={
|
buttons={
|
||||||
<>
|
<>
|
||||||
{profile.fields.length > 1 && (
|
<Button
|
||||||
<Button
|
className={classes.editButton}
|
||||||
className={classes.editButton}
|
onClick={handleCustomFieldReorder}
|
||||||
onClick={handleCustomFieldReorder}
|
disabled={profile.fields.length <= 1}
|
||||||
>
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='account_edit.custom_fields.reorder_button'
|
id='account_edit.custom_fields.reorder_button'
|
||||||
defaultMessage='Reorder fields'
|
defaultMessage='Reorder fields'
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{hasFields && (
|
|
||||||
<EditButton
|
|
||||||
item={messages.customFieldsName}
|
|
||||||
onClick={handleCustomFieldAdd}
|
|
||||||
disabled={profile.fields.length >= maxFieldCount}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</Button>
|
||||||
|
<EditButton
|
||||||
|
item={messages.customFieldsName}
|
||||||
|
onClick={handleCustomFieldAdd}
|
||||||
|
disabled={profile.fields.length >= maxFieldCount}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useAppDispatch,
|
useAppDispatch,
|
||||||
useAppSelector,
|
useAppSelector,
|
||||||
} from '@/mastodon/store';
|
} from '@/mastodon/store';
|
||||||
|
import { isUrlWithoutProtocol } from '@/mastodon/utils/checks';
|
||||||
|
|
||||||
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
|
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
|
||||||
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||||
@@ -48,7 +49,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
editValueHint: {
|
editValueHint: {
|
||||||
id: 'account_edit.field_edit_modal.value_hint',
|
id: 'account_edit.field_edit_modal.value_hint',
|
||||||
defaultMessage: 'E.g. “example.me”',
|
defaultMessage: 'E.g. “https://example.me”',
|
||||||
},
|
},
|
||||||
limitHeader: {
|
limitHeader: {
|
||||||
id: 'account_edit.field_edit_modal.limit_header',
|
id: 'account_edit.field_edit_modal.limit_header',
|
||||||
@@ -109,6 +110,10 @@ export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
|
|||||||
);
|
);
|
||||||
return hasLink && hasEmoji;
|
return hasLink && hasEmoji;
|
||||||
}, [customEmojiCodes, newLabel, newValue]);
|
}, [customEmojiCodes, newLabel, newValue]);
|
||||||
|
const hasLinkWithoutProtocol = useMemo(
|
||||||
|
() => isUrlWithoutProtocol(newValue),
|
||||||
|
[newValue],
|
||||||
|
);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
@@ -175,6 +180,19 @@ export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
|
|||||||
/>
|
/>
|
||||||
</Callout>
|
</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>
|
</ConfirmationModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -212,11 +212,9 @@ export const ReorderFieldsModal: FC<DialogModalProps> = ({ onClose }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
newFields.push({ name: field.name, value: field.value });
|
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]);
|
}, [dispatch, fieldKeys, fields, onClose]);
|
||||||
|
|
||||||
const emojis = useAppSelector((state) => state.custom_emojis);
|
const emojis = useAppSelector((state) => state.custom_emojis);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect } from 'react';
|
|||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { Helmet } from 'react-helmet';
|
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 { openModal } from '@/mastodon/actions/modal';
|
||||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
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 intl = useIntl();
|
||||||
const { name, description, tag, account_id } = collection;
|
const { name, description, tag, account_id } = collection;
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
const handleShare = useCallback(() => {
|
const handleShare = useCallback(() => {
|
||||||
dispatch(
|
dispatch(
|
||||||
@@ -97,12 +98,14 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
|||||||
}, [collection, dispatch]);
|
}, [collection, dispatch]);
|
||||||
|
|
||||||
const location = useLocation<{ newCollection?: boolean } | undefined>();
|
const location = useLocation<{ newCollection?: boolean } | undefined>();
|
||||||
const wasJustCreated = location.state?.newCollection;
|
const isNewCollection = location.state?.newCollection;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasJustCreated) {
|
if (isNewCollection) {
|
||||||
|
// Replace with current pathname to clear `newCollection` state
|
||||||
|
history.replace(location.pathname);
|
||||||
handleShare();
|
handleShare();
|
||||||
}
|
}
|
||||||
}, [handleShare, wasJustCreated]);
|
}, [history, handleShare, isNewCollection, location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.header}>
|
<div className={classes.header}>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const CollectionShareModal: React.FC<{
|
|||||||
onClose();
|
onClose();
|
||||||
dispatch(changeCompose(shareMessage));
|
dispatch(changeCompose(shareMessage));
|
||||||
dispatch(focusCompose());
|
dispatch(focusCompose());
|
||||||
}, [collectionLink, dispatch, intl, isOwnCollection, onClose]);
|
}, [onClose, collectionLink, dispatch, intl, isOwnCollection]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalShell>
|
<ModalShell>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useId, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
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 CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.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 { me } from 'mastodon/initial_state';
|
||||||
import {
|
import {
|
||||||
addCollectionItem,
|
addCollectionItem,
|
||||||
|
getCollectionItemIds,
|
||||||
removeCollectionItem,
|
removeCollectionItem,
|
||||||
|
updateCollectionEditorField,
|
||||||
} from 'mastodon/reducers/slices/collections';
|
} from 'mastodon/reducers/slices/collections';
|
||||||
import { store, useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { store, useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import type { TempCollectionState } from './state';
|
|
||||||
import { getCollectionEditorState } from './state';
|
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
import { WizardStepHeader } from './wizard_step_header';
|
import { WizardStepHeader } from './wizard_step_header';
|
||||||
|
|
||||||
@@ -52,9 +52,8 @@ function isOlderThanAWeek(date?: string): boolean {
|
|||||||
|
|
||||||
const AddedAccountItem: React.FC<{
|
const AddedAccountItem: React.FC<{
|
||||||
accountId: string;
|
accountId: string;
|
||||||
isRemovable: boolean;
|
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
}> = ({ accountId, isRemovable, onRemove }) => {
|
}> = ({ accountId, onRemove }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useAccount(accountId);
|
const account = useAccount(accountId);
|
||||||
|
|
||||||
@@ -86,17 +85,15 @@ const AddedAccountItem: React.FC<{
|
|||||||
id={accountId}
|
id={accountId}
|
||||||
extraAccountInfo={lastPostHint}
|
extraAccountInfo={lastPostHint}
|
||||||
>
|
>
|
||||||
{isRemovable && (
|
<IconButton
|
||||||
<IconButton
|
title={intl.formatMessage({
|
||||||
title={intl.formatMessage({
|
id: 'collections.remove_account',
|
||||||
id: 'collections.remove_account',
|
defaultMessage: 'Remove this account',
|
||||||
defaultMessage: 'Remove this account',
|
})}
|
||||||
})}
|
icon='remove'
|
||||||
icon='remove'
|
iconComponent={CancelIcon}
|
||||||
iconComponent={CancelIcon}
|
onClick={handleRemoveAccount}
|
||||||
onClick={handleRemoveAccount}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Account>
|
</Account>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -139,28 +136,25 @@ export const CollectionAccounts: React.FC<{
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const history = useHistory();
|
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('');
|
const { id, items } = collection ?? {};
|
||||||
// This state is only used when creating a new collection.
|
const isEditMode = !!id;
|
||||||
// In edit mode, the collection will be updated instantly
|
const collectionItems = items;
|
||||||
const [addedAccountIds, setAccountIds] = useState(initialItemIds);
|
|
||||||
|
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(
|
const accountIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isEditMode
|
isEditMode ? getCollectionItemIds(collectionItems) : addedAccountIds,
|
||||||
? (collectionItems
|
|
||||||
?.map((item) => item.account_id)
|
|
||||||
.filter((id): id is string => !!id) ?? [])
|
|
||||||
: addedAccountIds,
|
|
||||||
[isEditMode, collectionItems, addedAccountIds],
|
[isEditMode, collectionItems, addedAccountIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
|
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -233,28 +227,41 @@ export const CollectionAccounts: React.FC<{
|
|||||||
[dispatch, relationships],
|
[dispatch, relationships],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeAccountItem = useCallback((accountId: string) => {
|
const removeAccountItem = useCallback(
|
||||||
setAccountIds((ids) => ids.filter((id) => id !== accountId));
|
(accountId: string) => {
|
||||||
}, []);
|
dispatch(
|
||||||
|
updateCollectionEditorField({
|
||||||
|
field: 'accountIds',
|
||||||
|
value: accountIds.filter((id) => id !== accountId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[accountIds, dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
const addAccountItem = useCallback(
|
const addAccountItem = useCallback(
|
||||||
(accountId: string) => {
|
(accountId: string) => {
|
||||||
confirmFollowStatus(accountId, () => {
|
confirmFollowStatus(accountId, () => {
|
||||||
setAccountIds((ids) => [...ids, accountId]);
|
dispatch(
|
||||||
|
updateCollectionEditorField({
|
||||||
|
field: 'accountIds',
|
||||||
|
value: [...accountIds, accountId],
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[confirmFollowStatus],
|
[accountIds, confirmFollowStatus, dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleAccountItem = useCallback(
|
const toggleAccountItem = useCallback(
|
||||||
(item: SuggestionItem) => {
|
(item: SuggestionItem) => {
|
||||||
if (addedAccountIds.includes(item.id)) {
|
if (accountIds.includes(item.id)) {
|
||||||
removeAccountItem(item.id);
|
removeAccountItem(item.id);
|
||||||
} else {
|
} else {
|
||||||
addAccountItem(item.id);
|
addAccountItem(item.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addAccountItem, addedAccountIds, removeAccountItem],
|
[accountIds, addAccountItem, removeAccountItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const instantRemoveAccountItem = useCallback(
|
const instantRemoveAccountItem = useCallback(
|
||||||
@@ -406,7 +413,6 @@ export const CollectionAccounts: React.FC<{
|
|||||||
>
|
>
|
||||||
<AddedAccountItem
|
<AddedAccountItem
|
||||||
accountId={accountId}
|
accountId={accountId}
|
||||||
isRemovable={!isEditMode}
|
|
||||||
onRemove={handleRemoveAccountItem}
|
onRemove={handleRemoveAccountItem}
|
||||||
/>
|
/>
|
||||||
</Article>
|
</Article>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { isFulfilled } from '@reduxjs/toolkit';
|
import { isFulfilled } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { inputToHashtag } from '@/mastodon/utils/hashtags';
|
||||||
import type {
|
import type {
|
||||||
ApiCollectionJSON,
|
|
||||||
ApiCreateCollectionPayload,
|
ApiCreateCollectionPayload,
|
||||||
ApiUpdateCollectionPayload,
|
ApiUpdateCollectionPayload,
|
||||||
} from 'mastodon/api_types/collections';
|
} from 'mastodon/api_types/collections';
|
||||||
@@ -23,70 +23,77 @@ import { TextInputField } from 'mastodon/components/form_fields/text_input_field
|
|||||||
import {
|
import {
|
||||||
createCollection,
|
createCollection,
|
||||||
updateCollection,
|
updateCollection,
|
||||||
|
updateCollectionEditorField,
|
||||||
} from 'mastodon/reducers/slices/collections';
|
} 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 classes from './styles.module.scss';
|
||||||
import { WizardStepHeader } from './wizard_step_header';
|
import { WizardStepHeader } from './wizard_step_header';
|
||||||
|
|
||||||
export const CollectionDetails: React.FC<{
|
export const CollectionDetails: React.FC = () => {
|
||||||
collection?: ApiCollectionJSON | null;
|
|
||||||
}> = ({ collection }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation<TempCollectionState>();
|
const { id, name, description, topic, discoverable, sensitive, accountIds } =
|
||||||
|
useAppSelector((state) => state.collections.editor);
|
||||||
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 handleNameChange = useCallback(
|
const handleNameChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setName(event.target.value);
|
dispatch(
|
||||||
|
updateCollectionEditorField({
|
||||||
|
field: 'name',
|
||||||
|
value: event.target.value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDescriptionChange = useCallback(
|
const handleDescriptionChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setDescription(event.target.value);
|
dispatch(
|
||||||
|
updateCollectionEditorField({
|
||||||
|
field: 'description',
|
||||||
|
value: event.target.value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTopicChange = useCallback(
|
const handleTopicChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setTopic(event.target.value);
|
dispatch(
|
||||||
|
updateCollectionEditorField({
|
||||||
|
field: 'topic',
|
||||||
|
value: inputToHashtag(event.target.value),
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDiscoverableChange = useCallback(
|
const handleDiscoverableChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setDiscoverable(event.target.value === 'public');
|
dispatch(
|
||||||
|
updateCollectionEditorField({
|
||||||
|
field: 'discoverable',
|
||||||
|
value: event.target.value === 'public',
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSensitiveChange = useCallback(
|
const handleSensitiveChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSensitive(event.target.checked);
|
dispatch(
|
||||||
|
updateCollectionEditorField({
|
||||||
|
field: 'sensitive',
|
||||||
|
value: event.target.checked,
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
@@ -112,7 +119,7 @@ export const CollectionDetails: React.FC<{
|
|||||||
description,
|
description,
|
||||||
discoverable,
|
discoverable,
|
||||||
sensitive,
|
sensitive,
|
||||||
account_ids: initialItemIds,
|
account_ids: accountIds,
|
||||||
};
|
};
|
||||||
if (topic) {
|
if (topic) {
|
||||||
payload.tag_name = topic;
|
payload.tag_name = topic;
|
||||||
@@ -124,9 +131,7 @@ export const CollectionDetails: React.FC<{
|
|||||||
}),
|
}),
|
||||||
).then((result) => {
|
).then((result) => {
|
||||||
if (isFulfilled(result)) {
|
if (isFulfilled(result)) {
|
||||||
history.replace(
|
history.replace(`/collections`);
|
||||||
`/collections/${result.payload.collection.id}/edit/details`,
|
|
||||||
);
|
|
||||||
history.push(`/collections/${result.payload.collection.id}`, {
|
history.push(`/collections/${result.payload.collection.id}`, {
|
||||||
newCollection: true,
|
newCollection: true,
|
||||||
});
|
});
|
||||||
@@ -143,7 +148,7 @@ export const CollectionDetails: React.FC<{
|
|||||||
sensitive,
|
sensitive,
|
||||||
dispatch,
|
dispatch,
|
||||||
history,
|
history,
|
||||||
initialItemIds,
|
accountIds,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -215,6 +220,9 @@ export const CollectionDetails: React.FC<{
|
|||||||
}
|
}
|
||||||
value={topic}
|
value={topic}
|
||||||
onChange={handleTopicChange}
|
onChange={handleTopicChange}
|
||||||
|
autoCapitalize='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
spellCheck='false'
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
|||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
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 { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import { CollectionAccounts } from './accounts';
|
import { CollectionAccounts } from './accounts';
|
||||||
@@ -68,6 +71,7 @@ export const CollectionEditorPage: React.FC<{
|
|||||||
const collection = useAppSelector((state) =>
|
const collection = useAppSelector((state) =>
|
||||||
id ? state.collections.collections[id] : undefined,
|
id ? state.collections.collections[id] : undefined,
|
||||||
);
|
);
|
||||||
|
const editorStateId = useAppSelector((state) => state.collections.editor.id);
|
||||||
const isEditMode = !!id;
|
const isEditMode = !!id;
|
||||||
const isLoading = isEditMode && !collection;
|
const isLoading = isEditMode && !collection;
|
||||||
|
|
||||||
@@ -77,6 +81,18 @@ export const CollectionEditorPage: React.FC<{
|
|||||||
}
|
}
|
||||||
}, [dispatch, id]);
|
}, [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));
|
const pageTitle = intl.formatMessage(usePageTitle(id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,7 +120,7 @@ export const CollectionEditorPage: React.FC<{
|
|||||||
exact
|
exact
|
||||||
path={`${path}/details`}
|
path={`${path}/details`}
|
||||||
// eslint-disable-next-line react/jsx-no-bind
|
// eslint-disable-next-line react/jsx-no-bind
|
||||||
render={() => <CollectionDetails collection={collection} />}
|
render={() => <CollectionDetails />}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</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.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_hint": "E.g. “Personal website”",
|
||||||
"account_edit.field_edit_modal.name_label": "Label",
|
"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_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_cancel": "Dragging was cancelled. Field \"{item}\" was dropped.",
|
||||||
"account_edit.field_reorder_modal.drag_end": "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.create_collection": "Créer une collection",
|
||||||
"collections.delete_collection": "Supprimer la collection",
|
"collections.delete_collection": "Supprimer la collection",
|
||||||
"collections.description_length_hint": "Maximum 100 caractères",
|
"collections.description_length_hint": "Maximum 100 caractères",
|
||||||
|
"collections.detail.accept_inclusion": "D'accord",
|
||||||
"collections.detail.accounts_heading": "Comptes",
|
"collections.detail.accounts_heading": "Comptes",
|
||||||
"collections.detail.author_added_you": "{author} vous a ajouté·e à cette collection",
|
"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_author": "Organisée par {author}",
|
||||||
"collections.detail.curated_by_you": "Organisée par vous",
|
"collections.detail.curated_by_you": "Organisée par vous",
|
||||||
"collections.detail.loading": "Chargement de la collection…",
|
"collections.detail.loading": "Chargement de la collection…",
|
||||||
"collections.detail.other_accounts_in_collection": "Autres comptes dans cette 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.sensitive_note": "Cette collection contient des comptes et du contenu qui peut être sensibles.",
|
||||||
"collections.detail.share": "Partager la collection",
|
"collections.detail.share": "Partager la collection",
|
||||||
"collections.edit_details": "Modifier les détails",
|
"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.old_last_post_note": "Dernière publication il y a plus d'une semaine",
|
||||||
"collections.remove_account": "Supprimer ce compte",
|
"collections.remove_account": "Supprimer ce compte",
|
||||||
"collections.report_collection": "Signaler cette collection",
|
"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_label": "Chercher des comptes à ajouter…",
|
||||||
"collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes",
|
"collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes",
|
||||||
"collections.sensitive": "Sensible",
|
"collections.sensitive": "Sensible",
|
||||||
@@ -482,6 +487,9 @@
|
|||||||
"confirmations.remove_from_followers.confirm": "Supprimer l'abonné·e",
|
"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.message": "{name} cessera de vous suivre. Voulez-vous vraiment continuer ?",
|
||||||
"confirmations.remove_from_followers.title": "Supprimer l'abonné·e ?",
|
"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.confirm": "Retirer le message",
|
||||||
"confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.",
|
"confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.",
|
||||||
"confirmations.revoke_quote.title": "Retirer le message ?",
|
"confirmations.revoke_quote.title": "Retirer le message ?",
|
||||||
|
|||||||
@@ -338,12 +338,14 @@
|
|||||||
"collections.create_collection": "Créer une collection",
|
"collections.create_collection": "Créer une collection",
|
||||||
"collections.delete_collection": "Supprimer la collection",
|
"collections.delete_collection": "Supprimer la collection",
|
||||||
"collections.description_length_hint": "Maximum 100 caractères",
|
"collections.description_length_hint": "Maximum 100 caractères",
|
||||||
|
"collections.detail.accept_inclusion": "D'accord",
|
||||||
"collections.detail.accounts_heading": "Comptes",
|
"collections.detail.accounts_heading": "Comptes",
|
||||||
"collections.detail.author_added_you": "{author} vous a ajouté·e à cette collection",
|
"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_author": "Organisée par {author}",
|
||||||
"collections.detail.curated_by_you": "Organisée par vous",
|
"collections.detail.curated_by_you": "Organisée par vous",
|
||||||
"collections.detail.loading": "Chargement de la collection…",
|
"collections.detail.loading": "Chargement de la collection…",
|
||||||
"collections.detail.other_accounts_in_collection": "Autres comptes dans cette 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.sensitive_note": "Cette collection contient des comptes et du contenu qui peut être sensibles.",
|
||||||
"collections.detail.share": "Partager la collection",
|
"collections.detail.share": "Partager la collection",
|
||||||
"collections.edit_details": "Modifier les détails",
|
"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.old_last_post_note": "Dernière publication il y a plus d'une semaine",
|
||||||
"collections.remove_account": "Supprimer ce compte",
|
"collections.remove_account": "Supprimer ce compte",
|
||||||
"collections.report_collection": "Signaler cette collection",
|
"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_label": "Chercher des comptes à ajouter…",
|
||||||
"collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes",
|
"collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes",
|
||||||
"collections.sensitive": "Sensible",
|
"collections.sensitive": "Sensible",
|
||||||
@@ -482,6 +487,9 @@
|
|||||||
"confirmations.remove_from_followers.confirm": "Supprimer l'abonné·e",
|
"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.message": "{name} cessera de vous suivre. Voulez-vous vraiment continuer ?",
|
||||||
"confirmations.remove_from_followers.title": "Supprimer l'abonné·e ?",
|
"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.confirm": "Retirer le message",
|
||||||
"confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.",
|
"confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.",
|
||||||
"confirmations.revoke_quote.title": "Retirer le message ?",
|
"confirmations.revoke_quote.title": "Retirer le message ?",
|
||||||
|
|||||||
@@ -177,7 +177,10 @@
|
|||||||
"account_edit.field_edit_modal.value_label": "Érték",
|
"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_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_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_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.handle_label": "A(z) „{item}” mező húzása",
|
||||||
"account_edit.field_reorder_modal.title": "Mezők átrendezése",
|
"account_edit.field_reorder_modal.title": "Mezők átrendezése",
|
||||||
"account_edit.name_modal.add_title": "Megjelenítendő név hozzáadása",
|
"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.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.profile_tab.title": "Profil lap beállításai",
|
||||||
"account_edit.save": "Mentés",
|
"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.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.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.",
|
"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.blocks": "Servere moderate",
|
||||||
"about.contact": "Contact:",
|
"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.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.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.",
|
"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.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.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.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.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.powered_by": "Media socială descentralizată furnizată de {mastodon}",
|
||||||
"about.rules": "Reguli server",
|
"about.rules": "Reguli server",
|
||||||
"account.account_note_header": "Notă personală",
|
"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.add_or_remove_from_list": "Adaugă sau elimină din liste",
|
||||||
|
"account.badges.admin": "Admin",
|
||||||
|
"account.badges.blocked": "Blocat",
|
||||||
"account.badges.bot": "Robot",
|
"account.badges.bot": "Robot",
|
||||||
|
"account.badges.domain_blocked": "Domeniu blocat",
|
||||||
"account.badges.group": "Grup",
|
"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": "Blochează pe @{name}",
|
||||||
"account.block_domain": "Blochează domeniul {domain}",
|
"account.block_domain": "Blochează domeniul {domain}",
|
||||||
"account.block_short": "Blochează",
|
"account.block_short": "Blochează",
|
||||||
"account.blocked": "Blocat",
|
"account.blocked": "Blocat",
|
||||||
|
"account.blocking": "Blocarea",
|
||||||
"account.cancel_follow_request": "Retrage cererea de urmărire",
|
"account.cancel_follow_request": "Retrage cererea de urmărire",
|
||||||
"account.copy": "Copiază link-ul profilului",
|
"account.copy": "Copiază link-ul profilului",
|
||||||
"account.direct": "Menționează pe @{name} în privat",
|
"account.direct": "Menționează pe @{name} în privat",
|
||||||
"account.disable_notifications": "Nu îmi mai trimite notificări când postează @{name}",
|
"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": "Modifică profilul",
|
||||||
|
"account.edit_profile_short": "Editare",
|
||||||
"account.enable_notifications": "Trimite-mi o notificare când postează @{name}",
|
"account.enable_notifications": "Trimite-mi o notificare când postează @{name}",
|
||||||
"account.endorse": "Promovează pe profil",
|
"account.endorse": "Promovează pe profil",
|
||||||
"account.featured_tags.last_status_at": "Ultima postare pe {date}",
|
"account.featured_tags.last_status_at": "Ultima postare pe {date}",
|
||||||
|
|||||||
@@ -338,12 +338,14 @@
|
|||||||
"collections.create_collection": "Koleksiyon oluştur",
|
"collections.create_collection": "Koleksiyon oluştur",
|
||||||
"collections.delete_collection": "Koleksiyonu sil",
|
"collections.delete_collection": "Koleksiyonu sil",
|
||||||
"collections.description_length_hint": "100 karakterle sınırlı",
|
"collections.description_length_hint": "100 karakterle sınırlı",
|
||||||
|
"collections.detail.accept_inclusion": "Tamam",
|
||||||
"collections.detail.accounts_heading": "Hesaplar",
|
"collections.detail.accounts_heading": "Hesaplar",
|
||||||
"collections.detail.author_added_you": "{author} sizi koleksiyonuna ekledi",
|
"collections.detail.author_added_you": "{author} sizi koleksiyonuna ekledi",
|
||||||
"collections.detail.curated_by_author": "{author} tarafından derlenen",
|
"collections.detail.curated_by_author": "{author} tarafından derlenen",
|
||||||
"collections.detail.curated_by_you": "Sizin derledikleriniz",
|
"collections.detail.curated_by_you": "Sizin derledikleriniz",
|
||||||
"collections.detail.loading": "Koleksiyon yükleniyor…",
|
"collections.detail.loading": "Koleksiyon yükleniyor…",
|
||||||
"collections.detail.other_accounts_in_collection": "Bu koleksiyondaki diğer kişiler:",
|
"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.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.detail.share": "Bu koleksiyonu paylaş",
|
||||||
"collections.edit_details": "Ayrıntıları düzenle",
|
"collections.edit_details": "Ayrıntıları düzenle",
|
||||||
@@ -359,6 +361,9 @@
|
|||||||
"collections.old_last_post_note": "Son gönderi bir haftadan önce",
|
"collections.old_last_post_note": "Son gönderi bir haftadan önce",
|
||||||
"collections.remove_account": "Bu hesabı çıkar",
|
"collections.remove_account": "Bu hesabı çıkar",
|
||||||
"collections.report_collection": "Bu koleksiyonu bildir",
|
"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_label": "Eklemek için hesap arayın…",
|
||||||
"collections.search_accounts_max_reached": "Maksimum hesabı eklediniz",
|
"collections.search_accounts_max_reached": "Maksimum hesabı eklediniz",
|
||||||
"collections.sensitive": "Hassas",
|
"collections.sensitive": "Hassas",
|
||||||
@@ -482,6 +487,9 @@
|
|||||||
"confirmations.remove_from_followers.confirm": "Takipçi kaldır",
|
"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.message": "{name} sizi takip etmeyi bırakacaktır. Devam etmek istediğinize emin misiniz?",
|
||||||
"confirmations.remove_from_followers.title": "Takipçiyi kaldır?",
|
"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.confirm": "Gönderiyi kaldır",
|
||||||
"confirmations.revoke_quote.message": "Bu işlem geri alınamaz.",
|
"confirmations.revoke_quote.message": "Bu işlem geri alınamaz.",
|
||||||
"confirmations.revoke_quote.title": "Gönderiyi silmek ister misiniz?",
|
"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 { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { importFetchedAccounts } from '@/mastodon/actions/importer';
|
import { importFetchedAccounts } from '@/mastodon/actions/importer';
|
||||||
@@ -36,17 +37,69 @@ interface CollectionState {
|
|||||||
status: QueryStatus;
|
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 = {
|
const initialState: CollectionState = {
|
||||||
collections: {},
|
collections: {},
|
||||||
accountCollections: {},
|
accountCollections: {},
|
||||||
|
editor: {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
topic: '',
|
||||||
|
language: null,
|
||||||
|
discoverable: true,
|
||||||
|
sensitive: false,
|
||||||
|
accountIds: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const collectionSlice = createSlice({
|
const collectionSlice = createSlice({
|
||||||
name: 'collections',
|
name: 'collections',
|
||||||
initialState,
|
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) {
|
extraReducers(builder) {
|
||||||
/**
|
/**
|
||||||
* Fetching account collections
|
* Fetching account collections
|
||||||
@@ -104,6 +157,7 @@ const collectionSlice = createSlice({
|
|||||||
builder.addCase(updateCollection.fulfilled, (state, action) => {
|
builder.addCase(updateCollection.fulfilled, (state, action) => {
|
||||||
const { collection } = action.payload;
|
const { collection } = action.payload;
|
||||||
state.collections[collection.id] = collection;
|
state.collections[collection.id] = collection;
|
||||||
|
state.editor = initialState.editor;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,6 +186,7 @@ const collectionSlice = createSlice({
|
|||||||
const { collection } = actions.payload;
|
const { collection } = actions.payload;
|
||||||
|
|
||||||
state.collections[collection.id] = collection;
|
state.collections[collection.id] = collection;
|
||||||
|
state.editor = initialState.editor;
|
||||||
|
|
||||||
if (state.accountCollections[collection.account_id]) {
|
if (state.accountCollections[collection.account_id]) {
|
||||||
state.accountCollections[collection.account_id]?.collectionIds.unshift(
|
state.accountCollections[collection.account_id]?.collectionIds.unshift(
|
||||||
@@ -240,6 +295,9 @@ export const revokeCollectionInclusion = createAppAsyncThunk(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const collections = collectionSlice.reducer;
|
export const collections = collectionSlice.reducer;
|
||||||
|
export const collectionEditorActions = collectionSlice.actions;
|
||||||
|
export const updateCollectionEditorField =
|
||||||
|
collectionSlice.actions.updateEditorField;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selectors
|
* Selectors
|
||||||
@@ -278,3 +336,8 @@ export const selectAccountCollections = createAppSelector(
|
|||||||
} satisfies AccountCollectionQuery;
|
} 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`,
|
`${profileEditSlice.name}/patchProfile`,
|
||||||
(params: Partial<ApiProfileUpdateParams>) => apiPatchProfile(params),
|
(params: Partial<ApiProfileUpdateParams>) => apiPatchProfile(params),
|
||||||
transformProfile,
|
transformProfile,
|
||||||
{ useLoadingBar: false },
|
{
|
||||||
|
useLoadingBar: false,
|
||||||
|
condition(_, { getState }) {
|
||||||
|
return !getState().profileEdit.isPending;
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectFieldById = createAppSelector(
|
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;
|
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_PATTERN_REGEX = buildHashtagPatternRegex();
|
||||||
|
|
||||||
export const HASHTAG_REGEX = buildHashtagRegex();
|
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
|
return
|
||||||
end
|
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
|
request.headers['Signature'] = signature_value
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -11,30 +11,26 @@ class AdminMailer < ApplicationMailer
|
|||||||
|
|
||||||
after_action :set_important_headers!, only: :new_critical_software_updates
|
after_action :set_important_headers!, only: :new_critical_software_updates
|
||||||
|
|
||||||
|
around_action :set_locale
|
||||||
|
|
||||||
default to: -> { @me.user_email }
|
default to: -> { @me.user_email }
|
||||||
|
|
||||||
def new_report(report)
|
def new_report(report)
|
||||||
@report = report
|
@report = report
|
||||||
|
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(instance: @instance, id: @report.id)
|
||||||
mail subject: default_i18n_subject(instance: @instance, id: @report.id)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_appeal(appeal)
|
def new_appeal(appeal)
|
||||||
@appeal = appeal
|
@appeal = appeal
|
||||||
|
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(instance: @instance, username: @appeal.account.username)
|
||||||
mail subject: default_i18n_subject(instance: @instance, username: @appeal.account.username)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_pending_account(user)
|
def new_pending_account(user)
|
||||||
@account = user.account
|
@account = user.account
|
||||||
|
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(instance: @instance, username: @account.username)
|
||||||
mail subject: default_i18n_subject(instance: @instance, username: @account.username)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_trends(links, tags, statuses)
|
def new_trends(links, tags, statuses)
|
||||||
@@ -42,31 +38,23 @@ class AdminMailer < ApplicationMailer
|
|||||||
@tags = tags
|
@tags = tags
|
||||||
@statuses = statuses
|
@statuses = statuses
|
||||||
|
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(instance: @instance)
|
||||||
mail subject: default_i18n_subject(instance: @instance)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_software_updates
|
def new_software_updates
|
||||||
@software_updates = SoftwareUpdate.by_version
|
@software_updates = SoftwareUpdate.by_version
|
||||||
|
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(instance: @instance)
|
||||||
mail subject: default_i18n_subject(instance: @instance)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_critical_software_updates
|
def new_critical_software_updates
|
||||||
@software_updates = SoftwareUpdate.urgent.by_version
|
@software_updates = SoftwareUpdate.urgent.by_version
|
||||||
|
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(instance: @instance)
|
||||||
mail subject: default_i18n_subject(instance: @instance)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def auto_close_registrations
|
def auto_close_registrations
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(instance: @instance)
|
||||||
mail subject: default_i18n_subject(instance: @instance)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -79,6 +67,10 @@ class AdminMailer < ApplicationMailer
|
|||||||
@instance = Rails.configuration.x.local_domain
|
@instance = Rails.configuration.x.local_domain
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_locale(&block)
|
||||||
|
locale_for_account(@me, &block)
|
||||||
|
end
|
||||||
|
|
||||||
def set_important_headers!
|
def set_important_headers!
|
||||||
headers(
|
headers(
|
||||||
'Importance' => 'high',
|
'Importance' => 'high',
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class NotificationMailer < ApplicationMailer
|
|||||||
|
|
||||||
before_deliver :verify_functional_user
|
before_deliver :verify_functional_user
|
||||||
|
|
||||||
|
around_action :set_locale
|
||||||
|
|
||||||
default to: -> { email_address_with_name(@user.email, @me.username) }
|
default to: -> { email_address_with_name(@user.email, @me.username) }
|
||||||
|
|
||||||
layout 'mailer'
|
layout 'mailer'
|
||||||
@@ -22,45 +24,33 @@ class NotificationMailer < ApplicationMailer
|
|||||||
def mention
|
def mention
|
||||||
return if @status.blank?
|
return if @status.blank?
|
||||||
|
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(name: @status.account.acct)
|
||||||
mail subject: default_i18n_subject(name: @status.account.acct)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def quote
|
def quote
|
||||||
return if @status.blank?
|
return if @status.blank?
|
||||||
|
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(name: @status.account.acct)
|
||||||
mail subject: default_i18n_subject(name: @status.account.acct)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def follow
|
def follow
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(name: @account.acct)
|
||||||
mail subject: default_i18n_subject(name: @account.acct)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def favourite
|
def favourite
|
||||||
return if @status.blank?
|
return if @status.blank?
|
||||||
|
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(name: @account.acct)
|
||||||
mail subject: default_i18n_subject(name: @account.acct)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def reblog
|
def reblog
|
||||||
return if @status.blank?
|
return if @status.blank?
|
||||||
|
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(name: @account.acct)
|
||||||
mail subject: default_i18n_subject(name: @account.acct)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def follow_request
|
def follow_request
|
||||||
locale_for_account(@me) do
|
mail subject: default_i18n_subject(name: @account.acct)
|
||||||
mail subject: default_i18n_subject(name: @account.acct)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -81,6 +71,10 @@ class NotificationMailer < ApplicationMailer
|
|||||||
@account = @notification.from_account
|
@account = @notification.from_account
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_locale(&block)
|
||||||
|
locale_for_account(@me, &block)
|
||||||
|
end
|
||||||
|
|
||||||
def verify_functional_user
|
def verify_functional_user
|
||||||
throw(:abort) unless @user.functional?
|
throw(:abort) unless @user.functional?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
#
|
#
|
||||||
class Collection < ApplicationRecord
|
class Collection < ApplicationRecord
|
||||||
MAX_ITEMS = 25
|
MAX_ITEMS = 25
|
||||||
|
NAME_LENGTH_HARD_LIMIT = 256
|
||||||
|
DESCRIPTION_LENGTH_HARD_LIMIT = 2048
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :tag, optional: true
|
belongs_to :tag, optional: true
|
||||||
@@ -31,10 +33,16 @@ class Collection < ApplicationRecord
|
|||||||
has_many :collection_reports, dependent: :delete_all
|
has_many :collection_reports, dependent: :delete_all
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :description, presence: true,
|
validates :name, length: { maximum: 40 }, if: :local?
|
||||||
if: :local?
|
validates :name, length: { maximum: NAME_LENGTH_HARD_LIMIT }, if: :remote?
|
||||||
validates :description_html, presence: true,
|
validates :description,
|
||||||
if: :remote?
|
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 :local, inclusion: [true, false]
|
||||||
validates :sensitive, inclusion: [true, false]
|
validates :sensitive, inclusion: [true, false]
|
||||||
validates :discoverable, inclusion: [true, false]
|
validates :discoverable, inclusion: [true, false]
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class EmailDomainBlock < ApplicationRecord
|
|||||||
|
|
||||||
def blocking?(allow_with_approval: false)
|
def blocking?(allow_with_approval: false)
|
||||||
blocks = EmailDomainBlock.where(domain: domains_with_variants, allow_with_approval: allow_with_approval).by_domain_length
|
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?
|
blocks.any?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,11 @@ class Trends::History
|
|||||||
with_redis { |redis| redis.get(key_for(:uses)).to_i }
|
with_redis { |redis| redis.get(key_for(:uses)).to_i }
|
||||||
end
|
end
|
||||||
|
|
||||||
def add(account_id)
|
def add(value)
|
||||||
with_redis do |redis|
|
with_redis do |redis|
|
||||||
redis.pipelined do |pipeline|
|
redis.pipelined do |pipeline|
|
||||||
pipeline.incrby(key_for(:uses), 1)
|
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(:uses), EXPIRE_AFTER)
|
||||||
pipeline.expire(key_for(:accounts), EXPIRE_AFTER)
|
pipeline.expire(key_for(:accounts), EXPIRE_AFTER)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ class REST::CollectionSerializer < ActiveModel::Serializer
|
|||||||
end
|
end
|
||||||
|
|
||||||
def description
|
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
|
end
|
||||||
|
|
||||||
def items
|
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!
|
Sidekiq.strict_args!
|
||||||
|
|
||||||
Redis.raise_deprecations = true
|
Redis.raise_deprecations = true
|
||||||
|
|
||||||
|
# Silence deprecation warning from json-schema
|
||||||
|
JSON::Validator.use_multi_json = false
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ module ActionDispatch
|
|||||||
module GetIpExtensions
|
module GetIpExtensions
|
||||||
def calculate_ip
|
def calculate_ip
|
||||||
# Set by the Rack web server, this is a single value.
|
# 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.
|
# Could be a CSV list and/or repeated headers that were concatenated.
|
||||||
client_ips = ips_from(@req.client_ip).reverse!
|
client_ips = sanitize_ips(ips_from(@req.client_ip)).reverse!
|
||||||
forwarded_ips = ips_from(@req.x_forwarded_for).reverse!
|
forwarded_ips = sanitize_ips(@req.forwarded_for || []).reverse!
|
||||||
|
|
||||||
# `Client-Ip` and `X-Forwarded-For` should not, generally, both be set. If they
|
# `Client-Ip` and `X-Forwarded-For` should not, generally, both be set. If they
|
||||||
# are both set, it means that either:
|
# 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_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_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(:local) }
|
||||||
|
|
||||||
it { is_expected.to_not allow_value(nil).for(:sensitive) }
|
it { is_expected.to_not allow_value(nil).for(:sensitive) }
|
||||||
@@ -23,10 +27,14 @@ RSpec.describe Collection do
|
|||||||
context 'when collection is remote' do
|
context 'when collection is remote' do
|
||||||
subject { Fabricate.build :collection, local: false }
|
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_not validate_presence_of(:description) }
|
||||||
|
|
||||||
it { is_expected.to validate_presence_of(:description_html) }
|
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(:uri) }
|
||||||
|
|
||||||
it { is_expected.to validate_presence_of(:original_number_of_items) }
|
it { is_expected.to validate_presence_of(:original_number_of_items) }
|
||||||
|
|||||||
@@ -56,16 +56,20 @@ RSpec.describe EmailDomainBlock do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '.requires_approval?' do
|
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 }
|
let(:input) { nil }
|
||||||
|
|
||||||
context 'with a matching block requiring approval' do
|
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' }
|
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
|
end
|
||||||
|
|
||||||
context 'with a matching block not requiring approval' do
|
context 'with a matching block not requiring approval' do
|
||||||
|
|||||||
@@ -51,5 +51,14 @@ RSpec.describe REST::CollectionSerializer do
|
|||||||
expect(subject)
|
expect(subject)
|
||||||
.to include('description' => '<p>remote</p>')
|
.to include('description' => '<p>remote</p>')
|
||||||
end
|
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
|
||||||
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