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"