diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 5f0eaac8e0..4ec92f3412 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.76.2. +# using RuboCop version 1.77.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -28,7 +28,7 @@ Metrics/PerceivedComplexity: Max: 27 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowedVars. +# Configuration parameters: AllowedVars, DefaultToNil. Style/FetchEnvVar: Exclude: - 'config/initializers/paperclip.rb' diff --git a/.storybook/main.ts b/.storybook/main.ts index 638806c085..ba0ac2ae52 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -11,6 +11,7 @@ const config: StorybookConfig = { name: '@storybook/react-vite', options: {}, }, + staticDirs: ['./static'], }; export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts deleted file mode 100644 index a0bec9085f..0000000000 --- a/.storybook/preview.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Preview } from '@storybook/react-vite'; - -// If you want to run the dark theme during development, -// you can change the below to `/application.scss` -import '../app/javascript/styles/mastodon-light.scss'; - -const preview: Preview = { - // Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs - tags: ['autodocs'], - parameters: { - layout: 'centered', - - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo', - }, - }, -}; - -export default preview; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000000..c879cf10d1 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react'; + +import { IntlProvider } from 'react-intl'; + +import { configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; + +import type { Preview } from '@storybook/react-vite'; +import { http, passthrough } from 'msw'; +import { initialize, mswLoader } from 'msw-storybook-addon'; + +import type { LocaleData } from '@/mastodon/locales'; +import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers'; +import { defaultMiddleware } from '@/mastodon/store/store'; + +// If you want to run the dark theme during development, +// you can change the below to `/application.scss` +import '../app/javascript/styles/mastodon-light.scss'; + +const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { + query: { as: 'json' }, +}); + +// Initialize MSW +initialize(); + +const preview: Preview = { + // Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + globalTypes: { + locale: { + description: 'Locale for the story', + toolbar: { + title: 'Locale', + icon: 'globe', + items: Object.keys(localeFiles).map((path) => + path.replace('/mastodon/locales/', '').replace('.json', ''), + ), + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + locale: 'en', + }, + decorators: [ + (Story, { parameters }) => { + const { state = {} } = parameters; + let reducer = rootReducer; + if (typeof state === 'object' && state) { + reducer = reducerWithInitialState(state as Record); + } + const store = configureStore({ + reducer, + middleware(getDefaultMiddleware) { + return getDefaultMiddleware(defaultMiddleware); + }, + }); + return ( + + + + ); + }, + (Story, { globals }) => { + const currentLocale = (globals.locale as string) || 'en'; + const [messages, setMessages] = useState< + Record> + >({}); + const currentLocaleData = messages[currentLocale]; + + useEffect(() => { + async function loadLocaleData() { + const { default: localeFile } = (await import( + `@/mastodon/locales/${currentLocale}.json` + )) as { default: LocaleData['messages'] }; + setMessages((prevLocales) => ({ + ...prevLocales, + [currentLocale]: localeFile, + })); + } + if (!currentLocaleData) { + void loadLocaleData(); + } + }, [currentLocale, currentLocaleData]); + + return ( + + + + ); + }, + ], + loaders: [mswLoader], + parameters: { + layout: 'centered', + + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + }, + + state: {}, + + // Force docs to use an iframe as it breaks MSW handlers. + // See: https://github.com/mswjs/msw-storybook-addon/issues/83 + docs: { + story: { + inline: false, + }, + }, + + msw: { + handlers: [ + http.get('/index.json', passthrough), + http.get('/packs-dev/*', passthrough), + http.get('/sounds/*', passthrough), + ], + }, + }, +}; + +export default preview; diff --git a/.storybook/static/mockServiceWorker.js b/.storybook/static/mockServiceWorker.js new file mode 100644 index 0000000000..de7bc0f292 --- /dev/null +++ b/.storybook/static/mockServiceWorker.js @@ -0,0 +1,344 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.10.2' +const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + */ +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @returns {Promise} + */ +async function getResponse(event, client, requestId) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/Gemfile.lock b/Gemfile.lock index 7583b197e3..ed1cffefe5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -121,7 +121,7 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - bigdecimal (3.1.9) + bigdecimal (3.2.2) bindata (2.5.1) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) @@ -287,7 +287,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.62.0) + haml_lint (0.63.0) haml (>= 5.0) parallel (~> 1.10) rainbow @@ -761,7 +761,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) rspec-support (3.13.4) - rubocop (1.76.2) + rubocop (1.77.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -855,8 +855,8 @@ GEM stoplight (4.1.1) redlock (~> 1.0) stringio (3.1.7) - strong_migrations (2.3.0) - activerecord (>= 7) + strong_migrations (2.4.0) + activerecord (>= 7.1) swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb index 1ccd740686..cff25a254b 100644 --- a/app/controllers/admin/trends/tags_controller.rb +++ b/app/controllers/admin/trends/tags_controller.rb @@ -4,7 +4,7 @@ class Admin::Trends::TagsController < Admin::BaseController def index authorize :tag, :review? - @pending_tags_count = Tag.pending_review.async_count + @pending_tags_count = pending_tags.async_count @tags = filtered_tags.page(params[:page]) @form = Trends::TagBatch.new end @@ -22,6 +22,10 @@ class Admin::Trends::TagsController < Admin::BaseController private + def pending_tags + Trends::TagFilter.new(status: :pending_review).results + end + def filtered_tags Trends::TagFilter.new(filter_params).results end diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index 5e9ba5153c..a7031f8873 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -32,7 +32,7 @@ class Api::V1::FiltersController < Api::BaseController ApplicationRecord.transaction do @filter.update!(keyword_params) @filter.custom_filter.assign_attributes(filter_params) - raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1 + raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.many? @filter.custom_filter.save! end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c11fd2a635..42abe99048 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -98,7 +98,7 @@ class ApplicationController < ActionController::Base end def after_sign_out_path_for(_resource_or_scope) - if ENV['OMNIAUTH_ONLY'] == 'true' && ENV['OIDC_ENABLED'] == 'true' + if ENV['OMNIAUTH_ONLY'] == 'true' && Rails.configuration.x.omniauth.oidc_enabled? '/auth/auth/openid_connect/logout' else new_user_session_path diff --git a/app/javascript/mastodon/actions/tags_typed.ts b/app/javascript/mastodon/actions/tags_typed.ts index a3e5cfd125..1f686f0c43 100644 --- a/app/javascript/mastodon/actions/tags_typed.ts +++ b/app/javascript/mastodon/actions/tags_typed.ts @@ -1,12 +1,30 @@ +import { createAction } from '@reduxjs/toolkit'; + import { apiGetTag, apiFollowTag, apiUnfollowTag, apiFeatureTag, apiUnfeatureTag, + apiGetFollowedTags, } from 'mastodon/api/tags'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; +export const fetchFollowedHashtags = createDataLoadingThunk( + 'tags/fetch-followed', + async ({ next }: { next?: string } = {}) => { + const response = await apiGetFollowedTags(next); + return { + ...response, + replace: !next, + }; + }, +); + +export const markFollowedHashtagsStale = createAction( + 'tags/mark-followed-stale', +); + export const fetchHashtag = createDataLoadingThunk( 'tags/fetch', ({ tagId }: { tagId: string }) => apiGetTag(tagId), @@ -15,6 +33,9 @@ export const fetchHashtag = createDataLoadingThunk( export const followHashtag = createDataLoadingThunk( 'tags/follow', ({ tagId }: { tagId: string }) => apiFollowTag(tagId), + (_, { dispatch }) => { + void dispatch(markFollowedHashtagsStale()); + }, ); export const unfollowHashtag = createDataLoadingThunk( diff --git a/app/javascript/mastodon/components/alt_text_badge.tsx b/app/javascript/mastodon/components/alt_text_badge.tsx index 701cfbe8b4..07369795ac 100644 --- a/app/javascript/mastodon/components/alt_text_badge.tsx +++ b/app/javascript/mastodon/components/alt_text_badge.tsx @@ -33,6 +33,7 @@ export const AltTextBadge: React.FC<{ return ( <>