diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a232915b6f..2271802ca9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -50,6 +50,10 @@ You can contribute in the following ways:
 
 If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).
 
+## API Changes and Additions
+
+Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation).
+
 ## Bug reports
 
 Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/mastodon/mastodon/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected.
diff --git a/Gemfile.lock b/Gemfile.lock
index 7a7fdb01c4..405c31ae1a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -118,7 +118,7 @@ GEM
       minitest (>= 5.1)
       mutex_m
       tzinfo (~> 2.0)
-    addressable (2.8.5)
+    addressable (2.8.6)
       public_suffix (>= 2.0.2, < 6.0)
     aes_key_wrap (1.1.0)
     android_key_attestation (0.3.0)
@@ -220,9 +220,9 @@ GEM
       database_cleaner-core (~> 2.0.0)
     database_cleaner-core (2.0.1)
     date (3.3.4)
-    debug (1.8.0)
-      irb (>= 1.5.0)
-      reline (>= 0.3.1)
+    debug (1.9.0)
+      irb (~> 1.10)
+      reline (>= 0.3.8)
     debug_inspector (1.1.0)
     devise (4.9.3)
       bcrypt (~> 3.0)
@@ -484,8 +484,8 @@ GEM
     nokogiri (1.15.5)
       mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
-    oj (3.16.2)
-      bigdecimal (~> 3.1)
+    oj (3.16.3)
+      bigdecimal (>= 3.0)
     omniauth (2.1.1)
       hashie (>= 3.4.6)
       rack (>= 2.2.3)
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index fd54301b9f..2b22f2f75b 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -114,7 +114,7 @@ module ApplicationHelper
   end
 
   def fa_icon(icon, attributes = {})
-    class_names = attributes[:class]&.split(' ') || []
+    class_names = attributes[:class]&.split || []
     class_names << 'fa'
     class_names += icon.split.map { |cl| "fa-#{cl}" }
 
diff --git a/app/javascript/mastodon/actions/notifications_typed.ts b/app/javascript/mastodon/actions/notifications_typed.ts
index 7e51fa51e7..176362f4b1 100644
--- a/app/javascript/mastodon/actions/notifications_typed.ts
+++ b/app/javascript/mastodon/actions/notifications_typed.ts
@@ -18,6 +18,6 @@ export const notificationsUpdate = createAction(
     playSound: boolean;
   }) => ({
     payload: args,
-    meta: { playSound: playSound ? { sound: 'boop' } : undefined },
+    meta: { sound: playSound ? 'boop' : undefined },
   }),
 );
diff --git a/app/javascript/mastodon/locales/en-GB.json b/app/javascript/mastodon/locales/en-GB.json
index 7745311be5..1a67fecb60 100644
--- a/app/javascript/mastodon/locales/en-GB.json
+++ b/app/javascript/mastodon/locales/en-GB.json
@@ -21,6 +21,7 @@
   "account.blocked": "Blocked",
   "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow",
+  "account.copy": "Copy link to profile",
   "account.direct": "Privately mention @{name}",
   "account.disable_notifications": "Stop notifying me when @{name} posts",
   "account.domain_blocked": "Domain blocked",
@@ -191,6 +192,7 @@
   "conversation.mark_as_read": "Mark as read",
   "conversation.open": "View conversation",
   "conversation.with": "With {names}",
+  "copy_icon_button.copied": "Copied to clipboard",
   "copypaste.copied": "Copied",
   "copypaste.copy_to_clipboard": "Copy to clipboard",
   "directory.federated": "From known fediverse",
@@ -222,6 +224,7 @@
   "emoji_button.search_results": "Search results",
   "emoji_button.symbols": "Symbols",
   "emoji_button.travel": "Travel & Places",
+  "empty_column.account_hides_collections": "This user has chosen to not make this information available",
   "empty_column.account_suspended": "Account suspended",
   "empty_column.account_timeline": "No posts here!",
   "empty_column.account_unavailable": "Profile unavailable",
@@ -478,6 +481,8 @@
   "onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.",
   "onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!",
   "onboarding.follows.title": "Personalize your home feed",
+  "onboarding.profile.discoverable": "Make my profile discoverable",
+  "onboarding.profile.discoverable_hint": "When you opt in to discoverability on Mastodon, your posts may appear in search results and trending, and your profile may be suggested to people with similar interests to you.",
   "onboarding.profile.display_name": "Display name",
   "onboarding.profile.display_name_hint": "Your full name or your fun name…",
   "onboarding.profile.lead": "You can always complete this later in the settings, where even more customisation options are available.",
@@ -530,6 +535,7 @@
   "privacy.unlisted.short": "Unlisted",
   "privacy_policy.last_updated": "Last updated {date}",
   "privacy_policy.title": "Privacy Policy",
+  "recommended": "Recommended",
   "refresh": "Refresh",
   "regeneration_indicator.label": "Loading…",
   "regeneration_indicator.sublabel": "Your home feed is being prepared!",
@@ -600,6 +606,7 @@
   "search.quick_action.status_search": "Posts matching {x}",
   "search.search_or_paste": "Search or paste URL",
   "search_popout.full_text_search_disabled_message": "Unavailable on {domain}.",
+  "search_popout.full_text_search_logged_out_message": "Only available when logged in.",
   "search_popout.language_code": "ISO language code",
   "search_popout.options": "Search options",
   "search_popout.quick_actions": "Quick actions",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 537b8d0af1..2678d83a50 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -21,6 +21,7 @@
   "account.blocked": "Blokita",
   "account.browse_more_on_origin_server": "Foliumi pli ĉe la originala profilo",
   "account.cancel_follow_request": "Nuligi peton por sekvado",
+  "account.copy": "Kopii ligilon al profilo",
   "account.direct": "Private mencii @{name}",
   "account.disable_notifications": "Ne plu sciigi min, kiam @{name} mesaĝas",
   "account.domain_blocked": "Domajno blokita",
@@ -191,6 +192,7 @@
   "conversation.mark_as_read": "Marki legita",
   "conversation.open": "Vidi konversacion",
   "conversation.with": "Kun {names}",
+  "copy_icon_button.copied": "Kopiis al kliptabulo",
   "copypaste.copied": "Kopiita",
   "copypaste.copy_to_clipboard": "Kopii al dosierujo",
   "directory.federated": "El konata fediverso",
@@ -202,7 +204,9 @@
   "dismissable_banner.community_timeline": "Jen la plej novaj publikaj afiŝoj de uzantoj, kies kontojn gastigas {domain}.",
   "dismissable_banner.dismiss": "Eksigi",
   "dismissable_banner.explore_links": "Tiuj novaĵoj estas aktuale priparolataj de uzantoj en tiu ĉi kaj aliaj serviloj, sur la malcentrigita reto.",
+  "dismissable_banner.explore_statuses": "Ĉi tioj estas afiŝoj de socia reto kiu populariĝas hodiau.",
   "dismissable_banner.explore_tags": "Ĉi tiuj kradvostoj populariĝas en ĉi tiu kaj aliaj serviloj en la malcentraliza reto nun.",
+  "dismissable_banner.public_timeline": "Ĉi tioj estas plej lastaj publikaj afiŝoj de personoj ĉe socia reto kiu personoj ĉe {domain} sekvas.",
   "embed.instructions": "Enkorpigu ĉi tiun afiŝon en vian retejon per kopio de la suba kodo.",
   "embed.preview": "Ĝi aperos tiel:",
   "emoji_button.activity": "Agadoj",
@@ -220,6 +224,7 @@
   "emoji_button.search_results": "Serĉaj rezultoj",
   "emoji_button.symbols": "Simboloj",
   "emoji_button.travel": "Vojaĝoj kaj lokoj",
+  "empty_column.account_hides_collections": "Ĉi tiu uzanto elektis ne disponebligi ĉi tiu informon",
   "empty_column.account_suspended": "Konto suspendita",
   "empty_column.account_timeline": "Neniu afiŝo ĉi tie!",
   "empty_column.account_unavailable": "Profilo ne disponebla",
@@ -229,6 +234,8 @@
   "empty_column.direct": "Vi ankoraŭ ne havas privatan mencion. Kiam vi sendos aŭ ricevos iun, tiu aperos ĉi tie.",
   "empty_column.domain_blocks": "Ankoraŭ neniu domajno estas blokita.",
   "empty_column.explore_statuses": "Nenio tendencas nun. Rekontrolu poste!",
+  "empty_column.favourited_statuses": "Vi ankoraŭ ne havas stelumitan afiŝon.",
+  "empty_column.favourites": "Ankoraŭ neniu stelumis tiun afiŝon.",
   "empty_column.follow_requests": "Vi ne ankoraŭ havas iun peton de sekvado. Kiam vi ricevos unu, ĝi aperos ĉi tie.",
   "empty_column.followed_tags": "Vi ankoraŭ ne sekvas iujn kradvortojn. Kiam vi faras, ili aperos ĉi tie.",
   "empty_column.hashtag": "Ankoraŭ estas nenio per ĉi tiu kradvorto.",
@@ -292,19 +299,36 @@
   "hashtag.column_settings.tag_mode.any": "Iu ajn",
   "hashtag.column_settings.tag_mode.none": "Neniu",
   "hashtag.column_settings.tag_toggle": "Aldoni pliajn etikedojn por ĉi tiu kolumno",
+  "hashtag.counter_by_accounts": "{count, plural,one {{counter} partoprenanto} other {{counter} partoprenantoj}}",
+  "hashtag.counter_by_uses": "{count, plural,one {{counter} afiŝo} other {{counter} afiŝoj}}",
+  "hashtag.counter_by_uses_today": "{count, plural,one {{counter} afiŝo} other {{counter} afiŝoj}} hodiau",
   "hashtag.follow": "Sekvi la kradvorton",
   "hashtag.unfollow": "Ne plu sekvi la kradvorton",
+  "hashtags.and_other": "…kaj {count, plural,other {# pli}}",
+  "home.actions.go_to_explore": "Vidi kio populariĝas",
   "home.actions.go_to_suggestions": "Trovi homojn por sekvi",
   "home.column_settings.basic": "Bazaj agordoj",
   "home.column_settings.show_reblogs": "Montri diskonigojn",
   "home.column_settings.show_replies": "Montri respondojn",
+  "home.explore_prompt.body": "Via hejmafiŝaro havos miksitajn afiŝojn de kradvortoj kiujn vi elektis sekvi, personoj kiujn vi elektis sekvi, kaj afiŝoj kiujn ili suprenigis.",
+  "home.explore_prompt.title": "Ĉi tio estas via hejma paĝo en Mastodon.",
   "home.hide_announcements": "Kaŝi la anoncojn",
+  "home.pending_critical_update.body": "Ĝisdatigu vian servilon de Mastodon kiel eble plej baldau!",
+  "home.pending_critical_update.link": "Vidi ĝisdatigojn",
+  "home.pending_critical_update.title": "Kritika sekurĝisdatigo estas disponebla!",
   "home.show_announcements": "Montri anoncojn",
+  "interaction_modal.description.favourite": "Per konto ĉe Mastodon, vi povas stelumiti ĉi tiun afiŝon por sciigi la afiŝanton ke vi aprezigas ŝin kaj konservas por la estonteco.",
   "interaction_modal.description.follow": "Kun konto ĉe Mastodon, vi povos sekvi {name} por vidi ties mesaĝojn en via hejmo.",
   "interaction_modal.description.reblog": "Kun konto ĉe Mastodon, vi povas diskonigi ĉi tiun afiŝon, por ke viaj propraj sekvantoj vidu ĝin.",
   "interaction_modal.description.reply": "Kun konto ĉe Mastodon, vi povos respondi al ĉi tiu mesaĝo.",
+  "interaction_modal.login.action": "Prenu min hejmen",
+  "interaction_modal.login.prompt": "Domajno de via hejma servilo, ekz. mastodon.social",
+  "interaction_modal.no_account_yet": "Ĉu ne estas ĉe Mastodon?",
   "interaction_modal.on_another_server": "En alia servilo",
   "interaction_modal.on_this_server": "En ĉi tiu servilo",
+  "interaction_modal.sign_in": "Vi ne estas ensalutita al ĉi tiu servilo.",
+  "interaction_modal.sign_in_hint": "Gvideto: Tio estas la retejo kie vi registris. Vi ankau povas tajpi vian plenan uzantonomon!",
+  "interaction_modal.title.favourite": "Stelumi la afiŝon de {name}",
   "interaction_modal.title.follow": "Sekvi {name}",
   "interaction_modal.title.reblog": "Akceli la afiŝon de {name}",
   "interaction_modal.title.reply": "Respondi al la afiŝo de {name}",
@@ -320,6 +344,8 @@
   "keyboard_shortcuts.direct": "por malfermi la kolumnon pri privataj mencioj",
   "keyboard_shortcuts.down": "iri suben en la listo",
   "keyboard_shortcuts.enter": "malfermi mesaĝon",
+  "keyboard_shortcuts.favourite": "Stelumi afiŝon",
+  "keyboard_shortcuts.favourites": "Malfermi la liston de la stelumoj",
   "keyboard_shortcuts.federated": "Malfermi la frataran templinion",
   "keyboard_shortcuts.heading": "Klavaraj mallongigoj",
   "keyboard_shortcuts.home": "Malfermi la hejman templinion",
@@ -366,6 +392,7 @@
   "lists.search": "Serĉi inter la homoj, kiujn vi sekvas",
   "lists.subheading": "Viaj listoj",
   "load_pending": "{count,plural, one {# nova elemento} other {# novaj elementoj}}",
+  "loading_indicator.label": "Ŝargado…",
   "media_gallery.toggle_visible": "{number, plural, one {Kaŝi la bildon} other {Kaŝi la bildojn}}",
   "moved_to_account_banner.text": "Via konto {disabledAccount} estas malvalidigita ĉar vi movis ĝin al {movedToAccount}.",
   "mute_modal.duration": "Daŭro",
@@ -382,6 +409,7 @@
   "navigation_bar.domain_blocks": "Blokitaj domajnoj",
   "navigation_bar.edit_profile": "Redakti profilon",
   "navigation_bar.explore": "Esplori",
+  "navigation_bar.favourites": "Stelumoj",
   "navigation_bar.filters": "Silentigitaj vortoj",
   "navigation_bar.follow_requests": "Petoj de sekvado",
   "navigation_bar.followed_tags": "Sekvataj kradvortoj",
@@ -389,6 +417,7 @@
   "navigation_bar.lists": "Listoj",
   "navigation_bar.logout": "Adiaŭi",
   "navigation_bar.mutes": "Silentigitaj uzantoj",
+  "navigation_bar.opened_in_classic_interface": "Afiŝoj, kontoj, kaj aliaj specifaj paĝoj kiuj estas malfermititaj defaulta en la klasika reta interfaco.",
   "navigation_bar.personal": "Persone",
   "navigation_bar.pins": "Alpinglitaj mesaĝoj",
   "navigation_bar.preferences": "Preferoj",
@@ -398,6 +427,7 @@
   "not_signed_in_indicator.not_signed_in": "Necesas saluti por aliri tiun rimedon.",
   "notification.admin.report": "{name} raportis {target}",
   "notification.admin.sign_up": "{name} kreis konton",
+  "notification.favourite": "{name} stelumis vian afiŝon",
   "notification.follow": "{name} eksekvis vin",
   "notification.follow_request": "{name} petis sekvi vin",
   "notification.mention": "{name} menciis vin",
@@ -411,6 +441,7 @@
   "notifications.column_settings.admin.report": "Novaj raportoj:",
   "notifications.column_settings.admin.sign_up": "Novaj registriĝoj:",
   "notifications.column_settings.alert": "Sciigoj de la retumilo",
+  "notifications.column_settings.favourite": "Stelumoj:",
   "notifications.column_settings.filter_bar.advanced": "Montri ĉiujn kategoriojn",
   "notifications.column_settings.filter_bar.category": "Rapida filtra breto",
   "notifications.column_settings.filter_bar.show_bar": "Montri la breton de filtrilo",
@@ -428,6 +459,7 @@
   "notifications.column_settings.update": "Redaktoj:",
   "notifications.filter.all": "Ĉiuj",
   "notifications.filter.boosts": "Diskonigoj",
+  "notifications.filter.favourites": "Stelumoj",
   "notifications.filter.follows": "Sekvoj",
   "notifications.filter.mentions": "Mencioj",
   "notifications.filter.polls": "Balotenketaj rezultoj",
@@ -441,14 +473,29 @@
   "notifications_permission_banner.enable": "Ŝalti retumilajn sciigojn",
   "notifications_permission_banner.how_to_control": "Por ricevi sciigojn kiam Mastodon ne estas malfermita, ebligu labortablajn sciigojn. Vi povas regi precize kiuj specoj de interagoj generas labortablajn sciigojn per la supra butono {icon} post kiam ili estas ebligitaj.",
   "notifications_permission_banner.title": "Neniam preterlasas iun ajn",
+  "onboarding.action.back": "Prenu min reen",
+  "onboarding.actions.back": "Prenu min reen",
   "onboarding.actions.go_to_explore": "See what's trending",
   "onboarding.actions.go_to_home": "Go to your home feed",
   "onboarding.compose.template": "Saluton #Mastodon!",
   "onboarding.follows.empty": "Bedaŭrinde, neniu rezulto estas montrebla nuntempe. Vi povas provi serĉi aŭ foliumi la esploran paĝon por trovi kontojn por sekvi, aŭ retrovi baldaŭ.",
   "onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!",
   "onboarding.follows.title": "Popular on Mastodon",
+  "onboarding.profile.discoverable": "Trovebligi mian profilon",
+  "onboarding.profile.discoverable_hint": "Kiam vi aliĝi al trovebleco ĉe Mastodon, viaj afiŝoj eble aperos en serĉaj rezultoj kaj populariĝoj, kaj via profilo eble estas sugestota al personoj kun similaj intereseoj al vi.",
+  "onboarding.profile.display_name": "Publika nomo",
+  "onboarding.profile.display_name_hint": "Via plena nomo aŭ via kromnomo…",
+  "onboarding.profile.lead": "Vi ĉiam povas plenigi ĉi tion poste en la agordoj, kie eĉ pli da personecigagordoj estas disponeblaj.",
+  "onboarding.profile.note": "Sinprezento",
+  "onboarding.profile.note_hint": "Vi povas @mencii aliajn homojn aŭ #kradvortojn…",
   "onboarding.profile.save_and_continue": "Konservi kaj daŭrigi",
+  "onboarding.profile.title": "Profila fikso",
+  "onboarding.profile.upload_avatar": "Alŝuti profilbildon",
+  "onboarding.profile.upload_header": "Alŝuti profilkapbildon",
+  "onboarding.share.lead": "Sciigi personojn pri kiel ili povas trovi vin ĉe Mastodon!",
   "onboarding.share.message": "Mi estas {username} en #Mastodon! Sekvu min ĉe {url}",
+  "onboarding.share.next_steps": "Eblaj malantauaj paŝoj:",
+  "onboarding.share.title": "Disvastigi vian profilon",
   "onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:",
   "onboarding.start.skip": "Want to skip right ahead?",
   "onboarding.start.title": "Vi atingas ĝin!",
@@ -460,6 +507,9 @@
   "onboarding.steps.setup_profile.title": "Customize your profile",
   "onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon!",
   "onboarding.steps.share_profile.title": "Share your profile",
+  "onboarding.tips.2fa": "<strong>Ĉu vi scias?</strong> Vi povas sekurigi vian konton per efektivigi dufaktora autentigo en via kontoagordoj.",
+  "onboarding.tips.accounts_from_other_servers": "<strong>Ĉu vi scias?</strong> Ĉar Mastodon estas sencentra, kelkaj profiloj kiujn vi trovi estas gastigitaj ĉe aliaj serviloj kiuj ne estas via.",
+  "onboarding.tips.migration": "<strong>Ĉu vi scias?</strong> Se vi sentas ke {domain} ne estas bona servilelekto por vi en la estonteco, vi povas translokiĝi al alia servilo de Mastodon sen malgajni viajn sekvantojn.",
   "password_confirmation.mismatching": "Pasvorto konfirmo ne kongruas",
   "picture_in_picture.restore": "Remetu ĝin",
   "poll.closed": "Finita",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 03b656cc47..7db4bf7bc4 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -702,7 +702,7 @@
   "timeline_hint.resources.followers": "Les abonnés",
   "timeline_hint.resources.follows": "Les abonnements",
   "timeline_hint.resources.statuses": "Messages plus anciens",
-  "trends.counter_by_accounts": "{count, plural, one {{counter} personne} other {{counter} personnes}} au cours {days, plural, one {des dernières 24h} other {des {days} derniers jours}}",
+  "trends.counter_by_accounts": "{count, plural, one {{counter} pers.} other {{counter} pers.}} sur {days, plural, one {les dernières 24h} other {les {days} derniers jours}}",
   "trends.trending_now": "Tendance en ce moment",
   "ui.beforeunload": "Votre brouillon sera perdu si vous quittez Mastodon.",
   "units.short.billion": "{count}Md",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index d890e4188a..386b15811a 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -479,7 +479,7 @@
   "onboarding.actions.go_to_home": "Ugrás a saját hírfolyamra",
   "onboarding.compose.template": "Üdvözlet, #Mastodon!",
   "onboarding.follows.empty": "Sajnos jelenleg nem jeleníthető meg eredmény. Kipróbálhatod a keresést vagy böngészheted a felfedező oldalon a követni kívánt személyeket, vagy próbáld meg később.",
-  "onboarding.follows.lead": "A saját hírfolyamod az elsődleges tapasztalás a Mastodonon. Minél több embert követsz, annál aktívabb és érdekesebb a dolog. Az induláshoz itt van néhány javaslat:",
+  "onboarding.follows.lead": "A kezdőlapod a Mastodon használatának elsődleges módja. Minél több embert követsz, annál aktívabbak és érdekesebbek lesznek a dolgok. Az induláshoz itt van néhány javaslat:",
   "onboarding.follows.title": "Szabd személyre a kezdőlapodat",
   "onboarding.profile.discoverable": "Saját profil beállítása felfedezhetőként",
   "onboarding.profile.discoverable_hint": "A Mastodonon a felfedezhetőség választása esetén a saját bejegyzéseid megjelenhetnek a keresési eredmények és a felkapott tartalmak között, valamint a profilod a hozzád hasonló érdeklődési körrel rendelkező embereknél is ajánlásra kerülhet.",
@@ -720,7 +720,7 @@
   "upload_form.undo": "Törlés",
   "upload_form.video_description": "Leírás siket, hallássérült, vak vagy gyengénlátó emberek számára",
   "upload_modal.analyzing_picture": "Kép elemzése…",
-  "upload_modal.apply": "Alkalmazás",
+  "upload_modal.apply": "Alkalmaz",
   "upload_modal.applying": "Alkalmazás…",
   "upload_modal.choose_image": "Kép kiválasztása",
   "upload_modal.description_placeholder": "A gyors, barna róka átugrik a lusta kutya fölött",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 5b76cd67c5..4606916c1d 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -239,7 +239,7 @@
   "empty_column.follow_requests": "아직 팔로우 요청이 없습니다. 요청을 받았을 때 여기에 나타납니다.",
   "empty_column.followed_tags": "아직 아무 해시태그도 팔로우하고 있지 않습니다. 해시태그를 팔로우하면, 여기에 표시됩니다.",
   "empty_column.hashtag": "이 해시태그는 아직 사용되지 않았습니다.",
-  "empty_column.home": "당신의 홈 타임라인은 비어있습니다! 더 많은 사람들을 팔로우 하여 채워보세요. {suggestions}",
+  "empty_column.home": "당신의 홈 타임라인은 비어있습니다! 더 많은 사람을 팔로우하여 채워보세요. {suggestions}",
   "empty_column.list": "리스트에 아직 아무것도 없습니다. 리스트의 누군가가 게시물을 올리면 여기에 나타납니다.",
   "empty_column.lists": "아직 리스트가 없습니다. 리스트를 만들면 여기에 나타납니다.",
   "empty_column.mutes": "아직 아무도 뮤트하지 않았습니다.",
diff --git a/app/javascript/mastodon/locales/la.json b/app/javascript/mastodon/locales/la.json
index e4bd9365a8..3e5747ba8b 100644
--- a/app/javascript/mastodon/locales/la.json
+++ b/app/javascript/mastodon/locales/la.json
@@ -1,6 +1,6 @@
 {
   "about.contact": "Ratio:",
-  "about.domain_blocks.no_reason_available": "ratio abdere est",
+  "about.domain_blocks.no_reason_available": "Ratio abdere est",
   "account.account_note_header": "Annotatio",
   "account.badges.bot": "Robotum",
   "account.badges.group": "Congregatio",
@@ -49,7 +49,7 @@
   "dismissable_banner.explore_links": "These news stories are being talked about by people on this and other servers of the decentralized network right now.",
   "dismissable_banner.explore_tags": "These hashtags are gaining traction among people on this and other servers of the decentralized network right now.",
   "embed.instructions": "Embed this status on your website by copying the code below.",
-  "emoji_button.food": "cibus et potus",
+  "emoji_button.food": "Cibus et potus",
   "emoji_button.people": "Homines",
   "emoji_button.search": "Quaerere...",
   "empty_column.account_timeline": "Hic nulla contributa!",
@@ -57,13 +57,13 @@
   "empty_column.home": "Your home timeline is empty! Follow more people to fill it up. {suggestions}",
   "empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
   "explore.trending_statuses": "Contributa",
-  "generic.saved": "servavit",
+  "generic.saved": "Servavit",
   "hashtag.column_settings.tag_toggle": "Include additional tags in this column",
-  "keyboard_shortcuts.back": "to navigate back",
-  "keyboard_shortcuts.blocked": "to open blocked users list",
-  "keyboard_shortcuts.boost": "to boost",
-  "keyboard_shortcuts.column": "to focus a status in one of the columns",
-  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.back": "Re navigare",
+  "keyboard_shortcuts.blocked": "Aperire listam usorum obstructorum",
+  "keyboard_shortcuts.boost": "Inlustrare publicatio",
+  "keyboard_shortcuts.column": "Columnam dirigere",
+  "keyboard_shortcuts.compose": "TextArea Compositi Attendere",
   "keyboard_shortcuts.description": "Descriptio",
   "keyboard_shortcuts.direct": "to open direct messages column",
   "keyboard_shortcuts.down": "to move down in the list",
diff --git a/app/javascript/mastodon/locales/my.json b/app/javascript/mastodon/locales/my.json
index 4078a4c066..917419d172 100644
--- a/app/javascript/mastodon/locales/my.json
+++ b/app/javascript/mastodon/locales/my.json
@@ -21,6 +21,7 @@
   "account.blocked": "ဘလော့ထားသည်",
   "account.browse_more_on_origin_server": "မူရင်းပရိုဖိုင်တွင် ပိုမိုကြည့်ရှုပါ။",
   "account.cancel_follow_request": "စောင့်ကြည့်မှု ပယ်ဖျက်ခြင်း",
+  "account.copy": "လင့်ခ်ကို ပရိုဖိုင်သို့ ကူးယူပါ",
   "account.direct": "@{name} သီးသန့် သိရှိနိုင်အောင် မန်းရှင်းခေါ်မည်",
   "account.disable_notifications": "@{name} ပို့စ်တင်သည့်အခါ ကျွန်ုပ်ထံ အသိပေးခြင်း မပြုလုပ်ရန်။",
   "account.domain_blocked": "ဒိုမိန်း ပိတ်ပင်ထားခဲ့သည်",
@@ -191,6 +192,7 @@
   "conversation.mark_as_read": "ဖတ်ပြီးသားအဖြစ်မှတ်ထားပါ",
   "conversation.open": "Conversation ကိုကြည့်မည်",
   "conversation.with": "{အမည်များ} ဖြင့်",
+  "copy_icon_button.copied": "ကလစ်ဘုတ်သို့ ကူးပါ",
   "copypaste.copied": "ကူယူပြီးပါပြီ",
   "copypaste.copy_to_clipboard": "ကလစ်ဘုတ်သို့ ကူးပါ",
   "directory.federated": "သင် သိသော ဖက်ဒီမှ",
@@ -389,6 +391,7 @@
   "lists.search": "မိမိဖောလိုးထားသူများမှရှာဖွေမည်",
   "lists.subheading": "သင့်၏စာရင်းများ",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
+  "loading_indicator.label": "လုပ်ဆောင်နေသည်…",
   "media_gallery.toggle_visible": "{number, plural, one {Hide image} other {Hide images}}",
   "moved_to_account_banner.text": "{movedToAccount} အကောင့်သို့ပြောင်းလဲထားသဖြင့် {disabledAccount} အကောင့်မှာပိတ်ထားသည်",
   "mute_modal.duration": "ကြာချိန်",
@@ -477,6 +480,13 @@
   "onboarding.follows.empty": "ယခုအချိန် မည်သည့်ရလဒ်ကိုမျှ မပြသနိုင်ပါ။ လူများကိုစောင့်ကြည့်ရန်အတွက် Explore စာမျက်နှာကို အသုံးပြု၍ စမ်းကြည့်နိုင်သည် သို့မဟုတ် နောက်မှ ထပ်စမ်းကြည့်ပါ။",
   "onboarding.follows.lead": "သင့်ကိုယ်ပိုင်ပို့စ်များ တင်နိုင်သည်။ သင်စောင့်ကြည့်သူ များလေလေ၊ စိတ်ဝင်စားစရာကောင်းသောပို့စ်များ တွေ့ရလေဖြစ်သည်။ ဤပရိုဖိုင်များမှာ ကောင်းမွန်သောအစပြုမှုတစ်ခုဖြစ်ပြီး ၎င်းတို့ကိုစောင့်ကြည့်ခြင်းမှလည်း အချိန်မရွေး ပယ်ဖျက်နိုင်ပါသည်။",
   "onboarding.follows.title": "Mastodon တွင် ရေပန်းစားခြင်း",
+  "onboarding.profile.discoverable": "ပရိုဖိုင် ရှာဖွေနိုင်ပါမည်",
+  "onboarding.profile.display_name": "ဖော်ပြမည့်အမည်",
+  "onboarding.profile.display_name_hint": "သင့်အမည်အပြည့်အစုံ သို့မဟုတ် သင့်အမည်ပြောင်။",
+  "onboarding.profile.note": "ကိုယ်ရေးအကျဉ်း",
+  "onboarding.profile.save_and_continue": "သိမ်းပြီး ဆက်လုပ်ပါ",
+  "onboarding.profile.title": "ပရိုဖိုင်စနစ် ထည့်သွင်းခြင်း",
+  "onboarding.profile.upload_avatar": "ပရိုဖိုင်ပုံ အပ်လုဒ်လုပ်ပါ",
   "onboarding.share.lead": "Mastodon တွင် သင့်အား မည်သို့ရှာတွေ့နိုင်သည်ကို အသိပေးပါ။",
   "onboarding.share.message": "Mastodon ရှိ ကျွန်ုပ်၏အမည်မှာ {username} ဖြစ်သည်။ ကျွန်ုပ်ကို {url} တွင် စောင့်ကြည့်နိုင်ပါသည်",
   "onboarding.share.next_steps": "ဖြစ်နိုင်ချေရှိသော နောက်အဆင့်များ -",
@@ -520,6 +530,7 @@
   "privacy.unlisted.short": "စာရင်းမသွင်းထားပါ",
   "privacy_policy.last_updated": "နောက်ဆုံး ပြင်ဆင်ခဲ့သည့်ရက်စွဲ {date}",
   "privacy_policy.title": "ကိုယ်ရေးအချက်အလက်မူဝါဒ",
+  "recommended": "အကြံပြုသည်",
   "refresh": "ပြန်လည်စတင်ပါ",
   "regeneration_indicator.label": "လုပ်ဆောင်နေသည်…",
   "regeneration_indicator.sublabel": "သင့်ပင်မစာမျက်နှာကို ပြင်ဆင်နေပါသည်။",
@@ -590,6 +601,7 @@
   "search.quick_action.status_search": "{x} နှင့် ကိုက်ညီသော ပို့စ်များ",
   "search.search_or_paste": "URL ရိုက်ထည့်ပါ သို့မဟုတ် ရှာဖွေပါ",
   "search_popout.full_text_search_disabled_message": "{domain} တွင် မရနိုင်ပါ။",
+  "search_popout.full_text_search_logged_out_message": "အကောင့်ဝင်ထားမှသာ ရနိုင်သည်။",
   "search_popout.language_code": "ISO ဘာသာစကားကုဒ်",
   "search_popout.options": "ရွေးချယ်ထားသည်များ ရှာဖွေရန်",
   "search_popout.quick_actions": "အမြန်လုပ်ဆောင်မှုများ",
diff --git a/app/javascript/mastodon/locales/ne.json b/app/javascript/mastodon/locales/ne.json
index 0967ef424b..95f8e703c9 100644
--- a/app/javascript/mastodon/locales/ne.json
+++ b/app/javascript/mastodon/locales/ne.json
@@ -1 +1,53 @@
-{}
+{
+  "about.contact": "सम्पर्क:",
+  "about.disclaimer": "Mastodon नि:शुल्क, खुला स्रोत सफ्टवेयर, र Mastodon gGmbH को ट्रेडमार्क हो।",
+  "about.domain_blocks.no_reason_available": "कारण उपलब्ध छैन",
+  "about.domain_blocks.preamble": "Mastodon ले तपाइँलाई सामान्यतया फेडिभर्समा कुनै पनि अन्य सर्भरका सामग्री हेर्न र प्रयोगकर्ताहरूसँग अन्तरक्रिया गर्न दिन्छ। यी अपवादहरू हुन् जुन यस विशेष सर्भरमा बनाइएका छन्।",
+  "about.domain_blocks.silenced.title": "सीमित",
+  "about.domain_blocks.suspended.explanation": "यस सर्भरबाट कुनै पनि डेटा प्रशोधन, भण्डारण वा आदानप्रदान गरिने छैन, जसले यस सर्भरका प्रयोगकर्ताहरूसँग कुनै पनि अन्तरक्रिया वा सञ्चारलाई असम्भव बनाउँछ।",
+  "about.domain_blocks.suspended.title": "निलम्बित",
+  "about.not_available": "यो जानकारी यस सर्भरमा उपलब्ध गराइएको छैन।",
+  "about.powered_by": "{mastodon} द्वारा संचालित विकेन्द्रीकृत सामाजिक मिडिया",
+  "about.rules": "सर्भर नियमहरू",
+  "account.add_or_remove_from_list": "सूचीबाट थप्नुहोस् वा हटाउनुहोस्",
+  "account.badges.group": "समूह",
+  "account.block": "@{name} लाई ब्लक गर्नुहोस्",
+  "account.block_domain": "{domain} डोमेनलाई ब्लक गर्नुहोस्",
+  "account.block_short": "ब्लक",
+  "account.blocked": "ब्लक गरिएको",
+  "account.browse_more_on_origin_server": "मूल प्रोफाइलमा थप ब्राउज गर्नुहोस्",
+  "account.cancel_follow_request": "फलो अनुरोध रद्द गर्नुहोस",
+  "account.copy": "प्रोफाइलको लिङ्क प्रतिलिपि गर्नुहोस्",
+  "account.direct": "@{name} लाई निजी रूपमा उल्लेख गर्नुहोस्",
+  "account.disable_notifications": "@{name} ले पोस्ट गर्दा मलाई सूचित नगर्नुहोस्",
+  "account.domain_blocked": "डोमेन ब्लक गरिएको छ",
+  "account.edit_profile": "प्रोफाइल सम्पादन गर्नुहोस्",
+  "account.enable_notifications": "@{name} ले पोस्ट गर्दा मलाई सूचित गर्नुहोस्",
+  "account.endorse": "प्रोफाइलमा फिचर गर्नुहोस्",
+  "account.featured_tags.last_status_never": "कुनै पोस्ट छैन",
+  "account.follow": "फलो गर्नुहोस",
+  "account.followers.empty": "यस प्रयोगकर्तालाई अहिलेसम्म कसैले फलो गर्दैन।",
+  "account.follows.empty": "यो प्रयोगकर्ताले अहिलेसम्म कसैलाई फलो गरेको छैन।",
+  "account.go_to_profile": "प्रोफाइलमा जानुहोस्",
+  "account.hide_reblogs": "@{name} को बूस्टहरू लुकाउनुहोस्",
+  "account.link_verified_on": "यस लिङ्कको स्वामित्व {date} मा जाँच गरिएको थियो",
+  "account.media": "मिडिया",
+  "account.mention": "@{name} लाई उल्लेख गर्नुहोस्",
+  "account.no_bio": "कुनै विवरण प्रदान गरिएको छैन।",
+  "account.posts": "पोस्टहरू",
+  "account.requested": "स्वीकृतिको पर्खाइमा। फलो अनुरोध रद्द गर्न क्लिक गर्नुहोस्",
+  "account.requested_follow": "{name} ले तपाईंलाई फलो गर्न अनुरोध गर्नुभएको छ",
+  "account.share": "@{name} को प्रोफाइल सेयर गर्नुहोस्",
+  "account.show_reblogs": "@{name} को बूस्टहरू देखाउनुहोस्",
+  "account.statuses_counter": "{count, plural, one {{counter} पोस्ट} other {{counter} पोस्टहरू}}",
+  "account.unblock": "@{name} लाई अनब्लक गर्नुहोस्",
+  "account.unblock_domain": "{domain} डोमेनलाई अनब्लक गर्नुहोस्",
+  "account.unblock_short": "अनब्लक गर्नुहोस्",
+  "account.unendorse": "प्रोफाइलमा फिचर नगर्नुहोस्",
+  "account.unfollow": "अनफलो गर्नुहोस्",
+  "account_note.placeholder": "नोट लेख्न क्लिक गर्नुहोस्",
+  "admin.dashboard.retention.average": "औसत",
+  "admin.dashboard.retention.cohort_size": "नयाँ प्रयोगकर्ताहरू",
+  "alert.rate_limited.message": "कृपया {retry_time, time, medium} पछि पुन: प्रयास गर्नुहोस्।",
+  "alert.unexpected.message": "एउटा अनपेक्षित त्रुटि भयो।"
+}
diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json
index 2d1616960a..d5055b0dc5 100644
--- a/app/javascript/mastodon/locales/pt-PT.json
+++ b/app/javascript/mastodon/locales/pt-PT.json
@@ -7,7 +7,7 @@
   "about.domain_blocks.silenced.explanation": "Normalmente não verá perfis e conteúdo deste servidor, a menos que os procure explicitamente ou opte por os seguir.",
   "about.domain_blocks.silenced.title": "Limitados",
   "about.domain_blocks.suspended.explanation": "Nenhum dado deste servidor será processado, armazenado ou trocado, impossibilitando qualquer interação ou comunicação com os utilizadores dessas instâncias.",
-  "about.domain_blocks.suspended.title": "Supensos",
+  "about.domain_blocks.suspended.title": "Suspensos",
   "about.not_available": "Esta informação não foi disponibilizada neste servidor.",
   "about.powered_by": "Rede social descentralizada baseada no {mastodon}",
   "about.rules": "Regras do servidor",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index c4ce6f8cfd..46fcc01160 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -224,6 +224,7 @@
   "emoji_button.search_results": "Výsledky hľadania",
   "emoji_button.symbols": "Symboly",
   "emoji_button.travel": "Cestovanie a miesta",
+  "empty_column.account_hides_collections": "Tento užívateľ si zvolil nesprístupniť túto informáciu",
   "empty_column.account_suspended": "Účet bol pozastavený",
   "empty_column.account_timeline": "Nie sú tu žiadne príspevky!",
   "empty_column.account_unavailable": "Profil nedostupný",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index e6dd008bf2..4fb5a12de3 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -8,7 +8,7 @@
   "about.domain_blocks.silenced.title": "已受限",
   "about.domain_blocks.suspended.explanation": "來自此伺服器的資料都不會被處理、儲存或交換,也無法與此伺服器上的使用者互動或交流。",
   "about.domain_blocks.suspended.title": "已停權",
-  "about.not_available": "無法於本伺服器上使用此資訊。",
+  "about.not_available": "無法於此伺服器上使用此資訊。",
   "about.powered_by": "由 {mastodon} 提供的去中心化社群媒體",
   "about.rules": "伺服器規則",
   "account.account_note_header": "備註",
@@ -34,9 +34,9 @@
   "account.follow": "跟隨",
   "account.followers": "跟隨者",
   "account.followers.empty": "尚未有人跟隨這位使用者。",
-  "account.followers_counter": "被 {count, plural,one {{counter} 人}other {{counter} 人}}跟隨",
+  "account.followers_counter": "被 {count, plural, other {{counter} 人}}跟隨",
   "account.following": "跟隨中",
-  "account.following_counter": "正在跟隨 {count, plural, one {{counter} 人} other {{counter} 人}}",
+  "account.following_counter": "正在跟隨 {count,plural,other {{counter} 人}}",
   "account.follows.empty": "這位使用者尚未跟隨任何人。",
   "account.follows_you": "跟隨了您",
   "account.go_to_profile": "前往個人檔案",
@@ -72,8 +72,8 @@
   "account.unmute_notifications_short": "取消靜音推播通知",
   "account.unmute_short": "解除靜音",
   "account_note.placeholder": "按此新增備註",
-  "admin.dashboard.daily_retention": "註冊後使用者存留率(日)",
-  "admin.dashboard.monthly_retention": "註冊後使用者存留率(月)",
+  "admin.dashboard.daily_retention": "註冊後使用者存留率(日)",
+  "admin.dashboard.monthly_retention": "註冊後使用者存留率(月)",
   "admin.dashboard.retention.average": "平均",
   "admin.dashboard.retention.cohort": "註冊月份",
   "admin.dashboard.retention.cohort_size": "新使用者",
@@ -103,9 +103,9 @@
   "bundle_modal_error.message": "載入此元件時發生錯誤。",
   "bundle_modal_error.retry": "重試",
   "closed_registrations.other_server_instructions": "因為 Mastodon 是去中心化的,所以您也能於其他伺服器上建立帳號,並仍然與這個伺服器互動。",
-  "closed_registrations_modal.description": "目前無法於 {domain} 建立新帳號,但也請別忘了,您並不一定需要有 {domain} 伺服器的帳號,也能使用 Mastodon 。",
+  "closed_registrations_modal.description": "目前無法於 {domain} 建立新帳號,但也請別忘了,您並不一定需要有 {domain} 伺服器的帳號,也能使用 Mastodon。",
   "closed_registrations_modal.find_another_server": "尋找另一個伺服器",
-  "closed_registrations_modal.preamble": "Mastodon 是去中心化的,所以無論您於哪個伺服器新增帳號,都可以與此伺服器上的任何人跟隨及互動。您甚至能自行架一個自己的伺服器!",
+  "closed_registrations_modal.preamble": "Mastodon 是去中心化的,所以無論您於哪個伺服器新增帳號,都可以與此伺服器上的任何人跟隨及互動。您甚至能自行架設一個自己的伺服器!",
   "closed_registrations_modal.title": "註冊 Mastodon",
   "column.about": "關於",
   "column.blocks": "已封鎖的使用者",
@@ -155,7 +155,7 @@
   "compose_form.publish_form": "嘟出去",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.save_changes": "儲存變更",
-  "compose_form.sensitive.hide": "標記媒體為敏感內容",
+  "compose_form.sensitive.hide": "{count, plural, other {將媒體標記為敏感內容}}",
   "compose_form.sensitive.marked": "此媒體被標記為敏感內容",
   "compose_form.sensitive.unmarked": "此媒體未被標記為敏感內容",
   "compose_form.spoiler.marked": "移除內容警告",
@@ -207,14 +207,14 @@
   "dismissable_banner.explore_statuses": "這些於此伺服器以及去中心化網路中其他伺服器發出的嘟文正在被此伺服器上的人們熱烈討論著。越多不同人轉嘟及最愛排名更高。",
   "dismissable_banner.explore_tags": "這些主題標籤正在被此伺服器以及去中心化網路上的人們熱烈討論著。越多不同人所嘟出的主題標籤排名更高。",
   "dismissable_banner.public_timeline": "這些是來自 {domain} 使用者們跟隨中帳號所發表之最新公開嘟文。",
-  "embed.instructions": "若您欲於您的網站嵌入此嘟文,請複製以下程式碼。",
+  "embed.instructions": "如要將此嘟文嵌入您的網站,請複製以下程式碼。",
   "embed.preview": "它將顯示成這樣:",
   "emoji_button.activity": "活動",
   "emoji_button.clear": "清除",
   "emoji_button.custom": "自訂",
   "emoji_button.flags": "旗幟",
   "emoji_button.food": "食物 & 飲料",
-  "emoji_button.label": "插入表情符號",
+  "emoji_button.label": "插入表情圖案",
   "emoji_button.nature": "自然",
   "emoji_button.not_found": "啊就沒這表情符號吼!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "物件",
@@ -353,11 +353,11 @@
   "keyboard_shortcuts.legend": "顯示此說明選單",
   "keyboard_shortcuts.local": "開啟本站時間軸",
   "keyboard_shortcuts.mention": "提及作者",
-  "keyboard_shortcuts.muted": "開啟靜音使用者列表",
+  "keyboard_shortcuts.muted": "開啟靜音使用者清單",
   "keyboard_shortcuts.my_profile": "開啟個人檔案頁面",
   "keyboard_shortcuts.notifications": "開啟通知欄",
   "keyboard_shortcuts.open_media": "開啟媒體",
-  "keyboard_shortcuts.pinned": "開啟釘選的嘟文列表",
+  "keyboard_shortcuts.pinned": "開啟釘選的嘟文清單",
   "keyboard_shortcuts.profile": "開啟作者的個人檔案頁面",
   "keyboard_shortcuts.reply": "回應嘟文",
   "keyboard_shortcuts.requests": "開啟跟隨請求列表",
@@ -386,7 +386,7 @@
   "lists.new.create": "新增列表",
   "lists.new.title_placeholder": "新列表標題",
   "lists.replies_policy.followed": "任何跟隨的使用者",
-  "lists.replies_policy.list": "列表成員",
+  "lists.replies_policy.list": "成員清單",
   "lists.replies_policy.none": "沒有人",
   "lists.replies_policy.title": "顯示回覆:",
   "lists.search": "搜尋您跟隨的使用者",
@@ -452,7 +452,7 @@
   "notifications.column_settings.push": "推播通知",
   "notifications.column_settings.reblog": "轉嘟:",
   "notifications.column_settings.show": "於欄位中顯示",
-  "notifications.column_settings.sound": "播放聲音",
+  "notifications.column_settings.sound": "播放音效",
   "notifications.column_settings.status": "新嘟文:",
   "notifications.column_settings.unread_notifications.category": "未讀通知",
   "notifications.column_settings.unread_notifications.highlight": "突顯未讀通知",
@@ -477,7 +477,7 @@
   "onboarding.actions.back": "返回",
   "onboarding.actions.go_to_explore": "看看發生什麼新鮮事",
   "onboarding.actions.go_to_home": "前往您的首頁時間軸",
-  "onboarding.compose.template": "哈囉 #Mastodon!",
+  "onboarding.compose.template": "你好 #Mastodon!",
   "onboarding.follows.empty": "很遺憾,目前未能顯示任何結果。您可以嘗試使用搜尋、瀏覽探索頁面以找尋人們跟隨、或稍候再試。",
   "onboarding.follows.lead": "您的首頁時間軸是 Mastodon 的核心體驗。若您跟隨更多人的話,它將會變得更活躍有趣。這些個人檔案也許是個好起點,您可以隨時取消跟隨他們!",
   "onboarding.follows.title": "客製化您的首頁時間軸",
@@ -540,7 +540,7 @@
   "regeneration_indicator.label": "載入中…",
   "regeneration_indicator.sublabel": "您的首頁時間軸正在準備中!",
   "relative_time.days": "{number} 天",
-  "relative_time.full.days": "{number, plural, one {# 天} other {# 天}}前",
+  "relative_time.full.days": "{number, plural, other {# 天}}前",
   "relative_time.full.hours": "{number, plural, one {# 小時} other {# 小時}}前",
   "relative_time.full.just_now": "剛剛",
   "relative_time.full.minutes": "{number, plural, one {# 分鐘} other {# 分鐘}}前",
@@ -620,7 +620,7 @@
   "search_results.see_all": "檢視全部",
   "search_results.statuses": "嘟文",
   "search_results.title": "搜尋:{q}",
-  "server_banner.about_active_users": "最近三十日內使用此伺服器的人 (月活躍使用者)",
+  "server_banner.about_active_users": "最近三十日內使用此伺服器的人(月活躍使用者)",
   "server_banner.active_users": "活躍使用者",
   "server_banner.administered_by": "管理者:",
   "server_banner.introduction": "{domain} 是由 {mastodon} 提供之去中心化社群網路一部分。",
@@ -687,7 +687,7 @@
   "status.translated_from_with": "透過 {provider} 翻譯 {lang}",
   "status.uncached_media_warning": "無法預覽",
   "status.unmute_conversation": "解除此對話的靜音",
-  "status.unpin": "自個人檔案頁面取消釘選",
+  "status.unpin": "從個人檔案頁面取消釘選",
   "subscribed_languages.lead": "僅選定語言的嘟文才會出現於您的首頁上,並於變更後列出時間軸。選取「無」以接收所有語言的嘟文。",
   "subscribed_languages.save": "儲存變更",
   "subscribed_languages.target": "變更 {target} 的訂閱語言",
diff --git a/app/lib/attachment_batch.rb b/app/lib/attachment_batch.rb
index 13a9da828f..b28f5c3d7f 100644
--- a/app/lib/attachment_batch.rb
+++ b/app/lib/attachment_batch.rb
@@ -4,7 +4,8 @@ class AttachmentBatch
   # Maximum amount of objects you can delete in an S3 API call. It's
   # important to remember that this does not correspond to the number
   # of records in the batch, since records can have multiple attachments
-  LIMIT = 1_000
+  LIMIT = ENV.fetch('S3_BATCH_DELETE_LIMIT', 1000).to_i
+  MAX_RETRY = ENV.fetch('S3_BATCH_DELETE_RETRY', 3).to_i
 
   # Attributes generated and maintained by Paperclip (not all of them
   # are always used on every class, however)
@@ -95,6 +96,7 @@ class AttachmentBatch
     # objects can be processed at once, so we have to potentially
     # separate them into multiple calls.
 
+    retries = 0
     keys.each_slice(LIMIT) do |keys_slice|
       logger.debug { "Deleting #{keys_slice.size} objects" }
 
@@ -102,6 +104,17 @@ class AttachmentBatch
         objects: keys_slice.map { |key| { key: key } },
         quiet: true,
       })
+    rescue => e
+      retries += 1
+
+      if retries < MAX_RETRY
+        logger.debug "Retry #{retries}/#{MAX_RETRY} after #{e.message}"
+        sleep 2**retries
+        retry
+      else
+        logger.error "Batch deletion from S3 failed after #{e.message}"
+        raise e
+      end
     end
   end
 
diff --git a/app/lib/vacuum/preview_cards_vacuum.rb b/app/lib/vacuum/preview_cards_vacuum.rb
index 14fdeda1ca..9e34c87c30 100644
--- a/app/lib/vacuum/preview_cards_vacuum.rb
+++ b/app/lib/vacuum/preview_cards_vacuum.rb
@@ -14,9 +14,8 @@ class Vacuum::PreviewCardsVacuum
   private
 
   def vacuum_cached_images!
-    preview_cards_past_retention_period.find_each do |preview_card|
-      preview_card.image.destroy
-      preview_card.save
+    preview_cards_past_retention_period.find_in_batches do |preview_card|
+      AttachmentBatch.new(PreviewCard, preview_card).clear
     end
   end
 
diff --git a/app/models/preview_cards_status.rb b/app/models/preview_cards_status.rb
index 341771e4d3..214eec22e5 100644
--- a/app/models/preview_cards_status.rb
+++ b/app/models/preview_cards_status.rb
@@ -9,9 +9,7 @@
 #  url             :string
 #
 class PreviewCardsStatus < ApplicationRecord
-  # Composite primary keys are not properly supported in Rails. However,
-  # we shouldn't need this anyway...
-  self.primary_key = nil
+  self.primary_key = [:preview_card_id, :status_id]
 
   belongs_to :preview_card
   belongs_to :status
diff --git a/app/models/status.rb b/app/models/status.rb
index a14ef943d4..6b1598e7b1 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -84,8 +84,7 @@ class Status < ApplicationRecord
 
   has_and_belongs_to_many :tags
 
-  # Because of a composite primary key, the `dependent` option cannot be used on this association
-  has_one :preview_cards_status, inverse_of: :status # rubocop:disable Rails/HasManyOrHasOneDependent
+  has_one :preview_cards_status, inverse_of: :status, dependent: :delete
 
   has_one :notification, as: :activity, dependent: :destroy
   has_one :status_stat, inverse_of: :status, dependent: nil
@@ -153,7 +152,6 @@ class Status < ApplicationRecord
   # The `prepend: true` option below ensures this runs before
   # the `dependent: destroy` callbacks remove relevant records
   before_destroy :unlink_from_conversations!, prepend: true
-  before_destroy :reset_preview_card!
 
   cache_associated :application,
                    :media_attachments,
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 48167873e1..71ab1ac494 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -76,6 +76,15 @@ class FanOutOnWriteService < BaseService
       LocalNotificationWorker.push_bulk(mentions) do |mention|
         [mention.account_id, mention.id, 'Mention', 'mention']
       end
+
+      next unless update?
+
+      # This may result in duplicate update payloads, but this ensures clients
+      # are aware of edits to posts only appearing in mention notifications
+      # (e.g. private mentions or mentions by people they do not follow)
+      PushUpdateWorker.push_bulk(mentions.filter { |mention| subscribed_to_streaming_api?(mention.account_id) }) do |mention|
+        [mention.account_id, @status.id, "timeline:#{mention.account_id}:notifications", { 'update' => true }]
+      end
     end
   end
 
@@ -170,4 +179,8 @@ class FanOutOnWriteService < BaseService
   def broadcastable?
     @status.public_visibility? && !@account.silenced? && (!@status.reblog? || Setting.show_reblogs_in_public_timelines)
   end
+
+  def subscribed_to_streaming_api?(account_id)
+    redis.exists?("subscribed:timeline:#{account_id}") || redis.exists?("subscribed:timeline:#{account_id}:notifications")
+  end
 end
diff --git a/app/services/fetch_oembed_service.rb b/app/services/fetch_oembed_service.rb
index 1ae592238e..dc84b16b68 100644
--- a/app/services/fetch_oembed_service.rb
+++ b/app/services/fetch_oembed_service.rb
@@ -100,7 +100,7 @@ class FetchOEmbedService
   end
 
   def validate(oembed)
-    oembed if oembed[:version].to_s == '1.0' && oembed[:type].present?
+    oembed if oembed.present? && oembed[:version].to_s == '1.0' && oembed[:type].present?
   end
 
   def html
diff --git a/config/locales/activerecord.hr.yml b/config/locales/activerecord.hr.yml
index b095244dd6..a3e7d6d492 100644
--- a/config/locales/activerecord.hr.yml
+++ b/config/locales/activerecord.hr.yml
@@ -6,7 +6,9 @@ hr:
         expires_at: Krajnji rok
         options: Opcije
       user:
+        agreement: Ugovor o uslugama
         email: E-mail adresa
+        locale: Lokalitet
         password: Lozinka
       user/account:
         username: Korisničko ime
@@ -18,3 +20,16 @@ hr:
           attributes:
             username:
               invalid: mora sadržavati samo slova, brojeve i _
+              reserved: je rezervisano
+        admin/webhook:
+          attributes:
+            url:
+              invalid: nije validan URL
+        doorkeeper/application:
+          attributes:
+            website:
+              invalid: nije validan URL
+        import:
+          attributes:
+            data:
+              malformed: je neispravan
diff --git a/config/locales/activerecord.ru.yml b/config/locales/activerecord.ru.yml
index 14f9f61f6c..92d85af4d9 100644
--- a/config/locales/activerecord.ru.yml
+++ b/config/locales/activerecord.ru.yml
@@ -36,7 +36,7 @@ ru:
         status:
           attributes:
             reblog:
-              taken: поста уже существует
+              taken: пост уже существует
         user:
           attributes:
             email:
diff --git a/config/locales/activerecord.zh-TW.yml b/config/locales/activerecord.zh-TW.yml
index 792a9dbb22..24609332cd 100644
--- a/config/locales/activerecord.zh-TW.yml
+++ b/config/locales/activerecord.zh-TW.yml
@@ -28,7 +28,7 @@ zh-TW:
         doorkeeper/application:
           attributes:
             website:
-              invalid: 不是有效的 URL
+              invalid: 不是有效的網址
         import:
           attributes:
             data:
diff --git a/config/locales/doorkeeper.zh-TW.yml b/config/locales/doorkeeper.zh-TW.yml
index c0d42ec7b3..8ceaf36f3c 100644
--- a/config/locales/doorkeeper.zh-TW.yml
+++ b/config/locales/doorkeeper.zh-TW.yml
@@ -29,7 +29,7 @@ zh-TW:
       edit:
         title: 編輯應用程式
       form:
-        error: 唉呦!請看看表單以排查錯誤
+        error: 糟糕!請檢查表單以排查錯誤
       help:
         native_redirect_uri: 請使用 %{native_redirect_uri} 作本站測試
         redirect_uri: 每行輸入一個 URI
diff --git a/config/locales/en-GB.yml b/config/locales/en-GB.yml
index 987788a7ad..a6e74c4836 100644
--- a/config/locales/en-GB.yml
+++ b/config/locales/en-GB.yml
@@ -534,6 +534,7 @@ en-GB:
       total_reported: Reports about them
       total_storage: Media attachments
       totals_time_period_hint_html: The totals displayed below include data for all time.
+      unknown_instance: There is currently no record of this domain on this server.
     invites:
       deactivate_all: Deactivate all
       filter:
@@ -610,6 +611,7 @@ en-GB:
       created_at: Reported
       delete_and_resolve: Delete posts
       forwarded: Forwarded
+      forwarded_replies_explanation: This report is from a remote user and about remote content. It has been forwarded to you because the reported content is in reply to one of your users.
       forwarded_to: Forwarded to %{domain}
       mark_as_resolved: Mark as resolved
       mark_as_sensitive: Mark as sensitive
@@ -1038,6 +1040,14 @@ en-GB:
       hint_html: Just one more thing! We need to confirm you're a human (this is so we can keep the spam out!). Solve the CAPTCHA below and click "Continue".
       title: Security check
     confirmations:
+      awaiting_review: Your e-mail address is confirmed! The %{domain} staff is now reviewing your registration. You will receive an e-mail if they approve your account!
+      awaiting_review_title: Your registration is being reviewed
+      clicking_this_link: clicking this link
+      login_link: log in
+      proceed_to_login_html: You can now proceed to %{login_link}.
+      redirect_to_app_html: You should have been redirected to the <strong>%{app_name}</strong> app. If that did not happen, try %{clicking_this_link} or manually return to the app.
+      registration_complete: Your registration on %{domain} is now complete!
+      welcome_title: Welcome, %{name}!
       wrong_email_hint: If that e-mail address is not correct, you can change it in account settings.
     delete_account: Delete account
     delete_account_html: If you wish to delete your account, you can <a href="%{path}">proceed here</a>. You will be asked for confirmation.
@@ -1099,6 +1109,7 @@ en-GB:
       functional: Your account is fully operational.
       pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved.
       redirecting_to: Your account is inactive because it is currently redirecting to %{acct}.
+      self_destruct: As %{domain} is closing down, you will only get limited access to your account.
       view_strikes: View past strikes against your account
     too_fast: Form submitted too fast, try again.
     use_security_key: Use security key
@@ -1356,6 +1367,7 @@ en-GB:
       '86400': 1 day
     expires_in_prompt: Never
     generate: Generate invite link
+    invalid: This invite is not valid
     invited_by: 'You were invited by:'
     max_uses:
       one: 1 use
@@ -1568,6 +1580,9 @@ en-GB:
     over_daily_limit: You have exceeded the limit of %{limit} scheduled posts for today
     over_total_limit: You have exceeded the limit of %{limit} scheduled posts
     too_soon: The scheduled date must be in the future
+  self_destruct:
+    lead_html: Unfortunately, <strong>%{domain}</strong> is permanently closing down. If you had an account there, you will not be able to continue using it, but you can still request a backup of your data.
+    title: This server is closing down
   sessions:
     activity: Last activity
     browser: Browser
@@ -1736,6 +1751,7 @@ en-GB:
       default: "%b %d, %Y, %H:%M"
       month: "%b %Y"
       time: "%H:%M"
+      with_time_zone: "%b %d, %Y, %H:%M %Z"
   translation:
     errors:
       quota_exceeded: The server-wide usage quota for the translation service has been exceeded.
diff --git a/config/locales/my.yml b/config/locales/my.yml
index 03ed771a42..4ba4fcfad3 100644
--- a/config/locales/my.yml
+++ b/config/locales/my.yml
@@ -1324,6 +1324,7 @@ my:
       '86400': ၁ ရက်
     expires_in_prompt: ဘယ်တော့မှ
     generate: ဖိတ်ကြားချက်လင့်ခ် ဖန်တီးပါ
+    invalid: ဤဖိတ်ကြားချက်မှာ မမှန်ကန်ပါ
     invited_by: သင့်ကို ဖိတ်ခေါ်ထားသည် -
     max_uses:
       other: "%{count} အသုံးပြုမှုများ"
diff --git a/config/locales/simple_form.zh-TW.yml b/config/locales/simple_form.zh-TW.yml
index 13b2ad30af..a31ad5eb11 100644
--- a/config/locales/simple_form.zh-TW.yml
+++ b/config/locales/simple_form.zh-TW.yml
@@ -16,7 +16,7 @@ zh-TW:
         acct: 指定要移動至的帳號的「使用者名稱@網域名稱」
       account_warning_preset:
         text: 您可使用嘟文語法,例如網址、「#」標籤與提及功能
-        title: 可選的。不會向收件者顯示
+        title: 可選。不會向收件者顯示
       admin_account_action:
         include_statuses: 使用者可看到導致檢舉或警告的嘟文
         send_email_notification: 使用者將收到帳號發生之事情的解釋
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index 63779e5bd5..caf253c69c 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -656,6 +656,10 @@ sk:
       rules_check:
         action: Spravuj serverové pravidlá
         message_html: Neurčil/a si žiadne serverové pravidlá.
+      software_version_critical_check:
+        action: Pozri dostupné aktualizácie
+      software_version_patch_check:
+        action: Pozri dostupné aktualizácie
       upload_check_privacy_error:
         action: Pozri tu pre viac informácií
       upload_check_privacy_error_object_storage:
diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml
index 755ac34038..e17228da32 100644
--- a/config/locales/zh-TW.yml
+++ b/config/locales/zh-TW.yml
@@ -467,7 +467,7 @@ zh-TW:
           other: 錯誤嘗試於 %{count} 天。
         no_failures_recorded: 報告中沒有錯誤。
         title: 可用狀態
-        warning: 上一次嘗試連線至本伺服器失敗
+        warning: 上一次嘗試連線至此伺服器失敗
       back_to_all: 所有
       back_to_limited: 受限制的
       back_to_warning: 警告
@@ -876,7 +876,7 @@ zh-TW:
         publishers:
           no_publisher_selected: 因未選取任何發行者,所以什麼事都沒發生
         shared_by_over_week:
-          other: 上週被 %{count} 名使用者分享
+          other: 上週被 %{count} 位使用者分享
         title: 熱門連結
         usage_comparison: 於今日被 %{today} 人分享,相較於昨日 %{yesterday} 人
       not_allowed_to_trend: 不允許登上熱門
@@ -1273,7 +1273,7 @@ zh-TW:
       other: 選取 %{count} 個符合您搜尋的項目。
     today: 今天
     validation_errors:
-      other: 恩...似乎不太對勁耶?請檢查以下 %{count} 項錯誤
+      other: 恩...似乎發生了點錯誤?請檢查以下 %{count} 項錯誤
   imports:
     errors:
       empty: 空的 CSV 檔案
@@ -1796,7 +1796,7 @@ zh-TW:
     welcome:
       edit_profile_action: 設定個人檔案
       edit_profile_step: 您可以設定您的個人檔案,包括上傳大頭貼、變更顯示名稱等等。您也可以選擇於新的跟隨者跟隨前,先對他們進行審核。
-      explanation: 下面是幾個小幫助,希望它們能幫到您
+      explanation: 以下是幾個小技巧,希望它們能幫到您
       final_action: 開始嘟嘟
       final_step: '開始嘟嘟吧!即使您現在沒有跟隨者,其他人仍然能於本站時間軸、主題標籤等地方,看到您的公開嘟文。試著用 #introductions 這個主題標籤介紹一下自己吧。'
       full_handle: 您的完整帳號名稱
@@ -1805,7 +1805,7 @@ zh-TW:
       title: "%{name} 誠摯歡迎您的加入!"
   users:
     follow_limit_reached: 您無法跟隨多於 %{limit} 個人
-    go_to_sso_account_settings: 前往您的身分提供商 (identity provider) 之帳號設定
+    go_to_sso_account_settings: 前往您的身分識別提供者(IdP)之帳號設定
     invalid_otp_token: 兩階段認證碼不正確
     otp_lost_help_html: 如果您無法存取這兩者,您可以透過 %{email} 與我們聯繫
     seamless_external_login: 由於您是由外部系統登入,所以不能設定密碼與電子郵件。
diff --git a/lib/mastodon/cli/domains.rb b/lib/mastodon/cli/domains.rb
index 329f171672..e092497dc9 100644
--- a/lib/mastodon/cli/domains.rb
+++ b/lib/mastodon/cli/domains.rb
@@ -97,6 +97,8 @@ module Mastodon::CLI
       say("Removed #{custom_emojis_count} custom emojis#{dry_run_mode_suffix}", :green)
     end
 
+    CRAWL_SLEEP_TIME = 20
+
     option :concurrency, type: :numeric, default: 50, aliases: [:c]
     option :format, type: :string, default: 'summary', aliases: [:f]
     option :exclude_suspended, type: :boolean, default: false, aliases: [:x]
@@ -168,8 +170,8 @@ module Mastodon::CLI
         pool.post(domain, &work_unit)
       end
 
-      sleep 20
-      sleep 20 until pool.queue_length.zero?
+      sleep CRAWL_SLEEP_TIME
+      sleep CRAWL_SLEEP_TIME until pool.queue_length.zero?
 
       pool.shutdown
       pool.wait_for_termination(20)
diff --git a/lib/mastodon/cli/maintenance.rb b/lib/mastodon/cli/maintenance.rb
index c53d742548..43fd7fab33 100644
--- a/lib/mastodon/cli/maintenance.rb
+++ b/lib/mastodon/cli/maintenance.rb
@@ -185,15 +185,15 @@ module Mastodon::CLI
     end
 
     def schema_has_instances_view?
-      ActiveRecord::Migrator.current_version >= 2020_12_06_004238
+      migrator_version >= 2020_12_06_004238
     end
 
     def verify_schema_version!
-      if ActiveRecord::Migrator.current_version < MIN_SUPPORTED_VERSION
+      if migrator_version < MIN_SUPPORTED_VERSION
         say 'Your version of the database schema is too old and is not supported by this script.', :red
         say 'Please update to at least Mastodon 3.0.0 before running this script.', :red
         exit(1)
-      elsif ActiveRecord::Migrator.current_version > MAX_SUPPORTED_VERSION
+      elsif migrator_version > MAX_SUPPORTED_VERSION
         say 'Your version of the database schema is more recent than this script, this may cause unexpected errors.', :yellow
         exit(1) unless yes?('Continue anyway? (Yes/No)')
       end
@@ -228,7 +228,7 @@ module Mastodon::CLI
       end
 
       say 'Restoring index_accounts_on_username_and_domain_lower…'
-      if ActiveRecord::Migrator.current_version < 2020_06_20_164023
+      if migrator_version < 2020_06_20_164023
         ActiveRecord::Base.connection.add_index :accounts, 'lower (username), lower(domain)', name: 'index_accounts_on_username_and_domain_lower', unique: true
       else
         ActiveRecord::Base.connection.add_index :accounts, "lower (username), COALESCE(lower(domain), '')", name: 'index_accounts_on_username_and_domain_lower', unique: true
@@ -238,7 +238,7 @@ module Mastodon::CLI
       ActiveRecord::Base.connection.execute('REINDEX INDEX search_index;')
       ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_uri;')
       ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_url;')
-      ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_domain_and_id;') if ActiveRecord::Migrator.current_version >= 2023_05_24_190515
+      ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_domain_and_id;') if migrator_version >= 2023_05_24_190515
     end
 
     def deduplicate_users!
@@ -269,15 +269,15 @@ module Mastodon::CLI
       say 'Restoring users indexes…'
       ActiveRecord::Base.connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true
       ActiveRecord::Base.connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true
-      ActiveRecord::Base.connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true if ActiveRecord::Migrator.current_version < 2022_01_18_183010
+      ActiveRecord::Base.connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true if migrator_version < 2022_01_18_183010
 
-      if ActiveRecord::Migrator.current_version < 2022_03_10_060641
+      if migrator_version < 2022_03_10_060641
         ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true
       else
         ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true, where: 'reset_password_token IS NOT NULL', opclass: :text_pattern_ops
       end
 
-      ActiveRecord::Base.connection.execute('REINDEX INDEX index_users_on_unconfirmed_email;') if ActiveRecord::Migrator.current_version >= 2023_07_02_151753
+      ActiveRecord::Base.connection.execute('REINDEX INDEX index_users_on_unconfirmed_email;') if migrator_version >= 2023_07_02_151753
     end
 
     def deduplicate_users_process_confirmation_token
@@ -292,7 +292,7 @@ module Mastodon::CLI
     end
 
     def deduplicate_users_process_remember_token
-      if ActiveRecord::Migrator.current_version < 2022_01_18_183010
+      if migrator_version < 2022_01_18_183010
         ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE remember_token IS NOT NULL GROUP BY remember_token HAVING count(*) > 1").each do |row|
           users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)
           say "Unsetting remember token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow
@@ -346,7 +346,7 @@ module Mastodon::CLI
 
       remove_index_if_exists!(:announcement_reactions, 'index_announcement_reactions_on_account_id_and_announcement_id')
 
-      say 'Removing duplicate account identity proofs…'
+      say 'Removing duplicate announcement reactions…'
       ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM announcement_reactions GROUP BY account_id, announcement_id, name HAVING count(*) > 1").each do |row|
         AnnouncementReaction.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
       end
@@ -371,7 +371,7 @@ module Mastodon::CLI
       end
 
       say 'Restoring conversations indexes…'
-      if ActiveRecord::Migrator.current_version < 2022_03_07_083603
+      if migrator_version < 2022_03_07_083603
         ActiveRecord::Base.connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true
       else
         ActiveRecord::Base.connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true, where: 'uri IS NOT NULL', opclass: :text_pattern_ops
@@ -431,7 +431,7 @@ module Mastodon::CLI
     def deduplicate_domain_blocks!
       remove_index_if_exists!(:domain_blocks, 'index_domain_blocks_on_domain')
 
-      say 'Deduplicating domain_allows…'
+      say 'Deduplicating domain_blocks…'
       ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row|
         domain_blocks = DomainBlock.where(id: row['ids'].split(',')).by_severity.reverse.to_a
 
@@ -462,7 +462,7 @@ module Mastodon::CLI
         UnavailableDomain.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
       end
 
-      say 'Restoring domain_allows indexes…'
+      say 'Restoring unavailable_domains indexes…'
       ActiveRecord::Base.connection.add_index :unavailable_domains, ['domain'], name: 'index_unavailable_domains_on_domain', unique: true
     end
 
@@ -488,7 +488,7 @@ module Mastodon::CLI
       end
 
       say 'Restoring media_attachments indexes…'
-      if ActiveRecord::Migrator.current_version < 2022_03_10_060626
+      if migrator_version < 2022_03_10_060626
         ActiveRecord::Base.connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true
       else
         ActiveRecord::Base.connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true, where: 'shortcode IS NOT NULL', opclass: :text_pattern_ops
@@ -521,7 +521,7 @@ module Mastodon::CLI
       end
 
       say 'Restoring statuses indexes…'
-      if ActiveRecord::Migrator.current_version < 2022_03_10_060706
+      if migrator_version < 2022_03_10_060706
         ActiveRecord::Base.connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true
       else
         ActiveRecord::Base.connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true, where: 'uri IS NOT NULL', opclass: :text_pattern_ops
@@ -543,7 +543,7 @@ module Mastodon::CLI
       end
 
       say 'Restoring tags indexes…'
-      if ActiveRecord::Migrator.current_version < 2021_04_21_121431
+      if migrator_version < 2021_04_21_121431
         ActiveRecord::Base.connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true
       else
         ActiveRecord::Base.connection.execute 'CREATE UNIQUE INDEX CONCURRENTLY index_tags_on_name_lower_btree ON tags (lower(name) text_pattern_ops)'
@@ -707,12 +707,16 @@ module Mastodon::CLI
       end
     end
 
+    def migrator_version
+      ActiveRecord::Migrator.current_version
+    end
+
     def find_duplicate_accounts
       ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM accounts GROUP BY lower(username), COALESCE(lower(domain), '') HAVING count(*) > 1")
     end
 
     def remove_index_if_exists!(table, name)
-      ActiveRecord::Base.connection.remove_index(table, name: name)
+      ActiveRecord::Base.connection.remove_index(table, name: name) if ActiveRecord::Base.connection.index_name_exists?(table, name)
     rescue ArgumentError, ActiveRecord::StatementInvalid
       nil
     end
diff --git a/package.json b/package.json
index 692ebe8ee7..34e16b4809 100644
--- a/package.json
+++ b/package.json
@@ -205,7 +205,7 @@
     "prettier": "^3.0.0",
     "react-test-renderer": "^18.2.0",
     "stylelint": "^15.10.1",
-    "stylelint-config-standard-scss": "^11.0.0",
+    "stylelint-config-standard-scss": "^12.0.0",
     "typescript": "^5.0.4",
     "webpack-dev-server": "^3.11.3",
     "yargs": "^17.7.2"
diff --git a/spec/controllers/api/v1/accounts/identity_proofs_controller_spec.rb b/spec/controllers/api/v1/accounts/identity_proofs_controller_spec.rb
deleted file mode 100644
index 6351de7616..0000000000
--- a/spec/controllers/api/v1/accounts/identity_proofs_controller_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Api::V1::Accounts::IdentityProofsController do
-  render_views
-
-  let(:user)    { Fabricate(:user) }
-  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
-  let(:account) { Fabricate(:account) }
-
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'GET #index' do
-    it 'returns http success' do
-      get :index, params: { account_id: account.id, limit: 2 }
-
-      expect(response).to have_http_status(200)
-    end
-  end
-end
diff --git a/spec/controllers/api/v1/accounts/lists_controller_spec.rb b/spec/controllers/api/v1/accounts/lists_controller_spec.rb
deleted file mode 100644
index 418839cfa5..0000000000
--- a/spec/controllers/api/v1/accounts/lists_controller_spec.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Api::V1::Accounts::ListsController do
-  render_views
-
-  let(:user)    { Fabricate(:user) }
-  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:lists') }
-  let(:account) { Fabricate(:account) }
-  let(:list)    { Fabricate(:list, account: user.account) }
-
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-    user.account.follow!(account)
-    list.accounts << account
-  end
-
-  describe 'GET #index' do
-    it 'returns http success' do
-      get :index, params: { account_id: account.id }
-      expect(response).to have_http_status(200)
-    end
-  end
-end
diff --git a/spec/controllers/api/v1/accounts/lookup_controller_spec.rb b/spec/controllers/api/v1/accounts/lookup_controller_spec.rb
deleted file mode 100644
index 37407766f2..0000000000
--- a/spec/controllers/api/v1/accounts/lookup_controller_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Api::V1::Accounts::LookupController do
-  render_views
-
-  let(:user)    { Fabricate(:user) }
-  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
-  let(:account) { Fabricate(:account) }
-
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'GET #show' do
-    it 'returns http success' do
-      get :show, params: { account_id: account.id, acct: account.acct }
-
-      expect(response).to have_http_status(200)
-    end
-  end
-end
diff --git a/spec/controllers/api/v1/accounts/pins_controller_spec.rb b/spec/controllers/api/v1/accounts/pins_controller_spec.rb
deleted file mode 100644
index 36f525e756..0000000000
--- a/spec/controllers/api/v1/accounts/pins_controller_spec.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Api::V1::Accounts::PinsController do
-  let(:john)  { Fabricate(:user) }
-  let(:kevin) { Fabricate(:user) }
-  let(:token) { Fabricate(:accessible_access_token, resource_owner_id: john.id, scopes: 'write:accounts') }
-
-  before do
-    kevin.account.followers << john.account
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'POST #create' do
-    subject { post :create, params: { account_id: kevin.account.id } }
-
-    it 'creates account_pin', :aggregate_failures do
-      expect do
-        subject
-      end.to change { AccountPin.where(account: john.account, target_account: kevin.account).count }.by(1)
-      expect(response).to have_http_status(200)
-    end
-  end
-
-  describe 'DELETE #destroy' do
-    subject { delete :destroy, params: { account_id: kevin.account.id } }
-
-    before do
-      Fabricate(:account_pin, account: john.account, target_account: kevin.account)
-    end
-
-    it 'destroys account_pin', :aggregate_failures do
-      expect do
-        subject
-      end.to change { AccountPin.where(account: john.account, target_account: kevin.account).count }.by(-1)
-      expect(response).to have_http_status(200)
-    end
-  end
-end
diff --git a/spec/controllers/api/v1/accounts/search_controller_spec.rb b/spec/controllers/api/v1/accounts/search_controller_spec.rb
deleted file mode 100644
index aa9455a4a3..0000000000
--- a/spec/controllers/api/v1/accounts/search_controller_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Api::V1::Accounts::SearchController do
-  render_views
-
-  let(:user)  { Fabricate(:user) }
-  let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
-
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'GET #show' do
-    it 'returns http success' do
-      get :show, params: { q: 'query' }
-
-      expect(response).to have_http_status(200)
-    end
-  end
-end
diff --git a/spec/controllers/api/v1/featured_tags/suggestions_controller_spec.rb b/spec/controllers/api/v1/featured_tags/suggestions_controller_spec.rb
deleted file mode 100644
index 54c63dcc6f..0000000000
--- a/spec/controllers/api/v1/featured_tags/suggestions_controller_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Api::V1::FeaturedTags::SuggestionsController do
-  render_views
-
-  let(:user)    { Fabricate(:user) }
-  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
-  let(:account) { Fabricate(:account) }
-
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'GET #index' do
-    it 'returns http success' do
-      get :index, params: { account_id: account.id, limit: 2 }
-
-      expect(response).to have_http_status(200)
-    end
-  end
-end
diff --git a/spec/controllers/api/v2/suggestions_controller_spec.rb b/spec/controllers/api/v2/suggestions_controller_spec.rb
deleted file mode 100644
index 5e6508bfda..0000000000
--- a/spec/controllers/api/v2/suggestions_controller_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Api::V2::SuggestionsController do
-  render_views
-
-  let(:user)  { Fabricate(:user) }
-  let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') }
-
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'GET #index' do
-    it 'returns http success' do
-      get :index
-
-      expect(response).to have_http_status(200)
-    end
-  end
-end
diff --git a/spec/lib/mastodon/cli/accounts_spec.rb b/spec/lib/mastodon/cli/accounts_spec.rb
index 3216d0d1bd..563f6e877d 100644
--- a/spec/lib/mastodon/cli/accounts_spec.rb
+++ b/spec/lib/mastodon/cli/accounts_spec.rb
@@ -4,7 +4,11 @@ require 'rails_helper'
 require 'mastodon/cli/accounts'
 
 describe Mastodon::CLI::Accounts do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
@@ -27,25 +31,24 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#create' do
+    let(:action) { :create }
+
     shared_examples 'a new user with given email address and username' do
-      it 'creates a new user with the specified email address' do
-        cli.invoke(:create, arguments, options)
-
-        expect(User.find_by(email: options[:email])).to be_present
-      end
-
-      it 'creates a new local account with the specified username' do
-        cli.invoke(:create, arguments, options)
-
-        expect(Account.find_local('tootctl_username')).to be_present
-      end
-
-      it 'returns "OK" and newly generated password' do
+      it 'creates user and accounts from options and displays success message' do
         allow(SecureRandom).to receive(:hex).and_return('test_password')
 
-        expect { cli.invoke(:create, arguments, options) }.to output(
-          a_string_including("OK\nNew password: test_password")
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK', 'New password: test_password')
+        expect(user_from_options).to be_present
+        expect(account_from_options).to be_present
+      end
+
+      def user_from_options
+        User.find_by(email: options[:email])
+      end
+
+      def account_from_options
+        Account.find_local('tootctl_username')
       end
     end
 
@@ -61,9 +64,8 @@ describe Mastodon::CLI::Accounts do
           let(:options) { { email: 'invalid' } }
 
           it 'exits with an error message' do
-            expect { cli.invoke(:create, arguments, options) }.to output(
-              a_string_including('Failure/Error: email')
-            ).to_stdout
+            expect { subject }
+              .to output_results('Failure/Error: email')
               .and raise_error(SystemExit)
           end
         end
@@ -75,7 +77,7 @@ describe Mastodon::CLI::Accounts do
         it_behaves_like 'a new user with given email address and username'
 
         it 'creates a new user with confirmed status' do
-          cli.invoke(:create, arguments, options)
+          subject
 
           user = User.find_by(email: options[:email])
 
@@ -93,7 +95,7 @@ describe Mastodon::CLI::Accounts do
         it_behaves_like 'a new user with given email address and username'
 
         it 'creates a new user with approved status' do
-          cli.invoke(:create, arguments, options)
+          subject
 
           user = User.find_by(email: options[:email])
 
@@ -109,7 +111,7 @@ describe Mastodon::CLI::Accounts do
           it_behaves_like 'a new user with given email address and username'
 
           it 'creates a new user and assigns the specified role' do
-            cli.invoke(:create, arguments, options)
+            subject
 
             role = User.find_by(email: options[:email])&.role
 
@@ -121,9 +123,8 @@ describe Mastodon::CLI::Accounts do
           let(:options) { { email: 'tootctl@example.com', role: '404' } }
 
           it 'exits with an error message indicating the role name was not found' do
-            expect { cli.invoke(:create, arguments, options) }.to output(
-              a_string_including('Cannot find user role with that name')
-            ).to_stdout
+            expect { subject }
+              .to output_results('Cannot find user role with that name')
               .and raise_error(SystemExit)
           end
         end
@@ -139,16 +140,15 @@ describe Mastodon::CLI::Accounts do
           end
 
           it 'returns an error message indicating the username is already taken' do
-            expect { cli.invoke(:create, arguments, options) }.to output(
-              a_string_including("The chosen username is currently in use\nUse --force to reattach it anyway and delete the other user")
-            ).to_stdout
+            expect { subject }
+              .to output_results("The chosen username is currently in use\nUse --force to reattach it anyway and delete the other user")
           end
 
           context 'with --force option' do
             let(:options) { { email: 'tootctl_new@example.com', reattach: true, force: true } }
 
             it 'reattaches the account to the new user and deletes the previous user' do
-              cli.invoke(:create, arguments, options)
+              subject
 
               user = Account.find_local('tootctl_username')&.user
 
@@ -173,20 +173,21 @@ describe Mastodon::CLI::Accounts do
       let(:arguments) { ['tootctl_username'] }
 
       it 'raises a required argument missing error (Thor::RequiredArgumentMissingError)' do
-        expect { cli.invoke(:create, arguments) }
+        expect { subject }
           .to raise_error(Thor::RequiredArgumentMissingError)
       end
     end
   end
 
   describe '#modify' do
+    let(:action) { :modify }
+
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
 
       it 'exits with an error message indicating the user was not found' do
-        expect { cli.invoke(:modify, arguments) }.to output(
-          a_string_including('No user with such username')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No user with such username')
           .and raise_error(SystemExit)
       end
     end
@@ -196,15 +197,9 @@ describe Mastodon::CLI::Accounts do
       let(:arguments) { [user.account.username] }
 
       context 'when no option is provided' do
-        it 'returns a successful message' do
-          expect { cli.invoke(:modify, arguments) }.to output(
-            a_string_including('OK')
-          ).to_stdout
-        end
-
-        it 'does not modify the user' do
-          cli.invoke(:modify, arguments)
-
+        it 'returns a successful message and preserves user' do
+          expect { subject }
+            .to output_results('OK')
           expect(user).to eq(user.reload)
         end
       end
@@ -214,9 +209,8 @@ describe Mastodon::CLI::Accounts do
           let(:options) { { role: '404' } }
 
           it 'exits with an error message indicating the role was not found' do
-            expect { cli.invoke(:modify, arguments, options) }.to output(
-              a_string_including('Cannot find user role with that name')
-            ).to_stdout
+            expect { subject }
+              .to output_results('Cannot find user role with that name')
               .and raise_error(SystemExit)
           end
         end
@@ -226,7 +220,7 @@ describe Mastodon::CLI::Accounts do
           let(:options) { { role: default_role.name } }
 
           it "updates the user's role to the specified role" do
-            cli.invoke(:modify, arguments, options)
+            subject
 
             role = user.reload.role
 
@@ -241,7 +235,7 @@ describe Mastodon::CLI::Accounts do
         let(:user) { Fabricate(:user, role: role) }
 
         it "removes the user's role successfully" do
-          cli.invoke(:modify, arguments, options)
+          subject
 
           role = user.reload.role
 
@@ -254,13 +248,13 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { email: 'new_email@email.com' } }
 
         it "sets the user's unconfirmed email to the provided email address" do
-          cli.invoke(:modify, arguments, options)
+          subject
 
           expect(user.reload.unconfirmed_email).to eq(options[:email])
         end
 
         it "does not update the user's original email address" do
-          cli.invoke(:modify, arguments, options)
+          subject
 
           expect(user.reload.email).to eq('old_email@email.com')
         end
@@ -270,13 +264,13 @@ describe Mastodon::CLI::Accounts do
           let(:options) { { email: 'new_email@email.com', confirm: true } }
 
           it "updates the user's email address to the provided email" do
-            cli.invoke(:modify, arguments, options)
+            subject
 
             expect(user.reload.email).to eq(options[:email])
           end
 
           it "sets the user's email address as confirmed" do
-            cli.invoke(:modify, arguments, options)
+            subject
 
             expect(user.reload.confirmed?).to be(true)
           end
@@ -288,7 +282,7 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { confirm: true } }
 
         it "confirms the user's email address" do
-          cli.invoke(:modify, arguments, options)
+          subject
 
           expect(user.reload.confirmed?).to be(true)
         end
@@ -303,7 +297,7 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'approves the user' do
-          expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.approved }.from(false).to(true)
+          expect { subject }.to change { user.reload.approved }.from(false).to(true)
         end
       end
 
@@ -312,7 +306,7 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { disable: true } }
 
         it 'disables the user' do
-          expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.disabled }.from(false).to(true)
+          expect { subject }.to change { user.reload.disabled }.from(false).to(true)
         end
       end
 
@@ -321,7 +315,7 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { enable: true } }
 
         it 'enables the user' do
-          expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.disabled }.from(true).to(false)
+          expect { subject }.to change { user.reload.disabled }.from(true).to(false)
         end
       end
 
@@ -331,9 +325,8 @@ describe Mastodon::CLI::Accounts do
         it 'returns a new password for the user' do
           allow(SecureRandom).to receive(:hex).and_return('new_password')
 
-          expect { cli.invoke(:modify, arguments, options) }.to output(
-            a_string_including('new_password')
-          ).to_stdout
+          expect { subject }
+            .to output_results('new_password')
         end
       end
 
@@ -342,7 +335,7 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { disable_2fa: true } }
 
         it 'disables the two-factor authentication for the user' do
-          expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.otp_required_for_login }.from(true).to(false)
+          expect { subject }.to change { user.reload.otp_required_for_login }.from(true).to(false)
         end
       end
 
@@ -351,9 +344,8 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { email: 'invalid' } }
 
         it 'exits with an error message' do
-          expect { cli.invoke(:modify, arguments, options) }.to output(
-            a_string_including('Failure/Error: email')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Failure/Error: email')
             .and raise_error(SystemExit)
         end
       end
@@ -361,9 +353,8 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#delete' do
+    let(:action) { :delete }
     let(:account) { Fabricate(:account) }
-    let(:arguments) { [account.username] }
-    let(:options) { { email: account.user.email } }
     let(:delete_account_service) { instance_double(DeleteAccountService) }
 
     before do
@@ -372,26 +363,29 @@ describe Mastodon::CLI::Accounts do
     end
 
     context 'when both username and --email are provided' do
+      let(:arguments) { [account.username] }
+      let(:options) { { email: account.user.email } }
+
       it 'exits with an error message indicating that only one should be used' do
-        expect { cli.invoke(:delete, arguments, options) }.to output(
-          a_string_including('Use username or --email, not both')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Use username or --email, not both')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when neither username nor --email are provided' do
       it 'exits with an error message indicating that no username was provided' do
-        expect { cli.invoke(:delete) }.to output(
-          a_string_including('No username provided')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No username provided')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when username is provided' do
+      let(:arguments) { [account.username] }
+
       it 'deletes the specified user successfully' do
-        cli.invoke(:delete, arguments)
+        subject
 
         expect(delete_account_service).to have_received(:call).with(account, reserve_email: false).once
       end
@@ -399,34 +393,29 @@ describe Mastodon::CLI::Accounts do
       context 'with --dry-run option' do
         let(:options) { { dry_run: true } }
 
-        it 'does not delete the specified user' do
-          cli.invoke(:delete, arguments, options)
-
+        it 'outputs a successful message in dry run mode and does not delete the user' do
+          expect { subject }
+            .to output_results('OK (DRY RUN)')
           expect(delete_account_service).to_not have_received(:call).with(account, reserve_email: false)
         end
-
-        it 'outputs a successful message in dry run mode' do
-          expect { cli.invoke(:delete, arguments, options) }.to output(
-            a_string_including('OK (DRY RUN)')
-          ).to_stdout
-        end
       end
 
       context 'when the given username is not found' do
         let(:arguments) { ['non_existent_username'] }
 
         it 'exits with an error message indicating that no user was found' do
-          expect { cli.invoke(:delete, arguments) }.to output(
-            a_string_including('No user with such username')
-          ).to_stdout
+          expect { subject }
+            .to output_results('No user with such username')
             .and raise_error(SystemExit)
         end
       end
     end
 
     context 'when --email is provided' do
+      let(:options) { { email: account.user.email } }
+
       it 'deletes the specified user successfully' do
-        cli.invoke(:delete, nil, options)
+        subject
 
         expect(delete_account_service).to have_received(:call).with(account, reserve_email: false).once
       end
@@ -434,16 +423,12 @@ describe Mastodon::CLI::Accounts do
       context 'with --dry-run option' do
         let(:options) { { email: account.user.email, dry_run: true } }
 
-        it 'does not delete the user' do
-          cli.invoke(:delete, nil, options)
-
-          expect(delete_account_service).to_not have_received(:call).with(account, reserve_email: false)
-        end
-
-        it 'outputs a successful message in dry run mode' do
-          expect { cli.invoke(:delete, nil, options) }.to output(
-            a_string_including('OK (DRY RUN)')
-          ).to_stdout
+        it 'outputs a successful message in dry run mode and does not delete the user' do
+          expect { subject }
+            .to output_results('OK (DRY RUN)')
+          expect(delete_account_service)
+            .to_not have_received(:call)
+            .with(account, reserve_email: false)
         end
       end
 
@@ -451,9 +436,8 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { email: '404@example.com' } }
 
         it 'exits with an error message indicating that no user was found' do
-          expect { cli.invoke(:delete, nil, options) }.to output(
-            a_string_including('No user with such email')
-          ).to_stdout
+          expect { subject }
+            .to output_results('No user with such email')
             .and raise_error(SystemExit)
         end
       end
@@ -461,6 +445,7 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#approve' do
+    let(:action) { :approve }
     let(:total_users) { 4 }
 
     before do
@@ -469,8 +454,10 @@ describe Mastodon::CLI::Accounts do
     end
 
     context 'with --all option' do
+      let(:options) { { all: true } }
+
       it 'approves all pending registrations' do
-        cli.invoke(:approve, nil, all: true)
+        subject
 
         expect(User.pluck(:approved).all?(true)).to be(true)
       end
@@ -480,28 +467,28 @@ describe Mastodon::CLI::Accounts do
       context 'when the number is positive' do
         let(:options) { { number: 2 } }
 
-        it 'approves the earliest n pending registrations' do
-          cli.invoke(:approve, nil, options)
-
-          n_earliest_pending_registrations = User.order(created_at: :asc).first(options[:number])
+        it 'approves the earliest n pending registrations but not the remaining ones' do
+          subject
 
           expect(n_earliest_pending_registrations.all?(&:approved?)).to be(true)
+          expect(pending_registrations.all?(&:approved?)).to be(false)
         end
 
-        it 'does not approve the remaining pending registrations' do
-          cli.invoke(:approve, nil, options)
+        def n_earliest_pending_registrations
+          User.order(created_at: :asc).first(options[:number])
+        end
 
-          pending_registrations = User.order(created_at: :asc).last(total_users - options[:number])
-
-          expect(pending_registrations.all?(&:approved?)).to be(false)
+        def pending_registrations
+          User.order(created_at: :asc).last(total_users - options[:number])
         end
       end
 
       context 'when the number is negative' do
+        let(:options) { { number: -1 } }
+
         it 'exits with an error message indicating that the number must be positive' do
-          expect { cli.invoke(:approve, nil, number: -1) }.to output(
-            a_string_including('Number must be positive')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Number must be positive')
             .and raise_error(SystemExit)
         end
       end
@@ -509,15 +496,10 @@ describe Mastodon::CLI::Accounts do
       context 'when the given number is greater than the number of users' do
         let(:options) { { number: total_users * 2 } }
 
-        it 'approves all users' do
-          cli.invoke(:approve, nil, options)
-
-          expect(User.pluck(:approved).all?(true)).to be(true)
-        end
-
-        it 'does not raise any error' do
-          expect { cli.invoke(:approve, nil, options) }
+        it 'approves all users and does not raise any error' do
+          expect { subject }
             .to_not raise_error
+          expect(User.pluck(:approved).all?(true)).to be(true)
         end
       end
     end
@@ -528,7 +510,7 @@ describe Mastodon::CLI::Accounts do
         let(:arguments) { [user.account.username] }
 
         it 'approves the specified user successfully' do
-          cli.invoke(:approve, arguments)
+          subject
 
           expect(user.reload.approved?).to be(true)
         end
@@ -538,9 +520,8 @@ describe Mastodon::CLI::Accounts do
         let(:arguments) { ['non_existent_username'] }
 
         it 'exits with an error message indicating that no such account was found' do
-          expect { cli.invoke(:approve, arguments) }.to output(
-            a_string_including('No such account')
-          ).to_stdout
+          expect { subject }
+            .to output_results('No such account')
             .and raise_error(SystemExit)
         end
       end
@@ -548,13 +529,14 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#follow' do
+    let(:action) { :follow }
+
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
 
       it 'exits with an error message indicating that no account with the given username was found' do
-        expect { cli.invoke(:follow, arguments) }.to output(
-          a_string_including('No such account')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No such account')
           .and raise_error(SystemExit)
       end
     end
@@ -565,36 +547,32 @@ describe Mastodon::CLI::Accounts do
       let!(:follower_rony)    { Fabricate(:account, username: 'rony') }
       let!(:follower_charles) { Fabricate(:account, username: 'charles') }
       let(:follow_service)    { instance_double(FollowService, call: nil) }
+      let(:arguments) { [target_account.username] }
 
       before do
         allow(FollowService).to receive(:new).and_return(follow_service)
         stub_parallelize_with_progress!
       end
 
-      it 'makes all local accounts follow the target account' do
-        cli.follow(target_account.username)
-
+      it 'displays a successful message and makes all local accounts follow the target account' do
+        expect { subject }
+          .to output_results("OK, followed target from #{Account.local.count} accounts")
         expect(follow_service).to have_received(:call).with(follower_bob, target_account, any_args).once
         expect(follow_service).to have_received(:call).with(follower_rony, target_account, any_args).once
         expect(follow_service).to have_received(:call).with(follower_charles, target_account, any_args).once
       end
-
-      it 'displays a successful message' do
-        expect { cli.follow(target_account.username) }.to output(
-          a_string_including("OK, followed target from #{Account.local.count} accounts")
-        ).to_stdout
-      end
     end
   end
 
   describe '#unfollow' do
+    let(:action) { :unfollow }
+
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
 
       it 'exits with an error message indicating that no account with the given username was found' do
-        expect { cli.invoke(:unfollow, arguments) }.to output(
-          a_string_including('No such account')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No such account')
           .and raise_error(SystemExit)
       end
     end
@@ -605,6 +583,7 @@ describe Mastodon::CLI::Accounts do
       let!(:follower_rambo)  { Fabricate(:account, username: 'rambo', domain: nil) }
       let!(:follower_ana)    { Fabricate(:account, username: 'ana', domain: nil) }
       let(:unfollow_service) { instance_double(UnfollowService, call: nil) }
+      let(:arguments) { [target_account.username] }
 
       before do
         accounts = [follower_chris, follower_rambo, follower_ana]
@@ -613,30 +592,25 @@ describe Mastodon::CLI::Accounts do
         stub_parallelize_with_progress!
       end
 
-      it 'makes all local accounts unfollow the target account' do
-        cli.unfollow(target_account.username)
-
+      it 'displays a successful message and makes all local accounts unfollow the target account' do
+        expect { subject }
+          .to output_results('OK, unfollowed target from 3 accounts')
         expect(unfollow_service).to have_received(:call).with(follower_chris, target_account).once
         expect(unfollow_service).to have_received(:call).with(follower_rambo, target_account).once
         expect(unfollow_service).to have_received(:call).with(follower_ana, target_account).once
       end
-
-      it 'displays a successful message' do
-        expect { cli.unfollow(target_account.username) }.to output(
-          a_string_including('OK, unfollowed target from 3 accounts')
-        ).to_stdout
-      end
     end
   end
 
   describe '#backup' do
+    let(:action) { :backup }
+
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
 
       it 'exits with an error message indicating that there is no such account' do
-        expect { cli.invoke(:backup, arguments) }.to output(
-          a_string_including('No user with such username')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No user with such username')
           .and raise_error(SystemExit)
       end
     end
@@ -646,23 +620,17 @@ describe Mastodon::CLI::Accounts do
       let(:user) { account.user }
       let(:arguments) { [account.username] }
 
-      it 'creates a new backup for the specified user' do
-        expect { cli.invoke(:backup, arguments) }.to change { user.backups.count }.by(1)
-      end
-
-      it 'creates a backup job' do
-        allow(BackupWorker).to receive(:perform_async)
-
-        cli.invoke(:backup, arguments)
-        latest_backup = user.backups.last
+      before { allow(BackupWorker).to receive(:perform_async) }
 
+      it 'creates a new backup and backup job for the specified user and outputs success message' do
+        expect { subject }
+          .to change { user.backups.count }.by(1)
+          .and output_results('OK')
         expect(BackupWorker).to have_received(:perform_async).with(latest_backup.id).once
       end
 
-      it 'displays a successful message' do
-        expect { cli.invoke(:backup, arguments) }.to output(
-          a_string_including('OK')
-        ).to_stdout
+      def latest_backup
+        user.backups.last
       end
     end
   end
@@ -724,9 +692,8 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'displays a successful message' do
-        expect { cli.refresh }.to output(
-          a_string_including('Refreshed 2 accounts')
-        ).to_stdout
+        expect { cli.refresh }
+          .to output_results('Refreshed 2 accounts')
       end
 
       context 'with --dry-run option' do
@@ -761,9 +728,8 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'displays a successful message with (DRY RUN)' do
-          expect { cli.refresh }.to output(
-            a_string_including('Refreshed 2 accounts (DRY RUN)')
-          ).to_stdout
+          expect { cli.refresh }
+            .to output_results('Refreshed 2 accounts (DRY RUN)')
         end
       end
     end
@@ -823,9 +789,7 @@ describe Mastodon::CLI::Accounts do
           allow(account_example_com_a).to receive(:reset_avatar!).and_raise(Mastodon::UnexpectedResponseError)
 
           expect { cli.refresh(*arguments) }
-            .to output(
-              a_string_including("Account failed: #{account_example_com_a.username}@#{account_example_com_a.domain}")
-            ).to_stdout
+            .to output_results("Account failed: #{account_example_com_a.username}@#{account_example_com_a.domain}")
         end
       end
 
@@ -833,9 +797,8 @@ describe Mastodon::CLI::Accounts do
         it 'exits with an error message' do
           allow(Account).to receive(:find_remote).with(account_example_com_b.username, account_example_com_b.domain).and_return(nil)
 
-          expect { cli.refresh(*arguments) }.to output(
-            a_string_including('No such account')
-          ).to_stdout
+          expect { cli.refresh(*arguments) }
+            .to output_results('No such account')
             .and raise_error(SystemExit)
         end
       end
@@ -878,7 +841,6 @@ describe Mastodon::CLI::Accounts do
         allow(cli).to receive(:parallelize_with_progress).and_yield(account_example_com_a)
                                                          .and_yield(account_example_com_b)
                                                          .and_return([2, nil])
-
         cli.options = { domain: domain }
       end
 
@@ -925,32 +887,33 @@ describe Mastodon::CLI::Accounts do
 
     context 'when neither a list of accts nor options are provided' do
       it 'exits with an error message' do
-        expect { cli.refresh }.to output(
-          a_string_including('No account(s) given')
-        ).to_stdout
+        expect { cli.refresh }
+          .to output_results('No account(s) given')
           .and raise_error(SystemExit)
       end
     end
   end
 
   describe '#rotate' do
+    let(:action) { :rotate }
+
     context 'when neither username nor --all option are given' do
       it 'exits with an error message' do
-        expect { cli.rotate }.to output(
-          a_string_including('No account(s) given')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No account(s) given')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when a username is given' do
       let(:account) { Fabricate(:account) }
+      let(:arguments) { [account.username] }
 
       it 'correctly rotates keys for the specified account' do
         old_private_key = account.private_key
         old_public_key = account.public_key
 
-        cli.rotate(account.username)
+        subject
         account.reload
 
         expect(account.private_key).to_not eq(old_private_key)
@@ -960,34 +923,31 @@ describe Mastodon::CLI::Accounts do
       it 'broadcasts the new keys for the specified account' do
         allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in)
 
-        cli.rotate(account.username)
+        subject
 
         expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id, anything).once
       end
+    end
 
-      context 'when the given username is not found' do
-        it 'exits with an error message when the specified username is not found' do
-          expect { cli.rotate('non_existent_username') }.to output(
-            a_string_including('No such account')
-          ).to_stdout
-            .and raise_error(SystemExit)
-        end
+    context 'when the given username is not found' do
+      let(:arguments) { ['non_existent_username'] }
+
+      it 'exits with an error message when the specified username is not found' do
+        expect { subject }
+          .to output_results('No such account')
+          .and raise_error(SystemExit)
       end
     end
 
     context 'when --all option is provided' do
       let!(:accounts) { Fabricate.times(2, :account) }
-      let(:options)   { { all: true } }
-
-      before do
-        cli.options = { all: true }
-      end
+      let(:options) { { all: true } }
 
       it 'correctly rotates keys for all local accounts' do
         old_private_keys = accounts.map(&:private_key)
         old_public_keys = accounts.map(&:public_key)
 
-        cli.rotate
+        subject
         accounts.each(&:reload)
 
         expect(accounts.map(&:private_key)).to_not eq(old_private_keys)
@@ -997,7 +957,7 @@ describe Mastodon::CLI::Accounts do
       it 'broadcasts the new keys for each account' do
         allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in)
 
-        cli.rotate
+        subject
 
         accounts.each do |account|
           expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id, anything).once
@@ -1007,11 +967,12 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#merge' do
+    let(:action) { :merge }
+
     shared_examples 'an account not found' do |acct|
       it 'exits with an error message indicating that there is no such account' do
-        expect { cli.invoke(:merge, arguments) }.to output(
-          a_string_including("No such account (#{acct})")
-        ).to_stdout
+        expect { subject }
+          .to output_results("No such account (#{acct})")
           .and raise_error(SystemExit)
       end
     end
@@ -1061,9 +1022,8 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'exits with an error message indicating that the accounts do not have the same pub key' do
-        expect { cli.invoke(:merge, arguments) }.to output(
-          a_string_including("Accounts don't have the same public key, might not be duplicates!\nOverride with --force")
-        ).to_stdout
+        expect { subject }
+          .to output_results("Accounts don't have the same public key, might not be duplicates!\nOverride with --force")
           .and raise_error(SystemExit)
       end
 
@@ -1075,15 +1035,10 @@ describe Mastodon::CLI::Accounts do
           allow(from_account).to receive(:destroy)
         end
 
-        it 'merges "from_account" into "to_account"' do
-          cli.invoke(:merge, arguments, options)
+        it 'merges `from_account` into `to_account` and deletes `from_account`' do
+          subject
 
           expect(to_account).to have_received(:merge_with!).with(from_account).once
-        end
-
-        it 'deletes "from_account"' do
-          cli.invoke(:merge, arguments, options)
-
           expect(from_account).to have_received(:destroy).once
         end
       end
@@ -1103,21 +1058,17 @@ describe Mastodon::CLI::Accounts do
         allow(from_account).to receive(:destroy)
       end
 
-      it 'merges "from_account" into "to_account"' do
-        cli.invoke(:merge, arguments)
+      it 'merges "from_account" into "to_account" and deletes from_account' do
+        subject
 
         expect(to_account).to have_received(:merge_with!).with(from_account).once
-      end
-
-      it 'deletes "from_account"' do
-        cli.invoke(:merge, arguments)
-
         expect(from_account).to have_received(:destroy)
       end
     end
   end
 
   describe '#cull' do
+    let(:action) { :cull }
     let(:delete_account_service) { instance_double(DeleteAccountService, call: nil) }
     let!(:tom)   { Fabricate(:account, updated_at: 30.days.ago, username: 'tom', uri: 'https://example.com/users/tom', domain: 'example.com', protocol: :activitypub) }
     let!(:bob)   { Fabricate(:account, updated_at: 30.days.ago, last_webfingered_at: nil, username: 'bob', uri: 'https://example.org/users/bob', domain: 'example.org', protocol: :activitypub) }
@@ -1137,34 +1088,28 @@ describe Mastodon::CLI::Accounts do
         stub_request(:head, 'https://example.net/users/tales').to_return(status: 200)
       end
 
-      it 'deletes all inactive remote accounts that longer exist in the origin server' do
-        cli.cull
-
+      def expect_delete_inactive_remote_accounts
         expect(delete_account_service).to have_received(:call).with(bob, reserve_username: false).once
         expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
       end
 
-      it 'does not delete any active remote account that still exists in the origin server' do
-        cli.cull
-
+      def expect_not_delete_active_accounts
         expect(delete_account_service).to_not have_received(:call).with(tom, reserve_username: false)
         expect(delete_account_service).to_not have_received(:call).with(ana, reserve_username: false)
         expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false)
       end
 
-      it 'touches inactive remote accounts that have not been deleted' do
-        expect { cli.cull }.to(change { tales.reload.updated_at })
-      end
-
-      it 'displays the summary correctly' do
-        expect { cli.cull }.to output(
-          a_string_including('Visited 5 accounts, removed 2')
-        ).to_stdout
+      it 'touches inactive remote accounts that have not been deleted and summarizes activity' do
+        expect { subject }
+          .to change { tales.reload.updated_at }
+          .and output_results('Visited 5 accounts, removed 2')
+        expect_delete_inactive_remote_accounts
+        expect_not_delete_active_accounts
       end
     end
 
     context 'when a domain is specified' do
-      let(:domain) { 'example.net' }
+      let(:arguments) { ['example.net'] }
 
       before do
         stub_parallelize_with_progress!
@@ -1172,17 +1117,15 @@ describe Mastodon::CLI::Accounts do
         stub_request(:head, 'https://example.net/users/tales').to_return(status: 404)
       end
 
-      it 'deletes inactive remote accounts that longer exist in the specified domain' do
-        cli.cull(domain)
-
+      def expect_delete_inactive_remote_accounts
         expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
         expect(delete_account_service).to have_received(:call).with(tales, reserve_username: false).once
       end
 
-      it 'displays the summary correctly' do
-        expect { cli.cull(domain) }.to output(
-          a_string_including('Visited 2 accounts, removed 2')
-        ).to_stdout
+      it 'displays the summary correctly and deletes inactive remote accounts' do
+        expect { subject }
+          .to output_results('Visited 2 accounts, removed 2')
+        expect_delete_inactive_remote_accounts
       end
     end
 
@@ -1194,16 +1137,14 @@ describe Mastodon::CLI::Accounts do
           stub_request(:head, 'https://example.net/users/gon').to_return(status: 200)
         end
 
-        it 'skips accounts from the unavailable domain' do
-          cli.cull
-
+        def expect_skip_accounts_from_unavailable_domain
           expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false)
         end
 
-        it 'displays the summary correctly' do
-          expect { cli.cull }.to output(
-            a_string_including("Visited 5 accounts, removed 0\nThe following domains were not available during the check:\n    example.net")
-          ).to_stdout
+        it 'displays the summary correctly and skip accounts from unavailable domains' do
+          expect { subject }
+            .to output_results("Visited 5 accounts, removed 0\nThe following domains were not available during the check:\n    example.net")
+          expect_skip_accounts_from_unavailable_domain
         end
       end
 
@@ -1242,25 +1183,25 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#reset_relationships' do
+    let(:action) { :reset_relationships }
     let(:target_account) { Fabricate(:account) }
     let(:arguments)      { [target_account.username] }
 
     context 'when no option is given' do
       it 'exits with an error message indicating that at least one option is required' do
-        expect { cli.invoke(:reset_relationships, arguments) }.to output(
-          a_string_including('Please specify either --follows or --followers, or both')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Please specify either --follows or --followers, or both')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
+      let(:options) { { follows: true } }
 
       it 'exits with an error message indicating that there is no such account' do
-        expect { cli.invoke(:reset_relationships, arguments, follows: true) }.to output(
-          a_string_including('No such account')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No such account')
           .and raise_error(SystemExit)
       end
     end
@@ -1274,26 +1215,14 @@ describe Mastodon::CLI::Accounts do
 
         before do
           accounts.each { |account| target_account.follow!(account) }
-        end
-
-        it 'resets all "following" relationships from the target account' do
-          cli.invoke(:reset_relationships, arguments, options)
-
-          expect(target_account.reload.following).to be_empty
-        end
-
-        it 'calls BootstrapTimelineWorker once to rebuild the timeline' do
           allow(BootstrapTimelineWorker).to receive(:perform_async)
-
-          cli.invoke(:reset_relationships, arguments, options)
-
-          expect(BootstrapTimelineWorker).to have_received(:perform_async).with(target_account.id).once
         end
 
-        it 'displays a successful message' do
-          expect { cli.invoke(:reset_relationships, arguments, options) }.to output(
-            a_string_including("Processed #{total_relationships} relationships")
-          ).to_stdout
+        it 'resets following relationships and displays a successful message and rebuilds timeline' do
+          expect { subject }
+            .to output_results("Processed #{total_relationships} relationships")
+          expect(target_account.reload.following).to be_empty
+          expect(BootstrapTimelineWorker).to have_received(:perform_async).with(target_account.id).once
         end
       end
 
@@ -1304,17 +1233,11 @@ describe Mastodon::CLI::Accounts do
           accounts.each { |account| account.follow!(target_account) }
         end
 
-        it 'resets all "followers" relationships from the target account' do
-          cli.invoke(:reset_relationships, arguments, options)
-
+        it 'resets followers relationships and displays a successful message' do
+          expect { subject }
+            .to output_results("Processed #{total_relationships} relationships")
           expect(target_account.reload.followers).to be_empty
         end
-
-        it 'displays a successful message' do
-          expect { cli.invoke(:reset_relationships, arguments, options) }.to output(
-            a_string_including("Processed #{total_relationships} relationships")
-          ).to_stdout
-        end
       end
 
       context 'with --follows and --followers options' do
@@ -1323,38 +1246,22 @@ describe Mastodon::CLI::Accounts do
         before do
           accounts.first(2).each { |account| account.follow!(target_account) }
           accounts.last(1).each  { |account| target_account.follow!(account) }
-        end
-
-        it 'resets all "followers" relationships from the target account' do
-          cli.invoke(:reset_relationships, arguments, options)
-
-          expect(target_account.reload.followers).to be_empty
-        end
-
-        it 'resets all "following" relationships from the target account' do
-          cli.invoke(:reset_relationships, arguments, options)
-
-          expect(target_account.reload.following).to be_empty
-        end
-
-        it 'calls BootstrapTimelineWorker once to rebuild the timeline' do
           allow(BootstrapTimelineWorker).to receive(:perform_async)
-
-          cli.invoke(:reset_relationships, arguments, options)
-
-          expect(BootstrapTimelineWorker).to have_received(:perform_async).with(target_account.id).once
         end
 
-        it 'displays a successful message' do
-          expect { cli.invoke(:reset_relationships, arguments, options) }.to output(
-            a_string_including("Processed #{total_relationships} relationships")
-          ).to_stdout
+        it 'resets followers and following and displays a successful message and rebuilds timeline' do
+          expect { subject }
+            .to output_results("Processed #{total_relationships} relationships")
+          expect(target_account.reload.followers).to be_empty
+          expect(target_account.reload.following).to be_empty
+          expect(BootstrapTimelineWorker).to have_received(:perform_async).with(target_account.id).once
         end
       end
     end
   end
 
   describe '#prune' do
+    let(:action) { :prune }
     let!(:local_account)     { Fabricate(:account) }
     let!(:bot_account)       { Fabricate(:account, bot: true, domain: 'example.com') }
     let!(:group_account)     { Fabricate(:account, actor_type: 'Group', domain: 'example.com') }
@@ -1368,66 +1275,57 @@ describe Mastodon::CLI::Accounts do
       stub_parallelize_with_progress!
     end
 
-    it 'prunes all remote accounts with no interactions with local users' do
-      cli.prune
-
+    def expect_prune_remote_accounts_without_interaction
       prunable_account_ids = prunable_accounts.pluck(:id)
 
       expect(Account.where(id: prunable_account_ids).count).to eq(0)
     end
 
-    it 'displays a successful message' do
-      expect { cli.prune }.to output(
-        a_string_including("OK, pruned #{prunable_accounts.size} accounts")
-      ).to_stdout
+    it 'displays a successful message and handles accounts correctly' do
+      expect { subject }
+        .to output_results("OK, pruned #{prunable_accounts.size} accounts")
+      expect_prune_remote_accounts_without_interaction
+      expect_not_prune_local_accounts
+      expect_not_prune_bot_accounts
+      expect_not_prune_group_accounts
+      expect_not_prune_mentioned_accounts
     end
 
-    it 'does not prune local accounts' do
-      cli.prune
-
+    def expect_not_prune_local_accounts
       expect(Account.exists?(id: local_account.id)).to be(true)
     end
 
-    it 'does not prune bot accounts' do
-      cli.prune
-
+    def expect_not_prune_bot_accounts
       expect(Account.exists?(id: bot_account.id)).to be(true)
     end
 
-    it 'does not prune group accounts' do
-      cli.prune
-
+    def expect_not_prune_group_accounts
       expect(Account.exists?(id: group_account.id)).to be(true)
     end
 
-    it 'does not prune accounts that have been mentioned' do
-      cli.prune
-
+    def expect_not_prune_mentioned_accounts
       expect(Account.exists?(id: mentioned_account.id)).to be true
     end
 
     context 'with --dry-run option' do
-      before do
-        cli.options = { dry_run: true }
-      end
-
-      it 'does not prune any account' do
-        cli.prune
+      let(:options) { { dry_run: true } }
 
+      def expect_no_account_prunes
         prunable_account_ids = prunable_accounts.pluck(:id)
 
         expect(Account.where(id: prunable_account_ids).count).to eq(prunable_accounts.size)
       end
 
-      it 'displays a successful message with (DRY RUN)' do
-        expect { cli.prune }.to output(
-          a_string_including("OK, pruned #{prunable_accounts.size} accounts (DRY RUN)")
-        ).to_stdout
+      it 'displays a successful message with (DRY RUN) and doesnt prune anything' do
+        expect { subject }
+          .to output_results("OK, pruned #{prunable_accounts.size} accounts (DRY RUN)")
+        expect_no_account_prunes
       end
     end
   end
 
   describe '#migrate' do
+    let(:action) { :migrate }
     let!(:source_account)         { Fabricate(:account) }
     let!(:target_account)         { Fabricate(:account, domain: 'example.com') }
     let(:arguments)               { [source_account.username] }
@@ -1441,7 +1339,7 @@ describe Mastodon::CLI::Accounts do
 
     shared_examples 'a successful migration' do
       it 'calls the MoveService for the last migration' do
-        cli.invoke(:migrate, arguments, options)
+        subject
 
         last_migration = source_account.migrations.last
 
@@ -1449,9 +1347,8 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'displays a successful message' do
-        expect { cli.invoke(:migrate, arguments, options) }.to output(
-          a_string_including("OK, migrated #{source_account.acct} to #{target_account.acct}")
-        ).to_stdout
+        expect { subject }
+          .to output_results("OK, migrated #{source_account.acct} to #{target_account.acct}")
       end
     end
 
@@ -1459,29 +1356,27 @@ describe Mastodon::CLI::Accounts do
       let(:options) { { replay: true, target: "#{target_account.username}@example.com" } }
 
       it 'exits with an error message indicating that using both options is not possible' do
-        expect { cli.invoke(:migrate, arguments, options) }.to output(
-          a_string_including('Use --replay or --target, not both')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Use --replay or --target, not both')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when no option is given' do
       it 'exits with an error message indicating that at least one option must be used' do
-        expect { cli.invoke(:migrate, arguments, {}) }.to output(
-          a_string_including('Use either --replay or --target')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Use either --replay or --target')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
+      let(:options) { { replay: true } }
 
       it 'exits with an error message indicating that there is no such account' do
-        expect { cli.invoke(:migrate, arguments, replay: true) }.to output(
-          a_string_including("No such account: #{arguments.first}")
-        ).to_stdout
+        expect { subject }
+          .to output_results("No such account: #{arguments.first}")
           .and raise_error(SystemExit)
       end
     end
@@ -1491,9 +1386,8 @@ describe Mastodon::CLI::Accounts do
 
       context 'when the specified account has no previous migrations' do
         it 'exits with an error message indicating that the given account has no previous migrations' do
-          expect { cli.invoke(:migrate, arguments, options) }.to output(
-            a_string_including('The specified account has not performed any migration')
-          ).to_stdout
+          expect { subject }
+            .to output_results('The specified account has not performed any migration')
             .and raise_error(SystemExit)
         end
       end
@@ -1515,9 +1409,8 @@ describe Mastodon::CLI::Accounts do
           end
 
           it 'exits with an error message' do
-            expect { cli.invoke(:migrate, arguments, options) }.to output(
-              a_string_including('The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway')
-            ).to_stdout
+            expect { subject }
+              .to output_results('The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway')
               .and raise_error(SystemExit)
           end
         end
@@ -1544,9 +1437,8 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'exits with an error message indicating that there is no such account' do
-          expect { cli.invoke(:migrate, arguments, options) }.to output(
-            a_string_including("The specified target account could not be found: #{options[:target]}")
-          ).to_stdout
+          expect { subject }
+            .to output_results("The specified target account could not be found: #{options[:target]}")
             .and raise_error(SystemExit)
         end
       end
@@ -1557,7 +1449,7 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'creates a migration for the specified account with the target account' do
-          cli.invoke(:migrate, arguments, options)
+          subject
 
           last_migration = source_account.migrations.last
 
@@ -1569,9 +1461,8 @@ describe Mastodon::CLI::Accounts do
 
       context 'when the migration record is invalid' do
         it 'exits with an error indicating that the validation failed' do
-          expect { cli.invoke(:migrate, arguments, options) }.to output(
-            a_string_including('Error: Validation failed')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Error: Validation failed')
             .and raise_error(SystemExit)
         end
       end
@@ -1582,9 +1473,8 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'exits with an error message' do
-          expect { cli.invoke(:migrate, arguments, options) }.to output(
-            a_string_including('The specified account is redirecting to a different target account. Use --force if you want to change the migration target')
-          ).to_stdout
+          expect { subject }
+            .to output_results('The specified account is redirecting to a different target account. Use --force if you want to change the migration target')
             .and raise_error(SystemExit)
         end
       end
diff --git a/spec/lib/mastodon/cli/cache_spec.rb b/spec/lib/mastodon/cli/cache_spec.rb
index c1ce04710c..b1515801eb 100644
--- a/spec/lib/mastodon/cli/cache_spec.rb
+++ b/spec/lib/mastodon/cli/cache_spec.rb
@@ -4,22 +4,29 @@ require 'rails_helper'
 require 'mastodon/cli/cache'
 
 describe Mastodon::CLI::Cache do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#clear' do
+    let(:action) { :clear }
+
     before { allow(Rails.cache).to receive(:clear) }
 
     it 'clears the Rails cache' do
-      expect { cli.invoke(:clear) }.to output(
-        a_string_including('OK')
-      ).to_stdout
+      expect { subject }
+        .to output_results('OK')
       expect(Rails.cache).to have_received(:clear)
     end
   end
 
   describe '#recount' do
+    let(:action) { :recount }
+
     context 'with the `accounts` argument' do
       let(:arguments) { ['accounts'] }
       let(:account_stat) { Fabricate(:account_stat) }
@@ -29,9 +36,8 @@ describe Mastodon::CLI::Cache do
       end
 
       it 're-calculates account records in the cache' do
-        expect { cli.invoke(:recount, arguments) }.to output(
-          a_string_including('OK')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK')
 
         expect(account_stat.reload.statuses_count).to be_zero
       end
@@ -46,9 +52,8 @@ describe Mastodon::CLI::Cache do
       end
 
       it 're-calculates account records in the cache' do
-        expect { cli.invoke(:recount, arguments) }.to output(
-          a_string_including('OK')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK')
 
         expect(status_stat.reload.replies_count).to be_zero
       end
@@ -58,9 +63,9 @@ describe Mastodon::CLI::Cache do
       let(:arguments) { ['other-type'] }
 
       it 'Exits with an error message' do
-        expect { cli.invoke(:recount, arguments) }.to output(
-          a_string_including('Unknown')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('Unknown')
+          .and raise_error(SystemExit)
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb b/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb
index 6e4675748e..1745ea01bf 100644
--- a/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb
+++ b/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb
@@ -4,42 +4,45 @@ require 'rails_helper'
 require 'mastodon/cli/canonical_email_blocks'
 
 describe Mastodon::CLI::CanonicalEmailBlocks do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#find' do
+    let(:action) { :find }
     let(:arguments) { ['user@example.com'] }
 
     context 'when a block is present' do
       before { Fabricate(:canonical_email_block, email: 'user@example.com') }
 
       it 'announces the presence of the block' do
-        expect { cli.invoke(:find, arguments) }.to output(
-          a_string_including('user@example.com is blocked')
-        ).to_stdout
+        expect { subject }
+          .to output_results('user@example.com is blocked')
       end
     end
 
     context 'when a block is not present' do
       it 'announces the absence of the block' do
-        expect { cli.invoke(:find, arguments) }.to output(
-          a_string_including('user@example.com is not blocked')
-        ).to_stdout
+        expect { subject }
+          .to output_results('user@example.com is not blocked')
       end
     end
   end
 
   describe '#remove' do
+    let(:action) { :remove }
     let(:arguments) { ['user@example.com'] }
 
     context 'when a block is present' do
       before { Fabricate(:canonical_email_block, email: 'user@example.com') }
 
       it 'removes the block' do
-        expect { cli.invoke(:remove, arguments) }.to output(
-          a_string_including('Unblocked user@example.com')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Unblocked user@example.com')
 
         expect(CanonicalEmailBlock.matching_email('user@example.com')).to be_empty
       end
@@ -47,9 +50,8 @@ describe Mastodon::CLI::CanonicalEmailBlocks do
 
     context 'when a block is not present' do
       it 'announces the absence of the block' do
-        expect { cli.invoke(:remove, arguments) }.to output(
-          a_string_including('user@example.com is not blocked')
-        ).to_stdout
+        expect { subject }
+          .to output_results('user@example.com is not blocked')
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/domains_spec.rb b/spec/lib/mastodon/cli/domains_spec.rb
index add754159c..24f341c124 100644
--- a/spec/lib/mastodon/cli/domains_spec.rb
+++ b/spec/lib/mastodon/cli/domains_spec.rb
@@ -4,22 +4,75 @@ require 'rails_helper'
 require 'mastodon/cli/domains'
 
 describe Mastodon::CLI::Domains do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#purge' do
+    let(:action) { :purge }
+
     context 'with accounts from the domain' do
-      let(:options) { {} }
       let(:domain) { 'host.example' }
       let!(:account) { Fabricate(:account, domain: domain) }
+      let(:arguments) { [domain] }
 
       it 'removes the account' do
-        expect { cli.invoke(:purge, [domain], options) }.to output(
-          a_string_including('Removed 1 accounts')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Removed 1 accounts')
+
         expect { account.reload }.to raise_error(ActiveRecord::RecordNotFound)
       end
     end
   end
+
+  describe '#crawl' do
+    let(:action) { :crawl }
+
+    context 'with accounts from the domain' do
+      let(:domain) { 'host.example' }
+
+      before do
+        Fabricate(:account, domain: domain)
+        stub_request(:get, 'https://host.example/api/v1/instance').to_return(status: 200, body: {}.to_json)
+        stub_request(:get, 'https://host.example/api/v1/instance/peers').to_return(status: 200, body: {}.to_json)
+        stub_request(:get, 'https://host.example/api/v1/instance/activity').to_return(status: 200, body: {}.to_json)
+        stub_const('Mastodon::CLI::Domains::CRAWL_SLEEP_TIME', 0)
+      end
+
+      context 'with --format of summary' do
+        let(:options) { { format: 'summary' } }
+
+        it 'crawls the domains and summarizes results' do
+          expect { subject }
+            .to output_results('Visited 1 domains, 0 failed')
+        end
+      end
+
+      context 'with --format of domains' do
+        let(:options) { { format: 'domains' } }
+
+        it 'crawls the domains and summarizes results' do
+          expect { subject }
+            .to output_results(domain)
+        end
+      end
+
+      context 'with --format of json' do
+        let(:options) { { format: 'json' } }
+
+        it 'crawls the domains and summarizes results' do
+          expect { subject }
+            .to output_results(json_summary)
+        end
+
+        def json_summary
+          Oj.dump('host.example': { activity: {} })
+        end
+      end
+    end
+  end
 end
diff --git a/spec/lib/mastodon/cli/email_domain_blocks_spec.rb b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb
index f5cb6c332b..13deb05b6c 100644
--- a/spec/lib/mastodon/cli/email_domain_blocks_spec.rb
+++ b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb
@@ -4,96 +4,99 @@ require 'rails_helper'
 require 'mastodon/cli/email_domain_blocks'
 
 describe Mastodon::CLI::EmailDomainBlocks do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#list' do
+    let(:action) { :list }
+
     context 'with email domain block records' do
       let!(:parent_block) { Fabricate(:email_domain_block) }
       let!(:child_block) { Fabricate(:email_domain_block, parent: parent_block) }
-      let(:options) { {} }
 
       it 'lists the blocks' do
-        expect { cli.invoke(:list, [], options) }.to output(
-          a_string_including(parent_block.domain)
-          .and(a_string_including(child_block.domain))
-        ).to_stdout
+        expect { subject }
+          .to output_results(
+            parent_block.domain,
+            child_block.domain
+          )
       end
     end
   end
 
   describe '#add' do
-    context 'without any options' do
-      let(:options) { {} }
+    let(:action) { :add }
 
+    context 'without any options' do
       it 'warns about usage and exits' do
-        expect { cli.invoke(:add, [], options) }.to output(
-          a_string_including('No domain(s) given')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('No domain(s) given')
+          .and raise_error(SystemExit)
       end
     end
 
     context 'when blocks exist' do
       let(:options) { {} }
       let(:domain) { 'host.example' }
+      let(:arguments) { [domain] }
 
       before { Fabricate(:email_domain_block, domain: domain) }
 
       it 'does not add a new block' do
-        expect { cli.invoke(:add, [domain], options) }.to output(
-          a_string_including('is already blocked')
-        ).to_stdout
+        expect { subject }
+          .to output_results('is already blocked')
           .and(not_change(EmailDomainBlock, :count))
       end
     end
 
     context 'when no blocks exist' do
-      let(:options) { {} }
       let(:domain) { 'host.example' }
+      let(:arguments) { [domain] }
 
       it 'adds a new block' do
-        expect { cli.invoke(:add, [domain], options) }.to output(
-          a_string_including('Added 1')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Added 1')
           .and(change(EmailDomainBlock, :count).by(1))
       end
     end
   end
 
   describe '#remove' do
-    context 'without any options' do
-      let(:options) { {} }
+    let(:action) { :remove }
 
+    context 'without any options' do
       it 'warns about usage and exits' do
-        expect { cli.invoke(:remove, [], options) }.to output(
-          a_string_including('No domain(s) given')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('No domain(s) given')
+          .and raise_error(SystemExit)
       end
     end
 
     context 'when blocks exist' do
-      let(:options) { {} }
       let(:domain) { 'host.example' }
+      let(:arguments) { [domain] }
 
       before { Fabricate(:email_domain_block, domain: domain) }
 
       it 'removes the block' do
-        expect { cli.invoke(:remove, [domain], options) }.to output(
-          a_string_including('Removed 1')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Removed 1')
           .and(change(EmailDomainBlock, :count).by(-1))
       end
     end
 
     context 'when no blocks exist' do
-      let(:options) { {} }
       let(:domain) { 'host.example' }
+      let(:arguments) { [domain] }
 
       it 'does not remove a block' do
-        expect { cli.invoke(:remove, [domain], options) }.to output(
-          a_string_including('is not yet blocked')
-        ).to_stdout
+        expect { subject }
+          .to output_results('is not yet blocked')
           .and(not_change(EmailDomainBlock, :count))
       end
     end
diff --git a/spec/lib/mastodon/cli/emoji_spec.rb b/spec/lib/mastodon/cli/emoji_spec.rb
index 3441413b90..d05e972e77 100644
--- a/spec/lib/mastodon/cli/emoji_spec.rb
+++ b/spec/lib/mastodon/cli/emoji_spec.rb
@@ -4,10 +4,10 @@ require 'rails_helper'
 require 'mastodon/cli/emoji'
 
 describe Mastodon::CLI::Emoji do
-  subject { cli.invoke(action, args, options) }
+  subject { cli.invoke(action, arguments, options) }
 
   let(:cli) { described_class.new }
-  let(:args) { [] }
+  let(:arguments) { [] }
   let(:options) { {} }
 
   it_behaves_like 'CLI Command'
@@ -29,7 +29,7 @@ describe Mastodon::CLI::Emoji do
     context 'with existing custom emoji' do
       let(:import_path) { Rails.root.join('spec', 'fixtures', 'files', 'elite-assets.tar.gz') }
       let(:action) { :import }
-      let(:args) { [import_path] }
+      let(:arguments) { [import_path] }
 
       it 'reports about imported emoji' do
         expect { subject }
@@ -51,7 +51,7 @@ describe Mastodon::CLI::Emoji do
       after { FileUtils.rm_rf(export_path.dirname) }
 
       let(:export_path) { Rails.root.join('tmp', 'cli-tests', 'export.tar.gz') }
-      let(:args) { [export_path.dirname.to_s] }
+      let(:arguments) { [export_path.dirname.to_s] }
       let(:action) { :export }
 
       it 'reports about exported emoji' do
@@ -61,8 +61,4 @@ describe Mastodon::CLI::Emoji do
       end
     end
   end
-
-  def output_results(string)
-    output(a_string_including(string)).to_stdout
-  end
 end
diff --git a/spec/lib/mastodon/cli/feeds_spec.rb b/spec/lib/mastodon/cli/feeds_spec.rb
index e16113c854..1997980527 100644
--- a/spec/lib/mastodon/cli/feeds_spec.rb
+++ b/spec/lib/mastodon/cli/feeds_spec.rb
@@ -4,20 +4,25 @@ require 'rails_helper'
 require 'mastodon/cli/feeds'
 
 describe Mastodon::CLI::Feeds do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#build' do
+    let(:action) { :build }
+
     before { Fabricate(:account) }
 
     context 'with --all option' do
       let(:options) { { all: true } }
 
       it 'regenerates feeds for all accounts' do
-        expect { cli.invoke(:build, [], options) }.to output(
-          a_string_including('Regenerated feeds')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Regenerated feeds')
       end
     end
 
@@ -27,9 +32,8 @@ describe Mastodon::CLI::Feeds do
       let(:arguments) { ['alice'] }
 
       it 'regenerates feeds for the account' do
-        expect { cli.invoke(:build, arguments) }.to output(
-          a_string_including('OK')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK')
       end
     end
 
@@ -37,22 +41,23 @@ describe Mastodon::CLI::Feeds do
       let(:arguments) { ['invalid-username'] }
 
       it 'displays an error and exits' do
-        expect { cli.invoke(:build, arguments) }.to output(
-          a_string_including('No such account')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('No such account')
+          .and raise_error(SystemExit)
       end
     end
   end
 
   describe '#clear' do
+    let(:action) { :clear }
+
     before do
       allow(redis).to receive(:del).with(key_namespace)
     end
 
     it 'clears the redis `feed:*` namespace' do
-      expect { cli.invoke(:clear) }.to output(
-        a_string_including('OK')
-      ).to_stdout
+      expect { subject }
+        .to output_results('OK')
 
       expect(redis).to have_received(:del).with(key_namespace).once
     end
diff --git a/spec/lib/mastodon/cli/ip_blocks_spec.rb b/spec/lib/mastodon/cli/ip_blocks_spec.rb
index 684314dc7a..1d6c47268e 100644
--- a/spec/lib/mastodon/cli/ip_blocks_spec.rb
+++ b/spec/lib/mastodon/cli/ip_blocks_spec.rb
@@ -4,11 +4,16 @@ require 'rails_helper'
 require 'mastodon/cli/ip_blocks'
 
 describe Mastodon::CLI::IpBlocks do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#add' do
+    let(:action) { :add }
     let(:ip_list) do
       [
         '192.0.2.1',
@@ -25,29 +30,28 @@ describe Mastodon::CLI::IpBlocks do
       ]
     end
     let(:options) { { severity: 'no_access' } }
+    let(:arguments) { ip_list }
 
     shared_examples 'ip address blocking' do
-      it 'blocks all specified IP addresses' do
-        cli.invoke(:add, ip_list, options)
-
-        blocked_ip_addresses = IpBlock.where(ip: ip_list).pluck(:ip)
-        expected_ip_addresses = ip_list.map { |ip| IPAddr.new(ip) }
-
-        expect(blocked_ip_addresses).to match_array(expected_ip_addresses)
+      def blocked_ip_addresses
+        IpBlock.where(ip: ip_list).pluck(:ip)
       end
 
-      it 'sets the severity for all blocked IP addresses' do
-        cli.invoke(:add, ip_list, options)
-
-        blocked_ips_severity = IpBlock.where(ip: ip_list).pluck(:severity).all?(options[:severity])
-
-        expect(blocked_ips_severity).to be(true)
+      def expected_ip_addresses
+        ip_list.map { |ip| IPAddr.new(ip) }
       end
 
-      it 'displays a success message with a summary' do
-        expect { cli.invoke(:add, ip_list, options) }.to output(
-          a_string_including("Added #{ip_list.size}, skipped 0, failed 0")
-        ).to_stdout
+      def blocked_ips_severity
+        IpBlock.where(ip: ip_list).pluck(:severity).all?(options[:severity])
+      end
+
+      it 'blocks and sets severity for ip address and displays summary' do
+        expect { subject }
+          .to output_results("Added #{ip_list.size}, skipped 0, failed 0")
+        expect(blocked_ip_addresses)
+          .to match_array(expected_ip_addresses)
+        expect(blocked_ips_severity)
+          .to be(true)
       end
     end
 
@@ -57,27 +61,23 @@ describe Mastodon::CLI::IpBlocks do
 
     context 'when a specified IP address is already blocked' do
       let!(:blocked_ip) { IpBlock.create(ip: ip_list.last, severity: options[:severity]) }
+      let(:arguments) { ip_list }
 
-      it 'skips the already blocked IP address' do
-        allow(IpBlock).to receive(:new).and_call_original
+      before { allow(IpBlock).to receive(:new).and_call_original }
 
-        cli.invoke(:add, ip_list, options)
+      it 'skips already block ip and displays the correct summary' do
+        expect { subject }
+          .to output_results("#{ip_list.last} is already blocked\nAdded #{ip_list.size - 1}, skipped 1, failed 0")
 
         expect(IpBlock).to_not have_received(:new).with(ip: ip_list.last)
       end
 
-      it 'displays the correct summary' do
-        expect { cli.invoke(:add, ip_list, options) }.to output(
-          a_string_including("#{ip_list.last} is already blocked\nAdded #{ip_list.size - 1}, skipped 1, failed 0")
-        ).to_stdout
-      end
-
       context 'with --force option' do
         let!(:blocked_ip) { IpBlock.create(ip: ip_list.last, severity: 'no_access') }
         let(:options) { { severity: 'sign_up_requires_approval', force: true } }
 
         it 'overwrites the existing IP block record' do
-          expect { cli.invoke(:add, ip_list, options) }
+          expect { subject }
             .to change { blocked_ip.reload.severity }
             .from('no_access')
             .to('sign_up_requires_approval')
@@ -89,11 +89,11 @@ describe Mastodon::CLI::IpBlocks do
 
     context 'when a specified IP address is invalid' do
       let(:ip_list) { ['320.15.175.0', '9.5.105.255', '0.0.0.0'] }
+      let(:arguments) { ip_list }
 
       it 'displays the correct summary' do
-        expect { cli.invoke(:add, ip_list, options) }.to output(
-          a_string_including("#{ip_list.first} is invalid\nAdded #{ip_list.size - 1}, skipped 0, failed 1")
-        ).to_stdout
+        expect { subject }
+          .to output_results("#{ip_list.first} is invalid\nAdded #{ip_list.size - 1}, skipped 0, failed 1")
       end
     end
 
@@ -124,6 +124,7 @@ describe Mastodon::CLI::IpBlocks do
     context 'when a specified IP address fails to be blocked' do
       let(:ip_address) { '127.0.0.1' }
       let(:ip_block) { instance_double(IpBlock, ip: ip_address, save: false) }
+      let(:arguments) { [ip_address] }
 
       before do
         allow(IpBlock).to receive(:new).and_return(ip_block)
@@ -132,24 +133,25 @@ describe Mastodon::CLI::IpBlocks do
       end
 
       it 'displays an error message' do
-        expect { cli.invoke(:add, [ip_address], options) }
-          .to output(
-            a_string_including("#{ip_address} could not be saved")
-          ).to_stdout
+        expect { subject }
+          .to output_results("#{ip_address} could not be saved")
       end
     end
 
     context 'when no IP address is provided' do
+      let(:arguments) { [] }
+
       it 'exits with an error message' do
-        expect { cli.add }.to output(
-          a_string_including('No IP(s) given')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No IP(s) given')
           .and raise_error(SystemExit)
       end
     end
   end
 
   describe '#remove' do
+    let(:action) { :remove }
+
     context 'when removing exact matches' do
       let(:ip_list) do
         [
@@ -166,22 +168,17 @@ describe Mastodon::CLI::IpBlocks do
           '::/128',
         ]
       end
+      let(:arguments) { ip_list }
 
       before do
         ip_list.each { |ip| IpBlock.create(ip: ip, severity: :no_access) }
       end
 
-      it 'removes exact IP blocks' do
-        cli.invoke(:remove, ip_list)
-
+      it 'removes exact ip blocks and displays success message with a summary' do
+        expect { subject }
+          .to output_results("Removed #{ip_list.size}, skipped 0")
         expect(IpBlock.where(ip: ip_list)).to_not exist
       end
-
-      it 'displays success message with a summary' do
-        expect { cli.invoke(:remove, ip_list) }.to output(
-          a_string_including("Removed #{ip_list.size}, skipped 0")
-        ).to_stdout
-      end
     end
 
     context 'with --force option' do
@@ -191,62 +188,60 @@ describe Mastodon::CLI::IpBlocks do
       let(:arguments) { ['192.168.0.5', '10.0.1.50'] }
       let(:options) { { force: true } }
 
-      it 'removes blocks for IP ranges that cover given IP(s)' do
-        cli.invoke(:remove, arguments, options)
+      it 'removes blocks for IP ranges that cover given IP(s) and keeps other ranges' do
+        subject
 
-        expect(IpBlock.where(id: [first_ip_range_block.id, second_ip_range_block.id])).to_not exist
+        expect(covered_ranges).to_not exist
+        expect(other_ranges).to exist
       end
 
-      it 'does not remove other IP ranges' do
-        cli.invoke(:remove, arguments, options)
+      def covered_ranges
+        IpBlock.where(id: [first_ip_range_block.id, second_ip_range_block.id])
+      end
 
-        expect(IpBlock.where(id: third_ip_range_block.id)).to exist
+      def other_ranges
+        IpBlock.where(id: third_ip_range_block.id)
       end
     end
 
     context 'when a specified IP address is not blocked' do
       let(:unblocked_ip) { '192.0.2.1' }
+      let(:arguments) { [unblocked_ip] }
 
-      it 'skips the IP address' do
-        expect { cli.invoke(:remove, [unblocked_ip]) }.to output(
-          a_string_including("#{unblocked_ip} is not yet blocked")
-        ).to_stdout
-      end
-
-      it 'displays the summary correctly' do
-        expect { cli.invoke(:remove, [unblocked_ip]) }.to output(
-          a_string_including('Removed 0, skipped 1')
-        ).to_stdout
+      it 'skips the IP address and displays summary' do
+        expect { subject }
+          .to output_results(
+            "#{unblocked_ip} is not yet blocked",
+            'Removed 0, skipped 1'
+          )
       end
     end
 
     context 'when a specified IP address is invalid' do
       let(:invalid_ip) { '320.15.175.0' }
+      let(:arguments) { [invalid_ip] }
 
-      it 'skips the invalid IP address' do
-        expect { cli.invoke(:remove, [invalid_ip]) }.to output(
-          a_string_including("#{invalid_ip} is invalid")
-        ).to_stdout
-      end
-
-      it 'displays the summary correctly' do
-        expect { cli.invoke(:remove, [invalid_ip]) }.to output(
-          a_string_including('Removed 0, skipped 1')
-        ).to_stdout
+      it 'skips the invalid IP address and displays summary' do
+        expect { subject }
+          .to output_results(
+            "#{invalid_ip} is invalid",
+            'Removed 0, skipped 1'
+          )
       end
     end
 
     context 'when no IP address is provided' do
       it 'exits with an error message' do
-        expect { cli.remove }.to output(
-          a_string_including('No IP(s) given')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No IP(s) given')
           .and raise_error(SystemExit)
       end
     end
   end
 
   describe '#export' do
+    let(:action) { :export }
+
     let(:first_ip_range_block) { IpBlock.create(ip: '192.168.0.0/24', severity: :no_access) }
     let(:second_ip_range_block) { IpBlock.create(ip: '10.0.0.0/16', severity: :no_access) }
     let(:third_ip_range_block) { IpBlock.create(ip: '127.0.0.1', severity: :sign_up_block) }
@@ -255,15 +250,13 @@ describe Mastodon::CLI::IpBlocks do
       let(:options) { { format: 'plain' } }
 
       it 'exports blocked IPs with "no_access" severity in plain format' do
-        expect { cli.invoke(:export, nil, options) }.to output(
-          a_string_including("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}")
-        ).to_stdout
+        expect { subject }
+          .to output_results("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}")
       end
 
-      it 'does not export bloked IPs with different severities' do
-        expect { cli.invoke(:export, nil, options) }.to_not output(
-          a_string_including("#{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}")
-        ).to_stdout
+      it 'does not export blocked IPs with different severities' do
+        expect { subject }
+          .to_not output_results("#{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}")
       end
     end
 
@@ -271,23 +264,20 @@ describe Mastodon::CLI::IpBlocks do
       let(:options) { { format: 'nginx' } }
 
       it 'exports blocked IPs with "no_access" severity in plain format' do
-        expect { cli.invoke(:export, nil, options) }.to output(
-          a_string_including("deny #{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};\ndeny #{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix};")
-        ).to_stdout
+        expect { subject }
+          .to output_results("deny #{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};\ndeny #{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix};")
       end
 
-      it 'does not export bloked IPs with different severities' do
-        expect { cli.invoke(:export, nil, options) }.to_not output(
-          a_string_including("deny #{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};")
-        ).to_stdout
+      it 'does not export blocked IPs with different severities' do
+        expect { subject }
+          .to_not output_results("deny #{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};")
       end
     end
 
     context 'when --format option is not provided' do
       it 'exports blocked IPs in plain format by default' do
-        expect { cli.export }.to output(
-          a_string_including("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}")
-        ).to_stdout
+        expect { subject }
+          .to output_results("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}")
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/main_spec.rb b/spec/lib/mastodon/cli/main_spec.rb
index b5b5d69062..59f1fc4784 100644
--- a/spec/lib/mastodon/cli/main_spec.rb
+++ b/spec/lib/mastodon/cli/main_spec.rb
@@ -4,13 +4,20 @@ require 'rails_helper'
 require 'mastodon/cli/main'
 
 describe Mastodon::CLI::Main do
+  subject { cli.invoke(action, arguments, options) }
+
+  let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
+
   it_behaves_like 'CLI Command'
 
-  describe 'version' do
+  describe '#version' do
+    let(:action) { :version }
+
     it 'returns the Mastodon version' do
-      expect { described_class.new.invoke(:version) }.to output(
-        a_string_including(Mastodon::Version.to_s)
-      ).to_stdout
+      expect { subject }
+        .to output_results(Mastodon::Version.to_s)
     end
   end
 end
diff --git a/spec/lib/mastodon/cli/maintenance_spec.rb b/spec/lib/mastodon/cli/maintenance_spec.rb
index 95e695ab55..02169b7a42 100644
--- a/spec/lib/mastodon/cli/maintenance_spec.rb
+++ b/spec/lib/mastodon/cli/maintenance_spec.rb
@@ -4,20 +4,26 @@ require 'rails_helper'
 require 'mastodon/cli/maintenance'
 
 describe Mastodon::CLI::Maintenance do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#fix_duplicates' do
+    let(:action) { :fix_duplicates }
+
     context 'when the database version is too old' do
       before do
         allow(ActiveRecord::Migrator).to receive(:current_version).and_return(2000_01_01_000000) # Earlier than minimum
       end
 
       it 'Exits with error message' do
-        expect { cli.invoke :fix_duplicates }.to output(
-          a_string_including('is too old')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('is too old')
+          .and raise_error(SystemExit)
       end
     end
 
@@ -28,9 +34,9 @@ describe Mastodon::CLI::Maintenance do
       end
 
       it 'Exits with error message' do
-        expect { cli.invoke :fix_duplicates }.to output(
-          a_string_including('more recent')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('more recent')
+          .and raise_error(SystemExit)
       end
     end
 
@@ -41,9 +47,9 @@ describe Mastodon::CLI::Maintenance do
       end
 
       it 'Exits with error message' do
-        expect { cli.invoke :fix_duplicates }.to output(
-          a_string_including('Sidekiq is running')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('Sidekiq is running')
+          .and raise_error(SystemExit)
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/media_spec.rb b/spec/lib/mastodon/cli/media_spec.rb
index 6d510c1f5a..24e1467a3c 100644
--- a/spec/lib/mastodon/cli/media_spec.rb
+++ b/spec/lib/mastodon/cli/media_spec.rb
@@ -4,18 +4,24 @@ require 'rails_helper'
 require 'mastodon/cli/media'
 
 describe Mastodon::CLI::Media do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#remove' do
+    let(:action) { :remove }
+
     context 'with --prune-profiles and --remove-headers' do
       let(:options) { { prune_profiles: true, remove_headers: true } }
 
       it 'warns about usage and exits' do
-        expect { cli.invoke(:remove, [], options) }.to output(
-          a_string_including('--prune-profiles and --remove-headers should not be specified simultaneously')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('--prune-profiles and --remove-headers should not be specified simultaneously')
+          .and raise_error(SystemExit)
       end
     end
 
@@ -23,9 +29,9 @@ describe Mastodon::CLI::Media do
       let(:options) { { include_follows: true } }
 
       it 'warns about usage and exits' do
-        expect { cli.invoke(:remove, [], options) }.to output(
-          a_string_including('--include-follows can only be used with --prune-profiles or --remove-headers')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('--include-follows can only be used with --prune-profiles or --remove-headers')
+          .and raise_error(SystemExit)
       end
     end
 
@@ -38,9 +44,8 @@ describe Mastodon::CLI::Media do
         let(:options) { { prune_profiles: true } }
 
         it 'removes account avatars' do
-          expect { cli.invoke(:remove, [], options) }.to output(
-            a_string_including('Visited 1')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Visited 1')
 
           expect(account.reload.avatar).to be_blank
         end
@@ -50,9 +55,8 @@ describe Mastodon::CLI::Media do
         let(:options) { { remove_headers: true } }
 
         it 'removes account header' do
-          expect { cli.invoke(:remove, [], options) }.to output(
-            a_string_including('Visited 1')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Visited 1')
 
           expect(account.reload.header).to be_blank
         end
@@ -64,9 +68,8 @@ describe Mastodon::CLI::Media do
 
       context 'without options' do
         it 'removes account avatars' do
-          expect { cli.invoke(:remove) }.to output(
-            a_string_including('Removed 1')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Removed 1')
 
           expect(media_attachment.reload.file).to be_blank
           expect(media_attachment.reload.thumbnail).to be_blank
@@ -76,25 +79,50 @@ describe Mastodon::CLI::Media do
   end
 
   describe '#usage' do
-    context 'without options' do
-      let(:options) { {} }
+    let(:action) { :usage }
 
+    context 'without options' do
       it 'reports about storage size' do
-        expect { cli.invoke(:usage, [], options) }.to output(
-          a_string_including('0 Bytes')
-        ).to_stdout
+        expect { subject }
+          .to output_results('0 Bytes')
+      end
+    end
+  end
+
+  describe '#lookup' do
+    let(:action) { :lookup }
+    let(:arguments) { [url] }
+
+    context 'with valid url not connected to a record' do
+      let(:url) { 'https://example.host/assets/1' }
+
+      it 'warns about url and exits' do
+        expect { subject }
+          .to output_results('Not a media URL')
+          .and raise_error(SystemExit)
+      end
+    end
+
+    context 'with a valid media url' do
+      let(:status) { Fabricate(:status) }
+      let(:media_attachment) { Fabricate(:media_attachment, status: status) }
+      let(:url) { media_attachment.file.url(:original) }
+
+      it 'displays the url of a connected status' do
+        expect { subject }
+          .to output_results(status.id.to_s)
       end
     end
   end
 
   describe '#refresh' do
-    context 'without any options' do
-      let(:options) { {} }
+    let(:action) { :refresh }
 
+    context 'without any options' do
       it 'warns about usage and exits' do
-        expect { cli.invoke(:refresh, [], options) }.to output(
-          a_string_including('Specify the source')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('Specify the source')
+          .and raise_error(SystemExit)
       end
     end
 
@@ -108,9 +136,8 @@ describe Mastodon::CLI::Media do
       let(:status) { Fabricate(:status) }
 
       it 'redownloads the attachment file' do
-        expect { cli.invoke(:refresh, [], options) }.to output(
-          a_string_including('Downloaded 1 media')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Downloaded 1 media')
       end
     end
 
@@ -119,9 +146,9 @@ describe Mastodon::CLI::Media do
         let(:options) { { account: 'not-real-user@example.host' } }
 
         it 'warns about usage and exits' do
-          expect { cli.invoke(:refresh, [], options) }.to output(
-            a_string_including('No such account')
-          ).to_stdout.and raise_error(SystemExit)
+          expect { subject }
+            .to output_results('No such account')
+            .and raise_error(SystemExit)
         end
       end
 
@@ -135,9 +162,8 @@ describe Mastodon::CLI::Media do
         let(:account) { Fabricate(:account) }
 
         it 'redownloads the attachment file' do
-          expect { cli.invoke(:refresh, [], options) }.to output(
-            a_string_including('Downloaded 1 media')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Downloaded 1 media')
         end
       end
     end
@@ -153,9 +179,8 @@ describe Mastodon::CLI::Media do
       let(:account) { Fabricate(:account, domain: domain) }
 
       it 'redownloads the attachment file' do
-        expect { cli.invoke(:refresh, [], options) }.to output(
-          a_string_including('Downloaded 1 media')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Downloaded 1 media')
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/preview_cards_spec.rb b/spec/lib/mastodon/cli/preview_cards_spec.rb
index a766d250eb..951ae3758f 100644
--- a/spec/lib/mastodon/cli/preview_cards_spec.rb
+++ b/spec/lib/mastodon/cli/preview_cards_spec.rb
@@ -4,11 +4,17 @@ require 'rails_helper'
 require 'mastodon/cli/preview_cards'
 
 describe Mastodon::CLI::PreviewCards do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#remove' do
+    let(:action) { :remove }
+
     context 'with relevant preview cards' do
       before do
         Fabricate(:preview_card, updated_at: 10.years.ago, type: :link)
@@ -18,10 +24,11 @@ describe Mastodon::CLI::PreviewCards do
 
       context 'with no arguments' do
         it 'deletes thumbnails for local preview cards' do
-          expect { cli.invoke(:remove) }.to output(
-            a_string_including('Removed 2 preview cards')
-              .and(a_string_including('approx. 119 KB'))
-          ).to_stdout
+          expect { subject }
+            .to output_results(
+              'Removed 2 preview cards',
+              'approx. 119 KB'
+            )
         end
       end
 
@@ -29,10 +36,11 @@ describe Mastodon::CLI::PreviewCards do
         let(:options) { { link: true } }
 
         it 'deletes thumbnails for local preview cards' do
-          expect { cli.invoke(:remove, [], options) }.to output(
-            a_string_including('Removed 1 link-type preview cards')
-              .and(a_string_including('approx. 59.6 KB'))
-          ).to_stdout
+          expect { subject }
+            .to output_results(
+              'Removed 1 link-type preview cards',
+              'approx. 59.6 KB'
+            )
         end
       end
 
@@ -40,10 +48,11 @@ describe Mastodon::CLI::PreviewCards do
         let(:options) { { days: 365 } }
 
         it 'deletes thumbnails for local preview cards' do
-          expect { cli.invoke(:remove, [], options) }.to output(
-            a_string_including('Removed 1 preview cards')
-              .and(a_string_including('approx. 59.6 KB'))
-          ).to_stdout
+          expect { subject }
+            .to output_results(
+              'Removed 1 preview cards',
+              'approx. 59.6 KB'
+            )
         end
       end
     end
diff --git a/spec/lib/mastodon/cli/search_spec.rb b/spec/lib/mastodon/cli/search_spec.rb
index 785dc2bd61..cb0c80c11d 100644
--- a/spec/lib/mastodon/cli/search_spec.rb
+++ b/spec/lib/mastodon/cli/search_spec.rb
@@ -4,5 +4,79 @@ require 'rails_helper'
 require 'mastodon/cli/search'
 
 describe Mastodon::CLI::Search do
+  subject { cli.invoke(action, arguments, options) }
+
+  let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
+
   it_behaves_like 'CLI Command'
+
+  describe '#deploy' do
+    let(:action) { :deploy }
+
+    context 'with concurrency out of range' do
+      let(:options) { { concurrency: -100 } }
+
+      it 'Exits with error message' do
+        expect { subject }
+          .to output_results('this concurrency setting')
+          .and raise_error(SystemExit)
+      end
+    end
+
+    context 'with batch size out of range' do
+      let(:options) { { batch_size: -100_000 } }
+
+      it 'Exits with error message' do
+        expect { subject }
+          .to output_results('this batch_size setting')
+          .and raise_error(SystemExit)
+      end
+    end
+
+    context 'without options' do
+      before { stub_search_indexes }
+
+      let(:indexed_count) { 1 }
+      let(:deleted_count) { 2 }
+
+      it 'reports about storage size' do
+        expect { subject }
+          .to output_results(
+            "Indexed #{described_class::INDICES.size * indexed_count} records",
+            "de-indexed #{described_class::INDICES.size * deleted_count}"
+          )
+      end
+    end
+
+    def stub_search_indexes
+      described_class::INDICES.each do |index|
+        allow(index)
+          .to receive_messages(
+            specification: instance_double(Chewy::Index::Specification, changed?: true, lock!: nil),
+            purge: nil
+          )
+
+        importer_double = importer_double_for(index)
+        allow(importer_double).to receive(:on_progress).and_yield([indexed_count, deleted_count])
+        allow("Importer::#{index}Importer".constantize)
+          .to receive(:new)
+          .and_return(importer_double)
+      end
+    end
+
+    def importer_double_for(index)
+      instance_double(
+        "Importer::#{index}Importer".constantize,
+        clean_up!: nil,
+        estimate!: 100,
+        import!: nil,
+        on_failure: nil,
+        # on_progress: nil,
+        optimize_for_import!: nil,
+        optimize_for_search!: nil
+      )
+    end
+  end
 end
diff --git a/spec/lib/mastodon/cli/settings_spec.rb b/spec/lib/mastodon/cli/settings_spec.rb
index 7dcd1110ba..568ee00393 100644
--- a/spec/lib/mastodon/cli/settings_spec.rb
+++ b/spec/lib/mastodon/cli/settings_spec.rb
@@ -7,59 +7,53 @@ describe Mastodon::CLI::Settings do
   it_behaves_like 'CLI Command'
 
   describe 'subcommand "registrations"' do
+    subject { cli.invoke(action, arguments, options) }
+
     let(:cli) { Mastodon::CLI::Registrations.new }
+    let(:arguments) { [] }
+    let(:options) { {} }
 
     before do
       Setting.registrations_mode = nil
     end
 
     describe '#open' do
-      it 'changes "registrations_mode" to "open"' do
-        expect { cli.open }.to change(Setting, :registrations_mode).from(nil).to('open')
-      end
+      let(:action) { :open }
 
-      it 'displays success message' do
-        expect { cli.open }.to output(
-          a_string_including('OK')
-        ).to_stdout
+      it 'changes "registrations_mode" to "open" and displays success' do
+        expect { subject }
+          .to change(Setting, :registrations_mode).from(nil).to('open')
+          .and output_results('OK')
       end
     end
 
     describe '#approved' do
-      it 'changes "registrations_mode" to "approved"' do
-        expect { cli.approved }.to change(Setting, :registrations_mode).from(nil).to('approved')
-      end
+      let(:action) { :approved }
 
-      it 'displays success message' do
-        expect { cli.approved }.to output(
-          a_string_including('OK')
-        ).to_stdout
+      it 'changes "registrations_mode" to "approved" and displays success' do
+        expect { subject }
+          .to change(Setting, :registrations_mode).from(nil).to('approved')
+          .and output_results('OK')
       end
 
       context 'with --require-reason' do
-        before do
-          cli.options = { require_reason: true }
-        end
+        let(:options) { { require_reason: true } }
 
-        it 'changes "registrations_mode" to "approved"' do
-          expect { cli.approved }.to change(Setting, :registrations_mode).from(nil).to('approved')
-        end
-
-        it 'sets "require_invite_text" to "true"' do
-          expect { cli.approved }.to change(Setting, :require_invite_text).from(false).to(true)
+        it 'changes registrations_mode and require_invite_text' do
+          expect { subject }
+            .to change(Setting, :registrations_mode).from(nil).to('approved')
+            .and change(Setting, :require_invite_text).from(false).to(true)
         end
       end
     end
 
     describe '#close' do
-      it 'changes "registrations_mode" to "none"' do
-        expect { cli.close }.to change(Setting, :registrations_mode).from(nil).to('none')
-      end
+      let(:action) { :close }
 
-      it 'displays success message' do
-        expect { cli.close }.to output(
-          a_string_including('OK')
-        ).to_stdout
+      it 'changes "registrations_mode" to "none" and displays success' do
+        expect { subject }
+          .to change(Setting, :registrations_mode).from(nil).to('none')
+          .and output_results('OK')
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/statuses_spec.rb b/spec/lib/mastodon/cli/statuses_spec.rb
index 70e4e2c086..63d494bbb6 100644
--- a/spec/lib/mastodon/cli/statuses_spec.rb
+++ b/spec/lib/mastodon/cli/statuses_spec.rb
@@ -4,26 +4,31 @@ require 'rails_helper'
 require 'mastodon/cli/statuses'
 
 describe Mastodon::CLI::Statuses do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#remove', use_transactional_tests: false do
+    let(:action) { :remove }
+
     context 'with small batch size' do
       let(:options) { { batch_size: 0 } }
 
       it 'exits with error message' do
-        expect { cli.invoke :remove, [], options }.to output(
-          a_string_including('Cannot run')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('Cannot run')
+          .and raise_error(SystemExit)
       end
     end
 
     context 'with default batch size' do
       it 'removes unreferenced statuses' do
-        expect { cli.invoke :remove }.to output(
-          a_string_including('Done after')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Done after')
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/upgrade_spec.rb b/spec/lib/mastodon/cli/upgrade_spec.rb
index 0d6494eeee..6861e04887 100644
--- a/spec/lib/mastodon/cli/upgrade_spec.rb
+++ b/spec/lib/mastodon/cli/upgrade_spec.rb
@@ -4,23 +4,26 @@ require 'rails_helper'
 require 'mastodon/cli/upgrade'
 
 describe Mastodon::CLI::Upgrade do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#storage_schema' do
-    context 'with records that dont need upgrading' do
-      let(:options) { {} }
+    let(:action) { :storage_schema }
 
+    context 'with records that dont need upgrading' do
       before do
         Fabricate(:account)
         Fabricate(:media_attachment)
       end
 
       it 'does not upgrade storage for the attachments' do
-        expect { cli.invoke(:storage_schema, [], options) }.to output(
-          a_string_including('Upgraded storage schema of 0 records')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Upgraded storage schema of 0 records')
       end
     end
   end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index d30e7201c4..4394b470e6 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -88,6 +88,7 @@ RSpec.configure do |config|
   config.include Chewy::Rspec::Helpers
   config.include Redisable
   config.include SignedRequestHelpers, type: :request
+  config.include CommandLineHelpers, type: :cli
 
   config.around(:each, use_transactional_tests: false) do |example|
     self.use_transactional_tests = false
diff --git a/spec/controllers/api/v1/accounts/familiar_followers_controller_spec.rb b/spec/requests/api/v1/accounts/familiar_followers_spec.rb
similarity index 53%
rename from spec/controllers/api/v1/accounts/familiar_followers_controller_spec.rb
rename to spec/requests/api/v1/accounts/familiar_followers_spec.rb
index 3c7c7e8b84..fdc0a3a932 100644
--- a/spec/controllers/api/v1/accounts/familiar_followers_controller_spec.rb
+++ b/spec/requests/api/v1/accounts/familiar_followers_spec.rb
@@ -2,20 +2,16 @@
 
 require 'rails_helper'
 
-describe Api::V1::Accounts::FamiliarFollowersController do
-  render_views
-
-  let(:user)    { Fabricate(:user) }
-  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:follows') }
+describe 'Accounts Familiar Followers API' do
+  let(:user)     { Fabricate(:user) }
+  let(:token)    { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:scopes)   { 'read:follows' }
+  let(:headers)  { { 'Authorization' => "Bearer #{token.token}" } }
   let(:account) { Fabricate(:account) }
 
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'GET #index' do
+  describe 'GET /api/v1/accounts/familiar_followers' do
     it 'returns http success' do
-      get :index, params: { account_id: account.id, limit: 2 }
+      get '/api/v1/accounts/familiar_followers', params: { account_id: account.id, limit: 2 }, headers: headers
 
       expect(response).to have_http_status(200)
     end
@@ -26,7 +22,7 @@ describe Api::V1::Accounts::FamiliarFollowersController do
 
       it 'removes duplicate account IDs from params' do
         account_ids = [account_a, account_b, account_b, account_a, account_a].map { |a| a.id.to_s }
-        get :index, params: { id: account_ids }
+        get '/api/v1/accounts/familiar_followers', params: { id: account_ids }, headers: headers
 
         expect(body_as_json.pluck(:id)).to contain_exactly(account_a.id.to_s, account_b.id.to_s)
       end
diff --git a/spec/requests/api/v1/accounts/identity_proofs_spec.rb b/spec/requests/api/v1/accounts/identity_proofs_spec.rb
new file mode 100644
index 0000000000..3727af7e89
--- /dev/null
+++ b/spec/requests/api/v1/accounts/identity_proofs_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Accounts Identity Proofs API' do
+  let(:user)     { Fabricate(:user) }
+  let(:token)    { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:scopes)   { 'read:accounts' }
+  let(:headers)  { { 'Authorization' => "Bearer #{token.token}" } }
+  let(:account) { Fabricate(:account) }
+
+  describe 'GET /api/v1/accounts/identity_proofs' do
+    it 'returns http success' do
+      get "/api/v1/accounts/#{account.id}/identity_proofs", params: { limit: 2 }, headers: headers
+
+      expect(response).to have_http_status(200)
+    end
+  end
+end
diff --git a/spec/requests/api/v1/accounts/lists_spec.rb b/spec/requests/api/v1/accounts/lists_spec.rb
new file mode 100644
index 0000000000..48c0337e54
--- /dev/null
+++ b/spec/requests/api/v1/accounts/lists_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Accounts Lists API' do
+  let(:user)     { Fabricate(:user) }
+  let(:token)    { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:scopes)   { 'read:lists' }
+  let(:headers)  { { 'Authorization' => "Bearer #{token.token}" } }
+  let(:account) { Fabricate(:account) }
+  let(:list)    { Fabricate(:list, account: user.account) }
+
+  before do
+    user.account.follow!(account)
+    list.accounts << account
+  end
+
+  describe 'GET /api/v1/accounts/lists' do
+    it 'returns http success' do
+      get "/api/v1/accounts/#{account.id}/lists", headers: headers
+
+      expect(response).to have_http_status(200)
+    end
+  end
+end
diff --git a/spec/requests/api/v1/accounts/lookup_spec.rb b/spec/requests/api/v1/accounts/lookup_spec.rb
new file mode 100644
index 0000000000..4c022c7c13
--- /dev/null
+++ b/spec/requests/api/v1/accounts/lookup_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Accounts Lookup API' do
+  let(:user)     { Fabricate(:user) }
+  let(:token)    { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:scopes)   { 'read:accounts' }
+  let(:headers)  { { 'Authorization' => "Bearer #{token.token}" } }
+  let(:account) { Fabricate(:account) }
+
+  describe 'GET /api/v1/accounts/lookup' do
+    it 'returns http success' do
+      get '/api/v1/accounts/lookup', params: { account_id: account.id, acct: account.acct }, headers: headers
+
+      expect(response).to have_http_status(200)
+    end
+  end
+end
diff --git a/spec/controllers/api/v1/accounts/notes_controller_spec.rb b/spec/requests/api/v1/accounts/notes_spec.rb
similarity index 66%
rename from spec/controllers/api/v1/accounts/notes_controller_spec.rb
rename to spec/requests/api/v1/accounts/notes_spec.rb
index 75599b32b2..4f3ac68c74 100644
--- a/spec/controllers/api/v1/accounts/notes_controller_spec.rb
+++ b/spec/requests/api/v1/accounts/notes_spec.rb
@@ -2,21 +2,17 @@
 
 require 'rails_helper'
 
-describe Api::V1::Accounts::NotesController do
-  render_views
-
-  let(:user)    { Fabricate(:user) }
-  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:accounts') }
+describe 'Accounts Notes API' do
+  let(:user)     { Fabricate(:user) }
+  let(:token)    { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:scopes)   { 'write:accounts' }
+  let(:headers)  { { 'Authorization' => "Bearer #{token.token}" } }
   let(:account) { Fabricate(:account) }
   let(:comment) { 'foo' }
 
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'POST #create' do
+  describe 'POST /api/v1/accounts/:account_id/note' do
     subject do
-      post :create, params: { account_id: account.id, comment: comment }
+      post "/api/v1/accounts/#{account.id}/note", params: { comment: comment }, headers: headers
     end
 
     context 'when account note has reasonable length', :aggregate_failures do
diff --git a/spec/requests/api/v1/accounts/pins_spec.rb b/spec/requests/api/v1/accounts/pins_spec.rb
new file mode 100644
index 0000000000..c293715f7e
--- /dev/null
+++ b/spec/requests/api/v1/accounts/pins_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Accounts Pins API' do
+  let(:user)     { Fabricate(:user) }
+  let(:token)    { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:scopes)   { 'write:accounts' }
+  let(:headers)  { { 'Authorization' => "Bearer #{token.token}" } }
+  let(:kevin) { Fabricate(:user) }
+
+  before do
+    kevin.account.followers << user.account
+  end
+
+  describe 'POST /api/v1/accounts/:account_id/pin' do
+    subject { post "/api/v1/accounts/#{kevin.account.id}/pin", headers: headers }
+
+    it 'creates account_pin', :aggregate_failures do
+      expect do
+        subject
+      end.to change { AccountPin.where(account: user.account, target_account: kevin.account).count }.by(1)
+      expect(response).to have_http_status(200)
+    end
+  end
+
+  describe 'POST /api/v1/accounts/:account_id/unpin' do
+    subject { post "/api/v1/accounts/#{kevin.account.id}/unpin", headers: headers }
+
+    before do
+      Fabricate(:account_pin, account: user.account, target_account: kevin.account)
+    end
+
+    it 'destroys account_pin', :aggregate_failures do
+      expect do
+        subject
+      end.to change { AccountPin.where(account: user.account, target_account: kevin.account).count }.by(-1)
+      expect(response).to have_http_status(200)
+    end
+  end
+end
diff --git a/spec/requests/api/v1/accounts/relationships_spec.rb b/spec/requests/api/v1/accounts/relationships_spec.rb
index cea45168a2..b06ce0509d 100644
--- a/spec/requests/api/v1/accounts/relationships_spec.rb
+++ b/spec/requests/api/v1/accounts/relationships_spec.rb
@@ -31,8 +31,8 @@ describe 'GET /api/v1/accounts/relationships' do
         .to have_http_status(200)
       expect(body_as_json)
         .to be_an(Enumerable)
-        .and have_attributes(
-          first: include(
+        .and contain_exactly(
+          include(
             following: true,
             followed_by: false
           )
@@ -53,9 +53,11 @@ describe 'GET /api/v1/accounts/relationships' do
           expect(body_as_json)
             .to be_an(Enumerable)
             .and have_attributes(
-              size: 2,
-              first: include(simon_item),
-              second: include(lewis_item)
+              size: 2
+            )
+            .and contain_exactly(
+              include(simon_item),
+              include(lewis_item)
             )
         end
       end
@@ -71,10 +73,12 @@ describe 'GET /api/v1/accounts/relationships' do
           expect(body_as_json)
             .to be_an(Enumerable)
             .and have_attributes(
-              size: 3,
-              first: include(simon_item),
-              second: include(lewis_item),
-              third: include(bob_item)
+              size: 3
+            )
+            .and contain_exactly(
+              include(simon_item),
+              include(lewis_item),
+              include(bob_item)
             )
         end
       end
@@ -88,9 +92,11 @@ describe 'GET /api/v1/accounts/relationships' do
           expect(body_as_json)
             .to be_an(Enumerable)
             .and have_attributes(
-              size: 2,
-              first: include(simon_item),
-              second: include(lewis_item)
+              size: 2
+            )
+            .and contain_exactly(
+              include(simon_item),
+              include(lewis_item)
             )
         end
       end
@@ -116,7 +122,6 @@ describe 'GET /api/v1/accounts/relationships' do
           muting: false,
           requested: false,
           domain_blocking: false,
-
         }
       end
 
@@ -129,7 +134,6 @@ describe 'GET /api/v1/accounts/relationships' do
           muting: false,
           requested: false,
           domain_blocking: false,
-
         }
       end
     end
@@ -149,8 +153,10 @@ describe 'GET /api/v1/accounts/relationships' do
       expect(body_as_json)
         .to be_an(Enumerable)
         .and have_attributes(
-          size: 1,
-          first: include(
+          size: 1
+        )
+        .and contain_exactly(
+          include(
             following: true,
             showing_reblogs: true
           )
@@ -168,8 +174,8 @@ describe 'GET /api/v1/accounts/relationships' do
 
       expect(body_as_json)
         .to be_an(Enumerable)
-        .and have_attributes(
-          first: include(
+        .and contain_exactly(
+          include(
             following: false,
             showing_reblogs: false
           )
diff --git a/spec/requests/api/v1/accounts/search_spec.rb b/spec/requests/api/v1/accounts/search_spec.rb
new file mode 100644
index 0000000000..76b32e7b2c
--- /dev/null
+++ b/spec/requests/api/v1/accounts/search_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Accounts Search API' do
+  let(:user)     { Fabricate(:user) }
+  let(:token)    { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:scopes)   { 'read:accounts' }
+  let(:headers)  { { 'Authorization' => "Bearer #{token.token}" } }
+
+  describe 'GET /api/v1/accounts/search' do
+    it 'returns http success' do
+      get '/api/v1/accounts/search', params: { q: 'query' }, headers: headers
+
+      expect(response).to have_http_status(200)
+    end
+  end
+end
diff --git a/spec/requests/api/v1/featured_tags/suggestions_spec.rb b/spec/requests/api/v1/featured_tags/suggestions_spec.rb
new file mode 100644
index 0000000000..f7b453b740
--- /dev/null
+++ b/spec/requests/api/v1/featured_tags/suggestions_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Featured Tags Suggestions API' do
+  let(:user)    { Fabricate(:user) }
+  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:scopes)  { 'read:accounts' }
+  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
+  let(:account) { Fabricate(:account) }
+
+  describe 'GET /api/v1/featured_tags/suggestions' do
+    it 'returns http success' do
+      get '/api/v1/featured_tags/suggestions', params: { account_id: account.id, limit: 2 }, headers: headers
+
+      expect(response).to have_http_status(200)
+    end
+  end
+end
diff --git a/spec/controllers/api/v2/search_controller_spec.rb b/spec/requests/api/v2/search_spec.rb
similarity index 79%
rename from spec/controllers/api/v2/search_controller_spec.rb
rename to spec/requests/api/v2/search_spec.rb
index a16716a10c..d0778cba4d 100644
--- a/spec/controllers/api/v2/search_controller_spec.rb
+++ b/spec/requests/api/v2/search_spec.rb
@@ -2,25 +2,21 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V2::SearchController do
-  render_views
-
+describe 'Search API' do
   context 'with token' do
-    let(:user)  { Fabricate(:user) }
-    let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:search') }
+    let(:user)    { Fabricate(:user) }
+    let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+    let(:scopes)  { 'read:search' }
+    let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
 
-    before do
-      allow(controller).to receive(:doorkeeper_token) { token }
-    end
-
-    describe 'GET #index' do
+    describe 'GET /api/v2/search' do
       let!(:bob)   { Fabricate(:account, username: 'bob_test') }
       let!(:ana)   { Fabricate(:account, username: 'ana_test') }
       let!(:tom)   { Fabricate(:account, username: 'tom_test') }
       let(:params) { { q: 'test' } }
 
       it 'returns http success' do
-        get :index, params: params
+        get '/api/v2/search', headers: headers, params: params
 
         expect(response).to have_http_status(200)
       end
@@ -29,7 +25,7 @@ RSpec.describe Api::V2::SearchController do
         let(:params) { { q: 'test', type: 'accounts' } }
 
         it 'returns all matching accounts' do
-          get :index, params: params
+          get '/api/v2/search', headers: headers, params: params
 
           expect(body_as_json[:accounts].pluck(:id)).to contain_exactly(bob.id.to_s, ana.id.to_s, tom.id.to_s)
         end
@@ -38,7 +34,7 @@ RSpec.describe Api::V2::SearchController do
           let(:params) { { q: 'test1', resolve: '1' } }
 
           it 'returns http unauthorized' do
-            get :index, params: params
+            get '/api/v2/search', headers: headers, params: params
 
             expect(response).to have_http_status(200)
           end
@@ -48,7 +44,7 @@ RSpec.describe Api::V2::SearchController do
           let(:params) { { q: 'test1', offset: 1 } }
 
           it 'returns http unauthorized' do
-            get :index, params: params
+            get '/api/v2/search', headers: headers, params: params
 
             expect(response).to have_http_status(200)
           end
@@ -62,7 +58,7 @@ RSpec.describe Api::V2::SearchController do
           end
 
           it 'returns only the followed accounts' do
-            get :index, params: params
+            get '/api/v2/search', headers: headers, params: params
 
             expect(body_as_json[:accounts].pluck(:id)).to contain_exactly(ana.id.to_s)
           end
@@ -73,7 +69,7 @@ RSpec.describe Api::V2::SearchController do
         before { allow(Search).to receive(:new).and_raise(Mastodon::SyntaxError) }
 
         it 'returns http unprocessable_entity' do
-          get :index, params: params
+          get '/api/v2/search', headers: headers, params: params
 
           expect(response).to have_http_status(422)
         end
@@ -83,7 +79,7 @@ RSpec.describe Api::V2::SearchController do
         before { allow(Search).to receive(:new).and_raise(ActiveRecord::RecordNotFound) }
 
         it 'returns http not_found' do
-          get :index, params: params
+          get '/api/v2/search', headers: headers, params: params
 
           expect(response).to have_http_status(404)
         end
@@ -92,11 +88,11 @@ RSpec.describe Api::V2::SearchController do
   end
 
   context 'without token' do
-    describe 'GET #index' do
+    describe 'GET /api/v2/search' do
       let(:search_params) { nil }
 
       before do
-        get :index, params: search_params
+        get '/api/v2/search', params: search_params
       end
 
       context 'without a `q` param' do
diff --git a/spec/requests/api/v2/suggestions_spec.rb b/spec/requests/api/v2/suggestions_spec.rb
new file mode 100644
index 0000000000..5f1c97b8ae
--- /dev/null
+++ b/spec/requests/api/v2/suggestions_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Suggestions API' do
+  let(:user)    { Fabricate(:user) }
+  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:scopes)  { 'read' }
+  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
+
+  describe 'GET /api/v2/suggestions' do
+    it 'returns http success' do
+      get '/api/v2/suggestions', headers: headers
+
+      expect(response).to have_http_status(200)
+    end
+  end
+end
diff --git a/spec/services/fan_out_on_write_service_spec.rb b/spec/services/fan_out_on_write_service_spec.rb
index 3b554f9ea3..f657f298de 100644
--- a/spec/services/fan_out_on_write_service_spec.rb
+++ b/spec/services/fan_out_on_write_service_spec.rb
@@ -6,11 +6,12 @@ RSpec.describe FanOutOnWriteService, type: :service do
   subject { described_class.new }
 
   let(:last_active_at) { Time.now.utc }
-  let(:status) { Fabricate(:status, account: alice, visibility: visibility, text: 'Hello @bob #hoge') }
+  let(:status) { Fabricate(:status, account: alice, visibility: visibility, text: 'Hello @bob @eve #hoge') }
 
   let!(:alice) { Fabricate(:user, current_sign_in_at: last_active_at).account }
   let!(:bob)   { Fabricate(:user, current_sign_in_at: last_active_at, account_attributes: { username: 'bob' }).account }
   let!(:tom)   { Fabricate(:user, current_sign_in_at: last_active_at).account }
+  let!(:eve)   { Fabricate(:user, current_sign_in_at: last_active_at, account_attributes: { username: 'eve' }).account }
 
   before do
     bob.follow!(alice)
@@ -109,5 +110,24 @@ RSpec.describe FanOutOnWriteService, type: :service do
       expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
       expect(redis).to_not have_received(:publish).with('timeline:public', anything)
     end
+
+    context 'when handling status updates', :sidekiq_fake do
+      before do
+        subject.call(status)
+
+        status.snapshot!(at_time: status.created_at, rate_limit: false)
+        status.update!(text: 'Hello @bob @eve #hoge (edited)')
+        status.snapshot!(account_id: status.account_id)
+
+        redis.set("subscribed:timeline:#{eve.id}:notifications", '1')
+
+        Sidekiq::Worker.clear_all
+      end
+
+      it 'pushes the update to mentioned users through the notifications streaming channel' do
+        subject.call(status, update: true)
+        expect(PushUpdateWorker).to have_enqueued_sidekiq_job(anything, status.id, "timeline:#{eve.id}:notifications", { 'update' => true })
+      end
+    end
   end
 end
diff --git a/spec/support/command_line_helpers.rb b/spec/support/command_line_helpers.rb
new file mode 100644
index 0000000000..6f9d63d939
--- /dev/null
+++ b/spec/support/command_line_helpers.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module CommandLineHelpers
+  def output_results(*args)
+    output(
+      include(*args)
+    ).to_stdout
+  end
+end
diff --git a/yarn.lock b/yarn.lock
index 24563cd7a8..4f9fea29c1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -52,7 +52,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/compat-data@npm:^7.22.6, @babel/compat-data@npm:^7.22.9, @babel/compat-data@npm:^7.23.3, @babel/compat-data@npm:^7.23.5":
+"@babel/compat-data@npm:^7.22.6, @babel/compat-data@npm:^7.23.3, @babel/compat-data@npm:^7.23.5":
   version: 7.23.5
   resolution: "@babel/compat-data@npm:7.23.5"
   checksum: 081278ed46131a890ad566a59c61600a5f9557bd8ee5e535890c8548192532ea92590742fd74bd9db83d74c669ef8a04a7e1c85cdea27f960233e3b83c3a957c
@@ -60,37 +60,37 @@ __metadata:
   linkType: hard
 
 "@babel/core@npm:^7.10.4, @babel/core@npm:^7.11.1, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.22.1":
-  version: 7.23.5
-  resolution: "@babel/core@npm:7.23.5"
+  version: 7.23.6
+  resolution: "@babel/core@npm:7.23.6"
   dependencies:
     "@ampproject/remapping": "npm:^2.2.0"
     "@babel/code-frame": "npm:^7.23.5"
-    "@babel/generator": "npm:^7.23.5"
-    "@babel/helper-compilation-targets": "npm:^7.22.15"
+    "@babel/generator": "npm:^7.23.6"
+    "@babel/helper-compilation-targets": "npm:^7.23.6"
     "@babel/helper-module-transforms": "npm:^7.23.3"
-    "@babel/helpers": "npm:^7.23.5"
-    "@babel/parser": "npm:^7.23.5"
+    "@babel/helpers": "npm:^7.23.6"
+    "@babel/parser": "npm:^7.23.6"
     "@babel/template": "npm:^7.22.15"
-    "@babel/traverse": "npm:^7.23.5"
-    "@babel/types": "npm:^7.23.5"
+    "@babel/traverse": "npm:^7.23.6"
+    "@babel/types": "npm:^7.23.6"
     convert-source-map: "npm:^2.0.0"
     debug: "npm:^4.1.0"
     gensync: "npm:^1.0.0-beta.2"
     json5: "npm:^2.2.3"
     semver: "npm:^6.3.1"
-  checksum: 311a512a870ee330a3f9a7ea89e5df790b2b5af0b1bd98b10b4edc0de2ac440f0df4d69ea2c0ee38a4b89041b9a495802741d93603be7d4fd834ec8bb6970bd2
+  checksum: a02bae7d916029b70706dc301535e1b31e5d216f55d4ee6f64a15825c6b69ee2c14c52a213d1497ec414e925ed4e9d897d41fb0d75df9fea28ed2c0008790e31
   languageName: node
   linkType: hard
 
-"@babel/generator@npm:^7.23.5, @babel/generator@npm:^7.7.2":
-  version: 7.23.5
-  resolution: "@babel/generator@npm:7.23.5"
+"@babel/generator@npm:^7.23.6, @babel/generator@npm:^7.7.2":
+  version: 7.23.6
+  resolution: "@babel/generator@npm:7.23.6"
   dependencies:
-    "@babel/types": "npm:^7.23.5"
+    "@babel/types": "npm:^7.23.6"
     "@jridgewell/gen-mapping": "npm:^0.3.2"
     "@jridgewell/trace-mapping": "npm:^0.3.17"
     jsesc: "npm:^2.5.1"
-  checksum: 14c6e874f796c4368e919bed6003bb0adc3ce837760b08f9e646d20aeb5ae7d309723ce6e4f06bcb4a2b5753145446c8e4425851380f695e40e71e1760f49e7b
+  checksum: 53540e905cd10db05d9aee0a5304e36927f455ce66f95d1253bb8a179f286b88fa7062ea0db354c566fe27f8bb96567566084ffd259f8feaae1de5eccc8afbda
   languageName: node
   linkType: hard
 
@@ -122,16 +122,16 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/helper-compilation-targets@npm:^7.22.15, @babel/helper-compilation-targets@npm:^7.22.6":
-  version: 7.22.15
-  resolution: "@babel/helper-compilation-targets@npm:7.22.15"
+"@babel/helper-compilation-targets@npm:^7.22.15, @babel/helper-compilation-targets@npm:^7.22.6, @babel/helper-compilation-targets@npm:^7.23.6":
+  version: 7.23.6
+  resolution: "@babel/helper-compilation-targets@npm:7.23.6"
   dependencies:
-    "@babel/compat-data": "npm:^7.22.9"
-    "@babel/helper-validator-option": "npm:^7.22.15"
-    browserslist: "npm:^4.21.9"
+    "@babel/compat-data": "npm:^7.23.5"
+    "@babel/helper-validator-option": "npm:^7.23.5"
+    browserslist: "npm:^4.22.2"
     lru-cache: "npm:^5.1.1"
     semver: "npm:^6.3.1"
-  checksum: 45b9286861296e890f674a3abb199efea14a962a27d9b8adeb44970a9fd5c54e73a9e342e8414d2851cf4f98d5994537352fbce7b05ade32e9849bbd327f9ff1
+  checksum: ba38506d11185f48b79abf439462ece271d3eead1673dd8814519c8c903c708523428806f05f2ec5efd0c56e4e278698fac967e5a4b5ee842c32415da54bc6fa
   languageName: node
   linkType: hard
 
@@ -342,14 +342,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/helpers@npm:^7.23.5":
-  version: 7.23.5
-  resolution: "@babel/helpers@npm:7.23.5"
+"@babel/helpers@npm:^7.23.6":
+  version: 7.23.6
+  resolution: "@babel/helpers@npm:7.23.6"
   dependencies:
     "@babel/template": "npm:^7.22.15"
-    "@babel/traverse": "npm:^7.23.5"
-    "@babel/types": "npm:^7.23.5"
-  checksum: a37e2728eb4378a4888e5d614e28de7dd79b55ac8acbecd0e5c761273e2a02a8f33b34b1932d9069db55417ace2937cbf8ec37c42f1030ce6d228857d7ccaa4f
+    "@babel/traverse": "npm:^7.23.6"
+    "@babel/types": "npm:^7.23.6"
+  checksum: df1cf6607676ad36f52f652ec03536f2732d70aef5e76dba5c964e34d49f3c2d3dcf9fb3740db359f53071d74b64606a833d5ba156f79f437f71bfe06e2e7e19
   languageName: node
   linkType: hard
 
@@ -364,12 +364,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.22.15, @babel/parser@npm:^7.23.5":
-  version: 7.23.5
-  resolution: "@babel/parser@npm:7.23.5"
+"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.22.15, @babel/parser@npm:^7.23.6":
+  version: 7.23.6
+  resolution: "@babel/parser@npm:7.23.6"
   bin:
     parser: ./bin/babel-parser.js
-  checksum: 3356aa90d7bafb4e2c7310e7c2c3d443c4be4db74913f088d3d577a1eb914ea4188e05fd50a47ce907a27b755c4400c4e3cbeee73dbeb37761f6ca85954f5a20
+  checksum: 6f76cd5ccae1fa9bcab3525b0865c6222e9c1d22f87abc69f28c5c7b2c8816a13361f5bd06bddbd5faf903f7320a8feba02545c981468acec45d12a03db7755e
   languageName: node
   linkType: hard
 
@@ -836,14 +836,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/plugin-transform-for-of@npm:^7.23.3":
-  version: 7.23.3
-  resolution: "@babel/plugin-transform-for-of@npm:7.23.3"
+"@babel/plugin-transform-for-of@npm:^7.23.6":
+  version: 7.23.6
+  resolution: "@babel/plugin-transform-for-of@npm:7.23.6"
   dependencies:
     "@babel/helper-plugin-utils": "npm:^7.22.5"
+    "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.22.5"
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 8a36202cfee312ba80e509c7c2131e6773524e572b4dc64a8ee95bd912634fdeb5ea91c6c7747ee30e03562d0f0d333f88ed7dbb929b36b60b8d74189189e12f
+  checksum: 46681b6ab10f3ca2d961f50d4096b62ab5d551e1adad84e64be1ee23e72eb2f26a1e30e617e853c74f1349fffe4af68d33921a128543b6f24b6d46c09a3e2aec
   languageName: node
   linkType: hard
 
@@ -1200,8 +1201,8 @@ __metadata:
   linkType: hard
 
 "@babel/plugin-transform-runtime@npm:^7.22.4":
-  version: 7.23.4
-  resolution: "@babel/plugin-transform-runtime@npm:7.23.4"
+  version: 7.23.6
+  resolution: "@babel/plugin-transform-runtime@npm:7.23.6"
   dependencies:
     "@babel/helper-module-imports": "npm:^7.22.15"
     "@babel/helper-plugin-utils": "npm:^7.22.5"
@@ -1211,7 +1212,7 @@ __metadata:
     semver: "npm:^6.3.1"
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 6ac29012550cdd10b65ec43fef0c7f43904ec458c43d597f627d8f52807413e57ea94e3986dbace576d734e67c2d09be5e43e77c72567d18f8c4ac5e19844625
+  checksum: 94a7ee92f073df53fd8bebf9ed391a95553716077da1c6c3a57f10f042358c938495d55e6b09b4b50544c01f03560c4770c17698e1c24817a15d3668e8231249
   languageName: node
   linkType: hard
 
@@ -1333,11 +1334,11 @@ __metadata:
   linkType: hard
 
 "@babel/preset-env@npm:^7.11.0, @babel/preset-env@npm:^7.12.1, @babel/preset-env@npm:^7.22.4":
-  version: 7.23.5
-  resolution: "@babel/preset-env@npm:7.23.5"
+  version: 7.23.6
+  resolution: "@babel/preset-env@npm:7.23.6"
   dependencies:
     "@babel/compat-data": "npm:^7.23.5"
-    "@babel/helper-compilation-targets": "npm:^7.22.15"
+    "@babel/helper-compilation-targets": "npm:^7.23.6"
     "@babel/helper-plugin-utils": "npm:^7.22.5"
     "@babel/helper-validator-option": "npm:^7.23.5"
     "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "npm:^7.23.3"
@@ -1377,7 +1378,7 @@ __metadata:
     "@babel/plugin-transform-dynamic-import": "npm:^7.23.4"
     "@babel/plugin-transform-exponentiation-operator": "npm:^7.23.3"
     "@babel/plugin-transform-export-namespace-from": "npm:^7.23.4"
-    "@babel/plugin-transform-for-of": "npm:^7.23.3"
+    "@babel/plugin-transform-for-of": "npm:^7.23.6"
     "@babel/plugin-transform-function-name": "npm:^7.23.3"
     "@babel/plugin-transform-json-strings": "npm:^7.23.4"
     "@babel/plugin-transform-literals": "npm:^7.23.3"
@@ -1418,7 +1419,7 @@ __metadata:
     semver: "npm:^6.3.1"
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 2a0e1274dec045186e131c6433659b75492583290e8d41633c616f6bff829cb2e4b2f9a57f556283a54db3bd6aa697911e56a36f607911a29b731c445a5b5a06
+  checksum: 5b24d179af52f082d04b9b98cc4777e37bf31a97cef5a91d8917e996dbd75f2f743c88c40f80744cb8529355bb674619d150c0260c32d834aa4067e21d0c8962
   languageName: node
   linkType: hard
 
@@ -1483,11 +1484,11 @@ __metadata:
   linkType: hard
 
 "@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.3, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2":
-  version: 7.23.5
-  resolution: "@babel/runtime@npm:7.23.5"
+  version: 7.23.6
+  resolution: "@babel/runtime@npm:7.23.6"
   dependencies:
     regenerator-runtime: "npm:^0.14.0"
-  checksum: ca679cc91bb7e424bc2db87bb58cc3b06ade916b9adb21fbbdc43e54cdaacb3eea201ceba2a0464b11d2eb65b9fe6a6ffcf4d7521fa52994f19be96f1af14788
+  checksum: d886954e985ef8e421222f7a2848884d96a752e0020d3078b920dd104e672fdf23bcc6f51a44313a048796319f1ac9d09c2c88ec8cbb4e1f09174bcd3335b9ff
   languageName: node
   linkType: hard
 
@@ -1502,32 +1503,32 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/traverse@npm:7, @babel/traverse@npm:^7.23.5":
-  version: 7.23.5
-  resolution: "@babel/traverse@npm:7.23.5"
+"@babel/traverse@npm:7, @babel/traverse@npm:^7.23.6":
+  version: 7.23.6
+  resolution: "@babel/traverse@npm:7.23.6"
   dependencies:
     "@babel/code-frame": "npm:^7.23.5"
-    "@babel/generator": "npm:^7.23.5"
+    "@babel/generator": "npm:^7.23.6"
     "@babel/helper-environment-visitor": "npm:^7.22.20"
     "@babel/helper-function-name": "npm:^7.23.0"
     "@babel/helper-hoist-variables": "npm:^7.22.5"
     "@babel/helper-split-export-declaration": "npm:^7.22.6"
-    "@babel/parser": "npm:^7.23.5"
-    "@babel/types": "npm:^7.23.5"
-    debug: "npm:^4.1.0"
+    "@babel/parser": "npm:^7.23.6"
+    "@babel/types": "npm:^7.23.6"
+    debug: "npm:^4.3.1"
     globals: "npm:^11.1.0"
-  checksum: c5ea793080ca6719b0a1612198fd25e361cee1f3c14142d7a518d2a1eeb5c1d21f7eec1b26c20ea6e1ddd8ed12ab50b960ff95ffd25be353b6b46e1b54d6f825
+  checksum: 5b4ebb94a00a7e1daf111e4b0b45a7998d5b7598637a14e75e855e88cc1b702789e09a958726b5d599a003be1e9032dbdfde4b88ea6061332228738950d5582d
   languageName: node
   linkType: hard
 
-"@babel/types@npm:^7.0.0, @babel/types@npm:^7.0.0-beta.49, @babel/types@npm:^7.12.11, @babel/types@npm:^7.12.6, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.10, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.5, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3":
-  version: 7.23.5
-  resolution: "@babel/types@npm:7.23.5"
+"@babel/types@npm:^7.0.0, @babel/types@npm:^7.0.0-beta.49, @babel/types@npm:^7.12.11, @babel/types@npm:^7.12.6, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.10, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.6, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3":
+  version: 7.23.6
+  resolution: "@babel/types@npm:7.23.6"
   dependencies:
     "@babel/helper-string-parser": "npm:^7.23.4"
     "@babel/helper-validator-identifier": "npm:^7.22.20"
     to-fast-properties: "npm:^2.0.0"
-  checksum: 7dd5e2f59828ed046ad0b06b039df2524a8b728d204affb4fc08da2502b9dd3140b1356b5166515d229dc811539a8b70dcd4bc507e06d62a89f4091a38d0b0fb
+  checksum: 42cefce8a68bd09bb5828b4764aa5586c53c60128ac2ac012e23858e1c179347a4aac9c66fc577994fbf57595227611c5ec8270bf0cfc94ff033bbfac0550b70
   languageName: node
   linkType: hard
 
@@ -1726,9 +1727,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@eslint/eslintrc@npm:^2.1.3":
-  version: 2.1.3
-  resolution: "@eslint/eslintrc@npm:2.1.3"
+"@eslint/eslintrc@npm:^2.1.4":
+  version: 2.1.4
+  resolution: "@eslint/eslintrc@npm:2.1.4"
   dependencies:
     ajv: "npm:^6.12.4"
     debug: "npm:^4.3.2"
@@ -1739,14 +1740,14 @@ __metadata:
     js-yaml: "npm:^4.1.0"
     minimatch: "npm:^3.1.2"
     strip-json-comments: "npm:^3.1.1"
-  checksum: f4103f4346126292eb15581c5a1d12bef03410fd3719dedbdb92e1f7031d46a5a2d60de8566790445d5d4b70b75ba050876799a11f5fff8265a91ee3fa77dab0
+  checksum: 32f67052b81768ae876c84569ffd562491ec5a5091b0c1e1ca1e0f3c24fb42f804952fdd0a137873bc64303ba368a71ba079a6f691cee25beee9722d94cc8573
   languageName: node
   linkType: hard
 
-"@eslint/js@npm:8.54.0":
-  version: 8.54.0
-  resolution: "@eslint/js@npm:8.54.0"
-  checksum: d61fb4a0be6af2d8cb290121c329697664a75d6255a29926d5454fb02aeb02b87112f67fdf218d10abac42f90c570ac366126751baefc5405d0e017ed0c946c5
+"@eslint/js@npm:8.55.0":
+  version: 8.55.0
+  resolution: "@eslint/js@npm:8.55.0"
+  checksum: 88ab9fc57a651becd2b32ec40a3958db27fae133b1ae77bebd733aa5bbd00a92f325bb02f20ad680d31c731fa49b22f060a4777dd52eb3e27da013d940bd978d
   languageName: node
   linkType: hard
 
@@ -2435,7 +2436,7 @@ __metadata:
     stacktrace-js: "npm:^2.0.2"
     stringz: "npm:^2.1.0"
     stylelint: "npm:^15.10.1"
-    stylelint-config-standard-scss: "npm:^11.0.0"
+    stylelint-config-standard-scss: "npm:^12.0.0"
     substring-trie: "npm:^1.0.2"
     terser-webpack-plugin: "npm:^4.2.3"
     tesseract.js: "npm:^2.1.5"
@@ -3030,11 +3031,11 @@ __metadata:
   linkType: hard
 
 "@types/emoji-mart@npm:^3.0.9":
-  version: 3.0.13
-  resolution: "@types/emoji-mart@npm:3.0.13"
+  version: 3.0.14
+  resolution: "@types/emoji-mart@npm:3.0.14"
   dependencies:
     "@types/react": "npm:*"
-  checksum: 840f920c3242e1d274f0102e67cb2d00434e1fd370e0bcc8983b43b6b62322b01e3ddcd5fb078c60883e613530a7c70b8c40060624897543cd4da9441ca81486
+  checksum: 23ded65fce9b3355fbe903d3971cb67cc827a5d587464bb7e3f349615527ef4a9197b3bb59fa84c4391d1b901e7f200f686a7fc83f649ae2a51a0fb948cbadfb
   languageName: node
   linkType: hard
 
@@ -3178,12 +3179,12 @@ __metadata:
   linkType: hard
 
 "@types/jest@npm:^29.5.2":
-  version: 29.5.10
-  resolution: "@types/jest@npm:29.5.10"
+  version: 29.5.11
+  resolution: "@types/jest@npm:29.5.11"
   dependencies:
     expect: "npm:^29.0.0"
     pretty-format: "npm:^29.0.0"
-  checksum: b46171d59d12a5f69bbe710f65eaf59a8073337c6b4a67dff8158575caec53f1c61f8a7d645b34d6ac3c4ea398acd30f0c5d1c4a131c0c918798019264a3397d
+  checksum: 524a3394845214581278bf4d75055927261fbeac7e1a89cd621bd0636da37d265fe0a85eac58b5778758faad1cbd7c7c361dfc190c78ebde03a91cce33463261
   languageName: node
   linkType: hard
 
@@ -3377,11 +3378,11 @@ __metadata:
   linkType: hard
 
 "@types/react-helmet@npm:^6.1.6":
-  version: 6.1.9
-  resolution: "@types/react-helmet@npm:6.1.9"
+  version: 6.1.11
+  resolution: "@types/react-helmet@npm:6.1.11"
   dependencies:
     "@types/react": "npm:*"
-  checksum: d1823582903d6e70f1f447c7bec9e844b6f85f5de84cbcde5c8bbeecc064db1394c786ed9b9ded30544afe5c91e57c7e8105171df1643998f64c0aeab9f7f2aa
+  checksum: f7b3bb2151d992a108ae46fed876fb9c8119108397d9a01d150c5642782997542c8b3c52e742b56e8689b7dbfa62ca9cfc76aa7e05dec4e60c652f7ef53fa783
   languageName: node
   linkType: hard
 
@@ -3498,13 +3499,13 @@ __metadata:
   linkType: hard
 
 "@types/react@npm:*, @types/react@npm:16 || 17 || 18, @types/react@npm:>=16.9.11, @types/react@npm:^18.2.7":
-  version: 18.2.41
-  resolution: "@types/react@npm:18.2.41"
+  version: 18.2.43
+  resolution: "@types/react@npm:18.2.43"
   dependencies:
     "@types/prop-types": "npm:*"
     "@types/scheduler": "npm:*"
     csstype: "npm:^3.0.2"
-  checksum: 5cc72491ce8be95e7bbedd8bf039ca971772ecd22d989feb045af7e73247c7e6cff25a2f1c2200be461fb2f6b5aacef739e1ba9fd83c744209dfd3ce8aa75afe
+  checksum: 10477a50fbd3c0cc5b8a2ade679f442717f68fb27c8460b2aa1d3256cd18c48f742bbe5b9ee37a8c4c5f832ffa37b3a23c09fd96dd880a8e3182d8929c05e803
   languageName: node
   linkType: hard
 
@@ -3685,14 +3686,14 @@ __metadata:
   linkType: hard
 
 "@typescript-eslint/eslint-plugin@npm:^6.0.0":
-  version: 6.11.0
-  resolution: "@typescript-eslint/eslint-plugin@npm:6.11.0"
+  version: 6.13.2
+  resolution: "@typescript-eslint/eslint-plugin@npm:6.13.2"
   dependencies:
     "@eslint-community/regexpp": "npm:^4.5.1"
-    "@typescript-eslint/scope-manager": "npm:6.11.0"
-    "@typescript-eslint/type-utils": "npm:6.11.0"
-    "@typescript-eslint/utils": "npm:6.11.0"
-    "@typescript-eslint/visitor-keys": "npm:6.11.0"
+    "@typescript-eslint/scope-manager": "npm:6.13.2"
+    "@typescript-eslint/type-utils": "npm:6.13.2"
+    "@typescript-eslint/utils": "npm:6.13.2"
+    "@typescript-eslint/visitor-keys": "npm:6.13.2"
     debug: "npm:^4.3.4"
     graphemer: "npm:^1.4.0"
     ignore: "npm:^5.2.4"
@@ -3705,44 +3706,44 @@ __metadata:
   peerDependenciesMeta:
     typescript:
       optional: true
-  checksum: 6645aa09b9d51c5e3ea781eaf74da75b94f83f3e2d7b3dd988d5ce7eb82dd87e3509471cf2ee8c6b2428d907df5f1b02f29dbd04f54c2653f9566c8c4ce98009
+  checksum: 531a4406d872738d165c6a66cb26e976523c94053b022a8210dc9fd10e91b79b705bc0fcc77145e9744e4108b53bdba55e02a10dc17757b22be92aff57849384
   languageName: node
   linkType: hard
 
 "@typescript-eslint/parser@npm:^6.0.0":
-  version: 6.11.0
-  resolution: "@typescript-eslint/parser@npm:6.11.0"
+  version: 6.13.2
+  resolution: "@typescript-eslint/parser@npm:6.13.2"
   dependencies:
-    "@typescript-eslint/scope-manager": "npm:6.11.0"
-    "@typescript-eslint/types": "npm:6.11.0"
-    "@typescript-eslint/typescript-estree": "npm:6.11.0"
-    "@typescript-eslint/visitor-keys": "npm:6.11.0"
+    "@typescript-eslint/scope-manager": "npm:6.13.2"
+    "@typescript-eslint/types": "npm:6.13.2"
+    "@typescript-eslint/typescript-estree": "npm:6.13.2"
+    "@typescript-eslint/visitor-keys": "npm:6.13.2"
     debug: "npm:^4.3.4"
   peerDependencies:
     eslint: ^7.0.0 || ^8.0.0
   peerDependenciesMeta:
     typescript:
       optional: true
-  checksum: e7caeb20069102e21f468fc0dbe7ff6fb6b1efa9e72f4c9f39d4a865ed0633f39130b593ef9ae8f394ca1d70563e15410faf30a482a97809951eaac6ed3a67da
+  checksum: 2c62b8cd8a37eb2ea59cd00e559f51a9f57af746e2040e872af3c58ddd3f4071ad7b7009789bdeb0e0d4ee0343bfe96ee77288020f3ae22d08e1674203f5e156
   languageName: node
   linkType: hard
 
-"@typescript-eslint/scope-manager@npm:6.11.0":
-  version: 6.11.0
-  resolution: "@typescript-eslint/scope-manager@npm:6.11.0"
+"@typescript-eslint/scope-manager@npm:6.13.2":
+  version: 6.13.2
+  resolution: "@typescript-eslint/scope-manager@npm:6.13.2"
   dependencies:
-    "@typescript-eslint/types": "npm:6.11.0"
-    "@typescript-eslint/visitor-keys": "npm:6.11.0"
-  checksum: d8999e2d1a4cbde8a79df5e3ec416f0e3db9532d39f2f4bb5a0ebdf954ae75c183d3277579ba05268fe2c88e88ef87f0fa12f02bb8d95d9e67d92e411241f3a3
+    "@typescript-eslint/types": "npm:6.13.2"
+    "@typescript-eslint/visitor-keys": "npm:6.13.2"
+  checksum: 9b159e5bb10dfb5953e71488200b4126378fc7e987ce7d90946aea9ec40cd66c7ada92399657c5d9794189b764ca6f4eb38a8dcb9e4c5aa50ab6000a39636b9c
   languageName: node
   linkType: hard
 
-"@typescript-eslint/type-utils@npm:6.11.0":
-  version: 6.11.0
-  resolution: "@typescript-eslint/type-utils@npm:6.11.0"
+"@typescript-eslint/type-utils@npm:6.13.2":
+  version: 6.13.2
+  resolution: "@typescript-eslint/type-utils@npm:6.13.2"
   dependencies:
-    "@typescript-eslint/typescript-estree": "npm:6.11.0"
-    "@typescript-eslint/utils": "npm:6.11.0"
+    "@typescript-eslint/typescript-estree": "npm:6.13.2"
+    "@typescript-eslint/utils": "npm:6.13.2"
     debug: "npm:^4.3.4"
     ts-api-utils: "npm:^1.0.1"
   peerDependencies:
@@ -3750,23 +3751,23 @@ __metadata:
   peerDependenciesMeta:
     typescript:
       optional: true
-  checksum: ff68f2e052b8d688f1dc1a0050746704c8e0ab6263b47f1f52da73a7d251678e4950af23a95e1cd8e3fcea2457e6e5294ddbe01d29dafa2fdfb5b11ed9452a3f
+  checksum: 1ca97c78abdf479aea0c54e869fda2ae2f69de1974cc063062ce7b5b16c7fdf497ea15c50a29dd5941ea1b6b77e8f1213a5c272a747e334ac69ede083f327468
   languageName: node
   linkType: hard
 
-"@typescript-eslint/types@npm:6.11.0":
-  version: 6.11.0
-  resolution: "@typescript-eslint/types@npm:6.11.0"
-  checksum: 23182813db39a5e9b9bcc1e85306c953f7b8b22d3885e41fcac0bd725c170fbcb70f4ce55633678cc5921dcf062fa0e55635eb39480c118a4411a00354820223
+"@typescript-eslint/types@npm:6.13.2":
+  version: 6.13.2
+  resolution: "@typescript-eslint/types@npm:6.13.2"
+  checksum: 029918ca5b1442bb4bc435773504ce32191e2c3e2fde8d4176bb6513f03e3dfa2aa9724b2d22b1640656d666b97f7a7ebfeaf67b881d5e07250828fa83e3ebe8
   languageName: node
   linkType: hard
 
-"@typescript-eslint/typescript-estree@npm:6.11.0":
-  version: 6.11.0
-  resolution: "@typescript-eslint/typescript-estree@npm:6.11.0"
+"@typescript-eslint/typescript-estree@npm:6.13.2":
+  version: 6.13.2
+  resolution: "@typescript-eslint/typescript-estree@npm:6.13.2"
   dependencies:
-    "@typescript-eslint/types": "npm:6.11.0"
-    "@typescript-eslint/visitor-keys": "npm:6.11.0"
+    "@typescript-eslint/types": "npm:6.13.2"
+    "@typescript-eslint/visitor-keys": "npm:6.13.2"
     debug: "npm:^4.3.4"
     globby: "npm:^11.1.0"
     is-glob: "npm:^4.0.3"
@@ -3775,34 +3776,34 @@ __metadata:
   peerDependenciesMeta:
     typescript:
       optional: true
-  checksum: 3e183e554e1bc74f065da3015f7137eb40c262f989c547701b1e3f4f20134e574e56b749288cd00d77b9d1ddb705546613c2457661ffc63b6060ffa97ba3aac8
+  checksum: 1c4c59dce0c51fdfee34d9f418e64fe28e3ec1a97661efc8a3d2780bdff36aff38de9090d356a968f394fa6d4e9c058936ce9cd260d4c44a52761ecd74915bce
   languageName: node
   linkType: hard
 
-"@typescript-eslint/utils@npm:6.11.0, @typescript-eslint/utils@npm:^6.5.0":
-  version: 6.11.0
-  resolution: "@typescript-eslint/utils@npm:6.11.0"
+"@typescript-eslint/utils@npm:6.13.2, @typescript-eslint/utils@npm:^6.5.0":
+  version: 6.13.2
+  resolution: "@typescript-eslint/utils@npm:6.13.2"
   dependencies:
     "@eslint-community/eslint-utils": "npm:^4.4.0"
     "@types/json-schema": "npm:^7.0.12"
     "@types/semver": "npm:^7.5.0"
-    "@typescript-eslint/scope-manager": "npm:6.11.0"
-    "@typescript-eslint/types": "npm:6.11.0"
-    "@typescript-eslint/typescript-estree": "npm:6.11.0"
+    "@typescript-eslint/scope-manager": "npm:6.13.2"
+    "@typescript-eslint/types": "npm:6.13.2"
+    "@typescript-eslint/typescript-estree": "npm:6.13.2"
     semver: "npm:^7.5.4"
   peerDependencies:
     eslint: ^7.0.0 || ^8.0.0
-  checksum: c91eb4578607959acc2b43ddc791571682e45601a19b25d5d120786ed4af607656f83c5c1fa71972e549ddfb5542acf2f7d443ae93b32ee28192c22c106b8883
+  checksum: 84969be91e7949868eaaa289288c9d71927f0e427b572501b0991d8d62b40a4234f7287c35b35d276ccbb53e9ea5457b8250fcf4941e60e6b9ba4065fbfba416
   languageName: node
   linkType: hard
 
-"@typescript-eslint/visitor-keys@npm:6.11.0":
-  version: 6.11.0
-  resolution: "@typescript-eslint/visitor-keys@npm:6.11.0"
+"@typescript-eslint/visitor-keys@npm:6.13.2":
+  version: 6.13.2
+  resolution: "@typescript-eslint/visitor-keys@npm:6.13.2"
   dependencies:
-    "@typescript-eslint/types": "npm:6.11.0"
+    "@typescript-eslint/types": "npm:6.13.2"
     eslint-visitor-keys: "npm:^3.4.1"
-  checksum: 5f48329422b7f286196661d39e93e9defd7c5cf80e6c84c8d03459853f5d9f86a5e91c5e80ea572dcdb907ebbe503bbcc77aeb8b468c294b2aa7b3ccfc81cb88
+  checksum: c173bc1fcc42c3075a5ee094e7f3bf0279d98315c25ff49e20d02d79022b1d0402accfa113b070afb4d52a6f6d180594b67baa8b6a784eabdf82b54dd1ff454c
   languageName: node
   linkType: hard
 
@@ -5180,17 +5181,17 @@ __metadata:
   languageName: node
   linkType: hard
 
-"browserslist@npm:^4.0.0, browserslist@npm:^4.21.10, browserslist@npm:^4.21.4, browserslist@npm:^4.21.9, browserslist@npm:^4.22.1":
-  version: 4.22.1
-  resolution: "browserslist@npm:4.22.1"
+"browserslist@npm:^4.0.0, browserslist@npm:^4.21.10, browserslist@npm:^4.21.4, browserslist@npm:^4.22.1, browserslist@npm:^4.22.2":
+  version: 4.22.2
+  resolution: "browserslist@npm:4.22.2"
   dependencies:
-    caniuse-lite: "npm:^1.0.30001541"
-    electron-to-chromium: "npm:^1.4.535"
-    node-releases: "npm:^2.0.13"
+    caniuse-lite: "npm:^1.0.30001565"
+    electron-to-chromium: "npm:^1.4.601"
+    node-releases: "npm:^2.0.14"
     update-browserslist-db: "npm:^1.0.13"
   bin:
     browserslist: cli.js
-  checksum: 6810f2d63f171d0b7b8d38cf091708e00cb31525501810a507839607839320d66e657293b0aa3d7f051ecbc025cb07390a90c037682c1d05d12604991e41050b
+  checksum: 2a331aab90503130043ca41dd5d281fa1e89d5e076d07a2d75e76bf4d693bd56e73d5abcd8c4f39119da6328d450578c216cf1cd5c99b82d8a90a2ae6271b465
   languageName: node
   linkType: hard
 
@@ -5418,10 +5419,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001538, caniuse-lite@npm:^1.0.30001541":
-  version: 1.0.30001561
-  resolution: "caniuse-lite@npm:1.0.30001561"
-  checksum: 6e84c84026fee53edbdbb5aded7a04a036aae4c2e367cf6bdc90c6783a591e2fdcfcdebcc4e774aca61092e542a61200c8c16b06659396492426033c4dbcc618
+"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001538, caniuse-lite@npm:^1.0.30001565":
+  version: 1.0.30001568
+  resolution: "caniuse-lite@npm:1.0.30001568"
+  checksum: 13f01e5a2481134bd61cf565ce9fecbd8e107902927a0dcf534230a92191a81f1715792170f5f39719c767c3a96aa6df9917a8d5601f15bbd5e4041a8cfecc99
   languageName: node
   linkType: hard
 
@@ -6423,7 +6424,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"debug@npm:4, debug@npm:4.3.4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.4":
+"debug@npm:4, debug@npm:4.3.4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4":
   version: 4.3.4
   resolution: "debug@npm:4.3.4"
   dependencies:
@@ -6965,10 +6966,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"electron-to-chromium@npm:^1.4.535":
-  version: 1.4.576
-  resolution: "electron-to-chromium@npm:1.4.576"
-  checksum: b0b9e7ba803bf93ffac9cb830ed2b0e0eb07f20066127065f9ab9e08e4e6a5812040e03d76f6ee9bc59e03fb938fd414e83d4883b29111303e9e88633cf2dce4
+"electron-to-chromium@npm:^1.4.601":
+  version: 1.4.609
+  resolution: "electron-to-chromium@npm:1.4.609"
+  checksum: 9675a79388acbaff5953a4c61589af7da93e0d1f9d6a3b284c7630f10126eb0998557b07448514214d5a3d19025310039b55f405ab701b1253130fc94907f743
   languageName: node
   linkType: hard
 
@@ -7323,13 +7324,13 @@ __metadata:
   linkType: hard
 
 "eslint-config-prettier@npm:^9.0.0":
-  version: 9.0.0
-  resolution: "eslint-config-prettier@npm:9.0.0"
+  version: 9.1.0
+  resolution: "eslint-config-prettier@npm:9.1.0"
   peerDependencies:
     eslint: ">=7.0.0"
   bin:
     eslint-config-prettier: bin/cli.js
-  checksum: bc1f661915845c631824178942e5d02f858fe6d0ea796f0050d63e0f681927b92696e81139dd04714c08c3e7de580fd079c66162e40070155ba79eaee78ab5d0
+  checksum: 6d332694b36bc9ac6fdb18d3ca2f6ac42afa2ad61f0493e89226950a7091e38981b66bac2b47ba39d15b73fff2cd32c78b850a9cf9eed9ca9a96bfb2f3a2f10d
   languageName: node
   linkType: hard
 
@@ -7565,13 +7566,13 @@ __metadata:
   linkType: hard
 
 "eslint@npm:^8.41.0":
-  version: 8.54.0
-  resolution: "eslint@npm:8.54.0"
+  version: 8.55.0
+  resolution: "eslint@npm:8.55.0"
   dependencies:
     "@eslint-community/eslint-utils": "npm:^4.2.0"
     "@eslint-community/regexpp": "npm:^4.6.1"
-    "@eslint/eslintrc": "npm:^2.1.3"
-    "@eslint/js": "npm:8.54.0"
+    "@eslint/eslintrc": "npm:^2.1.4"
+    "@eslint/js": "npm:8.55.0"
     "@humanwhocodes/config-array": "npm:^0.11.13"
     "@humanwhocodes/module-importer": "npm:^1.0.1"
     "@nodelib/fs.walk": "npm:^1.2.8"
@@ -7608,7 +7609,7 @@ __metadata:
     text-table: "npm:^0.2.0"
   bin:
     eslint: bin/eslint.js
-  checksum: 4f205f832bdbd0218cde374b067791f4f76d7abe8de86b2dc849c273899051126d912ebf71531ee49b8eeaa22cad77febdc8f2876698dc2a76e84a8cb976af22
+  checksum: d28c0b60f19bb7d355cb8393e77b018c8f548dba3f820b799c89bb2e0c436ee26084e700c5e57e1e97e7972ec93065277849141b82e7b0c0d02c2dc1e553a2a1
   languageName: node
   linkType: hard
 
@@ -11954,10 +11955,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"node-releases@npm:^2.0.13":
-  version: 2.0.13
-  resolution: "node-releases@npm:2.0.13"
-  checksum: 2fb44bf70fc949d27f3a48a7fd1a9d1d603ddad4ccd091f26b3fb8b1da976605d919330d7388ccd55ca2ade0dc8b2e12841ba19ef249c8bb29bf82532d401af7
+"node-releases@npm:^2.0.14":
+  version: 2.0.14
+  resolution: "node-releases@npm:2.0.14"
+  checksum: 199fc93773ae70ec9969bc6d5ac5b2bbd6eb986ed1907d751f411fef3ede0e4bfdb45ceb43711f8078bea237b6036db8b1bf208f6ff2b70c7d615afd157f3ab9
   languageName: node
   linkType: hard
 
@@ -13336,11 +13337,11 @@ __metadata:
   linkType: hard
 
 "prettier@npm:^3.0.0":
-  version: 3.1.0
-  resolution: "prettier@npm:3.1.0"
+  version: 3.1.1
+  resolution: "prettier@npm:3.1.1"
   bin:
     prettier: bin/prettier.cjs
-  checksum: a45ea70aa97fde162ea4c4aba3dfc7859aa6a732a1db34458d9535dc3c2c16d3bc3fb5689e6cd76aa835562555303b02d9449fd2e15af3b73c8053557e25c5b6
+  checksum: facc944ba20e194ff4db765e830ffbcb642803381f0d2033ed397e79904fa4ccc877dc25ad68f42d36985c01d051c990ca1b905fb83d2d7d65fe69e4386fa1a3
   languageName: node
   linkType: hard
 
@@ -15781,62 +15782,62 @@ __metadata:
   languageName: node
   linkType: hard
 
-"stylelint-config-recommended-scss@npm:^13.1.0":
-  version: 13.1.0
-  resolution: "stylelint-config-recommended-scss@npm:13.1.0"
+"stylelint-config-recommended-scss@npm:^14.0.0":
+  version: 14.0.0
+  resolution: "stylelint-config-recommended-scss@npm:14.0.0"
   dependencies:
     postcss-scss: "npm:^4.0.9"
-    stylelint-config-recommended: "npm:^13.0.0"
-    stylelint-scss: "npm:^5.3.0"
+    stylelint-config-recommended: "npm:^14.0.0"
+    stylelint-scss: "npm:^6.0.0"
   peerDependencies:
     postcss: ^8.3.3
-    stylelint: ^15.10.0
+    stylelint: ^16.0.2
   peerDependenciesMeta:
     postcss:
       optional: true
-  checksum: e07d0172c7936b4f644138e4129df2f187d297f1f96ce5865ab21ccd1c22caf94220f7caf9d6985e93e515de4c0356f6cb9c924d00df2eee5b3bc237f7e5bb48
+  checksum: 9ddc92e7a5fa131b41cee1ab1f69251934ca35c0e2803dc613329cdead7b8b27d8457048a63db29f61a1442e7cdef14207f88a3abce00ec53fdefe0d604f7de3
   languageName: node
   linkType: hard
 
-"stylelint-config-recommended@npm:^13.0.0":
-  version: 13.0.0
-  resolution: "stylelint-config-recommended@npm:13.0.0"
+"stylelint-config-recommended@npm:^14.0.0":
+  version: 14.0.0
+  resolution: "stylelint-config-recommended@npm:14.0.0"
   peerDependencies:
-    stylelint: ^15.10.0
-  checksum: 80420a1ab616e8637b66223f88c597388990d9991cd6a28b8372049b83329d893412f83029bb253a82b52387e497b62e042bc898064a2f22574b0d8921f01dd2
+    stylelint: ^16.0.0
+  checksum: 4ad15c36e8c03291aa7bbe4b672ebfb0f46ab698e7580a0da8d29644046d102d7f31dbf00a2a6eab94b565c390c6fb0d5d528737b83ac3acf6dc2ef085a90b11
   languageName: node
   linkType: hard
 
-"stylelint-config-standard-scss@npm:^11.0.0":
-  version: 11.1.0
-  resolution: "stylelint-config-standard-scss@npm:11.1.0"
+"stylelint-config-standard-scss@npm:^12.0.0":
+  version: 12.0.0
+  resolution: "stylelint-config-standard-scss@npm:12.0.0"
   dependencies:
-    stylelint-config-recommended-scss: "npm:^13.1.0"
-    stylelint-config-standard: "npm:^34.0.0"
+    stylelint-config-recommended-scss: "npm:^14.0.0"
+    stylelint-config-standard: "npm:^35.0.0"
   peerDependencies:
     postcss: ^8.3.3
-    stylelint: ^15.10.0
+    stylelint: ^16.0.2
   peerDependenciesMeta:
     postcss:
       optional: true
-  checksum: 22d00e75c1eacce9883fd48c3d67b1107b0e39d7d86e9f73deaa332b11c39a9678c947ae2c34cd5159a452ec9a857694ed58b5a851087480d3c9a66dab629415
+  checksum: 7f3ccfb4175f9c50b69d30ca35a97887008c5ba493dbe7d5bce0b57b1eafd21b268177b82404368e7780600077cba784f98e1046671724be3b29a00c6a7913a4
   languageName: node
   linkType: hard
 
-"stylelint-config-standard@npm:^34.0.0":
-  version: 34.0.0
-  resolution: "stylelint-config-standard@npm:34.0.0"
+"stylelint-config-standard@npm:^35.0.0":
+  version: 35.0.0
+  resolution: "stylelint-config-standard@npm:35.0.0"
   dependencies:
-    stylelint-config-recommended: "npm:^13.0.0"
+    stylelint-config-recommended: "npm:^14.0.0"
   peerDependencies:
-    stylelint: ^15.10.0
-  checksum: 2494468af2359490b6ebb9723d9653f9e31db3a0772b8d9f0e081018b0079ef84ae6f90dcf94c879a3c374f299e334941e3dcff1afb603c2284d3103085b71fb
+    stylelint: ^16.0.0
+  checksum: 791fbc26cc3029ce3c2423a643e903545b5e4cd605251b18f0ce790bac6fbaaf380469845c1ff45f4e320126af9f8a9dc1ca85d0df9274277ae60da91e81895b
   languageName: node
   linkType: hard
 
-"stylelint-scss@npm:^5.3.0":
-  version: 5.3.1
-  resolution: "stylelint-scss@npm:5.3.1"
+"stylelint-scss@npm:^6.0.0":
+  version: 6.0.0
+  resolution: "stylelint-scss@npm:6.0.0"
   dependencies:
     known-css-properties: "npm:^0.29.0"
     postcss-media-query-parser: "npm:^0.2.3"
@@ -15844,8 +15845,8 @@ __metadata:
     postcss-selector-parser: "npm:^6.0.13"
     postcss-value-parser: "npm:^4.2.0"
   peerDependencies:
-    stylelint: ^14.5.1 || ^15.0.0
-  checksum: 5dfed5f9ac9812cd2ac6ef0272c720dee0326aaaee2998315a23bdcd71b8f04427f29cad634793eea2b45984182e20f03e90d43501e8e4d55bc956f80e2de477
+    stylelint: ^16.0.2
+  checksum: f5e971d19ef6879ae5c18cb8fba8033fe7928f241178e6afd80357cc080d2feddfd6f7fe564aaa696008aa10345df5885d9a4471c926b3e266088e015927782e
   languageName: node
   linkType: hard
 
@@ -16519,22 +16520,22 @@ __metadata:
   linkType: hard
 
 "typescript@npm:5, typescript@npm:^5.0.4":
-  version: 5.3.2
-  resolution: "typescript@npm:5.3.2"
+  version: 5.3.3
+  resolution: "typescript@npm:5.3.3"
   bin:
     tsc: bin/tsc
     tsserver: bin/tsserver
-  checksum: d7dbe1fbe19039e36a65468ea64b5d338c976550394ba576b7af9c68ed40c0bc5d12ecce390e4b94b287a09a71bd3229f19c2d5680611f35b7c53a3898791159
+  checksum: e33cef99d82573624fc0f854a2980322714986bc35b9cb4d1ce736ed182aeab78e2cb32b385efa493b2a976ef52c53e20d6c6918312353a91850e2b76f1ea44f
   languageName: node
   linkType: hard
 
 "typescript@patch:typescript@npm%3A5#optional!builtin<compat/typescript>, typescript@patch:typescript@npm%3A^5.0.4#optional!builtin<compat/typescript>":
-  version: 5.3.2
-  resolution: "typescript@patch:typescript@npm%3A5.3.2#optional!builtin<compat/typescript>::version=5.3.2&hash=e012d7"
+  version: 5.3.3
+  resolution: "typescript@patch:typescript@npm%3A5.3.3#optional!builtin<compat/typescript>::version=5.3.3&hash=e012d7"
   bin:
     tsc: bin/tsc
     tsserver: bin/tsserver
-  checksum: 73c8bad74e732d93211c9d77f28b03307e2f5fc6a0afc73f4b783261ab567686a16d6ae958bdaef383a00be1b0b8c8b6741dd6ca3d13af4963fa7e47456d49c7
+  checksum: 1d0a5f4ce496c42caa9a30e659c467c5686eae15d54b027ee7866744952547f1be1262f2d40de911618c242b510029d51d43ff605dba8fb740ec85ca2d3f9500
   languageName: node
   linkType: hard
 
@@ -17762,8 +17763,8 @@ __metadata:
   linkType: hard
 
 "ws@npm:^8.11.0, ws@npm:^8.12.1, ws@npm:^8.14.2":
-  version: 8.14.2
-  resolution: "ws@npm:8.14.2"
+  version: 8.15.0
+  resolution: "ws@npm:8.15.0"
   peerDependencies:
     bufferutil: ^4.0.1
     utf-8-validate: ">=5.0.2"
@@ -17772,7 +17773,7 @@ __metadata:
       optional: true
     utf-8-validate:
       optional: true
-  checksum: 35b4c2da048b8015c797fd14bcb5a5766216ce65c8a5965616a5440ca7b6c3681ee3cbd0ea0c184a59975556e9d58f2002abf8485a14d11d3371770811050a16
+  checksum: b778a405b2589ffbf549323e2f404f1f72e372a049d332d2f0b1f33057e9fbb14a05aa474cb156e4584b418cd95edf4297c0ca5263d6519e8009064bf8e0b80d
   languageName: node
   linkType: hard