From cab4cbfa5c58f90410bad3ccb0bce236c310b1d4 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 5 Sep 2023 15:37:23 +0200 Subject: [PATCH 01/62] =?UTF-8?q?Fix=20=E2=80=9CScoped=20order=20is=20igno?= =?UTF-8?q?red,=20it's=20forced=20to=20be=20batch=20order.=E2=80=9D=20warn?= =?UTF-8?q?ings=20(#26793)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/feed_manager.rb | 2 +- app/lib/importer/public_statuses_index_importer.rb | 2 +- app/lib/importer/statuses_index_importer.rb | 2 +- app/models/admin/status_batch_action.rb | 2 +- app/models/concerns/account_merging.rb | 4 ++-- app/models/concerns/account_statuses_search.rb | 2 +- app/models/trends/statuses.rb | 4 ++-- app/services/bulk_import_service.rb | 6 +++--- app/services/import_service.rb | 2 +- app/services/suspend_account_service.rb | 6 +++--- app/services/unsuspend_account_service.rb | 6 +++--- app/workers/move_worker.rb | 2 +- config/environments/test.rb | 3 +++ .../20221101190723_backfill_admin_action_logs.rb | 2 +- .../20221206114142_backfill_admin_action_logs_again.rb | 2 +- 15 files changed, 25 insertions(+), 22 deletions(-) diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index ad686c1f1..26e5b78e8 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -262,7 +262,7 @@ class FeedManager add_to_feed(:home, account.id, status, aggregate_reblogs: aggregate) end - account.following.includes(:account_stat).find_each do |target_account| + account.following.includes(:account_stat).reorder(nil).find_each do |target_account| if redis.zcard(timeline_key) >= limit oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i last_status_score = Mastodon::Snowflake.id_at(target_account.last_status_at) diff --git a/app/lib/importer/public_statuses_index_importer.rb b/app/lib/importer/public_statuses_index_importer.rb index ebaac3794..7dfe99886 100644 --- a/app/lib/importer/public_statuses_index_importer.rb +++ b/app/lib/importer/public_statuses_index_importer.rb @@ -27,6 +27,6 @@ class Importer::PublicStatusesIndexImporter < Importer::BaseImporter end def scope - Status.indexable + Status.indexable.reorder(nil) end end diff --git a/app/lib/importer/statuses_index_importer.rb b/app/lib/importer/statuses_index_importer.rb index 08ad3e379..1922f65f6 100644 --- a/app/lib/importer/statuses_index_importer.rb +++ b/app/lib/importer/statuses_index_importer.rb @@ -11,7 +11,7 @@ class Importer::StatusesIndexImporter < Importer::BaseImporter # from a different scope to avoid indexing them multiple times, but that # could end up being a very large array - scope.find_in_batches(batch_size: @batch_size) do |tmp| + scope.reorder(nil).find_in_batches(batch_size: @batch_size) do |tmp| in_work_unit(tmp.map(&:status_id)) do |status_ids| deleted = 0 diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb index b8bdec722..2bf49a7f4 100644 --- a/app/models/admin/status_batch_action.rb +++ b/app/models/admin/status_batch_action.rb @@ -22,7 +22,7 @@ class Admin::StatusBatchAction private def statuses - Status.with_discarded.where(id: status_ids) + Status.with_discarded.where(id: status_ids).reorder(nil) end def process_action! diff --git a/app/models/concerns/account_merging.rb b/app/models/concerns/account_merging.rb index 41071633d..14e157a3d 100644 --- a/app/models/concerns/account_merging.rb +++ b/app/models/concerns/account_merging.rb @@ -20,7 +20,7 @@ module AccountMerging ] owned_classes.each do |klass| - klass.where(account_id: other_account.id).find_each do |record| + klass.where(account_id: other_account.id).reorder(nil).find_each do |record| record.update_attribute(:account_id, id) rescue ActiveRecord::RecordNotUnique next @@ -33,7 +33,7 @@ module AccountMerging ] target_classes.each do |klass| - klass.where(target_account_id: other_account.id).find_each do |record| + klass.where(target_account_id: other_account.id).reorder(nil).find_each do |record| record.update_attribute(:target_account_id, id) rescue ActiveRecord::RecordNotUnique next diff --git a/app/models/concerns/account_statuses_search.rb b/app/models/concerns/account_statuses_search.rb index fa9238e6e..4b2bc4117 100644 --- a/app/models/concerns/account_statuses_search.rb +++ b/app/models/concerns/account_statuses_search.rb @@ -31,7 +31,7 @@ module AccountStatusesSearch def add_to_public_statuses_index! return unless Chewy.enabled? - statuses.without_reblogs.where(visibility: :public).find_in_batches do |batch| + statuses.without_reblogs.where(visibility: :public).reorder(nil).find_in_batches do |batch| PublicStatusesIndex.import(batch) end end diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb index 5cd352a6f..886a385a7 100644 --- a/app/models/trends/statuses.rb +++ b/app/models/trends/statuses.rb @@ -62,13 +62,13 @@ class Trends::Statuses < Trends::Base def refresh(at_time = Time.now.utc) # 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| + Status.where(id: StatusTrend.select(:status_id)).includes(:status_stat, :account).reorder(nil).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| + Status.where(id: recently_used_ids(at_time)).includes(:status_stat, :account).reorder(nil).find_in_batches(batch_size: BATCH_SIZE) do |statuses| calculate_scores(statuses, at_time) end diff --git a/app/services/bulk_import_service.rb b/app/services/bulk_import_service.rb index 591b11212..a361c7a3d 100644 --- a/app/services/bulk_import_service.rb +++ b/app/services/bulk_import_service.rb @@ -38,7 +38,7 @@ class BulkImportService < BaseService rows_by_acct = extract_rows_by_acct if @import.overwrite? - @account.following.find_each do |followee| + @account.following.reorder(nil).find_each do |followee| row = rows_by_acct.delete(followee.acct) if row.nil? @@ -67,7 +67,7 @@ class BulkImportService < BaseService rows_by_acct = extract_rows_by_acct if @import.overwrite? - @account.blocking.find_each do |blocked_account| + @account.blocking.reorder(nil).find_each do |blocked_account| row = rows_by_acct.delete(blocked_account.acct) if row.nil? @@ -93,7 +93,7 @@ class BulkImportService < BaseService rows_by_acct = extract_rows_by_acct if @import.overwrite? - @account.muting.find_each do |muted_account| + @account.muting.reorder(nil).find_each do |muted_account| row = rows_by_acct.delete(muted_account.acct) if row.nil? diff --git a/app/services/import_service.rb b/app/services/import_service.rb index 133c081be..6dafb5a0b 100644 --- a/app/services/import_service.rb +++ b/app/services/import_service.rb @@ -75,7 +75,7 @@ class ImportService < BaseService if @import.overwrite? presence_hash = items.each_with_object({}) { |(id, extra), mapping| mapping[id] = [true, extra] } - overwrite_scope.find_each do |target_account| + overwrite_scope.reorder(nil).find_each do |target_account| if presence_hash[target_account.acct] items.delete(target_account.acct) extra = presence_hash[target_account.acct][1] diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 9f21fb79b..e79c2d3d8 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -51,13 +51,13 @@ class SuspendAccountService < BaseService end def unmerge_from_home_timelines! - @account.followers_for_local_distribution.find_each do |follower| + @account.followers_for_local_distribution.reorder(nil).find_each do |follower| FeedManager.instance.unmerge_from_home(@account, follower) end end def unmerge_from_list_timelines! - @account.lists_for_local_distribution.find_each do |list| + @account.lists_for_local_distribution.reorder(nil).find_each do |list| FeedManager.instance.unmerge_from_list(@account, list) end end @@ -65,7 +65,7 @@ class SuspendAccountService < BaseService def privatize_media_attachments! attachment_names = MediaAttachment.attachment_definitions.keys - @account.media_attachments.find_each do |media_attachment| + @account.media_attachments.reorder(nil).find_each do |media_attachment| attachment_names.each do |attachment_name| attachment = media_attachment.public_send(attachment_name) styles = MediaAttachment::DEFAULT_STYLES | attachment.styles.keys diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb index e555932f8..93cd04a94 100644 --- a/app/services/unsuspend_account_service.rb +++ b/app/services/unsuspend_account_service.rb @@ -47,13 +47,13 @@ class UnsuspendAccountService < BaseService end def merge_into_home_timelines! - @account.followers_for_local_distribution.find_each do |follower| + @account.followers_for_local_distribution.reorder(nil).find_each do |follower| FeedManager.instance.merge_into_home(@account, follower) end end def merge_into_list_timelines! - @account.lists_for_local_distribution.find_each do |list| + @account.lists_for_local_distribution.reorder(nil).find_each do |list| FeedManager.instance.merge_into_list(@account, list) end end @@ -61,7 +61,7 @@ class UnsuspendAccountService < BaseService def publish_media_attachments! attachment_names = MediaAttachment.attachment_definitions.keys - @account.media_attachments.find_each do |media_attachment| + @account.media_attachments.reorder(nil).find_each do |media_attachment| attachment_names.each do |attachment_name| attachment = media_attachment.public_send(attachment_name) styles = MediaAttachment::DEFAULT_STYLES | attachment.styles.keys diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb index cb091671d..73ae268be 100644 --- a/app/workers/move_worker.rb +++ b/app/workers/move_worker.rb @@ -72,7 +72,7 @@ class MoveWorker def queue_follow_unfollows! bypass_locked = @target_account.local? - @source_account.followers.local.select(:id).find_in_batches do |accounts| + @source_account.followers.local.select(:id).reorder(nil).find_in_batches do |accounts| UnfollowFollowWorker.push_bulk(accounts.map(&:id)) { |follower_id| [follower_id, @source_account.id, @target_account.id, bypass_locked] } rescue => e @deferred_error = e diff --git a/config/environments/test.rb b/config/environments/test.rb index d90dca429..210329848 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -50,6 +50,9 @@ Rails.application.configure do config.x.vapid_private_key = vapid_key.private_key config.x.vapid_public_key = vapid_key.public_key + # Raise exceptions when a reorder occurs in in_batches + config.active_record.error_on_ignored_order = true + # Raise exceptions for disallowed deprecations. config.active_support.disallowed_deprecation = :raise diff --git a/db/post_migrate/20221101190723_backfill_admin_action_logs.rb b/db/post_migrate/20221101190723_backfill_admin_action_logs.rb index fa2ddbbca..6476f4b13 100644 --- a/db/post_migrate/20221101190723_backfill_admin_action_logs.rb +++ b/db/post_migrate/20221101190723_backfill_admin_action_logs.rb @@ -90,7 +90,7 @@ class BackfillAdminActionLogs < ActiveRecord::Migration[6.1] log.update_attribute('route_param', log.user.account_id) end - Admin::ActionLog.where(target_type: 'Report', human_identifier: nil).in_batches.update_all('human_identifier = target_id::text') + AdminActionLog.where(target_type: 'Report', human_identifier: nil).in_batches.update_all('human_identifier = target_id::text') AdminActionLog.includes(:domain_block).where(target_type: 'DomainBlock').find_each do |log| next if log.domain_block.nil? diff --git a/db/post_migrate/20221206114142_backfill_admin_action_logs_again.rb b/db/post_migrate/20221206114142_backfill_admin_action_logs_again.rb index 9c7ac7120..3c68470a7 100644 --- a/db/post_migrate/20221206114142_backfill_admin_action_logs_again.rb +++ b/db/post_migrate/20221206114142_backfill_admin_action_logs_again.rb @@ -90,7 +90,7 @@ class BackfillAdminActionLogsAgain < ActiveRecord::Migration[6.1] log.update_attribute('route_param', log.user.account_id) end - Admin::ActionLog.where(target_type: 'Report', human_identifier: nil).in_batches.update_all('human_identifier = target_id::text') + AdminActionLog.where(target_type: 'Report', human_identifier: nil).in_batches.update_all('human_identifier = target_id::text') AdminActionLog.includes(:domain_block).where(target_type: 'DomainBlock').find_each do |log| next if log.domain_block.nil? From b749de766f5a6158fd0b5f3c3201943083fc7979 Mon Sep 17 00:00:00 2001 From: Michael Stanclift Date: Tue, 5 Sep 2023 11:19:59 -0500 Subject: [PATCH 02/62] Migrate Dockerfile to Bookworm (#26802) --- Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index cdabc4c7c..b22284bbd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.4 -# This needs to be bullseye-slim because the Ruby image is built on bullseye-slim -ARG NODE_VERSION="16.20-bullseye-slim" +# This needs to be bookworm-slim because the Ruby image is built on bookworm-slim +ARG NODE_VERSION="16.20-bookworm-slim" FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby FROM node:${NODE_VERSION} as build @@ -20,7 +20,7 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends build-essential \ git \ libicu-dev \ - libidn11-dev \ + libidn-dev \ libpq-dev \ libjemalloc-dev \ zlib1g-dev \ @@ -64,13 +64,13 @@ RUN apt-get update && \ apt-get -y --no-install-recommends install whois \ wget \ procps \ - libssl1.1 \ + libssl3 \ libpq5 \ imagemagick \ ffmpeg \ libjemalloc2 \ - libicu67 \ - libidn11 \ + libicu72 \ + libidn12 \ libyaml-0-2 \ file \ ca-certificates \ From ea7de25de0c2715222aa08c2c8bd4bd4f239bd9f Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 5 Sep 2023 20:05:58 +0200 Subject: [PATCH 03/62] Fix video player not being displayed in reports interface (#26801) --- app/helpers/media_component_helper.rb | 1 + app/views/admin/reports/_media_attachments.html.haml | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/media_component_helper.rb b/app/helpers/media_component_helper.rb index a57d0b4b6..fa8f34fb4 100644 --- a/app/helpers/media_component_helper.rb +++ b/app/helpers/media_component_helper.rb @@ -14,6 +14,7 @@ module MediaComponentHelper blurhash: video.blurhash, frameRate: meta.dig('original', 'frame_rate'), inline: true, + aspectRatio: "#{meta.dig('original', 'width')} / #{meta.dig('original', 'height')}", media: [ serialize_media_attachment(video), ].as_json, diff --git a/app/views/admin/reports/_media_attachments.html.haml b/app/views/admin/reports/_media_attachments.html.haml index 2305805a7..8ecd7444d 100644 --- a/app/views/admin/reports/_media_attachments.html.haml +++ b/app/views/admin/reports/_media_attachments.html.haml @@ -1,6 +1,5 @@ - if status.ordered_media_attachments.first.video? - - video = status.ordered_media_attachments.first - = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), frameRate: video.file.meta.dig('original', 'frame_rate'), blurhash: video.blurhash, sensitive: status.sensitive?, visible: false, width: 610, height: 343, inline: true, alt: video.description, lang: status.language, media: [ActiveModelSerializers::SerializableResource.new(video, serializer: REST::MediaAttachmentSerializer)].as_json + = render_video_component(status, visible: false) - elsif status.ordered_media_attachments.first.audio? - audio = status.ordered_media_attachments.first = react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, lang: status.language, duration: audio.file.meta.dig(:original, :duration) From 548c032dbb90ae9c06b05fc05724c49d0b552fd9 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 5 Sep 2023 23:49:48 +0200 Subject: [PATCH 04/62] Improve interaction modal error handling (#26795) --- .../api/v1/peers/search_controller.rb | 2 + .../features/interaction_modal/index.jsx | 52 ++++++++++++++++--- .../packs/remote_interaction_helper.ts | 4 +- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb index 2c0eacdca..0c503d9bc 100644 --- a/app/controllers/api/v1/peers/search_controller.rb +++ b/app/controllers/api/v1/peers/search_controller.rb @@ -41,5 +41,7 @@ class Api::V1::Peers::SearchController < Api::BaseController domain = TagManager.instance.normalize_domain(domain) @domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain) end + rescue Addressable::URI::InvalidURIError + @domains = [] end end diff --git a/app/javascript/mastodon/features/interaction_modal/index.jsx b/app/javascript/mastodon/features/interaction_modal/index.jsx index 84e430925..2a9fa0e33 100644 --- a/app/javascript/mastodon/features/interaction_modal/index.jsx +++ b/app/javascript/mastodon/features/interaction_modal/index.jsx @@ -100,8 +100,41 @@ class LoginForm extends React.PureComponent { this.input = c; }; + isValueValid = (value) => { + let likelyAcct = false; + let url = null; + + if (value.startsWith('/')) { + return false; + } + + if (value.startsWith('@')) { + value = value.slice(1); + likelyAcct = true; + } + + // The user is in the middle of typing something, do not error out + if (value === '') { + return true; + } + + if (/^https?:\/\//.test(value) && !likelyAcct) { + url = value; + } else { + url = `https://${value}`; + } + + try { + new URL(url); + return true; + } catch(_) { + return false; + } + }; + handleChange = ({ target }) => { - this.setState(state => ({ value: target.value, isLoading: true, error: false, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions()); + const error = !this.isValueValid(target.value); + this.setState(state => ({ error, value: target.value, isLoading: true, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions()); }; handleMessage = (event) => { @@ -115,11 +148,18 @@ class LoginForm extends React.PureComponent { this.setState({ isSubmitting: false, error: true }); } else if (event.data?.type === 'fetchInteractionURL-success') { if (/^https?:\/\//.test(event.data.template)) { - if (localStorage) { - localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain); - } + try { + const url = new URL(event.data.template.replace('{uri}', encodeURIComponent(resourceUrl))); - window.location.href = event.data.template.replace('{uri}', encodeURIComponent(resourceUrl)); + if (localStorage) { + localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain); + } + + window.location.href = url; + } catch (e) { + console.error(e); + this.setState({ isSubmitting: false, error: true }); + } } else { this.setState({ isSubmitting: false, error: true }); } @@ -259,7 +299,7 @@ class LoginForm extends React.PureComponent { spellcheck='false' /> - + {hasPopOut && ( diff --git a/app/javascript/packs/remote_interaction_helper.ts b/app/javascript/packs/remote_interaction_helper.ts index 76528ff38..d5834c6c3 100644 --- a/app/javascript/packs/remote_interaction_helper.ts +++ b/app/javascript/packs/remote_interaction_helper.ts @@ -140,7 +140,9 @@ const fromAcct = (acct: string) => { }; const fetchInteractionURL = (uri_or_domain: string) => { - if (/^https?:\/\//.test(uri_or_domain)) { + if (uri_or_domain === '') { + fetchInteractionURLFailure(); + } else if (/^https?:\/\//.test(uri_or_domain)) { fromURL(uri_or_domain); } else if (uri_or_domain.includes('@')) { fromAcct(uri_or_domain); From 5d20733d8d8d5e4ba7f1f37fd8ee8fc13d6e3ab5 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 5 Sep 2023 23:54:24 +0200 Subject: [PATCH 05/62] Add infinite scrolling for search results in web UI (#26784) --- app/javascript/mastodon/actions/search.js | 22 ++- .../compose/components/search_results.jsx | 121 ++++-------- .../explore/components/search_section.jsx | 20 ++ .../mastodon/features/explore/results.jsx | 179 ++++++++++++++---- app/javascript/mastodon/locales/en.json | 5 +- app/javascript/mastodon/reducers/search.js | 20 +- .../styles/mastodon/components.scss | 59 +++--- 7 files changed, 255 insertions(+), 171 deletions(-) create mode 100644 app/javascript/mastodon/features/explore/components/search_section.jsx diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index 94e7f2ed7..21fd54076 100644 --- a/app/javascript/mastodon/actions/search.js +++ b/app/javascript/mastodon/actions/search.js @@ -37,17 +37,17 @@ export function submitSearch(type) { const signedIn = !!getState().getIn(['meta', 'me']); if (value.length === 0) { - dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '')); + dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type)); return; } - dispatch(fetchSearchRequest()); + dispatch(fetchSearchRequest(type)); api(getState).get('/api/v2/search', { params: { q: value, resolve: signedIn, - limit: 5, + limit: 11, type, }, }).then(response => { @@ -59,7 +59,7 @@ export function submitSearch(type) { dispatch(importFetchedStatuses(response.data.statuses)); } - dispatch(fetchSearchSuccess(response.data, value)); + dispatch(fetchSearchSuccess(response.data, value, type)); dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); }).catch(error => { dispatch(fetchSearchFail(error)); @@ -67,16 +67,18 @@ export function submitSearch(type) { }; } -export function fetchSearchRequest() { +export function fetchSearchRequest(searchType) { return { type: SEARCH_FETCH_REQUEST, + searchType, }; } -export function fetchSearchSuccess(results, searchTerm) { +export function fetchSearchSuccess(results, searchTerm, searchType) { return { type: SEARCH_FETCH_SUCCESS, results, + searchType, searchTerm, }; } @@ -90,15 +92,16 @@ export function fetchSearchFail(error) { export const expandSearch = type => (dispatch, getState) => { const value = getState().getIn(['search', 'value']); - const offset = getState().getIn(['search', 'results', type]).size; + const offset = getState().getIn(['search', 'results', type]).size - 1; - dispatch(expandSearchRequest()); + dispatch(expandSearchRequest(type)); api(getState).get('/api/v2/search', { params: { q: value, type, offset, + limit: 11, }, }).then(({ data }) => { if (data.accounts) { @@ -116,8 +119,9 @@ export const expandSearch = type => (dispatch, getState) => { }); }; -export const expandSearchRequest = () => ({ +export const expandSearchRequest = (searchType) => ({ type: SEARCH_EXPAND_REQUEST, + searchType, }); export const expandSearchSuccess = (results, searchTerm, searchType) => ({ diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx index b11ac478a..346d9b18a 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.jsx +++ b/app/javascript/mastodon/features/compose/components/search_results.jsx @@ -1,46 +1,36 @@ import PropTypes from 'prop-types'; -import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { Icon } from 'mastodon/components/icon'; import { LoadMore } from 'mastodon/components/load_more'; +import { SearchSection } from 'mastodon/features/explore/components/search_section'; import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; import AccountContainer from '../../../containers/account_container'; import StatusContainer from '../../../containers/status_container'; -import { searchEnabled } from '../../../initial_state'; -const messages = defineMessages({ - dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' }, -}); +const INITIAL_PAGE_LIMIT = 10; + +const withoutLastResult = list => { + if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) { + return list.skipLast(1); + } else { + return list; + } +}; class SearchResults extends ImmutablePureComponent { static propTypes = { results: ImmutablePropTypes.map.isRequired, - suggestions: ImmutablePropTypes.list.isRequired, - fetchSuggestions: PropTypes.func.isRequired, expandSearch: PropTypes.func.isRequired, - dismissSuggestion: PropTypes.func.isRequired, searchTerm: PropTypes.string, - intl: PropTypes.object.isRequired, }; - componentDidMount () { - if (this.props.searchTerm === '') { - this.props.fetchSuggestions(); - } - } - - componentDidUpdate () { - if (this.props.searchTerm === '') { - this.props.fetchSuggestions(); - } - } - handleLoadMoreAccounts = () => this.props.expandSearch('accounts'); handleLoadMoreStatuses = () => this.props.expandSearch('statuses'); @@ -48,97 +38,52 @@ class SearchResults extends ImmutablePureComponent { handleLoadMoreHashtags = () => this.props.expandSearch('hashtags'); render () { - const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props; - - if (searchTerm === '' && !suggestions.isEmpty()) { - return ( -
-
-
- - -
- - {suggestions && suggestions.map(suggestion => ( - - ))} -
-
- ); - } + const { results } = this.props; let accounts, statuses, hashtags; - let count = 0; if (results.get('accounts') && results.get('accounts').size > 0) { - count += results.get('accounts').size; accounts = ( -
-
- - {results.get('accounts').map(accountId => )} - - {results.get('accounts').size >= 5 && } -
- ); - } - - if (results.get('statuses') && results.get('statuses').size > 0) { - count += results.get('statuses').size; - statuses = ( -
-
- - {results.get('statuses').map(statusId => )} - - {results.get('statuses').size >= 5 && } -
- ); - } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) { - statuses = ( -
-
- -
- -
-
+ }> + {withoutLastResult(results.get('accounts')).map(accountId => )} + {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && } + ); } if (results.get('hashtags') && results.get('hashtags').size > 0) { - count += results.get('hashtags').size; hashtags = ( -
-
- - {results.get('hashtags').map(hashtag => )} - - {results.get('hashtags').size >= 5 && } -
+ }> + {withoutLastResult(results.get('hashtags')).map(hashtag => )} + {(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && } + ); } + if (results.get('statuses') && results.get('statuses').size > 0) { + statuses = ( + }> + {withoutLastResult(results.get('statuses')).map(statusId => )} + {(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && } + + ); + } + + return (
- +
{accounts} - {statuses} {hashtags} + {statuses}
); } } -export default injectIntl(SearchResults); +export default SearchResults; diff --git a/app/javascript/mastodon/features/explore/components/search_section.jsx b/app/javascript/mastodon/features/explore/components/search_section.jsx new file mode 100644 index 000000000..c84e3f7ce --- /dev/null +++ b/app/javascript/mastodon/features/explore/components/search_section.jsx @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +export const SearchSection = ({ title, onClickMore, children }) => ( +
+
+

{title}

+ {onClickMore && } +
+ + {children} +
+); + +SearchSection.propTypes = { + title: PropTypes.node.isRequired, + onClickMore: PropTypes.func, + children: PropTypes.children, +}; \ No newline at end of file diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx index 7d1ce69ad..1b9d2f30d 100644 --- a/app/javascript/mastodon/features/explore/results.jsx +++ b/app/javascript/mastodon/features/explore/results.jsx @@ -9,13 +9,15 @@ import { List as ImmutableList } from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import { expandSearch } from 'mastodon/actions/search'; +import { submitSearch, expandSearch } from 'mastodon/actions/search'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; -import { LoadMore } from 'mastodon/components/load_more'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { Icon } from 'mastodon/components/icon'; +import ScrollableList from 'mastodon/components/scrollable_list'; import Account from 'mastodon/containers/account_container'; import Status from 'mastodon/containers/status_container'; +import { SearchSection } from './components/search_section'; + const messages = defineMessages({ title: { id: 'search_results.title', defaultMessage: 'Search for {q}' }, }); @@ -24,85 +26,175 @@ const mapStateToProps = state => ({ isLoading: state.getIn(['search', 'isLoading']), results: state.getIn(['search', 'results']), q: state.getIn(['search', 'searchTerm']), + submittedType: state.getIn(['search', 'type']), }); -const appendLoadMore = (id, list, onLoadMore) => { - if (list.size >= 5) { - return list.push(); +const INITIAL_PAGE_LIMIT = 10; +const INITIAL_DISPLAY = 4; + +const hidePeek = list => { + if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) { + return list.skipLast(1); } else { return list; } }; -const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts', ImmutableList()).map(item => ( - -)), onLoadMore); +const renderAccounts = accounts => hidePeek(accounts).map(id => ( + +)); -const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags', ImmutableList()).map(item => ( - -)), onLoadMore); +const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => ( + +)); -const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses', ImmutableList()).map(item => ( - -)), onLoadMore); +const renderStatuses = statuses => hidePeek(statuses).map(id => ( + +)); class Results extends PureComponent { static propTypes = { - results: ImmutablePropTypes.map, + results: ImmutablePropTypes.contains({ + accounts: ImmutablePropTypes.orderedSet, + statuses: ImmutablePropTypes.orderedSet, + hashtags: ImmutablePropTypes.orderedSet, + }), isLoading: PropTypes.bool, multiColumn: PropTypes.bool, dispatch: PropTypes.func.isRequired, q: PropTypes.string, intl: PropTypes.object, + submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']), }; state = { - type: 'all', + type: this.props.submittedType || 'all', }; - handleSelectAll = () => this.setState({ type: 'all' }); - handleSelectAccounts = () => this.setState({ type: 'accounts' }); - handleSelectHashtags = () => this.setState({ type: 'hashtags' }); - handleSelectStatuses = () => this.setState({ type: 'statuses' }); - handleLoadMoreAccounts = () => this.loadMore('accounts'); - handleLoadMoreStatuses = () => this.loadMore('statuses'); - handleLoadMoreHashtags = () => this.loadMore('hashtags'); + static getDerivedStateFromProps(props, state) { + if (props.submittedType !== state.type) { + return { + type: props.submittedType || 'all', + }; + } - loadMore (type) { + return null; + }; + + handleSelectAll = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for a specific type, we need to resubmit + // the query to get all types of results + if (submittedType) { + dispatch(submitSearch()); + } + + this.setState({ type: 'all' }); + }; + + handleSelectAccounts = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for something else (but not everything), + // we need to resubmit the query for this specific type + if (submittedType !== 'accounts') { + dispatch(submitSearch('accounts')); + } + + this.setState({ type: 'accounts' }); + }; + + handleSelectHashtags = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for something else (but not everything), + // we need to resubmit the query for this specific type + if (submittedType !== 'hashtags') { + dispatch(submitSearch('hashtags')); + } + + this.setState({ type: 'hashtags' }); + } + + handleSelectStatuses = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for something else (but not everything), + // we need to resubmit the query for this specific type + if (submittedType !== 'statuses') { + dispatch(submitSearch('statuses')); + } + + this.setState({ type: 'statuses' }); + } + + handleLoadMoreAccounts = () => this._loadMore('accounts'); + handleLoadMoreStatuses = () => this._loadMore('statuses'); + handleLoadMoreHashtags = () => this._loadMore('hashtags'); + + _loadMore (type) { const { dispatch } = this.props; dispatch(expandSearch(type)); } + handleLoadMore = () => { + const { type } = this.state; + + if (type !== 'all') { + this._loadMore(type); + } + }; + render () { const { intl, isLoading, q, results } = this.props; const { type } = this.state; - let filteredResults = ImmutableList(); + // We request 1 more result than we display so we can tell if there'd be a next page + const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false; + + let filteredResults; if (!isLoading) { + const accounts = results.get('accounts', ImmutableList()); + const hashtags = results.get('hashtags', ImmutableList()); + const statuses = results.get('statuses', ImmutableList()); + switch(type) { case 'all': - filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses)); + filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? ( + <> + {accounts.size > 0 && ( + } onClickMore={this.handleLoadMoreAccounts}> + {accounts.take(INITIAL_DISPLAY).map(id => )} + + )} + + {hashtags.size > 0 && ( + } onClickMore={this.handleLoadMoreHashtags}> + {hashtags.take(INITIAL_DISPLAY).map(hashtag => )} + + )} + + {statuses.size > 0 && ( + } onClickMore={this.handleLoadMoreStatuses}> + {statuses.take(INITIAL_DISPLAY).map(id => )} + + )} + + ) : []; break; case 'accounts': - filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts)); + filteredResults = renderAccounts(accounts); break; case 'hashtags': - filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags)); + filteredResults = renderHashtags(hashtags); break; case 'statuses': - filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses)); + filteredResults = renderStatuses(statuses); break; } - - if (filteredResults.size === 0) { - filteredResults = ( -
- -
- ); - } } return ( @@ -115,7 +207,16 @@ class Results extends PureComponent {
- {isLoading ? : filteredResults} + } + bindToDocument + > + {filteredResults} +
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 13cddba72..2a99e8ebf 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -600,10 +600,9 @@ "search_results.all": "All", "search_results.hashtags": "Hashtags", "search_results.nothing_found": "Could not find anything for these search terms", + "search_results.see_all": "See all", "search_results.statuses": "Posts", - "search_results.statuses_fts_disabled": "Searching posts by their content is not enabled on this Mastodon server.", "search_results.title": "Search for {q}", - "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)", "server_banner.active_users": "active users", "server_banner.administered_by": "Administered by:", @@ -675,8 +674,6 @@ "subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.", "subscribed_languages.save": "Save changes", "subscribed_languages.target": "Change subscribed languages for {target}", - "suggestions.dismiss": "Dismiss suggestion", - "suggestions.header": "You might be interested in…", "tabs_bar.home": "Home", "tabs_bar.notifications": "Notifications", "time_remaining.days": "{number, plural, one {# day} other {# days}} left", diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js index ccef31403..c81d7ff3c 100644 --- a/app/javascript/mastodon/reducers/search.js +++ b/app/javascript/mastodon/reducers/search.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { COMPOSE_MENTION, @@ -12,6 +12,7 @@ import { SEARCH_FETCH_FAIL, SEARCH_FETCH_SUCCESS, SEARCH_SHOW, + SEARCH_EXPAND_REQUEST, SEARCH_EXPAND_SUCCESS, SEARCH_RESULT_CLICK, SEARCH_RESULT_FORGET, @@ -24,6 +25,7 @@ const initialState = ImmutableMap({ results: ImmutableMap(), isLoading: false, searchTerm: '', + type: null, recent: ImmutableOrderedSet(), }); @@ -37,6 +39,8 @@ export default function search(state = initialState, action) { map.set('results', ImmutableMap()); map.set('submitted', false); map.set('hidden', false); + map.set('searchTerm', ''); + map.set('type', null); }); case SEARCH_SHOW: return state.set('hidden', false); @@ -48,23 +52,27 @@ export default function search(state = initialState, action) { return state.withMutations(map => { map.set('isLoading', true); map.set('submitted', true); + map.set('type', action.searchType); }); case SEARCH_FETCH_FAIL: return state.set('isLoading', false); case SEARCH_FETCH_SUCCESS: return state.withMutations(map => { map.set('results', ImmutableMap({ - accounts: ImmutableList(action.results.accounts.map(item => item.id)), - statuses: ImmutableList(action.results.statuses.map(item => item.id)), - hashtags: fromJS(action.results.hashtags), + accounts: ImmutableOrderedSet(action.results.accounts.map(item => item.id)), + statuses: ImmutableOrderedSet(action.results.statuses.map(item => item.id)), + hashtags: ImmutableOrderedSet(fromJS(action.results.hashtags)), })); map.set('searchTerm', action.searchTerm); + map.set('type', action.searchType); map.set('isLoading', false); }); + case SEARCH_EXPAND_REQUEST: + return state.set('type', action.searchType); case SEARCH_EXPAND_SUCCESS: - const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id); - return state.updateIn(['results', action.searchType], list => list.concat(results)); + const results = action.searchType === 'hashtags' ? ImmutableOrderedSet(fromJS(action.results.hashtags)) : action.results[action.searchType].map(item => item.id); + return state.updateIn(['results', action.searchType], list => list.union(results)); case SEARCH_RESULT_CLICK: return state.update('recent', set => set.add(fromJS(action.result))); case SEARCH_RESULT_FORGET: diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 34c1594e8..a9d2dac80 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5172,22 +5172,39 @@ a.status-card { } .search-results__section { - margin-bottom: 5px; + border-bottom: 1px solid lighten($ui-base-color, 8%); - h5 { + &:last-child { + border-bottom: 0; + } + + &__header { background: darken($ui-base-color, 4%); border-bottom: 1px solid lighten($ui-base-color, 8%); - cursor: default; - display: flex; padding: 15px; font-weight: 500; - font-size: 16px; - color: $dark-text-color; + font-size: 14px; + color: $darker-text-color; + display: flex; + justify-content: space-between; - .fa { - display: inline-block; + h3 .fa { margin-inline-end: 5px; } + + button { + color: $highlight-text-color; + padding: 0; + border: 0; + background: 0; + font: inherit; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } } .account:last-child, @@ -6815,14 +6832,14 @@ a.status-card { .notification__filter-bar, .account__section-headline { - background: darken($ui-base-color, 4%); + background: $ui-base-color; border-bottom: 1px solid lighten($ui-base-color, 8%); cursor: default; display: flex; flex-shrink: 0; button { - background: darken($ui-base-color, 4%); + background: transparent; border: 0; margin: 0; } @@ -6842,26 +6859,18 @@ a.status-card { white-space: nowrap; &.active { - color: $secondary-text-color; + color: $primary-text-color; - &::before, - &::after { + &::before { display: block; content: ''; position: absolute; - bottom: 0; - left: 50%; - width: 0; - height: 0; - transform: translateX(-50%); - border-style: solid; - border-width: 0 10px 10px; - border-color: transparent transparent lighten($ui-base-color, 8%); - } - - &::after { bottom: -1px; - border-color: transparent transparent $ui-base-color; + left: 0; + width: 100%; + height: 3px; + border-radius: 4px; + background: $highlight-text-color; } } } From 9d290c23d2665a434c3f78eb5f6a6e2b71a722bd Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 5 Sep 2023 23:57:03 +0200 Subject: [PATCH 06/62] Remove obfuscation of reply count in web UI (#26768) --- .../mastodon/components/animated_number.tsx | 25 +++---------------- .../mastodon/components/icon_button.tsx | 4 +-- .../mastodon/components/status_action_bar.jsx | 2 +- .../picture_in_picture/components/footer.jsx | 2 +- 4 files changed, 6 insertions(+), 27 deletions(-) diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx index 05a7e0189..e98e30b24 100644 --- a/app/javascript/mastodon/components/animated_number.tsx +++ b/app/javascript/mastodon/components/animated_number.tsx @@ -6,21 +6,10 @@ import { reduceMotion } from '../initial_state'; import { ShortNumber } from './short_number'; -const obfuscatedCount = (count: number) => { - if (count < 0) { - return 0; - } else if (count <= 1) { - return count; - } else { - return '1+'; - } -}; - interface Props { value: number; - obfuscate?: boolean; } -export const AnimatedNumber: React.FC = ({ value, obfuscate }) => { +export const AnimatedNumber: React.FC = ({ value }) => { const [previousValue, setPreviousValue] = useState(value); const [direction, setDirection] = useState<1 | -1>(1); @@ -36,11 +25,7 @@ export const AnimatedNumber: React.FC = ({ value, obfuscate }) => { ); if (reduceMotion) { - return obfuscate ? ( - <>{obfuscatedCount(value)} - ) : ( - - ); + return ; } const styles = [ @@ -67,11 +52,7 @@ export const AnimatedNumber: React.FC = ({ value, obfuscate }) => { transform: `translateY(${style.y * 100}%)`, }} > - {obfuscate ? ( - obfuscatedCount(data as number) - ) : ( - - )} + ))} diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx index 9dbee2cc2..da6f19e9e 100644 --- a/app/javascript/mastodon/components/icon_button.tsx +++ b/app/javascript/mastodon/components/icon_button.tsx @@ -24,7 +24,6 @@ interface Props { overlay: boolean; tabIndex: number; counter?: number; - obfuscateCount?: boolean; href?: string; ariaHidden: boolean; } @@ -105,7 +104,6 @@ export class IconButton extends PureComponent { tabIndex, title, counter, - obfuscateCount, href, ariaHidden, } = this.props; @@ -131,7 +129,7 @@ export class IconButton extends PureComponent {