diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
index 982031c9d..f9dd36e36 100644
--- a/.github/workflows/build-image.yml
+++ b/.github/workflows/build-image.yml
@@ -49,8 +49,10 @@ jobs:
images: |
tootsuite/mastodon
ghcr.io/mastodon/mastodon
+ # Only tag with latest when ran against the latest stable branch
+ # This needs to be updated after each minor version release
flavor: |
- latest=auto
+ latest=${{ startsWith(github.ref, 'refs/tags/v4.1.') && 'auto' || 'false' }}
tags: |
type=edge,branch=main
type=pep440,pattern={{raw}}
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 975c9d28f..24f02d4d3 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -809,7 +809,6 @@ Style/FrozenStringLiteralComment:
- 'config/initializers/httplog.rb'
- 'config/initializers/inflections.rb'
- 'config/initializers/mail_delivery_job.rb'
- - 'config/initializers/makara.rb'
- 'config/initializers/mime_types.rb'
- 'config/initializers/oj.rb'
- 'config/initializers/omniauth.rb'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 91a2c48a1..d6f1b7bcb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,62 @@
All notable changes to this project will be documented in this file.
+## [4.1.4] - 2023-07-07
+
+### Fixed
+
+- Fix branding:generate_app_icons failing because of disallowed ICO coder ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25794))
+- Fix crash in admin interface when viewing a remote user with verified links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25796))
+- Fix processing of media files with unusual names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25788))
+
+## [4.1.3] - 2023-07-06
+
+### Added
+
+- Add fallback redirection when getting a webfinger query `LOCAL_DOMAIN@LOCAL_DOMAIN` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23600))
+
+### Changed
+
+- Change OpenGraph-based embeds to allow fullscreen ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25058))
+- Change AccessTokensVacuum to also delete expired tokens ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24868))
+- Change profile updates to be sent to recently-mentioned servers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24852))
+- Change automatic post deletion thresholds and load detection ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24614))
+- Change `/api/v1/statuses/:id/history` to always return at least one item ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25510))
+- Change auto-linking to allow carets in URL query params ([renchap](https://github.com/mastodon/mastodon/pull/25216))
+
+### Removed
+
+- Remove invalid `X-Frame-Options: ALLOWALL` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25070))
+
+### Fixed
+
+- Fix wrong view being displayed when a webhook fails validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25464))
+- Fix soft-deleted post cleanup scheduler overwhelming the streaming server ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25519))
+- Fix incorrect pagination headers in `/api/v2/admin/accounts` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25477))
+- Fix multiple inefficiencies in automatic post cleanup worker ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24607), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24785), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24840))
+- Fix performance of streaming by parsing message JSON once ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25278), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25361))
+- Fix CSP headers when `S3_ALIAS_HOST` includes a path component ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25273))
+- Fix `tootctl accounts approve --number N` not approving N earliest registrations ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24605))
+- Fix reports not being closed when performing batch suspensions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24988))
+- Fix being able to vote on your own polls ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25015))
+- Fix race condition when reblogging a status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25016))
+- Fix “Authorized applications” inefficiently and incorrectly getting last use date ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25060))
+- Fix “Authorized applications” crashing when listing apps with certain admin API scopes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25713))
+- Fix multiple N+1s in ConversationsController ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25134), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25399), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25499))
+- Fix user archive takeouts when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24431))
+- Fix searching for remote content by URL not working under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25637))
+- Fix inefficiencies in indexing content for search ([VyrCossont](https://github.com/mastodon/mastodon/pull/24285), [VyrCossont](https://github.com/mastodon/mastodon/pull/24342))
+
+### Security
+
+- Add finer permission requirements for managing webhooks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25463))
+- Update dependencies
+- Add hardening headers for user-uploaded files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25756))
+- Fix verified links possibly hiding important parts of the URL (CVE-2023-36462)
+- Fix timeout handling of outbound HTTP requests (CVE-2023-36461)
+- Fix arbitrary file creation through media processing (CVE-2023-36460)
+- Fix possible XSS in preview cards (CVE-2023-36459)
+
## [4.1.2] - 2023-04-04
### Fixed
diff --git a/Gemfile b/Gemfile
index 3feb3f954..24cb43e65 100644
--- a/Gemfile
+++ b/Gemfile
@@ -11,7 +11,6 @@ gem 'rack', '~> 2.2.7'
gem 'haml-rails', '~>2.0'
gem 'pg', '~> 1.5'
-gem 'makara', '~> 0.5'
gem 'pghero'
gem 'dotenv-rails', '~> 2.8'
diff --git a/Gemfile.lock b/Gemfile.lock
index b2d75e9d4..9bd708d61 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -399,8 +399,6 @@ GEM
net-imap
net-pop
net-smtp
- makara (0.5.1)
- activerecord (>= 5.2.0)
marcel (1.0.2)
mario-redis-lock (1.2.1)
redis (>= 3.0.5)
@@ -432,7 +430,7 @@ GEM
net-protocol
net-ssh (7.1.0)
nio4r (2.5.9)
- nokogiri (1.15.2)
+ nokogiri (1.15.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.15.0)
@@ -628,7 +626,7 @@ GEM
fugit (~> 1.1, >= 1.1.6)
safety_net_attestation (0.4.0)
jwt (~> 2.0)
- sanitize (6.0.1)
+ sanitize (6.0.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
scenic (1.7.0)
@@ -815,7 +813,6 @@ DEPENDENCIES
letter_opener_web (~> 2.0)
link_header (~> 0.0)
lograge (~> 0.12)
- makara (~> 0.5)
mario-redis-lock (~> 1.2)
memory_profiler
mime-types (~> 3.4.1)
diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb
index 937ecf6f7..86b54163b 100644
--- a/app/controllers/api/v1/timelines/home_controller.rb
+++ b/app/controllers/api/v1/timelines/home_controller.rb
@@ -6,13 +6,17 @@ class Api::V1::Timelines::HomeController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
- @statuses = load_statuses
- account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+ ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
+ @statuses = load_statuses
+ @relationships = StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+ @account_relationships = AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
+ end
render json: @statuses,
each_serializer: REST::StatusSerializer,
- relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
- account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id),
+ relationships: @relationships,
+ account_relationships: @account_relationships,
status: account_home_feed.regenerating? ? 206 : 200
end
diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb
index bba5418ac..0c59ea75d 100644
--- a/app/helpers/formatting_helper.rb
+++ b/app/helpers/formatting_helper.rb
@@ -59,6 +59,10 @@ module FormattingHelper
end
def account_field_value_format(field, with_rel_me: true)
- html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false)
+ if field.verified? && !field.account.local?
+ TextFormatter.shortened_link(field.value_for_verification)
+ else
+ html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false)
+ end
end
end
diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js
index 0220b0af5..051a9675b 100644
--- a/app/javascript/mastodon/actions/alerts.js
+++ b/app/javascript/mastodon/actions/alerts.js
@@ -12,52 +12,48 @@ export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
export const ALERT_NOOP = 'ALERT_NOOP';
-export function dismissAlert(alert) {
- return {
- type: ALERT_DISMISS,
- alert,
- };
-}
+export const dismissAlert = alert => ({
+ type: ALERT_DISMISS,
+ alert,
+});
-export function clearAlert() {
- return {
- type: ALERT_CLEAR,
- };
-}
+export const clearAlert = () => ({
+ type: ALERT_CLEAR,
+});
-export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
- return {
- type: ALERT_SHOW,
- title,
- message,
- message_values,
- };
-}
+export const showAlert = alert => ({
+ type: ALERT_SHOW,
+ alert,
+});
-export function showAlertForError(error, skipNotFound = false) {
+export const showAlertForError = (error, skipNotFound = false) => {
if (error.response) {
const { data, status, statusText, headers } = error.response;
+ // Skip these errors as they are reflected in the UI
if (skipNotFound && (status === 404 || status === 410)) {
- // Skip these errors as they are reflected in the UI
return { type: ALERT_NOOP };
}
+ // Rate limit errors
if (status === 429 && headers['x-ratelimit-reset']) {
- const reset_date = new Date(headers['x-ratelimit-reset']);
- return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
+ return showAlert({
+ title: messages.rateLimitedTitle,
+ message: messages.rateLimitedMessage,
+ values: { 'retry_time': new Date(headers['x-ratelimit-reset']) },
+ });
}
- let message = statusText;
- let title = `${status}`;
-
- if (data.error) {
- message = data.error;
- }
-
- return showAlert(title, message);
- } else {
- console.error(error);
- return showAlert();
+ return showAlert({
+ title: `${status}`,
+ message: data.error || statusText,
+ });
}
+
+ console.error(error);
+
+ return showAlert({
+ title: messages.unexpectedTitle,
+ message: messages.unexpectedMessage,
+ });
}
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index bb03106e4..c9ed07813 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -84,6 +84,8 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
+ open: { id: 'compose.published.open', defaultMessage: 'Open' },
+ published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
});
export const ensureComposeIsVisible = (getState, routerHistory) => {
@@ -260,6 +262,13 @@ export function submitCompose(routerHistory) {
insertIfOnline('public');
insertIfOnline(`account:${response.data.account.id}`);
}
+
+ dispatch(showAlert({
+ message: messages.published,
+ action: messages.open,
+ dismissAfter: 10000,
+ onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
+ }));
}).catch(function (error) {
dispatch(submitComposeFail(error));
});
@@ -289,18 +298,19 @@ export function submitComposeFail(error) {
export function uploadCompose(files) {
return function (dispatch, getState) {
const uploadLimit = 4;
- const media = getState().getIn(['compose', 'media_attachments']);
- const pending = getState().getIn(['compose', 'pending_media_attachments']);
+ const media = getState().getIn(['compose', 'media_attachments']);
+ const pending = getState().getIn(['compose', 'pending_media_attachments']);
const progress = new Array(files.length).fill(0);
+
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size + pending > uploadLimit) {
- dispatch(showAlert(undefined, messages.uploadErrorLimit));
+ dispatch(showAlert({ message: messages.uploadErrorLimit }));
return;
}
if (getState().getIn(['compose', 'poll'])) {
- dispatch(showAlert(undefined, messages.uploadErrorPoll));
+ dispatch(showAlert({ message: messages.uploadErrorPoll }));
return;
}
diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx
index dd5aff1d8..fd5ea6040 100644
--- a/app/javascript/mastodon/components/account.jsx
+++ b/app/javascript/mastodon/components/account.jsx
@@ -8,15 +8,15 @@ import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
-import { counterRenderer } from 'mastodon/components/common_counter';
import { EmptyAccount } from 'mastodon/components/empty_account';
-import ShortNumber from 'mastodon/components/short_number';
+import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { me } from '../initial_state';
import { Avatar } from './avatar';
import Button from './button';
+import { FollowersCounter } from './counters';
import { DisplayName } from './display_name';
import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp';
@@ -160,7 +160,7 @@ class Account extends ImmutablePureComponent {
{!minimal && (
- {verification} {muteTimeRemaining}
+ {verification} {muteTimeRemaining}
)}
diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx
index ad985a29e..3122d6421 100644
--- a/app/javascript/mastodon/components/animated_number.tsx
+++ b/app/javascript/mastodon/components/animated_number.tsx
@@ -4,7 +4,7 @@ import { TransitionMotion, spring } from 'react-motion';
import { reduceMotion } from '../initial_state';
-import ShortNumber from './short_number';
+import { ShortNumber } from './short_number';
const obfuscatedCount = (count: number) => {
if (count < 0) {
diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.tsx b/app/javascript/mastodon/components/autosuggest_hashtag.tsx
index c6798054d..59d66ec87 100644
--- a/app/javascript/mastodon/components/autosuggest_hashtag.tsx
+++ b/app/javascript/mastodon/components/autosuggest_hashtag.tsx
@@ -1,6 +1,6 @@
import { FormattedMessage } from 'react-intl';
-import ShortNumber from 'mastodon/components/short_number';
+import { ShortNumber } from 'mastodon/components/short_number';
interface Props {
tag: {
diff --git a/app/javascript/mastodon/components/common_counter.jsx b/app/javascript/mastodon/components/common_counter.jsx
deleted file mode 100644
index 23e1f2263..000000000
--- a/app/javascript/mastodon/components/common_counter.jsx
+++ /dev/null
@@ -1,60 +0,0 @@
-// @ts-check
-import { FormattedMessage } from 'react-intl';
-
-/**
- * Returns custom renderer for one of the common counter types
- * @param {"statuses" | "following" | "followers"} counterType
- * Type of the counter
- * @param {boolean} isBold Whether display number must be displayed in bold
- * @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
- * Renderer function
- * @throws If counterType is not covered by this function
- */
-export function counterRenderer(counterType, isBold = true) {
- /**
- * @type {(displayNumber: JSX.Element) => JSX.Element}
- */
- const renderCounter = isBold
- ? (displayNumber) => {displayNumber}
- : (displayNumber) => displayNumber;
-
- switch (counterType) {
- case 'statuses': {
- return (displayNumber, pluralReady) => (
-
- );
- }
- case 'following': {
- return (displayNumber, pluralReady) => (
-
- );
- }
- case 'followers': {
- return (displayNumber, pluralReady) => (
-
- );
- }
- default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
- }
-}
diff --git a/app/javascript/mastodon/components/counters.tsx b/app/javascript/mastodon/components/counters.tsx
new file mode 100644
index 000000000..e0c818f24
--- /dev/null
+++ b/app/javascript/mastodon/components/counters.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+export const StatusesCounter = (
+ displayNumber: React.ReactNode,
+ pluralReady: number
+) => (
+ {displayNumber},
+ }}
+ />
+);
+
+export const FollowingCounter = (
+ displayNumber: React.ReactNode,
+ pluralReady: number
+) => (
+ {displayNumber},
+ }}
+ />
+);
+
+export const FollowersCounter = (
+ displayNumber: React.ReactNode,
+ pluralReady: number
+) => (
+ {displayNumber},
+ }}
+ />
+);
diff --git a/app/javascript/mastodon/components/dismissable_banner.jsx b/app/javascript/mastodon/components/dismissable_banner.jsx
deleted file mode 100644
index 5aecc88b1..000000000
--- a/app/javascript/mastodon/components/dismissable_banner.jsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { injectIntl, defineMessages } from 'react-intl';
-
-import { bannerSettings } from 'mastodon/settings';
-
-import { IconButton } from './icon_button';
-
-const messages = defineMessages({
- dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
-});
-
-class DismissableBanner extends PureComponent {
-
- static propTypes = {
- id: PropTypes.string.isRequired,
- children: PropTypes.node,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- visible: !bannerSettings.get(this.props.id),
- };
-
- handleDismiss = () => {
- const { id } = this.props;
- this.setState({ visible: false }, () => bannerSettings.set(id, true));
- };
-
- render () {
- const { visible } = this.state;
-
- if (!visible) {
- return null;
- }
-
- const { children, intl } = this.props;
-
- return (
-
-
- {children}
-
-
-
-
-
-
- );
- }
-
-}
-
-export default injectIntl(DismissableBanner);
diff --git a/app/javascript/mastodon/components/dismissable_banner.tsx b/app/javascript/mastodon/components/dismissable_banner.tsx
new file mode 100644
index 000000000..d5cdb0750
--- /dev/null
+++ b/app/javascript/mastodon/components/dismissable_banner.tsx
@@ -0,0 +1,47 @@
+import type { PropsWithChildren } from 'react';
+import { useCallback, useState } from 'react';
+
+import { defineMessages, useIntl } from 'react-intl';
+
+import { bannerSettings } from 'mastodon/settings';
+
+import { IconButton } from './icon_button';
+
+const messages = defineMessages({
+ dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
+});
+
+interface Props {
+ id: string;
+}
+
+export const DismissableBanner: React.FC> = ({
+ id,
+ children,
+}) => {
+ const [visible, setVisible] = useState(!bannerSettings.get(id));
+ const intl = useIntl();
+
+ const handleDismiss = useCallback(() => {
+ setVisible(false);
+ bannerSettings.set(id, true);
+ }, [id]);
+
+ if (!visible) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/app/javascript/mastodon/components/hashtag.jsx b/app/javascript/mastodon/components/hashtag.jsx
index 4a7b9ef71..14bb4ddc6 100644
--- a/app/javascript/mastodon/components/hashtag.jsx
+++ b/app/javascript/mastodon/components/hashtag.jsx
@@ -11,7 +11,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
-import ShortNumber from 'mastodon/components/short_number';
+import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
class SilentErrorBoundary extends Component {
diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx
index b0fb0f8b2..026c1cfe9 100644
--- a/app/javascript/mastodon/components/media_gallery.jsx
+++ b/app/javascript/mastodon/components/media_gallery.jsx
@@ -327,7 +327,10 @@ class MediaGallery extends PureComponent {
if (uncached) {
spoilerButton = (
);
} else if (visible) {
@@ -335,7 +338,10 @@ class MediaGallery extends PureComponent {
} else {
spoilerButton = (
);
}
diff --git a/app/javascript/mastodon/components/server_banner.jsx b/app/javascript/mastodon/components/server_banner.jsx
index 998237860..63eec5349 100644
--- a/app/javascript/mastodon/components/server_banner.jsx
+++ b/app/javascript/mastodon/components/server_banner.jsx
@@ -9,7 +9,7 @@ import { connect } from 'react-redux';
import { fetchServer } from 'mastodon/actions/server';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
-import ShortNumber from 'mastodon/components/short_number';
+import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
import Account from 'mastodon/containers/account_container';
import { domain } from 'mastodon/initial_state';
diff --git a/app/javascript/mastodon/components/short_number.jsx b/app/javascript/mastodon/components/short_number.jsx
deleted file mode 100644
index b7ac4f5fd..000000000
--- a/app/javascript/mastodon/components/short_number.jsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import PropTypes from 'prop-types';
-import { memo } from 'react';
-
-import { FormattedMessage, FormattedNumber } from 'react-intl';
-
-import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
-
-// @ts-check
-
-/**
- * @callback ShortNumberRenderer
- * @param {JSX.Element} displayNumber Number to display
- * @param {number} pluralReady Number used for pluralization
- * @returns {JSX.Element} Final render of number
- */
-
-/**
- * @typedef {object} ShortNumberProps
- * @property {number} value Number to display in short variant
- * @property {ShortNumberRenderer} [renderer]
- * Custom renderer for numbers, provided as a prop. If another renderer
- * passed as a child of this component, this prop won't be used.
- * @property {ShortNumberRenderer} [children]
- * Custom renderer for numbers, provided as a child. If another renderer
- * passed as a prop of this component, this one will be used instead.
- */
-
-/**
- * Component that renders short big number to a shorter version
- * @param {ShortNumberProps} param0 Props for the component
- * @returns {JSX.Element} Rendered number
- */
-function ShortNumber({ value, renderer, children }) {
- const shortNumber = toShortNumber(value);
- const [, division] = shortNumber;
-
- if (children != null && renderer != null) {
- console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
- }
-
- const customRenderer = children != null ? children : renderer;
-
- const displayNumber = ;
-
- return customRenderer != null
- ? customRenderer(displayNumber, pluralReady(value, division))
- : displayNumber;
-}
-
-ShortNumber.propTypes = {
- value: PropTypes.number.isRequired,
- renderer: PropTypes.func,
- children: PropTypes.func,
-};
-
-/**
- * @typedef {object} ShortNumberCounterProps
- * @property {import('../utils/number').ShortNumber} value Short number
- */
-
-/**
- * Renders short number into corresponding localizable react fragment
- * @param {ShortNumberCounterProps} param0 Props for the component
- * @returns {JSX.Element} FormattedMessage ready to be embedded in code
- */
-function ShortNumberCounter({ value }) {
- const [rawNumber, unit, maxFractionDigits = 0] = value;
-
- const count = (
-
- );
-
- let values = { count, rawNumber };
-
- switch (unit) {
- case DECIMAL_UNITS.THOUSAND: {
- return (
-
- );
- }
- case DECIMAL_UNITS.MILLION: {
- return (
-
- );
- }
- case DECIMAL_UNITS.BILLION: {
- return (
-
- );
- }
- // Not sure if we should go farther - @Sasha-Sorokin
- default: return count;
- }
-}
-
-ShortNumberCounter.propTypes = {
- value: PropTypes.arrayOf(PropTypes.number),
-};
-
-export default memo(ShortNumber);
diff --git a/app/javascript/mastodon/components/short_number.tsx b/app/javascript/mastodon/components/short_number.tsx
new file mode 100644
index 000000000..010586c04
--- /dev/null
+++ b/app/javascript/mastodon/components/short_number.tsx
@@ -0,0 +1,90 @@
+import { memo } from 'react';
+
+import { FormattedMessage, FormattedNumber } from 'react-intl';
+
+import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
+
+type ShortNumberRenderer = (
+ displayNumber: JSX.Element,
+ pluralReady: number
+) => JSX.Element;
+
+interface ShortNumberProps {
+ value: number;
+ renderer?: ShortNumberRenderer;
+ children?: ShortNumberRenderer;
+}
+
+export const ShortNumberRenderer: React.FC = ({
+ value,
+ renderer,
+ children,
+}) => {
+ const shortNumber = toShortNumber(value);
+ const [, division] = shortNumber;
+
+ if (children && renderer) {
+ console.warn(
+ 'Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'
+ );
+ }
+
+ const customRenderer = children || renderer || null;
+
+ const displayNumber = ;
+
+ return (
+ customRenderer?.(displayNumber, pluralReady(value, division)) ||
+ displayNumber
+ );
+};
+export const ShortNumber = memo(ShortNumberRenderer);
+
+interface ShortNumberCounterProps {
+ value: number[];
+}
+const ShortNumberCounter: React.FC = ({ value }) => {
+ const [rawNumber, unit, maxFractionDigits = 0] = value;
+
+ const count = (
+
+ );
+
+ const values = { count, rawNumber };
+
+ switch (unit) {
+ case DECIMAL_UNITS.THOUSAND: {
+ return (
+
+ );
+ }
+ case DECIMAL_UNITS.MILLION: {
+ return (
+
+ );
+ }
+ case DECIMAL_UNITS.BILLION: {
+ return (
+
+ );
+ }
+ // Not sure if we should go farther - @Sasha-Sorokin
+ default:
+ return count;
+ }
+};
diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx
index a3a1a124a..e272890f0 100644
--- a/app/javascript/mastodon/components/status_action_bar.jsx
+++ b/app/javascript/mastodon/components/status_action_bar.jsx
@@ -252,7 +252,6 @@ class StatusActionBar extends ImmutablePureComponent {
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
const { signedIn, permissions } = this.context.identity;
- const anonymousAccess = !signedIn;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
const mutingConversation = status.get('muted');
@@ -278,71 +277,73 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
- menu.push(null);
-
- menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
-
- if (writtenByMe && pinnableStatus) {
- menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
- }
-
- menu.push(null);
-
- if (writtenByMe || withDismiss) {
- menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
- menu.push(null);
- }
-
- if (writtenByMe) {
- menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
- menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true });
- menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true });
- } else {
- menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick });
- menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
+ if (signedIn) {
menu.push(null);
- if (relationship && relationship.get('muting')) {
- menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
+ menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
+
+ if (writtenByMe && pinnableStatus) {
+ menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+ }
+
+ menu.push(null);
+
+ if (writtenByMe || withDismiss) {
+ menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+ menu.push(null);
+ }
+
+ if (writtenByMe) {
+ menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true });
+ menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true });
} else {
- menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick, dangerous: true });
- }
-
- if (relationship && relationship.get('blocking')) {
- menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
- } else {
- menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick, dangerous: true });
- }
-
- if (!this.props.onFilter) {
- menu.push(null);
- menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick, dangerous: true });
- menu.push(null);
- }
-
- menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport, dangerous: true });
-
- if (account.get('acct') !== account.get('username')) {
- const domain = account.get('acct').split('@')[1];
-
+ menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick });
+ menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
menu.push(null);
- if (relationship && relationship.get('domain_blocking')) {
- menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
+ if (relationship && relationship.get('muting')) {
+ menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
} else {
- menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain, dangerous: true });
+ menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick, dangerous: true });
}
- }
- if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
- menu.push(null);
- if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
- menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
- menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
+ if (relationship && relationship.get('blocking')) {
+ menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick, dangerous: true });
}
- if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+
+ if (!this.props.onFilter) {
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick, dangerous: true });
+ menu.push(null);
+ }
+
+ menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport, dangerous: true });
+
+ if (account.get('acct') !== account.get('username')) {
const domain = account.get('acct').split('@')[1];
- menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
+
+ menu.push(null);
+
+ if (relationship && relationship.get('domain_blocking')) {
+ menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain, dangerous: true });
+ }
+ }
+
+ if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
+ menu.push(null);
+ if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
+ menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
+ menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
+ }
+ if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+ const domain = account.get('acct').split('@')[1];
+ menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
+ }
}
}
}
@@ -387,7 +388,6 @@ class StatusActionBar extends ImmutablePureComponent {
+
);
diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx
index b718e860d..d95184cdb 100644
--- a/app/javascript/mastodon/features/account/components/header.jsx
+++ b/app/javascript/mastodon/features/account/components/header.jsx
@@ -11,10 +11,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { Avatar } from 'mastodon/components/avatar';
import Button from 'mastodon/components/button';
-import { counterRenderer } from 'mastodon/components/common_counter';
+import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
-import ShortNumber from 'mastodon/components/short_number';
+import { ShortNumber } from 'mastodon/components/short_number';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { autoPlayGif, me, domain } from 'mastodon/initial_state';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
@@ -290,7 +290,6 @@ class Header extends ImmutablePureComponent {
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') });
- menu.push(null);
}
if ('share' in navigator) {
@@ -451,21 +450,21 @@ class Header extends ImmutablePureComponent {
diff --git a/app/javascript/mastodon/features/community_timeline/index.jsx b/app/javascript/mastodon/features/community_timeline/index.jsx
index 7e3b9babe..2d94cabed 100644
--- a/app/javascript/mastodon/features/community_timeline/index.jsx
+++ b/app/javascript/mastodon/features/community_timeline/index.jsx
@@ -7,7 +7,7 @@ import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
+import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import { domain } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
diff --git a/app/javascript/mastodon/features/directory/components/account_card.jsx b/app/javascript/mastodon/features/directory/components/account_card.jsx
index cf1c63f9e..795979530 100644
--- a/app/javascript/mastodon/features/directory/components/account_card.jsx
+++ b/app/javascript/mastodon/features/directory/components/account_card.jsx
@@ -19,7 +19,7 @@ import { openModal } from 'mastodon/actions/modal';
import { Avatar } from 'mastodon/components/avatar';
import Button from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
-import ShortNumber from 'mastodon/components/short_number';
+import { ShortNumber } from 'mastodon/components/short_number';
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
import { makeGetAccount } from 'mastodon/selectors';
diff --git a/app/javascript/mastodon/features/explore/components/story.jsx b/app/javascript/mastodon/features/explore/components/story.jsx
index 0a9fbb190..73ec99c14 100644
--- a/app/javascript/mastodon/features/explore/components/story.jsx
+++ b/app/javascript/mastodon/features/explore/components/story.jsx
@@ -5,7 +5,7 @@ import classNames from 'classnames';
import { Blurhash } from 'mastodon/components/blurhash';
import { accountsCountRenderer } from 'mastodon/components/hashtag';
-import ShortNumber from 'mastodon/components/short_number';
+import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
export default class Story extends PureComponent {
diff --git a/app/javascript/mastodon/features/explore/index.jsx b/app/javascript/mastodon/features/explore/index.jsx
index 185db0732..1a66adc87 100644
--- a/app/javascript/mastodon/features/explore/index.jsx
+++ b/app/javascript/mastodon/features/explore/index.jsx
@@ -11,7 +11,7 @@ import { connect } from 'react-redux';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import Search from 'mastodon/features/compose/containers/search_container';
-import { showTrends } from 'mastodon/initial_state';
+import { trendsEnabled } from 'mastodon/initial_state';
import Links from './links';
import SearchResults from './results';
@@ -26,7 +26,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
layout: state.getIn(['meta', 'layout']),
- isSearching: state.getIn(['search', 'submitted']) || !showTrends,
+ isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled,
});
class Explore extends PureComponent {
diff --git a/app/javascript/mastodon/features/explore/links.jsx b/app/javascript/mastodon/features/explore/links.jsx
index 49c667f02..8b199bf47 100644
--- a/app/javascript/mastodon/features/explore/links.jsx
+++ b/app/javascript/mastodon/features/explore/links.jsx
@@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { fetchTrendingLinks } from 'mastodon/actions/trends';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
+import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import Story from './components/story';
diff --git a/app/javascript/mastodon/features/explore/statuses.jsx b/app/javascript/mastodon/features/explore/statuses.jsx
index eb2fe777a..3271929db 100644
--- a/app/javascript/mastodon/features/explore/statuses.jsx
+++ b/app/javascript/mastodon/features/explore/statuses.jsx
@@ -9,7 +9,7 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
+import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
diff --git a/app/javascript/mastodon/features/explore/tags.jsx b/app/javascript/mastodon/features/explore/tags.jsx
index f558b48a6..1a4d25969 100644
--- a/app/javascript/mastodon/features/explore/tags.jsx
+++ b/app/javascript/mastodon/features/explore/tags.jsx
@@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
+import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
diff --git a/app/javascript/mastodon/features/firehose/index.jsx b/app/javascript/mastodon/features/firehose/index.jsx
index 9ba4fd5b2..e5b47d3fe 100644
--- a/app/javascript/mastodon/features/firehose/index.jsx
+++ b/app/javascript/mastodon/features/firehose/index.jsx
@@ -10,7 +10,7 @@ import { addColumn } from 'mastodon/actions/columns';
import { changeSetting } from 'mastodon/actions/settings';
import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming';
import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
+import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import initialState, { domain } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
diff --git a/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx
index a6993c641..2af85b6d5 100644
--- a/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx
+++ b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx
@@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import background from 'mastodon/../images/friends-cropped.png';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
+import { DismissableBanner } from 'mastodon/components/dismissable_banner';
export const ExplorePrompt = () => (
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
index b63796a8b..1e62ed9a5 100644
--- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (permission === 'granted') {
dispatch(changePushNotifications(path.slice(1), checked));
} else {
- dispatch(showAlert(undefined, messages.permissionDenied));
+ dispatch(showAlert({ message: messages.permissionDenied }));
}
}));
} else {
@@ -47,7 +47,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (permission === 'granted') {
dispatch(changeSetting(['notifications', ...path], checked));
} else {
- dispatch(showAlert(undefined, messages.permissionDenied));
+ dispatch(showAlert({ message: messages.permissionDenied }));
}
}));
} else {
diff --git a/app/javascript/mastodon/features/public_timeline/index.jsx b/app/javascript/mastodon/features/public_timeline/index.jsx
index 352baa833..3bfb25ba7 100644
--- a/app/javascript/mastodon/features/public_timeline/index.jsx
+++ b/app/javascript/mastodon/features/public_timeline/index.jsx
@@ -7,7 +7,7 @@ import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
-import DismissableBanner from 'mastodon/components/dismissable_banner';
+import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import { domain } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx
index 9331531b9..c73ab59c3 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.jsx
+++ b/app/javascript/mastodon/features/status/components/action_bar.jsx
@@ -203,71 +203,74 @@ class ActionBar extends PureComponent {
let menu = [];
- if (publicStatus) {
- if (isRemote) {
- menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
- }
-
- menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
-
- if ('share' in navigator) {
- menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare });
- }
-
- menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
- menu.push(null);
+ if (publicStatus && isRemote) {
+ menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
}
- if (writtenByMe) {
- if (pinnableStatus) {
- menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
- menu.push(null);
- }
+ menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
- menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
- menu.push(null);
- menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
- menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true });
- menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true });
- } else {
- menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ if (publicStatus && 'share' in navigator) {
+ menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare });
+ }
+
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+ }
+
+ if (signedIn) {
menu.push(null);
- if (relationship && relationship.get('muting')) {
- menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
+ if (writtenByMe) {
+ if (pinnableStatus) {
+ menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+ menu.push(null);
+ }
+
+ menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true });
+ menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true });
} else {
- menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick, dangerous: true });
- }
-
- if (relationship && relationship.get('blocking')) {
- menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
- } else {
- menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick, dangerous: true });
- }
-
- menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true });
-
- if (account.get('acct') !== account.get('username')) {
- const domain = account.get('acct').split('@')[1];
-
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push(null);
- if (relationship && relationship.get('domain_blocking')) {
- menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
+ if (relationship && relationship.get('muting')) {
+ menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
} else {
- menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain, dangerous: true });
+ menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick, dangerous: true });
}
- }
- if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
- menu.push(null);
- if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
- menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
- menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
+ if (relationship && relationship.get('blocking')) {
+ menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick, dangerous: true });
}
- if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true });
+
+ if (account.get('acct') !== account.get('username')) {
const domain = account.get('acct').split('@')[1];
- menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
+
+ menu.push(null);
+
+ if (relationship && relationship.get('domain_blocking')) {
+ menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain, dangerous: true });
+ }
+ }
+
+ if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
+ menu.push(null);
+ if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
+ menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
+ menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
+ }
+ if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+ const domain = account.get('acct').split('@')[1];
+ menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
+ }
}
}
}
@@ -301,7 +304,7 @@ class ActionBar extends PureComponent {
-
+
);
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
index d5e98461a..dc406fa55 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
@@ -7,7 +7,7 @@ import { Link } from 'react-router-dom';
import { WordmarkLogo } from 'mastodon/components/logo';
import NavigationPortal from 'mastodon/components/navigation_portal';
-import { timelinePreview, showTrends } from 'mastodon/initial_state';
+import { timelinePreview, trendsEnabled } from 'mastodon/initial_state';
import ColumnLink from './column_link';
import DisabledAccountBanner from './disabled_account_banner';
@@ -65,7 +65,7 @@ class NavigationPanel extends Component {
>
)}
- {showTrends ? (
+ {trendsEnabled ? (
) : (
diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js
index c1d19f710..3d60cfdad 100644
--- a/app/javascript/mastodon/features/ui/containers/notifications_container.js
+++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js
@@ -7,26 +7,27 @@ import { NotificationStack } from 'react-notification';
import { dismissAlert } from '../../../actions/alerts';
import { getAlerts } from '../../../selectors';
-const mapStateToProps = (state, { intl }) => {
- const notifications = getAlerts(state);
+const formatIfNeeded = (intl, message, values) => {
+ if (typeof message === 'object') {
+ return intl.formatMessage(message, values);
+ }
- notifications.forEach(notification => ['title', 'message'].forEach(key => {
- const value = notification[key];
-
- if (typeof value === 'object') {
- notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
- }
- }));
-
- return { notifications };
+ return message;
};
-const mapDispatchToProps = (dispatch) => {
- return {
- onDismiss: alert => {
- dispatch(dismissAlert(alert));
- },
- };
-};
+const mapStateToProps = (state, { intl }) => ({
+ notifications: getAlerts(state).map(alert => ({
+ ...alert,
+ action: formatIfNeeded(intl, alert.action, alert.values),
+ title: formatIfNeeded(intl, alert.title, alert.values),
+ message: formatIfNeeded(intl, alert.message, alert.values),
+ })),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+ onDismiss (alert) {
+ dispatch(dismissAlert(alert));
+ },
+});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx
index 59327f049..b38acfc14 100644
--- a/app/javascript/mastodon/features/ui/index.jsx
+++ b/app/javascript/mastodon/features/ui/index.jsx
@@ -22,7 +22,7 @@ import { clearHeight } from '../../actions/height_cache';
import { expandNotifications } from '../../actions/notifications';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines';
-import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
+import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding } from '../../initial_state';
import BundleColumnError from './components/bundle_column_error';
import Header from './components/header';
@@ -170,7 +170,7 @@ class SwitchingColumnsArea extends PureComponent {
}
} else if (singleUserMode && owner && initialState?.accounts[owner]) {
redirect = ;
- } else if (showTrends && trendsAsLanding) {
+ } else if (trendsEnabled && trendsAsLanding) {
redirect = ;
} else {
redirect = ;
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 1f0f9d5b1..5ad61e1f6 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -69,12 +69,13 @@
* @property {boolean} reduce_motion
* @property {string} repository
* @property {boolean} search_enabled
+ * @property {boolean} trends_enabled
* @property {boolean} single_user_mode
* @property {string} source_url
* @property {string} streaming_api_base_url
* @property {boolean} timeline_preview
* @property {string} title
- * @property {boolean} trends
+ * @property {boolean} show_trends
* @property {boolean} trends_as_landing_page
* @property {boolean} unfollow_modal
* @property {boolean} use_blurhash
@@ -121,7 +122,8 @@ export const reduceMotion = getMeta('reduce_motion');
export const registrationsOpen = getMeta('registrations_open');
export const repository = getMeta('repository');
export const searchEnabled = getMeta('search_enabled');
-export const showTrends = getMeta('trends');
+export const trendsEnabled = getMeta('trends_enabled');
+export const showTrends = getMeta('show_trends');
export const singleUserMode = getMeta('single_user_mode');
export const source_url = getMeta('source_url');
export const timelinePreview = getMeta('timeline_preview');
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 010a5f6af..ebe849f75 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -135,6 +135,8 @@
"community.column_settings.remote_only": "Remote only",
"compose.language.change": "Change language",
"compose.language.search": "Search languages...",
+ "compose.published.body": "Post published.",
+ "compose.published.open": "Open",
"compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.",
@@ -619,6 +621,8 @@
"status.history.created": "{name} created {date}",
"status.history.edited": "{name} edited {date}",
"status.load_more": "Load more",
+ "status.media.open": "Click to open",
+ "status.media.show": "Click to show",
"status.media_hidden": "Media hidden",
"status.mention": "Mention @{name}",
"status.more": "More",
@@ -651,7 +655,7 @@
"status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}",
"status.translate": "Translate",
"status.translated_from_with": "Translated from {lang} using {provider}",
- "status.uncached_media_warning": "Not available",
+ "status.uncached_media_warning": "Preview not available",
"status.unlisted_quote": "Unlisted quote",
"status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile",
diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js
index bd49d748f..1ca9b62a0 100644
--- a/app/javascript/mastodon/reducers/alerts.js
+++ b/app/javascript/mastodon/reducers/alerts.js
@@ -1,4 +1,4 @@
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { List as ImmutableList } from 'immutable';
import {
ALERT_SHOW,
@@ -8,17 +8,20 @@ import {
const initialState = ImmutableList([]);
+let id = 0;
+
+const addAlert = (state, alert) =>
+ state.push({
+ key: id++,
+ ...alert,
+ });
+
export default function alerts(state = initialState, action) {
switch(action.type) {
case ALERT_SHOW:
- return state.push(ImmutableMap({
- key: state.size > 0 ? state.last().get('key') + 1 : 0,
- title: action.title,
- message: action.message,
- message_values: action.message_values,
- }));
+ return addAlert(state, action.alert);
case ALERT_DISMISS:
- return state.filterNot(item => item.get('key') === action.alert.key);
+ return state.filterNot(item => item.key === action.alert.key);
case ALERT_CLEAR:
return state.clear();
default:
diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts
index 67aa5f6c5..ad3077e37 100644
--- a/app/javascript/mastodon/reducers/index.ts
+++ b/app/javascript/mastodon/reducers/index.ts
@@ -26,7 +26,6 @@ import lists from './lists';
import markers from './markers';
import media_attachments from './media_attachments';
import meta from './meta';
-import { missedUpdatesReducer } from './missed_updates';
import { modalReducer } from './modal';
import mutes from './mutes';
import notifications from './notifications';
@@ -82,7 +81,6 @@ const reducers = {
suggestions,
polls,
trends,
- missed_updates: missedUpdatesReducer,
markers,
picture_in_picture,
history,
diff --git a/app/javascript/mastodon/reducers/missed_updates.ts b/app/javascript/mastodon/reducers/missed_updates.ts
deleted file mode 100644
index a587fcb03..000000000
--- a/app/javascript/mastodon/reducers/missed_updates.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Record } from 'immutable';
-
-import type { Action } from 'redux';
-
-import { focusApp, unfocusApp } from '../actions/app';
-import { NOTIFICATIONS_UPDATE } from '../actions/notifications';
-
-interface MissedUpdatesState {
- focused: boolean;
- unread: number;
-}
-const initialState = Record({
- focused: true,
- unread: 0,
-})();
-
-export function missedUpdatesReducer(
- state = initialState,
- action: Action
-) {
- switch (action.type) {
- case focusApp.type:
- return state.set('focused', true).set('unread', 0);
- case unfocusApp.type:
- return state.set('focused', false);
- case NOTIFICATIONS_UPDATE:
- return state.get('focused')
- ? state
- : state.update('unread', (x) => x + 1);
- default:
- return state;
- }
-}
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 9b5a2f8b6..cb8be0b39 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -117,26 +117,16 @@ export const makeGetPictureInPicture = () => {
}));
};
-const getAlertsBase = state => state.get('alerts');
+const ALERT_DEFAULTS = {
+ dismissAfter: 5000,
+ style: false,
+};
-export const getAlerts = createSelector([getAlertsBase], (base) => {
- let arr = [];
-
- base.forEach(item => {
- arr.push({
- message: item.get('message'),
- message_values: item.get('message_values'),
- title: item.get('title'),
- key: item.get('key'),
- dismissAfter: 5000,
- barStyle: {
- zIndex: 200,
- },
- });
- });
-
- return arr;
-});
+export const getAlerts = createSelector(state => state.get('alerts'), alerts =>
+ alerts.map(item => ({
+ ...ALERT_DEFAULTS,
+ ...item,
+ })).toArray());
export const makeGetNotification = () => createSelector([
(_, base) => base,
diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss
index 4fa1a0361..1c2386f02 100644
--- a/app/javascript/styles/contrast/diff.scss
+++ b/app/javascript/styles/contrast/diff.scss
@@ -15,7 +15,8 @@
.status__content a,
.link-footer a,
.reply-indicator__content a,
-.status__content__read-more-button {
+.status__content__read-more-button,
+.status__content__translate-button {
text-decoration: underline;
&:hover,
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 1ba9c8211..6fe7428f2 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -989,7 +989,8 @@ body > [data-popper-placement] {
}
}
-.status__content__read-more-button {
+.status__content__read-more-button,
+.status__content__translate-button {
display: block;
font-size: 15px;
line-height: 22px;
@@ -4286,34 +4287,31 @@ a.status-card.compact:hover {
}
&__overlay {
- display: block;
- background: transparent;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba($black, 0.5);
width: 100%;
height: 100%;
+ padding: 0;
+ margin: 0;
border: 0;
+ border-radius: 4px;
&__label {
- display: inline-block;
- background: rgba($base-overlay-background, 0.5);
- border-radius: 8px;
- padding: 8px 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ flex-direction: column;
color: $primary-text-color;
font-weight: 500;
font-size: 14px;
}
- &:hover,
- &:focus,
- &:active {
- .spoiler-button__overlay__label {
- background: rgba($base-overlay-background, 0.8);
- }
- }
-
- &:disabled {
- .spoiler-button__overlay__label {
- background: rgba($base-overlay-background, 0.5);
- }
+ &__action {
+ font-weight: 400;
+ font-size: 13px;
}
}
}
@@ -9150,3 +9148,62 @@ noscript {
}
}
}
+
+.notification-list {
+ position: fixed;
+ bottom: 2rem;
+ inset-inline-start: 0;
+ z-index: 999;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.notification-bar {
+ flex: 0 0 auto;
+ position: relative;
+ inset-inline-start: -100%;
+ width: auto;
+ padding: 15px;
+ margin: 0;
+ color: $primary-text-color;
+ background: rgba($black, 0.85);
+ backdrop-filter: blur(8px);
+ border: 1px solid rgba(lighten($ui-base-color, 4%), 0.85);
+ border-radius: 8px;
+ box-shadow: 0 10px 15px -3px rgba($base-shadow-color, 0.25),
+ 0 4px 6px -4px rgba($base-shadow-color, 0.25);
+ cursor: default;
+ transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1);
+ transform: translateZ(0);
+ font-size: 15px;
+ line-height: 21px;
+
+ &.notification-bar-active {
+ inset-inline-start: 1rem;
+ }
+}
+
+.notification-bar-title {
+ margin-inline-end: 5px;
+}
+
+.notification-bar-title,
+.notification-bar-action {
+ font-weight: 700;
+}
+
+.notification-bar-action {
+ text-transform: uppercase;
+ margin-inline-start: 10px;
+ cursor: pointer;
+ color: $highlight-text-color;
+ border-radius: 4px;
+ padding: 0 4px;
+
+ &:hover,
+ &:focus,
+ &:active {
+ background: rgba($ui-base-color, 0.85);
+ }
+}
diff --git a/app/lib/activitypub/activity/flag.rb b/app/lib/activitypub/activity/flag.rb
index dc1932f59..304cf0ad2 100644
--- a/app/lib/activitypub/activity/flag.rb
+++ b/app/lib/activitypub/activity/flag.rb
@@ -4,13 +4,14 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity
def perform
return if skip_reports?
- target_accounts = object_uris.filter_map { |uri| account_from_uri(uri) }.select(&:local?)
- target_statuses_by_account = object_uris.filter_map { |uri| status_from_uri(uri) }.select(&:local?).group_by(&:account_id)
+ target_accounts = object_uris.filter_map { |uri| account_from_uri(uri) }
+ target_statuses_by_account = object_uris.filter_map { |uri| status_from_uri(uri) }.group_by(&:account_id)
target_accounts.each do |target_account|
- target_statuses = target_statuses_by_account[target_account.id]
+ target_statuses = target_statuses_by_account[target_account.id]
+ replied_to_accounts = Account.local.where(id: target_statuses.filter_map(&:in_reply_to_account_id))
- next if target_account.suspended?
+ next if target_account.suspended? || (!target_account.local? && replied_to_accounts.none?)
ReportService.new.call(
@account,
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 4bde6fc91..425effa1a 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -7,11 +7,48 @@ require 'resolv'
# Monkey-patch the HTTP.rb timeout class to avoid using a timeout block
# around the Socket#open method, since we use our own timeout blocks inside
# that method
+#
+# Also changes how the read timeout behaves so that it is cumulative (closer
+# to HTTP::Timeout::Global, but still having distinct timeouts for other
+# operation types)
class HTTP::Timeout::PerOperation
def connect(socket_class, host, port, nodelay = false)
@socket = socket_class.open(host, port)
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
end
+
+ # Reset deadline when the connection is re-used for different requests
+ def reset_counter
+ @deadline = nil
+ end
+
+ # Read data from the socket
+ def readpartial(size, buffer = nil)
+ @deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
+
+ timeout = false
+ loop do
+ result = @socket.read_nonblock(size, buffer, exception: false)
+
+ return :eof if result.nil?
+
+ remaining_time = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ raise HTTP::TimeoutError, "Read timed out after #{@read_timeout} seconds" if timeout || remaining_time <= 0
+ return result if result != :wait_readable
+
+ # marking the socket for timeout. Why is this not being raised immediately?
+ # it seems there is some race-condition on the network level between calling
+ # #read_nonblock and #wait_readable, in which #read_nonblock signalizes waiting
+ # for reads, and when waiting for x seconds, it returns nil suddenly without completing
+ # the x seconds. In a normal case this would be a timeout on wait/read, but it can
+ # also mean that the socket has been closed by the server. Therefore we "mark" the
+ # socket for timeout and try to read more bytes. If it returns :eof, it's all good, no
+ # timeout. Else, the first timeout was a proper timeout.
+ # This hack has to be done because io/wait#wait_readable doesn't provide a value for when
+ # the socket is closed by the server, and HTTP::Parser doesn't provide the limit for the chunks.
+ timeout = true unless @socket.to_io.wait_readable(remaining_time)
+ end
+ end
end
class Request
diff --git a/app/lib/text_formatter.rb b/app/lib/text_formatter.rb
index 7a70037d3..dd3e91e7d 100644
--- a/app/lib/text_formatter.rb
+++ b/app/lib/text_formatter.rb
@@ -52,6 +52,26 @@ class TextFormatter
html.html_safe # rubocop:disable Rails/OutputSafety
end
+ class << self
+ include ERB::Util
+
+ def shortened_link(url, rel_me: false)
+ url = Addressable::URI.parse(url).to_s
+ rel = rel_me ? (DEFAULT_REL + %w(me)) : DEFAULT_REL
+
+ prefix = url.match(URL_PREFIX_REGEX).to_s
+ display_url = url[prefix.length, 30]
+ suffix = url[prefix.length + 30..-1]
+ cutoff = url[prefix.length..-1].length > 30
+
+ <<~HTML.squish.html_safe # rubocop:disable Rails/OutputSafety
+ #{h(prefix)}#{h(display_url)}#{h(suffix)}
+ HTML
+ rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
+ h(url)
+ end
+ end
+
private
def rewrite
@@ -74,19 +94,7 @@ class TextFormatter
end
def link_to_url(entity)
- url = Addressable::URI.parse(entity[:url]).to_s
- rel = with_rel_me? ? (DEFAULT_REL + %w(me)) : DEFAULT_REL
-
- prefix = url.match(URL_PREFIX_REGEX).to_s
- display_url = url[prefix.length, 30]
- suffix = url[prefix.length + 30..-1]
- cutoff = url[prefix.length..-1].length > 30
-
- <<~HTML.squish
- #{h(prefix)}#{h(display_url)}#{h(suffix)}
- HTML
- rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
- h(entity[:url])
+ TextFormatter.shortened_link(entity[:url], rel_me: with_rel_me?)
end
def link_to_hashtag(entity)
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index bc6d87ae6..5baf9b38a 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -6,45 +6,52 @@ class AdminMailer < ApplicationMailer
helper :accounts
helper :languages
- def new_report(recipient, report)
- @report = report
- @me = recipient
- @instance = Rails.configuration.x.local_domain
+ before_action :process_params
+ before_action :set_instance
+
+ default to: -> { @me.user_email }
+
+ def new_report(report)
+ @report = report
locale_for_account(@me) do
- mail to: @me.user_email, subject: I18n.t('admin_mailer.new_report.subject', instance: @instance, id: @report.id)
+ mail subject: default_i18n_subject(instance: @instance, id: @report.id)
end
end
- def new_appeal(recipient, appeal)
- @appeal = appeal
- @me = recipient
- @instance = Rails.configuration.x.local_domain
+ def new_appeal(appeal)
+ @appeal = appeal
locale_for_account(@me) do
- mail to: @me.user_email, subject: I18n.t('admin_mailer.new_appeal.subject', instance: @instance, username: @appeal.account.username)
+ mail subject: default_i18n_subject(instance: @instance, username: @appeal.account.username)
end
end
- def new_pending_account(recipient, user)
- @account = user.account
- @me = recipient
- @instance = Rails.configuration.x.local_domain
+ def new_pending_account(user)
+ @account = user.account
locale_for_account(@me) do
- mail to: @me.user_email, subject: I18n.t('admin_mailer.new_pending_account.subject', instance: @instance, username: @account.username)
+ mail subject: default_i18n_subject(instance: @instance, username: @account.username)
end
end
- def new_trends(recipient, links, tags, statuses)
+ def new_trends(links, tags, statuses)
@links = links
@tags = tags
@statuses = statuses
- @me = recipient
- @instance = Rails.configuration.x.local_domain
locale_for_account(@me) do
- mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trends.subject', instance: @instance)
+ mail subject: default_i18n_subject(instance: @instance)
end
end
+
+ private
+
+ def process_params
+ @me = params[:recipient]
+ end
+
+ def set_instance
+ @instance = Rails.configuration.x.local_domain
+ end
end
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index 7cd3bab1a..277612366 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -1,83 +1,76 @@
# frozen_string_literal: true
class NotificationMailer < ApplicationMailer
- helper :accounts
- helper :statuses
+ helper :accounts,
+ :statuses,
+ :routing
- helper RoutingHelper
+ before_action :process_params
+ before_action :set_status, only: [:mention, :favourite, :reblog]
+ before_action :set_account, only: [:follow, :favourite, :reblog, :follow_request]
- def mention(recipient, notification)
- @me = recipient
- @user = recipient.user
- @type = 'mention'
- @status = notification.target_status
+ default to: -> { email_address_with_name(@user.email, @me.username) }
+ def mention
return unless @user.functional? && @status.present?
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
- mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct)
+ mail subject: default_i18n_subject(name: @status.account.acct)
end
end
- def follow(recipient, notification)
- @me = recipient
- @user = recipient.user
- @type = 'follow'
- @account = notification.from_account
-
+ def follow
return unless @user.functional?
locale_for_account(@me) do
- mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow.subject', name: @account.acct)
+ mail subject: default_i18n_subject(name: @account.acct)
end
end
- def favourite(recipient, notification)
- @me = recipient
- @user = recipient.user
- @type = 'favourite'
- @account = notification.from_account
- @status = notification.target_status
-
+ def favourite
return unless @user.functional? && @status.present?
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
- mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct)
+ mail subject: default_i18n_subject(name: @account.acct)
end
end
- def reblog(recipient, notification)
- @me = recipient
- @user = recipient.user
- @type = 'reblog'
- @account = notification.from_account
- @status = notification.target_status
-
+ def reblog
return unless @user.functional? && @status.present?
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
- mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct)
+ mail subject: default_i18n_subject(name: @account.acct)
end
end
- def follow_request(recipient, notification)
- @me = recipient
- @user = recipient.user
- @type = 'follow_request'
- @account = notification.from_account
-
+ def follow_request
return unless @user.functional?
locale_for_account(@me) do
- mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
+ mail subject: default_i18n_subject(name: @account.acct)
end
end
private
+ def process_params
+ @notification = params[:notification]
+ @me = params[:recipient]
+ @user = @me.user
+ @type = action_name
+ end
+
+ def set_status
+ @status = @notification.target_status
+ end
+
+ def set_account
+ @account = @notification.from_account
+ end
+
def thread_by_conversation(conversation)
return if conversation.nil?
diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb
index 9cafedc20..c0ee1bdce 100644
--- a/app/models/concerns/attachmentable.rb
+++ b/app/models/concerns/attachmentable.rb
@@ -22,15 +22,14 @@ module Attachmentable
included do
def self.has_attached_file(name, options = {}) # rubocop:disable Naming/PredicateName
- options = { validate_media_type: false }.merge(options)
super(name, options)
- send(:"before_#{name}_post_process") do
+
+ send(:"before_#{name}_validate", prepend: true) do
attachment = send(name)
check_image_dimension(attachment)
set_file_content_type(attachment)
obfuscate_file_name(attachment)
set_file_extension(attachment)
- Paperclip::Validators::MediaTypeSpoofDetectionValidator.new(attributes: [name]).validate(self)
end
end
end
diff --git a/app/models/trends.rb b/app/models/trends.rb
index d07d62b71..7ca51e0b3 100644
--- a/app/models/trends.rb
+++ b/app/models/trends.rb
@@ -35,7 +35,7 @@ module Trends
return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty?
User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user|
- AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
+ AdminMailer.with(recipient: user.account).new_trends(links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
end
end
diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb
index c94f7c023..fcbdb1a5f 100644
--- a/app/models/trends/links.rb
+++ b/app/models/trends/links.rb
@@ -3,6 +3,8 @@
class Trends::Links < Trends::Base
PREFIX = 'trending_links'
+ BATCH_SIZE = 100
+
self.default_options = {
threshold: 5,
review_threshold: 3,
@@ -67,8 +69,21 @@ class Trends::Links < Trends::Base
end
def refresh(at_time = Time.now.utc)
- preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + PreviewCardTrend.pluck(:preview_card_id)).uniq)
- calculate_scores(preview_cards, at_time)
+ # First, recalculate scores for links that were trending previously. We split the queries
+ # to avoid having to load all of the IDs into Ruby just to send them back into Postgres
+ PreviewCard.where(id: PreviewCardTrend.select(:preview_card_id)).find_in_batches(batch_size: BATCH_SIZE) do |preview_cards|
+ calculate_scores(preview_cards, at_time)
+ end
+
+ # Then, calculate scores for links that were used today. There are potentially some
+ # duplicate items here that we might process one more time, but that should be fine
+ PreviewCard.where(id: recently_used_ids(at_time)).find_in_batches(batch_size: BATCH_SIZE) do |preview_cards|
+ calculate_scores(preview_cards, at_time)
+ end
+
+ # Now that all trends have up-to-date scores, and all the ones below the threshold have
+ # been removed, we can recalculate their positions
+ PreviewCardTrend.connection.exec_update('UPDATE preview_card_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM preview_card_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE preview_card_trends.id = t0.id')
end
def request_review
@@ -139,10 +154,7 @@ class Trends::Links < Trends::Base
to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] }
to_delete = items.filter { |(score, _)| score < options[:decay_threshold] }
- PreviewCardTrend.transaction do
- PreviewCardTrend.upsert_all(to_insert.map { |(score, preview_card)| { preview_card_id: preview_card.id, score: score, language: preview_card.language, allowed: preview_card.trendable? || false } }, unique_by: :preview_card_id) if to_insert.any?
- PreviewCardTrend.where(preview_card_id: to_delete.map { |(_, preview_card)| preview_card.id }).delete_all if to_delete.any?
- PreviewCardTrend.connection.exec_update('UPDATE preview_card_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM preview_card_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE preview_card_trends.id = t0.id')
- end
+ PreviewCardTrend.upsert_all(to_insert.map { |(score, preview_card)| { preview_card_id: preview_card.id, score: score, language: preview_card.language, allowed: preview_card.trendable? || false } }, unique_by: :preview_card_id) if to_insert.any?
+ PreviewCardTrend.where(preview_card_id: to_delete.map { |(_, preview_card)| preview_card.id }).delete_all if to_delete.any?
end
end
diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb
index 84bff9c02..5cd352a6f 100644
--- a/app/models/trends/statuses.rb
+++ b/app/models/trends/statuses.rb
@@ -3,6 +3,8 @@
class Trends::Statuses < Trends::Base
PREFIX = 'trending_statuses'
+ BATCH_SIZE = 100
+
self.default_options = {
threshold: 5,
review_threshold: 3,
@@ -58,8 +60,21 @@ class Trends::Statuses < Trends::Base
end
def refresh(at_time = Time.now.utc)
- statuses = Status.where(id: (recently_used_ids(at_time) + StatusTrend.pluck(:status_id)).uniq).includes(:status_stat, :account)
- calculate_scores(statuses, at_time)
+ # First, recalculate scores for statuses that were trending previously. We split the queries
+ # to avoid having to load all of the IDs into Ruby just to send them back into Postgres
+ Status.where(id: StatusTrend.select(:status_id)).includes(:status_stat, :account).find_in_batches(batch_size: BATCH_SIZE) do |statuses|
+ calculate_scores(statuses, at_time)
+ end
+
+ # Then, calculate scores for statuses that were used today. There are potentially some
+ # duplicate items here that we might process one more time, but that should be fine
+ Status.where(id: recently_used_ids(at_time)).includes(:status_stat, :account).find_in_batches(batch_size: BATCH_SIZE) do |statuses|
+ calculate_scores(statuses, at_time)
+ end
+
+ # Now that all trends have up-to-date scores, and all the ones below the threshold have
+ # been removed, we can recalculate their positions
+ StatusTrend.connection.exec_update('UPDATE status_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM status_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE status_trends.id = t0.id')
end
def request_review
@@ -117,10 +132,7 @@ class Trends::Statuses < Trends::Base
to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] }
to_delete = items.filter { |(score, _)| score < options[:decay_threshold] }
- StatusTrend.transaction do
- StatusTrend.upsert_all(to_insert.map { |(score, status)| { status_id: status.id, account_id: status.account_id, score: score, language: status.language, allowed: status.trendable? || false } }, unique_by: :status_id) if to_insert.any?
- StatusTrend.where(status_id: to_delete.map { |(_, status)| status.id }).delete_all if to_delete.any?
- StatusTrend.connection.exec_update('UPDATE status_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM status_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE status_trends.id = t0.id')
- end
+ StatusTrend.upsert_all(to_insert.map { |(score, status)| { status_id: status.id, account_id: status.account_id, score: score, language: status.language, allowed: status.trendable? || false } }, unique_by: :status_id) if to_insert.any?
+ StatusTrend.where(status_id: to_delete.map { |(_, status)| status.id }).delete_all if to_delete.any?
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 5ee14bbda..fa445af81 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -475,7 +475,7 @@ class User < ApplicationRecord
User.those_who_can(:manage_users).includes(:account).find_each do |u|
next unless u.allows_pending_account_emails?
- AdminMailer.new_pending_account(u.account, self).deliver_later
+ AdminMailer.with(recipient: u.account).new_pending_account(self).deliver_later
end
end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 769ba653e..7676942a7 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -25,7 +25,7 @@ class InitialStateSerializer < ActiveModel::Serializer
limited_federation_mode: Rails.configuration.x.whitelist_mode,
mascot: instance_presenter.mascot&.file&.url,
profile_directory: Setting.profile_directory,
- trends: Setting.trends,
+ trends_enabled: Setting.trends,
registrations_open: Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode,
timeline_preview: Setting.timeline_preview,
activity_api_enabled: Setting.activity_api_enabled,
@@ -47,7 +47,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:advanced_layout] = object.current_account.user.setting_advanced_layout
store[:use_blurhash] = object.current_account.user.setting_use_blurhash
store[:use_pending_items] = object.current_account.user.setting_use_pending_items
- store[:trends] = Setting.trends && object.current_account.user.setting_trends
+ store[:show_trends] = Setting.trends && object.current_account.user.setting_trends
store[:crop_images] = object.current_account.user.setting_crop_images
else
store[:auto_play_gif] = Setting.auto_play_gif
diff --git a/app/serializers/rest/preview_card_serializer.rb b/app/serializers/rest/preview_card_serializer.rb
index 8413b23d8..08bc07edd 100644
--- a/app/serializers/rest/preview_card_serializer.rb
+++ b/app/serializers/rest/preview_card_serializer.rb
@@ -11,4 +11,8 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
def image
object.image? ? full_asset_url(object.image.url(:original)) : nil
end
+
+ def html
+ Sanitize.fragment(object.html, Sanitize::Config::MASTODON_OEMBED)
+ end
end
diff --git a/app/services/appeal_service.rb b/app/services/appeal_service.rb
index 399a053d6..ef052e354 100644
--- a/app/services/appeal_service.rb
+++ b/app/services/appeal_service.rb
@@ -23,7 +23,7 @@ class AppealService < BaseService
def notify_staff!
User.those_who_can(:manage_appeals).includes(:account).each do |u|
- AdminMailer.new_appeal(u.account, @appeal).deliver_later if u.allows_appeal_emails?
+ AdminMailer.with(recipient: u.account).new_appeal(@appeal).deliver_later if u.allows_appeal_emails?
end
end
end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index ad9e6e3d6..06b48d558 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -162,7 +162,12 @@ class NotifyService < BaseService
end
def send_email!
- NotificationMailer.public_send(@notification.type, @recipient, @notification).deliver_later(wait: 2.minutes) if NotificationMailer.respond_to?(@notification.type)
+ return unless NotificationMailer.respond_to?(@notification.type)
+
+ NotificationMailer
+ .with(recipient: @recipient, notification: @notification)
+ .public_send(@notification.type)
+ .deliver_later(wait: 2.minutes)
end
def email_needed?
diff --git a/app/services/report_service.rb b/app/services/report_service.rb
index 0ce525b07..39ebd5cd8 100644
--- a/app/services/report_service.rb
+++ b/app/services/report_service.rb
@@ -40,16 +40,20 @@ class ReportService < BaseService
User.those_who_can(:manage_reports).includes(:account).each do |u|
LocalNotificationWorker.perform_async(u.account_id, @report.id, 'Report', 'admin.report')
- AdminMailer.new_report(u.account, @report).deliver_later if u.allows_report_emails?
+ AdminMailer.with(recipient: u.account).new_report(@report).deliver_later if u.allows_report_emails?
end
end
def forward_to_origin!
- ActivityPub::DeliveryWorker.perform_async(
- payload,
- some_local_account.id,
- @target_account.inbox_url
- )
+ # Send report to the server where the account originates from
+ ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, @target_account.inbox_url)
+
+ # Send report to servers to which the account was replying to, so they also have a chance to act
+ inbox_urls = Account.remote.where(id: Status.where(id: reported_status_ids).where.not(in_reply_to_account_id: nil).select(:in_reply_to_account_id)).inboxes - [@target_account.inbox_url]
+
+ inbox_urls.each do |inbox_url|
+ ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url)
+ end
end
def forward?
diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb
index 758cebd4b..47826c211 100644
--- a/app/workers/feed_insert_worker.rb
+++ b/app/workers/feed_insert_worker.rb
@@ -4,19 +4,23 @@ class FeedInsertWorker
include Sidekiq::Worker
def perform(status_id, id, type = 'home', options = {})
- @type = type.to_sym
- @status = Status.find(status_id)
- @options = options.symbolize_keys
+ ApplicationRecord.connected_to(role: :primary) do
+ @type = type.to_sym
+ @status = Status.find(status_id)
+ @options = options.symbolize_keys
- case @type
- when :home, :tags
- @follower = Account.find(id)
- when :list
- @list = List.find(id)
- @follower = @list.account
+ case @type
+ when :home, :tags
+ @follower = Account.find(id)
+ when :list
+ @list = List.find(id)
+ @follower = @list.account
+ end
end
- check_and_insert
+ ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
+ check_and_insert
+ end
rescue ActiveRecord::RecordNotFound
true
end
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
index e526d2887..50cfcc3f0 100644
--- a/app/workers/merge_worker.rb
+++ b/app/workers/merge_worker.rb
@@ -5,7 +5,14 @@ class MergeWorker
include Redisable
def perform(from_account_id, into_account_id)
- FeedManager.instance.merge_into_home(Account.find(from_account_id), Account.find(into_account_id))
+ ApplicationRecord.connected_to(role: :primary) do
+ @from_account = Account.find(from_account_id)
+ @into_account = Account.find(into_account_id)
+ end
+
+ ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
+ FeedManager.instance.merge_into_home(@from_account, @into_account)
+ end
rescue ActiveRecord::RecordNotFound
true
ensure
diff --git a/app/workers/regeneration_worker.rb b/app/workers/regeneration_worker.rb
index 5c13c894f..5ac095e65 100644
--- a/app/workers/regeneration_worker.rb
+++ b/app/workers/regeneration_worker.rb
@@ -6,8 +6,13 @@ class RegenerationWorker
sidekiq_options lock: :until_executed
def perform(account_id, _ = :home)
- account = Account.find(account_id)
- PrecomputeFeedService.new.call(account)
+ ApplicationRecord.connected_to(role: :primary) do
+ @account = Account.find(account_id)
+ end
+
+ ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
+ PrecomputeFeedService.new.call(@account)
+ end
rescue ActiveRecord::RecordNotFound
true
end
diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb
index 1a23faae5..f911ea2f9 100644
--- a/app/workers/unmerge_worker.rb
+++ b/app/workers/unmerge_worker.rb
@@ -6,7 +6,14 @@ class UnmergeWorker
sidekiq_options queue: 'pull'
def perform(from_account_id, into_account_id)
- FeedManager.instance.unmerge_from_home(Account.find(from_account_id), Account.find(into_account_id))
+ ApplicationRecord.connected_to(role: :primary) do
+ @from_account = Account.find(from_account_id)
+ @into_account = Account.find(into_account_id)
+ end
+
+ ApplicationRecord.connected_to(role: :read, prevent_writes: true) do
+ FeedManager.instance.unmerge_from_home(@from_account, @into_account)
+ end
rescue ActiveRecord::RecordNotFound
true
end
diff --git a/config/application.rb b/config/application.rb
index d3c99baa1..8c4ec27e7 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -28,6 +28,7 @@ require_relative '../lib/paperclip/url_generator_extensions'
require_relative '../lib/paperclip/attachment_extensions'
require_relative '../lib/paperclip/lazy_thumbnail'
require_relative '../lib/paperclip/gif_transcoder'
+require_relative '../lib/paperclip/media_type_spoof_detector_extensions'
require_relative '../lib/paperclip/transcoder'
require_relative '../lib/paperclip/type_corrector'
require_relative '../lib/paperclip/response_with_limit_adapter'
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index 80c5f6d4e..d89591cfe 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -18,6 +18,9 @@
},
"user_input": "id",
"confidence": "Weak",
+ "cwe_id": [
+ 89
+ ],
"note": ""
},
{
@@ -38,26 +41,9 @@
},
"user_input": "ids.join(\",\")",
"confidence": "Weak",
- "note": ""
- },
- {
- "warning_type": "Redirect",
- "warning_code": 18,
- "fingerprint": "5fad11cd67f905fab9b1d5739d01384a1748ebe78c5af5ac31518201925265a7",
- "check_name": "Redirect",
- "message": "Possible unprotected redirect",
- "file": "app/controllers/remote_interaction_controller.rb",
- "line": 24,
- "link": "https://brakemanscanner.org/docs/warning_types/redirect/",
- "code": "redirect_to(RemoteFollow.new(resource_params).interact_address_for(Status.find(params[:id])))",
- "render_path": null,
- "location": {
- "type": "method",
- "class": "RemoteInteractionController",
- "method": "create"
- },
- "user_input": "RemoteFollow.new(resource_params).interact_address_for(Status.find(params[:id]))",
- "confidence": "High",
+ "cwe_id": [
+ 89
+ ],
"note": ""
},
{
@@ -88,6 +74,9 @@
},
"user_input": "(Unresolved Model).new.strike",
"confidence": "Weak",
+ "cwe_id": [
+ 79
+ ],
"note": ""
},
{
@@ -108,26 +97,9 @@
},
"user_input": "SecureRandom.hex(16)",
"confidence": "Medium",
- "note": ""
- },
- {
- "warning_type": "Mass Assignment",
- "warning_code": 105,
- "fingerprint": "7631e93d0099506e7c3e5c91ba8d88523b00a41a0834ae30031a5a4e8bb3020a",
- "check_name": "PermitAttributes",
- "message": "Potentially dangerous key allowed for mass assignment",
- "file": "app/controllers/api/v2/search_controller.rb",
- "line": 28,
- "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
- "code": "params.permit(:type, :offset, :min_id, :max_id, :account_id)",
- "render_path": null,
- "location": {
- "type": "method",
- "class": "Api::V2::SearchController",
- "method": "search_params"
- },
- "user_input": ":account_id",
- "confidence": "High",
+ "cwe_id": [
+ 89
+ ],
"note": ""
},
{
@@ -137,7 +109,7 @@
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/admin/reports_controller.rb",
- "line": 90,
+ "line": 88,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.permit(:resolved, :account_id, :target_account_id)",
"render_path": null,
@@ -148,6 +120,9 @@
},
"user_input": ":account_id",
"confidence": "High",
+ "cwe_id": [
+ 915
+ ],
"note": ""
},
{
@@ -157,7 +132,7 @@
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/notifications_controller.rb",
- "line": 81,
+ "line": 77,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.permit(:account_id, :types => ([]), :exclude_types => ([]))",
"render_path": null,
@@ -168,26 +143,9 @@
},
"user_input": ":account_id",
"confidence": "High",
- "note": ""
- },
- {
- "warning_type": "Redirect",
- "warning_code": 18,
- "fingerprint": "ba568ac09683f98740f663f3d850c31785900215992e8c090497d359a2563d50",
- "check_name": "Redirect",
- "message": "Possible unprotected redirect",
- "file": "app/controllers/remote_follow_controller.rb",
- "line": 21,
- "link": "https://brakemanscanner.org/docs/warning_types/redirect/",
- "code": "redirect_to(RemoteFollow.new(resource_params).subscribe_address_for(@account))",
- "render_path": null,
- "location": {
- "type": "method",
- "class": "RemoteFollowController",
- "method": "create"
- },
- "user_input": "RemoteFollow.new(resource_params).subscribe_address_for(@account)",
- "confidence": "High",
+ "cwe_id": [
+ 915
+ ],
"note": ""
},
{
@@ -218,6 +176,9 @@
},
"user_input": "(Unresolved Model).new.url",
"confidence": "Weak",
+ "cwe_id": [
+ 79
+ ],
"note": ""
},
{
@@ -238,9 +199,12 @@
},
"user_input": ":account_id",
"confidence": "High",
+ "cwe_id": [
+ 915
+ ],
"note": ""
}
],
- "updated": "2022-03-22 07:48:32 +0100",
- "brakeman_version": "5.2.1"
+ "updated": "2023-07-05 14:34:42 -0400",
+ "brakeman_version": "5.4.1"
}
diff --git a/config/database.yml b/config/database.yml
index 34acf2f19..f7ecbd981 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -27,10 +27,20 @@ test:
port: <%= ENV['DB_PORT'] %>
production:
- <<: *default
- database: <%= ENV['DB_NAME'] || 'mastodon_production' %>
- username: <%= ENV['DB_USER'] || 'mastodon' %>
- password: <%= (ENV['DB_PASS'] || '').to_json %>
- host: <%= ENV['DB_HOST'] || 'localhost' %>
- port: <%= ENV['DB_PORT'] || 5432 %>
- prepared_statements: <%= ENV['PREPARED_STATEMENTS'] || 'true' %>
+ primary:
+ <<: *default
+ database: <%= ENV['DB_NAME'] || 'mastodon_production' %>
+ username: <%= ENV['DB_USER'] || 'mastodon' %>
+ password: <%= (ENV['DB_PASS'] || '').to_json %>
+ host: <%= ENV['DB_HOST'] || 'localhost' %>
+ port: <%= ENV['DB_PORT'] || 5432 %>
+ prepared_statements: <%= ENV['PREPARED_STATEMENTS'] || 'true' %>
+ read:
+ <<: *default
+ database: <%= ENV['DB_REPLICA_NAME'] ||ENV['DB_NAME'] || 'mastodon_production' %>
+ username: <%= ENV['DB_REPLICA_USER'] ||ENV['DB_USER'] || 'mastodon' %>
+ password: <%= (ENV['DB_REPLICA_PASS'] || ENV['DB_PASS'] || '').to_json %>
+ host: <%= ENV['DB_REPLICA_HOST'] ||ENV['DB_HOST'] || 'localhost' %>
+ port: <%= ENV['DB_REPLICA_PORT'] ||ENV['DB_PORT'] || 5432 %>
+ prepared_statements: <%= ENV['PREPARED_STATEMENTS'] || 'true' %>
+ replica: true
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index 035a0e999..cb00a62d5 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -63,6 +63,7 @@ ignore_unused:
- 'admin_mailer.new_appeal.actions.*'
- 'statuses.attached.*'
- 'move_handler.carry_{mutes,blocks}_over_text'
+ - 'admin_mailer.*.subject'
- 'notification_mailer.*'
- 'imports.overwrite_preambles.{following,blocking,muting,domain_blocking,bookmarks}_html'
- 'imports.preambles.{following,blocking,muting,domain_blocking,bookmarks}_html'
diff --git a/config/imagemagick/policy.xml b/config/imagemagick/policy.xml
new file mode 100644
index 000000000..1052476b3
--- /dev/null
+++ b/config/imagemagick/policy.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/initializers/makara.rb b/config/initializers/makara.rb
deleted file mode 100644
index dc88fa63c..000000000
--- a/config/initializers/makara.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-Makara::Cookie::DEFAULT_OPTIONS[:same_site] = :lax
-Makara::Cookie::DEFAULT_OPTIONS[:secure] = Rails.env.production? || ENV['LOCAL_HTTPS'] == 'true'
diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb
index 093d2ba9a..f2da410db 100644
--- a/config/initializers/paperclip.rb
+++ b/config/initializers/paperclip.rb
@@ -153,3 +153,10 @@ unless defined?(Seahorse)
end
end
end
+
+# Set our ImageMagick security policy, but allow admins to override it
+ENV['MAGICK_CONFIGURE_PATH'] = begin
+ imagemagick_config_paths = ENV.fetch('MAGICK_CONFIGURE_PATH', '').split(File::PATH_SEPARATOR)
+ imagemagick_config_paths << Rails.root.join('config', 'imagemagick').expand_path.to_s
+ imagemagick_config_paths.join(File::PATH_SEPARATOR)
+end
diff --git a/dist/nginx.conf b/dist/nginx.conf
index bed4bd3db..fc68e9a6d 100644
--- a/dist/nginx.conf
+++ b/dist/nginx.conf
@@ -109,6 +109,8 @@ server {
location ~ ^/system/ {
add_header Cache-Control "public, max-age=2419200, immutable";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
+ add_header X-Content-Type-Options nosniff;
+ add_header Content-Security-Policy "default-src 'none'; form-action 'none'";
try_files $uri =404;
}
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 455cdc9b8..cbec893e0 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -13,7 +13,7 @@ module Mastodon
end
def patch
- 2
+ 4
end
def flags
diff --git a/lib/paperclip/media_type_spoof_detector_extensions.rb b/lib/paperclip/media_type_spoof_detector_extensions.rb
new file mode 100644
index 000000000..a406ef312
--- /dev/null
+++ b/lib/paperclip/media_type_spoof_detector_extensions.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Paperclip
+ module MediaTypeSpoofDetectorExtensions
+ def calculated_content_type
+ return @calculated_content_type if defined?(@calculated_content_type)
+
+ @calculated_content_type = type_from_file_command.chomp
+
+ # The `file` command fails to recognize some MP3 files as such
+ @calculated_content_type = type_from_marcel if @calculated_content_type == 'application/octet-stream' && type_from_marcel == 'audio/mpeg'
+ @calculated_content_type
+ end
+
+ def type_from_marcel
+ @type_from_marcel ||= Marcel::MimeType.for Pathname.new(@file.path),
+ name: @file.path
+ end
+ end
+end
+
+Paperclip::MediaTypeSpoofDetector.prepend(Paperclip::MediaTypeSpoofDetectorExtensions)
diff --git a/lib/paperclip/transcoder.rb b/lib/paperclip/transcoder.rb
index afd9f58ff..be40b4924 100644
--- a/lib/paperclip/transcoder.rb
+++ b/lib/paperclip/transcoder.rb
@@ -19,10 +19,7 @@ module Paperclip
def make
metadata = VideoMetadataExtractor.new(@file.path)
- unless metadata.valid?
- Paperclip.log("Unsupported file #{@file.path}")
- return File.open(@file.path)
- end
+ raise Paperclip::Error, "Error while transcoding #{@file.path}: unsupported file" unless metadata.valid?
update_attachment_type(metadata)
update_options_from_metadata(metadata)
diff --git a/lib/public_file_server_middleware.rb b/lib/public_file_server_middleware.rb
index 3799230a2..7e02e37a0 100644
--- a/lib/public_file_server_middleware.rb
+++ b/lib/public_file_server_middleware.rb
@@ -32,6 +32,11 @@ class PublicFileServerMiddleware
end
end
+ # Override the default CSP header set by the CSP middleware
+ headers['Content-Security-Policy'] = "default-src 'none'; form-action 'none'" if request_path.start_with?(paperclip_root_url)
+
+ headers['X-Content-Type-Options'] = 'nosniff'
+
[status, headers, response]
end
diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb
index 40911d8a1..82d8c57ce 100644
--- a/lib/sanitize_ext/sanitize_config.rb
+++ b/lib/sanitize_ext/sanitize_config.rb
@@ -92,26 +92,26 @@ class Sanitize
]
)
- MASTODON_OEMBED ||= freeze_config merge(
- RELAXED,
- elements: RELAXED[:elements] + %w(audio embed iframe source video),
+ MASTODON_OEMBED ||= freeze_config(
+ elements: %w(audio embed iframe source video),
- attributes: merge(
- RELAXED[:attributes],
+ attributes: {
'audio' => %w(controls),
'embed' => %w(height src type width),
'iframe' => %w(allowfullscreen frameborder height scrolling src width),
'source' => %w(src type),
'video' => %w(controls height loop width),
- 'div' => [:data]
- ),
+ },
- protocols: merge(
- RELAXED[:protocols],
+ protocols: {
'embed' => { 'src' => HTTP_PROTOCOLS },
'iframe' => { 'src' => HTTP_PROTOCOLS },
- 'source' => { 'src' => HTTP_PROTOCOLS }
- )
+ 'source' => { 'src' => HTTP_PROTOCOLS },
+ },
+
+ add_attributes: {
+ 'iframe' => { 'sandbox' => 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms' },
+ }
)
end
end
diff --git a/lib/tasks/branding.rake b/lib/tasks/branding.rake
index d1c1c9ded..d97c97c99 100644
--- a/lib/tasks/branding.rake
+++ b/lib/tasks/branding.rake
@@ -40,7 +40,7 @@ namespace :branding do
output_dest = Rails.root.join('app', 'javascript', 'icons')
rsvg_convert = Terrapin::CommandLine.new('rsvg-convert', '-w :size -h :size --keep-aspect-ratio :input -o :output')
- convert = Terrapin::CommandLine.new('convert', ':input :output')
+ convert = Terrapin::CommandLine.new('convert', ':input :output', environment: { 'MAGICK_CONFIGURE_PATH' => nil })
favicon_sizes = [16, 32, 48]
apple_icon_sizes = [57, 60, 72, 76, 114, 120, 144, 152, 167, 180, 1024]
diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb
index 01b7e4a71..f923ff079 100644
--- a/spec/controllers/api/v1/reports_controller_spec.rb
+++ b/spec/controllers/api/v1/reports_controller_spec.rb
@@ -23,8 +23,6 @@ RSpec.describe Api::V1::ReportsController do
let(:rule_ids) { nil }
before do
- allow(AdminMailer).to receive(:new_report)
- .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward }
end
@@ -41,7 +39,7 @@ RSpec.describe Api::V1::ReportsController do
end
it 'sends e-mails to admins' do
- expect(AdminMailer).to have_received(:new_report).with(admin.account, Report)
+ expect(ActionMailer::Base.deliveries.first.to).to eq([admin.email])
end
context 'when a status does not belong to the reported account' do
diff --git a/spec/controllers/disputes/appeals_controller_spec.rb b/spec/controllers/disputes/appeals_controller_spec.rb
index a0f9c7b91..c8444a2a9 100644
--- a/spec/controllers/disputes/appeals_controller_spec.rb
+++ b/spec/controllers/disputes/appeals_controller_spec.rb
@@ -14,13 +14,11 @@ RSpec.describe Disputes::AppealsController do
let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
before do
- allow(AdminMailer).to receive(:new_appeal)
- .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } }
end
it 'notifies staff about new appeal' do
- expect(AdminMailer).to have_received(:new_appeal).with(admin.account, Appeal.last)
+ expect(ActionMailer::Base.deliveries.first.to).to eq([admin.email])
end
it 'redirects back to the strike page' do
diff --git a/spec/fixtures/files/attachment-jpg.123456_abcd b/spec/fixtures/files/attachment-jpg.123456_abcd
new file mode 100644
index 000000000..f1d40539a
Binary files /dev/null and b/spec/fixtures/files/attachment-jpg.123456_abcd differ
diff --git a/spec/fixtures/files/boop.mp3 b/spec/fixtures/files/boop.mp3
new file mode 100644
index 000000000..ba106a3a3
Binary files /dev/null and b/spec/fixtures/files/boop.mp3 differ
diff --git a/spec/mailers/admin_mailer_spec.rb b/spec/mailers/admin_mailer_spec.rb
index 8e2eec40f..9123804a4 100644
--- a/spec/mailers/admin_mailer_spec.rb
+++ b/spec/mailers/admin_mailer_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe AdminMailer do
let(:sender) { Fabricate(:account, username: 'John') }
let(:recipient) { Fabricate(:account, username: 'Mike') }
let(:report) { Fabricate(:report, account: sender, target_account: recipient) }
- let(:mail) { described_class.new_report(recipient, report) }
+ let(:mail) { described_class.with(recipient: recipient).new_report(report) }
before do
recipient.user.update(locale: :en)
@@ -27,7 +27,7 @@ RSpec.describe AdminMailer do
describe '.new_appeal' do
let(:appeal) { Fabricate(:appeal) }
let(:recipient) { Fabricate(:account, username: 'Kurt') }
- let(:mail) { described_class.new_appeal(recipient, appeal) }
+ let(:mail) { described_class.with(recipient: recipient).new_appeal(appeal) }
before do
recipient.user.update(locale: :en)
@@ -47,7 +47,7 @@ RSpec.describe AdminMailer do
describe '.new_pending_account' do
let(:recipient) { Fabricate(:account, username: 'Barklums') }
let(:user) { Fabricate(:user) }
- let(:mail) { described_class.new_pending_account(recipient, user) }
+ let(:mail) { described_class.with(recipient: recipient).new_pending_account(user) }
before do
recipient.user.update(locale: :en)
@@ -69,7 +69,7 @@ RSpec.describe AdminMailer do
let(:links) { [] }
let(:statuses) { [] }
let(:tags) { [] }
- let(:mail) { described_class.new_trends(recipient, links, tags, statuses) }
+ let(:mail) { described_class.with(recipient: recipient).new_trends(links, tags, statuses) }
before do
recipient.user.update(locale: :en)
diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb
index bf364b625..3efb97cb1 100644
--- a/spec/mailers/notification_mailer_spec.rb
+++ b/spec/mailers/notification_mailer_spec.rb
@@ -23,7 +23,8 @@ RSpec.describe NotificationMailer do
describe 'mention' do
let(:mention) { Mention.create!(account: receiver.account, status: foreign_status) }
- let(:mail) { described_class.mention(receiver.account, Notification.create!(account: receiver.account, activity: mention)) }
+ let(:notification) { Notification.create!(account: receiver.account, activity: mention) }
+ let(:mail) { prepared_mailer_for(receiver.account).mention }
include_examples 'localized subject', 'notification_mailer.mention.subject', name: 'bob'
@@ -40,7 +41,8 @@ RSpec.describe NotificationMailer do
describe 'follow' do
let(:follow) { sender.follow!(receiver.account) }
- let(:mail) { described_class.follow(receiver.account, Notification.create!(account: receiver.account, activity: follow)) }
+ let(:notification) { Notification.create!(account: receiver.account, activity: follow) }
+ let(:mail) { prepared_mailer_for(receiver.account).follow }
include_examples 'localized subject', 'notification_mailer.follow.subject', name: 'bob'
@@ -56,7 +58,8 @@ RSpec.describe NotificationMailer do
describe 'favourite' do
let(:favourite) { Favourite.create!(account: sender, status: own_status) }
- let(:mail) { described_class.favourite(own_status.account, Notification.create!(account: receiver.account, activity: favourite)) }
+ let(:notification) { Notification.create!(account: receiver.account, activity: favourite) }
+ let(:mail) { prepared_mailer_for(own_status.account).favourite }
include_examples 'localized subject', 'notification_mailer.favourite.subject', name: 'bob'
@@ -73,7 +76,8 @@ RSpec.describe NotificationMailer do
describe 'reblog' do
let(:reblog) { Status.create!(account: sender, reblog: own_status) }
- let(:mail) { described_class.reblog(own_status.account, Notification.create!(account: receiver.account, activity: reblog)) }
+ let(:notification) { Notification.create!(account: receiver.account, activity: reblog) }
+ let(:mail) { prepared_mailer_for(own_status.account).reblog }
include_examples 'localized subject', 'notification_mailer.reblog.subject', name: 'bob'
@@ -90,7 +94,8 @@ RSpec.describe NotificationMailer do
describe 'follow_request' do
let(:follow_request) { Fabricate(:follow_request, account: sender, target_account: receiver.account) }
- let(:mail) { described_class.follow_request(receiver.account, Notification.create!(account: receiver.account, activity: follow_request)) }
+ let(:notification) { Notification.create!(account: receiver.account, activity: follow_request) }
+ let(:mail) { prepared_mailer_for(receiver.account).follow_request }
include_examples 'localized subject', 'notification_mailer.follow_request.subject', name: 'bob'
@@ -103,4 +108,10 @@ RSpec.describe NotificationMailer do
expect(mail.body.encoded).to match('bob has requested to follow you')
end
end
+
+ private
+
+ def prepared_mailer_for(recipient)
+ described_class.with(recipient: recipient, notification: notification)
+ end
end
diff --git a/spec/mailers/previews/admin_mailer_preview.rb b/spec/mailers/previews/admin_mailer_preview.rb
index 9572768cd..bc8f0193b 100644
--- a/spec/mailers/previews/admin_mailer_preview.rb
+++ b/spec/mailers/previews/admin_mailer_preview.rb
@@ -5,16 +5,16 @@
class AdminMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_pending_account
def new_pending_account
- AdminMailer.new_pending_account(Account.first, User.pending.first)
+ AdminMailer.with(recipient: Account.first).new_pending_account(User.pending.first)
end
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends
def new_trends
- AdminMailer.new_trends(Account.first, PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3))
+ AdminMailer.with(recipient: Account.first).new_trends(PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3))
end
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal
def new_appeal
- AdminMailer.new_appeal(Account.first, Appeal.first)
+ AdminMailer.with(recipient: Account.first).new_appeal(Appeal.first)
end
end
diff --git a/spec/mailers/previews/notification_mailer_preview.rb b/spec/mailers/previews/notification_mailer_preview.rb
index bc41662a1..a63c20c27 100644
--- a/spec/mailers/previews/notification_mailer_preview.rb
+++ b/spec/mailers/previews/notification_mailer_preview.rb
@@ -5,36 +5,40 @@
class NotificationMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/mention
def mention
- m = Mention.last
- NotificationMailer.mention(m.account, Notification.find_by(activity: m))
+ activity = Mention.last
+ mailer_for(activity.account, activity).mention
end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow
def follow
- f = Follow.last
- NotificationMailer.follow(f.target_account, Notification.find_by(activity: f))
+ activity = Follow.last
+ mailer_for(activity.target_account, activity).follow
end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow_request
def follow_request
- f = Follow.last
- NotificationMailer.follow_request(f.target_account, Notification.find_by(activity: f))
+ activity = Follow.last
+ mailer_for(activity.target_account, activity).follow_request
end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/favourite
def favourite
- f = Favourite.last
- NotificationMailer.favourite(f.status.account, Notification.find_by(activity: f))
+ activity = Favourite.last
+ mailer_for(activity.status.account, activity).favourite
end
# Preview this email at http://localhost:3000/rails/mailers/notification_mailer/reblog
def reblog
- r = Status.where.not(reblog_of_id: nil).first
- NotificationMailer.reblog(r.reblog.account, Notification.find_by(activity: r))
+ activity = Status.where.not(reblog_of_id: nil).first
+ mailer_for(activity.reblog.account, activity).reblog
end
- # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/digest
- def digest
- NotificationMailer.digest(Account.first, since: 90.days.ago)
+ private
+
+ def mailer_for(account, activity)
+ NotificationMailer.with(
+ recipient: account,
+ notification: Notification.find_by(activity: activity)
+ )
end
end
diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb
index 2dfc6cf92..90e4f2f47 100644
--- a/spec/models/media_attachment_spec.rb
+++ b/spec/models/media_attachment_spec.rb
@@ -152,6 +152,26 @@ RSpec.describe MediaAttachment, paperclip_processing: true do
end
end
+ describe 'mp3 with large cover art' do
+ let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('boop.mp3')) }
+
+ it 'detects it as an audio file' do
+ expect(media.type).to eq 'audio'
+ end
+
+ it 'sets meta for the duration' do
+ expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102)
+ end
+
+ it 'extracts thumbnail' do
+ expect(media.thumbnail.present?).to be true
+ end
+
+ it 'gives the file a random name' do
+ expect(media.file_file_name).to_not eq 'boop.mp3'
+ end
+ end
+
describe 'jpeg' do
let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('attachment.jpg')) }
diff --git a/spec/requests/api/v2/media_spec.rb b/spec/requests/api/v2/media_spec.rb
new file mode 100644
index 000000000..89384d0ca
--- /dev/null
+++ b/spec/requests/api/v2/media_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Media API', paperclip_processing: true do
+ let(:user) { Fabricate(:user) }
+ let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+ let(:scopes) { 'write' }
+ let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
+
+ describe 'POST /api/v2/media' do
+ it 'returns http success' do
+ post '/api/v2/media', headers: headers, params: { file: fixture_file_upload('attachment-jpg.123456_abcd', 'image/jpeg') }
+ expect(File.exist?(user.account.media_attachments.first.file.path(:small))).to be true
+ expect(response).to have_http_status(200)
+ end
+ end
+end
diff --git a/spec/services/report_service_spec.rb b/spec/services/report_service_spec.rb
index b8ceedb85..660ce3db2 100644
--- a/spec/services/report_service_spec.rb
+++ b/spec/services/report_service_spec.rb
@@ -17,24 +17,45 @@ RSpec.describe ReportService, type: :service do
context 'with a remote account' do
let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') }
+ let(:forward) { false }
before do
stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
end
- it 'sends ActivityPub payload when forward is true' do
- subject.call(source_account, remote_account, forward: true)
- expect(a_request(:post, 'http://example.com/inbox')).to have_been_made
+ context 'when forward is true' do
+ let(:forward) { true }
+
+ it 'sends ActivityPub payload when forward is true' do
+ subject.call(source_account, remote_account, forward: forward)
+ expect(a_request(:post, 'http://example.com/inbox')).to have_been_made
+ end
+
+ it 'has an uri' do
+ report = subject.call(source_account, remote_account, forward: forward)
+ expect(report.uri).to_not be_nil
+ end
+
+ context 'when reporting a reply' do
+ let(:remote_thread_account) { Fabricate(:account, domain: 'foo.com', protocol: :activitypub, inbox_url: 'http://foo.com/inbox') }
+ let(:reported_status) { Fabricate(:status, account: remote_account, thread: Fabricate(:status, account: remote_thread_account)) }
+
+ before do
+ stub_request(:post, 'http://foo.com/inbox').to_return(status: 200)
+ end
+
+ it 'sends ActivityPub payload to the author of the replied-to post' do
+ subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward)
+ expect(a_request(:post, 'http://foo.com/inbox')).to have_been_made
+ end
+ end
end
- it 'does not send anything when forward is false' do
- subject.call(source_account, remote_account, forward: false)
- expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made
- end
-
- it 'has an uri' do
- report = subject.call(source_account, remote_account, forward: true)
- expect(report.uri).to_not be_nil
+ context 'when forward is false' do
+ it 'does not send anything' do
+ subject.call(source_account, remote_account, forward: forward)
+ expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made
+ end
end
end