diff --git a/Gemfile.lock b/Gemfile.lock
index c8a945da2..73e3bd975 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -532,7 +532,7 @@ GEM
premailer (~> 1.7, >= 1.7.9)
private_address_check (0.5.0)
public_suffix (5.0.3)
- puma (6.3.0)
+ puma (6.3.1)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
diff --git a/app/controllers/settings/privacy_controller.rb b/app/controllers/settings/privacy_controller.rb
index 83be8772a..c2648eedd 100644
--- a/app/controllers/settings/privacy_controller.rb
+++ b/app/controllers/settings/privacy_controller.rb
@@ -18,7 +18,7 @@ class Settings::PrivacyController < Settings::BaseController
private
def account_params
- params.require(:account).permit(:discoverable, :locked, :hide_collections, settings: UserSettings.keys)
+ params.require(:account).permit(:discoverable, :unlocked, :show_collections, settings: UserSettings.keys)
end
def set_account
diff --git a/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx b/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx
index df298f333..ecce92b30 100644
--- a/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx
+++ b/app/javascript/mastodon/components/admin/ReportReasonSelector.jsx
@@ -8,6 +8,7 @@ import classNames from 'classnames';
import api from 'mastodon/api';
const messages = defineMessages({
+ legal: { id: 'report.categories.legal', defaultMessage: 'Legal' },
other: { id: 'report.categories.other', defaultMessage: 'Other' },
spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
@@ -150,6 +151,7 @@ class ReportReasonSelector extends PureComponent {
return (
+
{rules.map(rule => )}
diff --git a/app/javascript/mastodon/components/hashtag_bar.jsx b/app/javascript/mastodon/components/hashtag_bar.jsx
new file mode 100644
index 000000000..3c7e24228
--- /dev/null
+++ b/app/javascript/mastodon/components/hashtag_bar.jsx
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import { useMemo, useState, useCallback } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { Link } from 'react-router-dom';
+
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const domParser = new DOMParser();
+
+// About two lines on desktop
+const VISIBLE_HASHTAGS = 7;
+
+export const HashtagBar = ({ hashtags, text }) => {
+ const renderedHashtags = useMemo(() => {
+ const body = domParser.parseFromString(text, 'text/html').documentElement;
+ return [].filter.call(body.querySelectorAll('a[href]'), link => link.textContent[0] === '#' || (link.previousSibling?.textContent?.[link.previousSibling.textContent.length - 1] === '#')).map(node => node.textContent);
+ }, [text]);
+
+ const invisibleHashtags = useMemo(() => (
+ hashtags.filter(hashtag => !renderedHashtags.some(textContent => textContent.localeCompare(`#${hashtag.get('name')}`, undefined, { sensitivity: 'accent' }) === 0 || textContent.localeCompare(hashtag.get('name'), undefined, { sensitivity: 'accent' }) === 0))
+ ), [hashtags, renderedHashtags]);
+
+ const [expanded, setExpanded] = useState(false);
+ const handleClick = useCallback(() => setExpanded(true), []);
+
+ if (invisibleHashtags.isEmpty()) {
+ return null;
+ }
+
+ const revealedHashtags = expanded ? invisibleHashtags : invisibleHashtags.take(VISIBLE_HASHTAGS);
+
+ return (
+
+ {revealedHashtags.map(hashtag => (
+
+ #{hashtag.get('name')}
+
+ ))}
+
+ {!expanded && invisibleHashtags.size > VISIBLE_HASHTAGS && }
+
+ );
+};
+
+HashtagBar.propTypes = {
+ hashtags: ImmutablePropTypes.list,
+ text: PropTypes.string,
+};
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
index 37951d578..7c34684d7 100644
--- a/app/javascript/mastodon/components/status.jsx
+++ b/app/javascript/mastodon/components/status.jsx
@@ -22,6 +22,7 @@ import { displayMedia } from '../initial_state';
import { Avatar } from './avatar';
import { AvatarOverlay } from './avatar_overlay';
import { DisplayName } from './display_name';
+import { HashtagBar } from './hashtag_bar';
import { RelativeTimestamp } from './relative_timestamp';
import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';
@@ -580,6 +581,8 @@ class Status extends ImmutablePureComponent {
{media}
+
+
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx
index 7348905c5..c1815b916 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.jsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx
@@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { AnimatedNumber } from 'mastodon/components/animated_number';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
+import { HashtagBar } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
@@ -314,6 +315,8 @@ class DetailedStatus extends ImmutablePureComponent {
{media}
+
+
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 5b7b4f6b3..d4cd8c794 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -300,6 +300,7 @@
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} post} other {{counter} posts}} today",
"hashtag.follow": "Follow hashtag",
"hashtag.unfollow": "Unfollow hashtag",
+ "hashtags.and_other": "…and {count, plural, other {# more}}",
"home.actions.go_to_explore": "See what's trending",
"home.actions.go_to_suggestions": "Find people to follow",
"home.column_settings.basic": "Basic",
@@ -532,6 +533,7 @@
"reply_indicator.cancel": "Cancel",
"report.block": "Block",
"report.block_explanation": "You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.",
+ "report.categories.legal": "Legal",
"report.categories.other": "Other",
"report.categories.spam": "Spam",
"report.categories.violation": "Content violates one or more server rules",
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 4508ebed3..3ec96e47a 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1111,7 +1111,8 @@ body > [data-popper-placement] {
.audio-player,
.attachment-list,
.picture-in-picture-placeholder,
- .status-card {
+ .status-card,
+ .hashtag-bar {
margin-inline-start: $thread-margin;
width: calc(100% - ($thread-margin));
}
@@ -9256,3 +9257,26 @@ noscript {
}
}
}
+
+.hashtag-bar {
+ margin-top: 16px;
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 14px;
+ gap: 4px;
+
+ a {
+ display: inline-flex;
+ border-radius: 4px;
+ background: rgba($highlight-text-color, 0.2);
+ color: $highlight-text-color;
+ padding: 0.4em 0.6em;
+ text-decoration: none;
+
+ &:hover,
+ &:focus,
+ &:active {
+ background: rgba($highlight-text-color, 0.3);
+ }
+ }
+}
diff --git a/app/lib/cache_buster.rb b/app/lib/cache_buster.rb
index 035611518..c54b0da1a 100644
--- a/app/lib/cache_buster.rb
+++ b/app/lib/cache_buster.rb
@@ -2,8 +2,14 @@
class CacheBuster
def initialize(options = {})
- @secret_header = options[:secret_header] || 'Secret-Header'
- @secret = options[:secret] || 'True'
+ ActiveSupport::Deprecation.warn('Default values for the cache buster secret header name and values will be removed in Mastodon 4.3. Please set them explicitely if you rely on those.') unless options[:http_method] || (options[:secret] && options[:secret_header])
+
+ @secret_header = options[:secret_header] ||
+ (options[:http_method] ? nil : 'Secret-Header')
+ @secret = options[:secret] ||
+ (options[:http_method] ? nil : 'True')
+
+ @http_method = options[:http_method] || 'GET'
end
def bust(url)
@@ -21,8 +27,9 @@ class CacheBuster
end
def build_request(url, http_client)
- Request.new(:get, url, http_client: http_client).tap do |request|
- request.add_headers(@secret_header => @secret)
- end
+ request = Request.new(@http_method.downcase.to_sym, url, http_client: http_client)
+ request.add_headers(@secret_header => @secret) if @secret_header.present? && @secret && !@secret.empty?
+
+ request
end
end
diff --git a/app/lib/request.rb b/app/lib/request.rb
index e5a9476a8..fa0e3472f 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -117,7 +117,7 @@ class Request
def perform
begin
- response = http_client.public_send(@verb, @url.to_s, @options.merge(headers: headers))
+ response = http_client.request(@verb, @url.to_s, @options.merge(headers: headers))
rescue => e
raise e.class, "#{e.message} on #{@url}", e.backtrace[0]
end
diff --git a/app/lib/video_metadata_extractor.rb b/app/lib/video_metadata_extractor.rb
index 2896620cb..f27d34868 100644
--- a/app/lib/video_metadata_extractor.rb
+++ b/app/lib/video_metadata_extractor.rb
@@ -43,6 +43,9 @@ class VideoMetadataExtractor
@height = video_stream[:height]
@frame_rate = video_stream[:avg_frame_rate] == '0/0' ? nil : Rational(video_stream[:avg_frame_rate])
@r_frame_rate = video_stream[:r_frame_rate] == '0/0' ? nil : Rational(video_stream[:r_frame_rate])
+ # For some video streams the frame_rate reported by `ffprobe` will be 0/0, but for these streams we
+ # should use `r_frame_rate` instead. Video screencast generated by Gnome Screencast have this issue.
+ @frame_rate ||= @r_frame_rate
end
if (audio_stream = audio_streams.first)
diff --git a/app/models/account.rb b/app/models/account.rb
index 5bd1bfdb6..929cd1c80 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -440,8 +440,21 @@ class Account < ApplicationRecord
EntityCache.instance.mention(username, domain)
end
end
+
+ def inverse_alias(key, original_key)
+ define_method("#{key}=") do |value|
+ public_send("#{original_key}=", !ActiveModel::Type::Boolean.new.cast(value))
+ end
+
+ define_method(key) do
+ !public_send(original_key)
+ end
+ end
end
+ inverse_alias :show_collections, :hide_collections
+ inverse_alias :unlocked, :locked
+
def emojis
@emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
end
diff --git a/app/models/report.rb b/app/models/report.rb
index f6fd23cf3..eaf662d1e 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -48,7 +48,10 @@ class Report < ApplicationRecord
validate :validate_rule_ids
- # entries here needs to be kept in sync with app/javascript/mastodon/features/notifications/components/report.jsx
+ # entries here need to be kept in sync with the front-end:
+ # - app/javascript/mastodon/features/notifications/components/report.jsx
+ # - app/javascript/mastodon/features/report/category.jsx
+ # - app/javascript/mastodon/components/admin/ReportReasonSelector.jsx
enum category: {
other: 0,
spam: 1_000,
diff --git a/app/models/status.rb b/app/models/status.rb
index 65f92936e..ae68f8c85 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -410,13 +410,25 @@ class Status < ApplicationRecord
account_ids.uniq!
+ status_ids = cached_items.map { |item| item.reblog? ? item.reblog_of_id : item.id }.uniq
+
return if account_ids.empty?
accounts = Account.where(id: account_ids).includes(:account_stat, :user).index_by(&:id)
+ status_stats = StatusStat.where(status_id: status_ids).index_by(&:status_id)
+
cached_items.each do |item|
item.account = accounts[item.account_id]
item.reblog.account = accounts[item.reblog.account_id] if item.reblog?
+
+ if item.reblog?
+ status_stat = status_stats[item.reblog.id]
+ item.reblog.status_stat = status_stat if status_stat.present?
+ else
+ status_stat = status_stats[item.id]
+ item.status_stat = status_stat if status_stat.present?
+ end
end
end
diff --git a/app/views/settings/privacy/show.html.haml b/app/views/settings/privacy/show.html.haml
index 66e495e10..ce31e60f0 100644
--- a/app/views/settings/privacy/show.html.haml
+++ b/app/views/settings/privacy/show.html.haml
@@ -18,7 +18,7 @@
= f.input :discoverable, as: :boolean, wrapper: :with_label, recommended: true
.fields-group
- = f.input :locked, as: :boolean, wrapper: :with_label
+ = f.input :unlocked, as: :boolean, wrapper: :with_label
%h4= t('privacy.search')
@@ -33,7 +33,7 @@
%p.lead= t('privacy.privacy_hint_html')
.fields-group
- = f.input :hide_collections, as: :boolean, wrapper: :with_label
+ = f.input :show_collections, as: :boolean, wrapper: :with_label
= f.simple_fields_for :settings, current_user.settings do |ff|
.fields-group
diff --git a/config/application.rb b/config/application.rb
index 372adc168..2a62c37e8 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -51,6 +51,7 @@ require_relative '../lib/rails/engine_extensions'
require_relative '../lib/active_record/database_tasks_extensions'
require_relative '../lib/active_record/batches'
require_relative '../lib/simple_navigation/item_extensions'
+require_relative '../lib/http_extensions'
Dotenv::Railtie.load
diff --git a/config/initializers/cache_buster.rb b/config/initializers/cache_buster.rb
index 227e450f3..a49fba671 100644
--- a/config/initializers/cache_buster.rb
+++ b/config/initializers/cache_buster.rb
@@ -6,5 +6,6 @@ Rails.application.configure do
config.x.cache_buster = {
secret_header: ENV['CACHE_BUSTER_SECRET_HEADER'],
secret: ENV['CACHE_BUSTER_SECRET'],
+ http_method: ENV['CACHE_BUSTER_HTTP_METHOD'] || 'GET',
}
end
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index e16b54efe..443b7617f 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -6,9 +6,9 @@ en:
discoverable: Your public posts and profile may be featured or recommended in various areas of Mastodon and your profile may be suggested to other users.
display_name: Your full name or your fun name.
fields: Your homepage, pronouns, age, anything you want.
- hide_collections: People will not be able to browse through your follows and followers. People that you follow will see that you follow them regardless.
- locked: People will request to follow you and you will be able to either accept or reject new followers.
note: 'You can @mention other people or #hashtags.'
+ show_collections: People will be able to browse through your follows and followers. People that you follow will see that you follow them regardless.
+ unlocked: People will be able to follow you without requesting approval. Uncheck if you want to review follow requests and chose whether to accept or reject new followers.
account_alias:
acct: Specify the username@domain of the account you want to move from
account_migration:
@@ -143,8 +143,8 @@ en:
fields:
name: Label
value: Content
- hide_collections: Hide follows and followers from profile
- locked: Manually review new followers
+ show_collections: Show follows and followers on profile
+ unlocked: Automatically accept new followers
account_alias:
acct: Handle of the old account
account_migration:
diff --git a/lib/http_extensions.rb b/lib/http_extensions.rb
new file mode 100644
index 000000000..2bc0618c4
--- /dev/null
+++ b/lib/http_extensions.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# Monkey patching until https://github.com/httprb/http/pull/757 is merged
+unless HTTP::Request::METHODS.include?(:purge)
+ module HTTP
+ class Request
+ METHODS = METHODS.dup.push(:purge).freeze
+ end
+ end
+end
diff --git a/spec/controllers/concerns/cache_concern_spec.rb b/spec/controllers/concerns/cache_concern_spec.rb
index bf328d679..fffd2b266 100644
--- a/spec/controllers/concerns/cache_concern_spec.rb
+++ b/spec/controllers/concerns/cache_concern_spec.rb
@@ -13,12 +13,17 @@ RSpec.describe CacheConcern do
def empty_relation
render plain: cache_collection(Status.none, Status).size
end
+
+ def account_statuses_favourites
+ render plain: cache_collection(Status.where(account_id: params[:id]), Status).map(&:favourites_count)
+ end
end
before do
routes.draw do
- get 'empty_array' => 'anonymous#empty_array'
- post 'empty_relation' => 'anonymous#empty_relation'
+ get 'empty_array' => 'anonymous#empty_array'
+ get 'empty_relation' => 'anonymous#empty_relation'
+ get 'account_statuses_favourites' => 'anonymous#account_statuses_favourites'
end
end
@@ -36,5 +41,20 @@ RSpec.describe CacheConcern do
expect(response.body).to eq '0'
end
end
+
+ context 'when given a collection of statuses' do
+ let!(:account) { Fabricate(:account) }
+ let!(:status) { Fabricate(:status, account: account) }
+
+ it 'correctly updates with new interactions' do
+ get :account_statuses_favourites, params: { id: account.id }
+ expect(response.body).to eq '[0]'
+
+ FavouriteService.new.call(account, status)
+
+ get :account_statuses_favourites, params: { id: account.id }
+ expect(response.body).to eq '[1]'
+ end
+ end
end
end
diff --git a/spec/lib/cache_buster_spec.rb b/spec/lib/cache_buster_spec.rb
new file mode 100644
index 000000000..84085608e
--- /dev/null
+++ b/spec/lib/cache_buster_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe CacheBuster do
+ subject { described_class.new(secret_header: secret_header, secret: secret, http_method: http_method) }
+
+ let(:secret_header) { nil }
+ let(:secret) { nil }
+ let(:http_method) { nil }
+
+ let(:purge_url) { 'https://example.com/test_purge' }
+
+ describe '#bust' do
+ shared_examples 'makes_request' do
+ it 'makes an HTTP purging request' do
+ method = http_method&.to_sym || :get
+ stub_request(method, purge_url).to_return(status: 200)
+
+ subject.bust(purge_url)
+
+ test_request = a_request(method, purge_url)
+
+ test_request = test_request.with(headers: { secret_header => secret }) if secret && secret_header
+
+ expect(test_request).to have_been_made.once
+ end
+ end
+
+ context 'when using default options' do
+ include_examples 'makes_request'
+ end
+
+ context 'when specifying a secret header' do
+ let(:secret_header) { 'X-Purge-Secret' }
+ let(:secret) { SecureRandom.hex(20) }
+
+ include_examples 'makes_request'
+ end
+
+ context 'when specifying a PURGE method' do
+ let(:http_method) { 'purge' }
+
+ context 'when not using headers' do
+ include_examples 'makes_request'
+ end
+
+ context 'when specifying a secret header' do
+ let(:secret_header) { 'X-Purge-Secret' }
+ let(:secret) { SecureRandom.hex(20) }
+
+ include_examples 'makes_request'
+ end
+ end
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index 90fee96e1..62be744da 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1677,9 +1677,9 @@
"@jridgewell/sourcemap-codec" "^1.4.14"
"@material-design-icons/svg@^0.14.10":
- version "0.14.10"
- resolved "https://registry.yarnpkg.com/@material-design-icons/svg/-/svg-0.14.10.tgz#25804b66d0740b0bf8d6841fa343dfdd60f22e82"
- integrity sha512-rXxfqj5Su8i51aG8s8QRIe7mX1gB+C/ZCroLu3JvIsO3+Vx6PcWP97HLwIl7AQH/jYIHQlKq0E6OMqU91u5fCg==
+ version "0.14.11"
+ resolved "https://registry.yarnpkg.com/@material-design-icons/svg/-/svg-0.14.11.tgz#f90a2c8de801523c3b17e606c89313121c8bb3b4"
+ integrity sha512-jpAksWZIVLB5/qTAeqANns7pH/faIQR3jgV2yROUNKZkzpJ428h7e1/byJB+rFZNI0hgZpY9nOVMLhc1J41HtA==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -9199,9 +9199,9 @@ pg-types@^4.0.1:
postgres-range "^1.1.1"
pg@^8.5.0:
- version "8.11.2"
- resolved "https://registry.yarnpkg.com/pg/-/pg-8.11.2.tgz#1a23f6de7bfb65ba56e4dd15df96668d319900c4"
- integrity sha512-l4rmVeV8qTIrrPrIR3kZQqBgSN93331s9i6wiUiLOSk0Q7PmUxZD/m1rQI622l3NfqBby9Ar5PABfS/SulfieQ==
+ version "8.11.3"
+ resolved "https://registry.yarnpkg.com/pg/-/pg-8.11.3.tgz#d7db6e3fe268fcedd65b8e4599cda0b8b4bf76cb"
+ integrity sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==
dependencies:
buffer-writer "2.0.0"
packet-reader "1.0.0"
@@ -9564,9 +9564,9 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.2.15, postcss@^8.4.24, postcss@^8.4.25:
- version "8.4.27"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.27.tgz#234d7e4b72e34ba5a92c29636734349e0d9c3057"
- integrity sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==
+ version "8.4.28"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.28.tgz#c6cc681ed00109072816e1557f889ef51cf950a5"
+ integrity sha512-Z7V5j0cq8oEKyejIKfpD8b4eBy9cwW2JWPk0+fB1HOAMsfHbnAXLLS+PfVWlzMSLQaWttKDt607I0XHmpE67Vw==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"