From 60b9fa641d32f2c70d0efc1c30d1d0fdc03f5295 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 17 Aug 2023 16:11:48 +0200 Subject: [PATCH 01/11] Fix cached posts including stale stats (#26409) --- app/models/status.rb | 12 ++++++++++ .../concerns/cache_concern_spec.rb | 24 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/models/status.rb b/app/models/status.rb index 5a277269c5..86fd8334a2 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -367,13 +367,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/spec/controllers/concerns/cache_concern_spec.rb b/spec/controllers/concerns/cache_concern_spec.rb index bf328d679d..fffd2b266e 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 From b5acf13886c0ce7ec68b31c8dd82d55ee1c7b2ef Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 17 Aug 2023 16:43:37 +0200 Subject: [PATCH 02/11] Update dependency pg to v8.11.3 (#26519) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 4862776d95..37efb2747a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9184,9 +9184,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" From b95867ad1f31f867dacc0f1584cbd16d51ce8a73 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Fri, 18 Aug 2023 08:18:40 +0200 Subject: [PATCH 03/11] Allow setting a custom HTTP method in CacheBuster (#26528) Co-authored-by: Jorijn Schrijvershof --- app/lib/cache_buster.rb | 17 ++++++--- app/lib/request.rb | 2 +- config/application.rb | 1 + config/initializers/cache_buster.rb | 1 + lib/http_extensions.rb | 10 ++++++ spec/lib/cache_buster_spec.rb | 56 +++++++++++++++++++++++++++++ 6 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 lib/http_extensions.rb create mode 100644 spec/lib/cache_buster_spec.rb diff --git a/app/lib/cache_buster.rb b/app/lib/cache_buster.rb index 035611518e..c54b0da1a1 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 e5a9476a8e..fa0e3472f6 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/config/application.rb b/config/application.rb index 372adc1680..2a62c37e8b 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 227e450f35..a49fba671b 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/lib/http_extensions.rb b/lib/http_extensions.rb new file mode 100644 index 0000000000..2bc0618c4c --- /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/lib/cache_buster_spec.rb b/spec/lib/cache_buster_spec.rb new file mode 100644 index 0000000000..84085608e8 --- /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 From 13ffe91c8160f27eee634e89c525ba83ffe2f0fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolai=20S=C3=B8borg?= Date: Fri, 18 Aug 2023 08:32:47 +0200 Subject: [PATCH 04/11] Fix `frame_rate` for videos where `ffprobe` reports 0/0 (#26500) --- app/lib/video_metadata_extractor.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/lib/video_metadata_extractor.rb b/app/lib/video_metadata_extractor.rb index 2896620cb2..f27d34868a 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) From 581ebf2bb5b8deeda21b6801c34e7bbdd29fac29 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 18 Aug 2023 08:33:33 +0200 Subject: [PATCH 05/11] Update dependency puma to v6.3.1 (#26537) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index c8a945da2d..73e3bd9753 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) From 1cb978bcc3d291a045f367e072ca0af1a1c4dbbc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 18 Aug 2023 11:10:05 +0200 Subject: [PATCH 06/11] Update dependency @material-design-icons/svg to v0.14.11 (#26536) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 37efb2747a..7b851a088d 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" From bb51c0676d0cf27babc2c01ee337ca5fd24ae37c Mon Sep 17 00:00:00 2001 From: Christian Schmidt Date: Fri, 18 Aug 2023 12:06:08 +0200 Subject: [PATCH 07/11] Remove redundant ready() wrapper (#26533) --- app/javascript/packs/public.jsx | 493 ++++++++++++++++---------------- 1 file changed, 245 insertions(+), 248 deletions(-) diff --git a/app/javascript/packs/public.jsx b/app/javascript/packs/public.jsx index da43bba7d6..9e30ecaa01 100644 --- a/app/javascript/packs/public.jsx +++ b/app/javascript/packs/public.jsx @@ -65,287 +65,284 @@ function loaded() { }; }; - ready(() => { - const locale = document.documentElement.lang; + const locale = document.documentElement.lang; - const dateTimeFormat = new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - }); + const dateTimeFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }); - const dateFormat = new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - timeFormat: false, - }); + const dateFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + timeFormat: false, + }); - const timeFormat = new Intl.DateTimeFormat(locale, { - timeStyle: 'short', - hour12: false, - }); + const timeFormat = new Intl.DateTimeFormat(locale, { + timeStyle: 'short', + hour12: false, + }); - const formatMessage = ({ id, defaultMessage }, values) => { - const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale); - return messageFormat.format(values); - }; + const formatMessage = ({ id, defaultMessage }, values) => { + const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale); + return messageFormat.format(values); + }; - [].forEach.call(document.querySelectorAll('.emojify'), (content) => { - content.innerHTML = emojify(content.innerHTML); - }); + [].forEach.call(document.querySelectorAll('.emojify'), (content) => { + content.innerHTML = emojify(content.innerHTML); + }); - [].forEach.call(document.querySelectorAll('time.formatted'), (content) => { - const datetime = new Date(content.getAttribute('datetime')); - const formattedDate = dateTimeFormat.format(datetime); + [].forEach.call(document.querySelectorAll('time.formatted'), (content) => { + const datetime = new Date(content.getAttribute('datetime')); + const formattedDate = dateTimeFormat.format(datetime); - content.title = formattedDate; - content.textContent = formattedDate; - }); + content.title = formattedDate; + content.textContent = formattedDate; + }); - const isToday = date => { - const today = new Date(); + const isToday = date => { + const today = new Date(); - return date.getDate() === today.getDate() && - date.getMonth() === today.getMonth() && - date.getFullYear() === today.getFullYear(); - }; - const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale); + return date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear(); + }; + const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale); - [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => { - const datetime = new Date(content.getAttribute('datetime')); + [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => { + const datetime = new Date(content.getAttribute('datetime')); - let formattedContent; + let formattedContent; - if (isToday(datetime)) { - const formattedTime = timeFormat.format(datetime); + if (isToday(datetime)) { + const formattedTime = timeFormat.format(datetime); - formattedContent = todayFormat.format({ time: formattedTime }); - } else { - formattedContent = dateFormat.format(datetime); - } + formattedContent = todayFormat.format({ time: formattedTime }); + } else { + formattedContent = dateFormat.format(datetime); + } - content.title = formattedContent; - content.textContent = formattedContent; - }); + content.title = formattedContent; + content.textContent = formattedContent; + }); - [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => { - const datetime = new Date(content.getAttribute('datetime')); - const now = new Date(); + [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => { + const datetime = new Date(content.getAttribute('datetime')); + const now = new Date(); - const timeGiven = content.getAttribute('datetime').includes('T'); - content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime); - content.textContent = timeAgoString({ - formatMessage, - formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date), - }, datetime, now, now.getFullYear(), timeGiven); - }); + const timeGiven = content.getAttribute('datetime').includes('T'); + content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime); + content.textContent = timeAgoString({ + formatMessage, + formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date), + }, datetime, now, now.getFullYear(), timeGiven); + }); - const reactComponents = document.querySelectorAll('[data-component]'); + const reactComponents = document.querySelectorAll('[data-component]'); - if (reactComponents.length > 0) { - import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container') - .then(({ default: MediaContainer }) => { - [].forEach.call(reactComponents, (component) => { - [].forEach.call(component.children, (child) => { - component.removeChild(child); - }); + if (reactComponents.length > 0) { + import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container') + .then(({ default: MediaContainer }) => { + [].forEach.call(reactComponents, (component) => { + [].forEach.call(component.children, (child) => { + component.removeChild(child); }); - - const content = document.createElement('div'); - - const root = createRoot(content); - root.render(); - document.body.appendChild(content); - scrollToDetailedStatus(); - }) - .catch(error => { - console.error(error); - scrollToDetailedStatus(); }); - } else { - scrollToDetailedStatus(); - } - delegate(document, '#user_account_attributes_username', 'input', throttle(() => { - const username = document.getElementById('user_account_attributes_username'); + const content = document.createElement('div'); - if (username.value && username.value.length > 0) { - axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => { - username.setCustomValidity(formatMessage(messages.usernameTaken)); - }).catch(() => { - username.setCustomValidity(''); - }); - } else { + const root = createRoot(content); + root.render(); + document.body.appendChild(content); + scrollToDetailedStatus(); + }) + .catch(error => { + console.error(error); + scrollToDetailedStatus(); + }); + } else { + scrollToDetailedStatus(); + } + + delegate(document, '#user_account_attributes_username', 'input', throttle(() => { + const username = document.getElementById('user_account_attributes_username'); + + if (username.value && username.value.length > 0) { + axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => { + username.setCustomValidity(formatMessage(messages.usernameTaken)); + }).catch(() => { username.setCustomValidity(''); - } - }, 500, { leading: false, trailing: true })); - - delegate(document, '#user_password,#user_password_confirmation', 'input', () => { - const password = document.getElementById('user_password'); - const confirmation = document.getElementById('user_password_confirmation'); - if (!confirmation) return; - - if (confirmation.value && confirmation.value.length > password.maxLength) { - confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength)); - } else if (password.value && password.value !== confirmation.value) { - confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch)); - } else { - confirmation.setCustomValidity(''); - } - }); - - delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original')); - delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static')); - - delegate(document, '.status__content__spoiler-link', 'click', function() { - const statusEl = this.parentNode.parentNode; - - if (statusEl.dataset.spoiler === 'expanded') { - statusEl.dataset.spoiler = 'folded'; - this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format(); - } else { - statusEl.dataset.spoiler = 'expanded'; - this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format(); - } - - return false; - }); - - [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => { - const statusEl = spoilerLink.parentNode.parentNode; - const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more'); - spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format(); - }); - }); - - delegate(document, '#account_display_name', 'input', ({ target }) => { - const name = document.querySelector('.card .display-name strong'); - if (name) { - if (target.value) { - name.innerHTML = emojify(escapeTextContentForBrowser(target.value)); - } else { - name.textContent = target.dataset.default; - } - } - }); - - delegate(document, '#account_avatar', 'change', ({ target }) => { - const avatar = document.querySelector('.card .avatar img'); - const [file] = target.files || []; - const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; - - avatar.src = url; - }); - - const getProfileAvatarAnimationHandler = (swapTo) => { - //animate avatar gifs on the profile page when moused over - return ({ target }) => { - const swapSrc = target.getAttribute(swapTo); - //only change the img source if autoplay is off and the image src is actually different - if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) { - target.src = swapSrc; - } - }; - }; - - delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original')); - - delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static')); - - delegate(document, '#account_header', 'change', ({ target }) => { - const header = document.querySelector('.card .card__img img'); - const [file] = target.files || []; - const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; - - header.src = url; - }); - - delegate(document, '#account_locked', 'change', ({ target }) => { - const lock = document.querySelector('.card .display-name i'); - - if (lock) { - if (target.checked) { - delete lock.dataset.hidden; - } else { - lock.dataset.hidden = 'true'; - } - } - }); - - delegate(document, '.input-copy input', 'click', ({ target }) => { - target.focus(); - target.select(); - target.setSelectionRange(0, target.value.length); - }); - - delegate(document, '.input-copy button', 'click', ({ target }) => { - const input = target.parentNode.querySelector('.input-copy__wrapper input'); - - const oldReadOnly = input.readonly; - - input.readonly = false; - input.focus(); - input.select(); - input.setSelectionRange(0, input.value.length); - - try { - if (document.execCommand('copy')) { - input.blur(); - target.parentNode.classList.add('copied'); - - setTimeout(() => { - target.parentNode.classList.remove('copied'); - }, 700); - } - } catch (err) { - console.error(err); - } - - input.readonly = oldReadOnly; - }); - - const toggleSidebar = () => { - const sidebar = document.querySelector('.sidebar ul'); - const toggleButton = document.querySelector('.sidebar__toggle__icon'); - - if (sidebar.classList.contains('visible')) { - document.body.style.overflow = null; - toggleButton.setAttribute('aria-expanded', 'false'); + }); } else { - document.body.style.overflow = 'hidden'; - toggleButton.setAttribute('aria-expanded', 'true'); + username.setCustomValidity(''); } + }, 500, { leading: false, trailing: true })); - toggleButton.classList.toggle('active'); - sidebar.classList.toggle('visible'); - }; + delegate(document, '#user_password,#user_password_confirmation', 'input', () => { + const password = document.getElementById('user_password'); + const confirmation = document.getElementById('user_password_confirmation'); + if (!confirmation) return; - delegate(document, '.sidebar__toggle__icon', 'click', () => { - toggleSidebar(); - }); - - delegate(document, '.sidebar__toggle__icon', 'keydown', e => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault(); - toggleSidebar(); + if (confirmation.value && confirmation.value.length > password.maxLength) { + confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength)); + } else if (password.value && password.value !== confirmation.value) { + confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch)); + } else { + confirmation.setCustomValidity(''); } }); - // Empty the honeypot fields in JS in case something like an extension - // automatically filled them. - delegate(document, '#registration_new_user,#new_user', 'submit', () => { - ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => { - const field = document.getElementById(id); - if (field) { - field.value = ''; - } - }); + delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original')); + delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static')); + + delegate(document, '.status__content__spoiler-link', 'click', function() { + const statusEl = this.parentNode.parentNode; + + if (statusEl.dataset.spoiler === 'expanded') { + statusEl.dataset.spoiler = 'folded'; + this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format(); + } else { + statusEl.dataset.spoiler = 'expanded'; + this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format(); + } + + return false; + }); + + [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => { + const statusEl = spoilerLink.parentNode.parentNode; + const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more'); + spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format(); }); } +delegate(document, '#account_display_name', 'input', ({ target }) => { + const name = document.querySelector('.card .display-name strong'); + if (name) { + if (target.value) { + name.innerHTML = emojify(escapeTextContentForBrowser(target.value)); + } else { + name.textContent = target.dataset.default; + } + } +}); + +delegate(document, '#account_avatar', 'change', ({ target }) => { + const avatar = document.querySelector('.card .avatar img'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + + avatar.src = url; +}); + +const getProfileAvatarAnimationHandler = (swapTo) => { + //animate avatar gifs on the profile page when moused over + return ({ target }) => { + const swapSrc = target.getAttribute(swapTo); + //only change the img source if autoplay is off and the image src is actually different + if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) { + target.src = swapSrc; + } + }; +}; + +delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original')); + +delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static')); + +delegate(document, '#account_header', 'change', ({ target }) => { + const header = document.querySelector('.card .card__img img'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; + + header.src = url; +}); + +delegate(document, '#account_locked', 'change', ({ target }) => { + const lock = document.querySelector('.card .display-name i'); + + if (lock) { + if (target.checked) { + delete lock.dataset.hidden; + } else { + lock.dataset.hidden = 'true'; + } + } +}); + +delegate(document, '.input-copy input', 'click', ({ target }) => { + target.focus(); + target.select(); + target.setSelectionRange(0, target.value.length); +}); + +delegate(document, '.input-copy button', 'click', ({ target }) => { + const input = target.parentNode.querySelector('.input-copy__wrapper input'); + + const oldReadOnly = input.readonly; + + input.readonly = false; + input.focus(); + input.select(); + input.setSelectionRange(0, input.value.length); + + try { + if (document.execCommand('copy')) { + input.blur(); + target.parentNode.classList.add('copied'); + + setTimeout(() => { + target.parentNode.classList.remove('copied'); + }, 700); + } + } catch (err) { + console.error(err); + } + + input.readonly = oldReadOnly; +}); + +const toggleSidebar = () => { + const sidebar = document.querySelector('.sidebar ul'); + const toggleButton = document.querySelector('.sidebar__toggle__icon'); + + if (sidebar.classList.contains('visible')) { + document.body.style.overflow = null; + toggleButton.setAttribute('aria-expanded', 'false'); + } else { + document.body.style.overflow = 'hidden'; + toggleButton.setAttribute('aria-expanded', 'true'); + } + + toggleButton.classList.toggle('active'); + sidebar.classList.toggle('visible'); +}; + +delegate(document, '.sidebar__toggle__icon', 'click', () => { + toggleSidebar(); +}); + +delegate(document, '.sidebar__toggle__icon', 'keydown', e => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + toggleSidebar(); + } +}); + +// Empty the honeypot fields in JS in case something like an extension +// automatically filled them. +delegate(document, '#registration_new_user,#new_user', 'submit', () => { + ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => { + const field = document.getElementById(id); + if (field) { + field.value = ''; + } + }); +}); function main() { ready(loaded); From 6375e390af924649348832d30568e0948871e92b Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 18 Aug 2023 15:05:35 +0200 Subject: [PATCH 08/11] Fix: support both DATABASE_URL and DB_PASS (#26295) --- streaming/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/streaming/index.js b/streaming/index.js index 2112ca4336..a241fa3280 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -110,6 +110,11 @@ const pgConfigFromEnv = (env) => { if (env.DATABASE_URL) { baseConfig = dbUrlToConfig(env.DATABASE_URL); + + // Support overriding the database password in the connection URL + if (!baseConfig.password && env.DB_PASS) { + baseConfig.password = env.DB_PASS; + } } else { baseConfig = pgConfigs[environment]; From e7bea8f004711b34f7abe7b6517adfabe0e5626f Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 18 Aug 2023 16:06:46 +0200 Subject: [PATCH 09/11] Fix already initialized constant warning (#26542) --- lib/http_extensions.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/http_extensions.rb b/lib/http_extensions.rb index 2bc0618c4c..048f85f87b 100644 --- a/lib/http_extensions.rb +++ b/lib/http_extensions.rb @@ -2,9 +2,7 @@ # 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 + methods = HTTP::Request::METHODS.dup + HTTP::Request.send(:remove_const, :METHODS) + HTTP::Request.const_set(:METHODS, methods.push(:purge).freeze) end From ee702e36e58d638bcf75b2eae2ca86499693465e Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 18 Aug 2023 18:20:55 +0200 Subject: [PATCH 10/11] Change follow recommendation materialized view to be faster in most cases (#26545) Co-authored-by: Renaud Chaput --- app/models/follow_recommendation.rb | 3 +- ...56_create_global_follow_recommendations.rb | 8 +++++ ...30818142253_drop_follow_recommendations.rb | 12 +++++++ db/schema.rb | 20 ++++++------ .../global_follow_recommendations_v01.sql | 32 +++++++++++++++++++ 5 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 db/migrate/20230818141056_create_global_follow_recommendations.rb create mode 100644 db/post_migrate/20230818142253_drop_follow_recommendations.rb create mode 100644 db/views/global_follow_recommendations_v01.sql diff --git a/app/models/follow_recommendation.rb b/app/models/follow_recommendation.rb index 123570b124..9d2648394b 100644 --- a/app/models/follow_recommendation.rb +++ b/app/models/follow_recommendation.rb @@ -2,7 +2,7 @@ # == Schema Information # -# Table name: follow_recommendations +# Table name: global_follow_recommendations # # account_id :bigint(8) primary key # rank :decimal(, ) @@ -11,6 +11,7 @@ class FollowRecommendation < ApplicationRecord self.primary_key = :account_id + self.table_name = :global_follow_recommendations belongs_to :account_summary, foreign_key: :account_id, inverse_of: false belongs_to :account diff --git a/db/migrate/20230818141056_create_global_follow_recommendations.rb b/db/migrate/20230818141056_create_global_follow_recommendations.rb new file mode 100644 index 0000000000..b88c71b9d7 --- /dev/null +++ b/db/migrate/20230818141056_create_global_follow_recommendations.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class CreateGlobalFollowRecommendations < ActiveRecord::Migration[7.0] + def change + create_view :global_follow_recommendations, materialized: { no_data: true } + safety_assured { add_index :global_follow_recommendations, :account_id, unique: true } + end +end diff --git a/db/post_migrate/20230818142253_drop_follow_recommendations.rb b/db/post_migrate/20230818142253_drop_follow_recommendations.rb new file mode 100644 index 0000000000..95913d6caa --- /dev/null +++ b/db/post_migrate/20230818142253_drop_follow_recommendations.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class DropFollowRecommendations < ActiveRecord::Migration[7.0] + def up + drop_view :follow_recommendations, materialized: true + end + + def down + create_view :follow_recommendations, version: 2, materialized: { no_data: true } + safety_assured { add_index :follow_recommendations, :account_id, unique: true } + end +end diff --git a/db/schema.rb b/db/schema.rb index 7cca196ea0..8b758fc7df 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_08_14_223300) do +ActiveRecord::Schema[7.0].define(version: 2023_08_18_142253) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1331,34 +1331,36 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_14_223300) do SQL add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true - create_view "follow_recommendations", materialized: true, sql_definition: <<-SQL + create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL SELECT t0.account_id, sum(t0.rank) AS rank, array_agg(t0.reason) AS reason FROM ( SELECT account_summaries.account_id, ((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank, 'most_followed'::text AS reason - FROM (((follows + FROM ((follows JOIN account_summaries ON ((account_summaries.account_id = follows.target_account_id))) JOIN users ON ((users.account_id = follows.account_id))) - LEFT JOIN follow_recommendation_suppressions ON ((follow_recommendation_suppressions.account_id = follows.target_account_id))) - WHERE ((users.current_sign_in_at >= (now() - 'P30D'::interval)) AND (account_summaries.sensitive = false) AND (follow_recommendation_suppressions.id IS NULL)) + WHERE ((users.current_sign_in_at >= (now() - 'P30D'::interval)) AND (account_summaries.sensitive = false) AND (NOT (EXISTS ( SELECT 1 + FROM follow_recommendation_suppressions + WHERE (follow_recommendation_suppressions.account_id = follows.target_account_id))))) GROUP BY account_summaries.account_id HAVING (count(follows.id) >= 5) UNION ALL SELECT account_summaries.account_id, (sum((status_stats.reblogs_count + status_stats.favourites_count)) / (1.0 + sum((status_stats.reblogs_count + status_stats.favourites_count)))) AS rank, 'most_interactions'::text AS reason - FROM (((status_stats + FROM ((status_stats JOIN statuses ON ((statuses.id = status_stats.status_id))) JOIN account_summaries ON ((account_summaries.account_id = statuses.account_id))) - LEFT JOIN follow_recommendation_suppressions ON ((follow_recommendation_suppressions.account_id = statuses.account_id))) - WHERE ((statuses.id >= (((date_part('epoch'::text, (now() - 'P30D'::interval)) * (1000)::double precision))::bigint << 16)) AND (account_summaries.sensitive = false) AND (follow_recommendation_suppressions.id IS NULL)) + WHERE ((statuses.id >= (((date_part('epoch'::text, (now() - 'P30D'::interval)) * (1000)::double precision))::bigint << 16)) AND (account_summaries.sensitive = false) AND (NOT (EXISTS ( SELECT 1 + FROM follow_recommendation_suppressions + WHERE (follow_recommendation_suppressions.account_id = statuses.account_id))))) GROUP BY account_summaries.account_id HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0 GROUP BY t0.account_id ORDER BY (sum(t0.rank)) DESC; SQL - add_index "follow_recommendations", ["account_id"], name: "index_follow_recommendations_on_account_id", unique: true + add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true end diff --git a/db/views/global_follow_recommendations_v01.sql b/db/views/global_follow_recommendations_v01.sql new file mode 100644 index 0000000000..de693c9fcd --- /dev/null +++ b/db/views/global_follow_recommendations_v01.sql @@ -0,0 +1,32 @@ +SELECT + account_id, + sum(rank) AS rank, + array_agg(reason) AS reason +FROM ( + SELECT + account_summaries.account_id AS account_id, + count(follows.id) / (1.0 + count(follows.id)) AS rank, + 'most_followed' AS reason + FROM follows + INNER JOIN account_summaries ON account_summaries.account_id = follows.target_account_id + INNER JOIN users ON users.account_id = follows.account_id + WHERE users.current_sign_in_at >= (now() - interval '30 days') + AND account_summaries.sensitive = 'f' + AND NOT EXISTS (SELECT 1 FROM follow_recommendation_suppressions WHERE follow_recommendation_suppressions.account_id = follows.target_account_id) + GROUP BY account_summaries.account_id + HAVING count(follows.id) >= 5 + UNION ALL + SELECT account_summaries.account_id AS account_id, + sum(status_stats.reblogs_count + status_stats.favourites_count) / (1.0 + sum(status_stats.reblogs_count + status_stats.favourites_count)) AS rank, + 'most_interactions' AS reason + FROM status_stats + INNER JOIN statuses ON statuses.id = status_stats.status_id + INNER JOIN account_summaries ON account_summaries.account_id = statuses.account_id + WHERE statuses.id >= ((date_part('epoch', now() - interval '30 days') * 1000)::bigint << 16) + AND account_summaries.sensitive = 'f' + AND NOT EXISTS (SELECT 1 FROM follow_recommendation_suppressions WHERE follow_recommendation_suppressions.account_id = statuses.account_id) + GROUP BY account_summaries.account_id + HAVING sum(status_stats.reblogs_count + status_stats.favourites_count) >= 5 +) t0 +GROUP BY account_id +ORDER BY rank DESC From bb23116e8d17b0fa7564a0f713c48753cfba7023 Mon Sep 17 00:00:00 2001 From: Christian Schmidt Date: Fri, 18 Aug 2023 18:24:32 +0200 Subject: [PATCH 11/11] Fix profile picture preview (#26538) --- app/javascript/packs/public.jsx | 12 ++---------- app/javascript/styles/mastodon/forms.scss | 10 ++++++++++ app/views/settings/profiles/show.html.haml | 16 ++++++++-------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/javascript/packs/public.jsx b/app/javascript/packs/public.jsx index 9e30ecaa01..1d917d60ee 100644 --- a/app/javascript/packs/public.jsx +++ b/app/javascript/packs/public.jsx @@ -231,8 +231,8 @@ delegate(document, '#account_display_name', 'input', ({ target }) => { } }); -delegate(document, '#account_avatar', 'change', ({ target }) => { - const avatar = document.querySelector('.card .avatar img'); +delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => { + const avatar = document.getElementById(target.id + '-preview'); const [file] = target.files || []; const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; @@ -254,14 +254,6 @@ delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnima delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static')); -delegate(document, '#account_header', 'change', ({ target }) => { - const header = document.querySelector('.card .card__img img'); - const [file] = target.files || []; - const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; - - header.src = url; -}); - delegate(document, '#account_locked', 'change', ({ target }) => { const lock = document.querySelector('.card .display-name i'); diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index f69b699a0a..beb45ab6e9 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -309,9 +309,19 @@ code { border-radius: 4px; background: url('images/void.png'); + &[src$='missing.png'] { + visibility: hidden; + } + &:last-child { margin-bottom: 0; } + + &#account_avatar-preview { + width: 90px; + height: 90px; + object-fit: cover; + } } } diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 92b7f42569..7c13dc7f44 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -35,10 +35,10 @@ .fields-group = f.input :avatar, wrapper: :with_block_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar', dimensions: '400x400', size: number_to_human_size(AccountAvatar::LIMIT)) - - if @account.avatar.present? - .fields-row__column.fields-row__column-6 - .fields-group - = image_tag @account.avatar.url, class: 'fields-group__thumbnail', width: 90, height: 90 + .fields-row__column.fields-row__column-6 + .fields-group + = image_tag @account.avatar.url, class: 'fields-group__thumbnail', id: 'account_avatar-preview' + - if @account.avatar.present? = link_to settings_profile_picture_path('avatar'), data: { method: :delete }, class: 'link-button link-button--destructive' do = fa_icon 'trash fw' = t('generic.delete') @@ -48,10 +48,10 @@ .fields-group = f.input :header, wrapper: :with_block_label, input_html: { accept: AccountHeader::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.header', dimensions: '1500x500', size: number_to_human_size(AccountHeader::LIMIT)) - - if @account.header.present? - .fields-row__column.fields-row__column-6 - .fields-group - = image_tag @account.header.url, class: 'fields-group__thumbnail' + .fields-row__column.fields-row__column-6 + .fields-group + = image_tag @account.header.url, class: 'fields-group__thumbnail', id: 'account_header-preview' + - if @account.header.present? = link_to settings_profile_picture_path('header'), data: { method: :delete }, class: 'link-button link-button--destructive' do = fa_icon 'trash fw' = t('generic.delete')