diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d2d34db80d..efdebc3bda 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -21,9 +21,7 @@ import { reducerWithInitialState } from '@/mastodon/reducers'; import { defaultMiddleware } from '@/mastodon/store/store'; import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; -// 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'; +import '../app/javascript/styles/application.scss'; import './styles.css'; import { modes } from './modes'; diff --git a/Gemfile.lock b/Gemfile.lock index 52c3e37f85..3651d496c3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -343,8 +343,9 @@ GEM activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.8.2) - irb (1.16.0) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jd-paperclip-azure (3.0.0) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7c4d3fb86c..f6d3ce35ab 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,18 +11,12 @@ class ApplicationController < ActionController::Base include CacheConcern include PreloadingConcern include DomainControlHelper - include ThemingConcern include DatabaseHelper include AuthorizedFetchHelper include SelfDestructHelper helper_method :current_account helper_method :current_session - helper_method :current_flavour - helper_method :current_skin - helper_method :current_theme - helper_method :color_scheme - helper_method :contrast helper_method :single_user_mode? helper_method :use_seamless_external_login? helper_method :sso_account_settings @@ -176,25 +170,6 @@ class ApplicationController < ActionController::Base @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present? end - def color_scheme - current = current_user&.setting_color_scheme - return current if current && current != 'auto' - - return 'dark' if current_skin.include?('default') || current_skin.include?('contrast') - return 'light' if current_skin.include?('light') - - 'auto' - end - - def contrast - current = current_user&.setting_contrast - return current if current && current != 'auto' - - return 'high' if current_skin.include?('contrast') - - 'auto' - end - def respond_with_error(code) respond_to do |format| format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } diff --git a/app/controllers/concerns/theming_concern.rb b/app/controllers/concerns/theming_concern.rb deleted file mode 100644 index 38b31e932f..0000000000 --- a/app/controllers/concerns/theming_concern.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module ThemingConcern - extend ActiveSupport::Concern - - private - - def current_flavour - @current_flavour ||= [current_user&.setting_flavour, Setting.flavour, 'glitch', 'vanilla'].find { |flavour| Themes.instance.flavours.include?(flavour) } - end - - def current_skin - @current_skin ||= begin - skins = Themes.instance.skins_for(current_flavour) - [current_user&.setting_skin, Setting.skin, 'system', 'default'].find { |skin| skins.include?(skin) } - end - end - - def current_theme - # NOTE: this is slightly different from upstream, as it's a derived value used - # for the sole purpose of pointing to the appropriate stylesheet pack - [current_flavour, current_skin] - end -end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1dee3f96a8..8b2269eb2d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -89,12 +89,6 @@ module ApplicationHelper Rails.env.production? ? site_title : "#{site_title} (Dev)" end - def page_color_scheme - return content_for(:force_color_scheme) if content_for(:force_color_scheme) - - color_scheme - end - def label_for_scope(scope) safe_join [ tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }), diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb index 7202dfd9ab..7f91f598a0 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -20,9 +20,6 @@ module ThemeHelper def theme_style_tags(flavour_and_skin) flavour, theme = flavour_and_skin - # TODO: get rid of that when we retire the themes and perform the settings migration - theme = 'default' if %w(mastodon-light contrast system).include?(theme) - vite_stylesheet_tag "skins/#{flavour}/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous' end @@ -51,6 +48,33 @@ module ThemeHelper ) end + def current_flavour + [current_user&.setting_flavour, Setting.flavour, 'glitch', 'vanilla'].find { |flavour| Themes.instance.flavours.include?(flavour) } + end + + def current_skin + skins = Themes.instance.skins_for(current_flavour) + [current_user&.setting_skin, Setting.skin, 'system', 'default'].find { |skin| skins.include?(skin) } + end + + def current_theme + # NOTE: this is slightly different from upstream, as it's a derived value used + # for the sole purpose of pointing to the appropriate stylesheet pack + [current_flavour, current_skin] + end + + def color_scheme + current_user&.setting_color_scheme || 'auto' + end + + def contrast + current_user&.setting_contrast || 'auto' + end + + def page_color_scheme + content_for(:force_color_scheme).presence || color_scheme + end + private def active_custom_stylesheet diff --git a/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts b/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts deleted file mode 100644 index d979d895ac..0000000000 --- a/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useCallback } from 'react'; - -import { useSearchParam } from '@/mastodon/hooks/useSearchParam'; - -export function useFilters() { - const [boosts, setBoosts] = useSearchParam('boosts'); - const [replies, setReplies] = useSearchParam('replies'); - - const handleSetBoosts = useCallback( - (value: boolean) => { - setBoosts(value ? '1' : null); - }, - [setBoosts], - ); - const handleSetReplies = useCallback( - (value: boolean) => { - setReplies(value ? '1' : null); - }, - [setReplies], - ); - - return { - boosts: boosts === '1', - replies: replies === '1', - setBoosts: handleSetBoosts, - setReplies: handleSetReplies, - }; -} diff --git a/app/javascript/mastodon/features/account_timeline/v2/context.tsx b/app/javascript/mastodon/features/account_timeline/v2/context.tsx new file mode 100644 index 0000000000..d0d1332c2d --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/context.tsx @@ -0,0 +1,97 @@ +import type { FC, ReactNode } from 'react'; +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import { useStorage } from '@/mastodon/hooks/useStorage'; + +interface AccountTimelineContextValue { + accountId: string; + boosts: boolean; + replies: boolean; + showAllPinned: boolean; + setBoosts: (value: boolean) => void; + setReplies: (value: boolean) => void; + onShowAllPinned: () => void; +} + +const AccountTimelineContext = + createContext(null); + +export const AccountTimelineProvider: FC<{ + accountId: string; + children: ReactNode; +}> = ({ accountId, children }) => { + const { getItem, setItem } = useStorage({ + type: 'session', + prefix: `filters-${accountId}:`, + }); + const [boosts, setBoosts] = useState( + () => (getItem('boosts') === '0' ? false : true), // Default to enabled. + ); + const [replies, setReplies] = useState(() => + getItem('replies') === '1' ? true : false, + ); + + const handleSetBoosts = useCallback( + (value: boolean) => { + setBoosts(value); + setItem('boosts', value ? '1' : '0'); + }, + [setBoosts, setItem], + ); + const handleSetReplies = useCallback( + (value: boolean) => { + setReplies(value); + setItem('replies', value ? '1' : '0'); + }, + [setReplies, setItem], + ); + + const [showAllPinned, setShowAllPinned] = useState(false); + const handleShowAllPinned = useCallback(() => { + setShowAllPinned(true); + }, []); + + // Memoize the context value to avoid unnecessary re-renders. + const value = useMemo( + () => ({ + accountId, + boosts, + replies, + showAllPinned, + setBoosts: handleSetBoosts, + setReplies: handleSetReplies, + onShowAllPinned: handleShowAllPinned, + }), + [ + accountId, + boosts, + handleSetBoosts, + handleSetReplies, + handleShowAllPinned, + replies, + showAllPinned, + ], + ); + + return ( + + {children} + + ); +}; + +export function useAccountContext() { + const values = useContext(AccountTimelineContext); + if (!values) { + throw new Error( + 'useAccountFilters must be used within an AccountTimelineProvider', + ); + } + return values; +} diff --git a/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx index 48ce9cfaa5..b8061fced4 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx @@ -13,8 +13,7 @@ import { useOverflowButton } from '@/mastodon/hooks/useOverflow'; import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; -import { useFilters } from '../hooks/useFilters'; - +import { useAccountContext } from './context'; import classes from './styles.module.scss'; export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => { @@ -83,7 +82,7 @@ export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => { function useTagNavigate() { // Get current account, tag, and filters. const { acct, tagged } = useParams<{ acct: string; tagged?: string }>(); - const { boosts, replies } = useFilters(); + const { boosts, replies } = useAccountContext(); const history = useAppHistory(); diff --git a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx index d9adec13fa..28dcb5f5c4 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx @@ -12,8 +12,8 @@ import { Icon } from '@/mastodon/components/icon'; import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; import { AccountTabs } from '../components/tabs'; -import { useFilters } from '../hooks/useFilters'; +import { useAccountContext } from './context'; import classes from './styles.module.scss'; export const AccountFilters: FC = () => { @@ -42,7 +42,7 @@ const FilterDropdown: FC = () => { setOpen(false); }, []); - const { boosts, replies, setBoosts, setReplies } = useFilters(); + const { boosts, replies, setBoosts, setReplies } = useAccountContext(); const handleChange: ChangeEventHandler = useCallback( (event) => { const { name, checked } = event.target; @@ -101,7 +101,6 @@ const FilterDropdown: FC = () => { = ({ multiColumn }) => { // Add this key to remount the timeline when accountId changes. return ( - + - + ); }; @@ -71,7 +70,7 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({ multiColumn, }) => { const { tagged } = useParams<{ tagged?: string }>(); - const { boosts, replies } = useFilters(); + const { boosts, replies } = useAccountContext(); const key = timelineKey({ type: 'account', userId: accountId, diff --git a/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx index eec92cdc38..7a8523c9de 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx @@ -1,12 +1,5 @@ -import type { FC, ReactNode } from 'react'; -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; +import type { FC } from 'react'; +import { useEffect, useMemo } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -28,42 +21,9 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { isRedesignEnabled } from '../common'; import { PinnedBadge } from '../components/badges'; +import { useAccountContext } from './context'; import classes from './styles.module.scss'; -const PinnedStatusContext = createContext<{ - showAllPinned: boolean; - onShowAllPinned: () => void; -}>({ - showAllPinned: false, - onShowAllPinned: () => { - throw new Error('No onShowAllPinned provided'); - }, -}); - -export const PinnedStatusProvider: FC<{ children: ReactNode }> = ({ - children, -}) => { - const [showAllPinned, setShowAllPinned] = useState(false); - const handleShowAllPinned = useCallback(() => { - setShowAllPinned(true); - }, []); - - // Memoize so the context doesn't change every render. - const value = useMemo( - () => ({ - showAllPinned, - onShowAllPinned: handleShowAllPinned, - }), - [handleShowAllPinned, showAllPinned], - ); - - return ( - - {children} - - ); -}; - export function usePinnedStatusIds({ accountId, tagged, @@ -89,7 +49,7 @@ export function usePinnedStatusIds({ selectTimelineByKey(state, pinnedKey), ); - const { showAllPinned } = useContext(PinnedStatusContext); + const { showAllPinned } = useAccountContext(); const pinnedTimelineItems = pinnedTimeline?.items; // Make a const to avoid the React Compiler complaining. const pinnedStatusIds = useMemo(() => { @@ -125,7 +85,7 @@ export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({ }; export const PinnedShowAllButton: FC = () => { - const { onShowAllPinned } = useContext(PinnedStatusContext); + const { onShowAllPinned } = useAccountContext(); if (!isRedesignEnabled()) { return null; diff --git a/app/javascript/mastodon/hooks/useStorage.ts b/app/javascript/mastodon/hooks/useStorage.ts new file mode 100644 index 0000000000..6ee64217d2 --- /dev/null +++ b/app/javascript/mastodon/hooks/useStorage.ts @@ -0,0 +1,64 @@ +import { useCallback, useMemo } from 'react'; + +export function useStorage({ + type = 'local', + prefix = '', +}: { type?: 'local' | 'session'; prefix?: string } = {}) { + const storageType = type === 'local' ? 'localStorage' : 'sessionStorage'; + const isAvailable = useMemo( + () => storageAvailable(storageType), + [storageType], + ); + + const getItem = useCallback( + (key: string) => { + if (!isAvailable) { + return null; + } + try { + return window[storageType].getItem(prefix ? `${prefix};${key}` : key); + } catch { + return null; + } + }, + [isAvailable, storageType, prefix], + ); + const setItem = useCallback( + (key: string, value: string) => { + if (!isAvailable) { + return; + } + try { + window[storageType].setItem(prefix ? `${prefix};${key}` : key, value); + } catch {} + }, + [isAvailable, storageType, prefix], + ); + + return { + isAvailable, + getItem, + setItem, + }; +} + +// Tests the storage availability for the given type. Taken from MDN: +// https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API +export function storageAvailable(type: 'localStorage' | 'sessionStorage') { + let storage; + try { + storage = window[type]; + const x = '__storage_test__'; + storage.setItem(x, x); + storage.removeItem(x); + return true; + } catch (e) { + return ( + e instanceof DOMException && + e.name === 'QuotaExceededError' && + // acknowledge QuotaExceededError only if there's something already stored + storage && + storage.length !== 0 + ); + } +} diff --git a/app/javascript/skins/glitch/contrast/common.scss b/app/javascript/skins/glitch/contrast/common.scss deleted file mode 100644 index 10266f0c6a..0000000000 --- a/app/javascript/skins/glitch/contrast/common.scss +++ /dev/null @@ -1 +0,0 @@ -@use '@/flavours/glitch/styles/contrast'; diff --git a/app/javascript/skins/glitch/contrast/names.yml b/app/javascript/skins/glitch/contrast/names.yml deleted file mode 100644 index 5e73574395..0000000000 --- a/app/javascript/skins/glitch/contrast/names.yml +++ /dev/null @@ -1,12 +0,0 @@ -en: - skins: - glitch: - contrast: High contrast -cs: - skins: - glitch: - contrast: Vysoký kontrast -es: - skins: - glitch: - contrast: Alto contraste diff --git a/app/javascript/skins/glitch/mastodon-light/common.scss b/app/javascript/skins/glitch/mastodon-light/common.scss deleted file mode 100644 index 69ecf29424..0000000000 --- a/app/javascript/skins/glitch/mastodon-light/common.scss +++ /dev/null @@ -1 +0,0 @@ -@use '@/flavours/glitch/styles/mastodon-light'; diff --git a/app/javascript/skins/glitch/mastodon-light/names.yml b/app/javascript/skins/glitch/mastodon-light/names.yml deleted file mode 100644 index a2c20548f6..0000000000 --- a/app/javascript/skins/glitch/mastodon-light/names.yml +++ /dev/null @@ -1,12 +0,0 @@ -en: - skins: - glitch: - mastodon-light: Mastodon (light) -cs: - skins: - glitch: - mastodon-light: Mastodon (světlý) -es: - skins: - glitch: - mastodon-light: Mastodon (claro) diff --git a/app/javascript/skins/vanilla/contrast/common.scss b/app/javascript/skins/vanilla/contrast/common.scss deleted file mode 100644 index c3899e2db9..0000000000 --- a/app/javascript/skins/vanilla/contrast/common.scss +++ /dev/null @@ -1 +0,0 @@ -@use '@/styles/contrast'; diff --git a/app/javascript/skins/vanilla/contrast/names.yml b/app/javascript/skins/vanilla/contrast/names.yml deleted file mode 100644 index 51d23f72d7..0000000000 --- a/app/javascript/skins/vanilla/contrast/names.yml +++ /dev/null @@ -1,12 +0,0 @@ -en: - skins: - vanilla: - contrast: High contrast -cs: - skins: - vanilla: - contrast: Vysoký kontrast -es: - skins: - vanilla: - contrast: Alto contraste diff --git a/app/javascript/skins/vanilla/mastodon-light/common.scss b/app/javascript/skins/vanilla/mastodon-light/common.scss deleted file mode 100644 index c26152f44d..0000000000 --- a/app/javascript/skins/vanilla/mastodon-light/common.scss +++ /dev/null @@ -1 +0,0 @@ -@use '@/styles/mastodon-light'; diff --git a/app/javascript/skins/vanilla/mastodon-light/names.yml b/app/javascript/skins/vanilla/mastodon-light/names.yml deleted file mode 100644 index fc6721e15c..0000000000 --- a/app/javascript/skins/vanilla/mastodon-light/names.yml +++ /dev/null @@ -1,12 +0,0 @@ -en: - skins: - vanilla: - mastodon-light: Mastodon (light) -cs: - skins: - vanilla: - mastodon-light: Mastodon (světlý) -es: - skins: - glitch: - mastodon-light: Mastodon (claro) diff --git a/app/javascript/styles/contrast.scss b/app/javascript/styles/contrast.scss deleted file mode 100644 index cc23627a15..0000000000 --- a/app/javascript/styles/contrast.scss +++ /dev/null @@ -1 +0,0 @@ -@use 'common'; diff --git a/app/javascript/styles/mastodon-light.scss b/app/javascript/styles/mastodon-light.scss deleted file mode 100644 index cc23627a15..0000000000 --- a/app/javascript/styles/mastodon-light.scss +++ /dev/null @@ -1 +0,0 @@ -@use 'common'; diff --git a/app/services/activitypub/fetch_featured_tags_collection_service.rb b/app/services/activitypub/fetch_featured_tags_collection_service.rb index 92ef5c07d3..26a26576dd 100644 --- a/app/services/activitypub/fetch_featured_tags_collection_service.rb +++ b/app/services/activitypub/fetch_featured_tags_collection_service.rb @@ -18,6 +18,8 @@ class ActivityPub::FetchFeaturedTagsCollectionService < BaseService private def process_items(items) + return if items.nil? + names = items.filter_map { |item| item['type'] == 'Hashtag' && item['name']&.delete_prefix('#') }.take(FeaturedTag::LIMIT) tags = names.index_by { |name| HashtagNormalizer.new.normalize(name) } normalized_names = tags.keys diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index ca6cbec0b6..fb506a8246 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -29,7 +29,12 @@ class BlockDomainService < BaseService suspend_accounts! end - DomainClearMediaWorker.perform_async(domain_block.id) if domain_block.reject_media? + if domain_block.suspend? + # Account images and attachments are already handled by `suspend_accounts!` + PurgeCustomEmojiWorker.perform_async(blocked_domain) + elsif domain_block.reject_media? + DomainClearMediaWorker.perform_async(domain_block.id) + end end def silence_accounts! diff --git a/app/services/create_collection_service.rb b/app/services/create_collection_service.rb index 7e8935e1a4..bcc68d01cd 100644 --- a/app/services/create_collection_service.rb +++ b/app/services/create_collection_service.rb @@ -27,7 +27,7 @@ class CreateCollectionService @accounts_to_add.each do |account_to_add| raise Mastodon::NotPermittedError, I18n.t('accounts.errors.cannot_be_added_to_collections') unless AccountPolicy.new(@account, account_to_add).feature? - @collection.collection_items.build(account: account_to_add) + @collection.collection_items.build(account: account_to_add, state: :accepted) end end diff --git a/app/validators/date_of_birth_validator.rb b/app/validators/date_of_birth_validator.rb index 79119d2c4c..b1d05a2ad0 100644 --- a/app/validators/date_of_birth_validator.rb +++ b/app/validators/date_of_birth_validator.rb @@ -3,8 +3,6 @@ class DateOfBirthValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) record.errors.add(attribute, :below_limit) if value.present? && value.to_date > min_age.ago - rescue Date::Error - record.errors.add(attribute, :invalid) end private diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml index d4697a4af5..2fcdaac9b0 100644 --- a/app/views/layouts/embedded.html.haml +++ b/app/views/layouts/embedded.html.haml @@ -15,7 +15,7 @@ = vite_client_tag = vite_react_refresh_tag = vite_polyfills_tag - = theme_style_tags current_theme + = theme_style_tags ['glitch', 'default'] = vite_preload_file_tag "mastodon/locales/#{I18n.locale}.json" # TODO: fix preload for flavour = render_initial_state = flavoured_vite_typescript_tag 'embed.tsx', integrity: true, crossorigin: 'anonymous' diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index 4b29d6e79d..63ae8cf798 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -21,8 +21,8 @@ selected: current_user.time_zone || Time.zone.tzinfo.name, wrapper: :with_label - - if Mastodon::Feature.new_theme_options_enabled? - .fields-group + .fields-group + = f.simple_fields_for :settings, current_user.settings do |ff| .input.horizontal-options = ff.input :'web.color_scheme', as: :radio_buttons, diff --git a/app/workers/purge_custom_emoji_worker.rb b/app/workers/purge_custom_emoji_worker.rb new file mode 100644 index 0000000000..4d973ba7cb --- /dev/null +++ b/app/workers/purge_custom_emoji_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class PurgeCustomEmojiWorker + include Sidekiq::IterableJob + + def build_enumerator(domain, cursor:) + return if domain.blank? + + active_record_batches_enumerator(CustomEmoji.by_domain_and_subdomains(domain), cursor:) + end + + def each_iteration(custom_emojis, _domain) + AttachmentBatch.new(CustomEmoji, custom_emojis).delete + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 2b9a182ec1..8d8657d1d2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2022,10 +2022,7 @@ en: review_link: Review terms of service title: The terms of service of %{domain} are changing themes: - contrast: Mastodon (High contrast) - default: Mastodon (Dark) - mastodon-light: Mastodon (Light) - system: Automatic (use system theme) + default: Mastodon time: formats: default: "%b %d, %Y, %H:%M" diff --git a/config/settings.yml b/config/settings.yml index 016ca997b4..0ceed43a05 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -20,7 +20,7 @@ defaults: &defaults preview_sensitive_media: false noindex: false flavour: 'glitch' - skin: 'system' + skin: 'default' trends: true trendable_by_default: false trending_status_cw: true diff --git a/db/migrate/20260209142402_migrate_default_theme_setting.rb b/db/migrate/20260209142402_migrate_default_theme_setting.rb new file mode 100644 index 0000000000..bea6d3d040 --- /dev/null +++ b/db/migrate/20260209142402_migrate_default_theme_setting.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class MigrateDefaultThemeSetting < ActiveRecord::Migration[8.0] + class Setting < ApplicationRecord; end + + def up + Setting.reset_column_information + + setting = Setting.find_by(var: 'theme') + return unless setting.present? && setting.attributes['value'].present? + + theme = YAML.safe_load(setting.attributes['value'], permitted_classes: [ActiveSupport::HashWithIndifferentAccess, Symbol]) + return unless %w(mastodon-light contrast system).include?(theme) + + setting.update_column('value', "--- default\n") + end + + def down; end +end diff --git a/db/migrate/20260209143308_migrate_user_theme.rb b/db/migrate/20260209143308_migrate_user_theme.rb new file mode 100644 index 0000000000..93870055db --- /dev/null +++ b/db/migrate/20260209143308_migrate_user_theme.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class MigrateUserTheme < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + # Dummy classes, to make migration possible across version changes + class User < ApplicationRecord; end + + def up + User.where.not(settings: nil).find_each do |user| + settings = Oj.load(user.attributes_before_type_cast['settings']) + next if settings.nil? || settings['theme'].blank? || %w(system default mastodon-light contrast).exclude?(settings['theme']) + + case settings['theme'] + when 'default' + settings['web.color_scheme'] = 'dark' + settings['web.contrast'] = 'auto' + when 'contrast' + settings['web.color_scheme'] = 'dark' + settings['web.contrast'] = 'high' + when 'mastodon-light' + settings['web.color_scheme'] = 'light' + settings['web.contrast'] = 'auto' + end + + settings['theme'] = 'default' + + user.update_column('settings', Oj.dump(settings)) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index aae92d94b6..9b19d78eef 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_01_27_141820) do +ActiveRecord::Schema[8.0].define(version: 2026_02_09_143308) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" diff --git a/lib/tasks/tests.rake b/lib/tasks/tests.rake index 0c5c6a09ca..6f41d07955 100644 --- a/lib/tasks/tests.rake +++ b/lib/tasks/tests.rake @@ -154,6 +154,11 @@ namespace :tests do exit(1) end + unless Setting.theme == 'default' + puts 'Default theme setting not migrated as expected' + exit(1) + end + puts 'No errors found. Database state is consistent with a successful migration process.' end @@ -177,7 +182,8 @@ namespace :tests do (id, thing_type, thing_id, var, value, created_at, updated_at) VALUES (7, NULL, NULL, 'timeline_preview', E'--- false\n', now(), now()), - (8, NULL, NULL, 'trends_as_landing_page', E'--- false\n', now(), now()); + (8, NULL, NULL, 'trends_as_landing_page', E'--- false\n', now(), now()), + (9, NULL, NULL, 'theme', E'--- system', now(), now()); /* Doorkeeper records While the `read:me` scope was technically not valid in 3.3.0, diff --git a/spec/helpers/theme_helper_spec.rb b/spec/helpers/theme_helper_spec.rb index 7566cc2cba..7a91786ef0 100644 --- a/spec/helpers/theme_helper_spec.rb +++ b/spec/helpers/theme_helper_spec.rb @@ -6,35 +6,13 @@ RSpec.describe ThemeHelper do describe 'theme_style_tags' do let(:result) { helper.theme_style_tags(theme) } - context 'when using "system" theme' do - let(:theme) { ['glitch', 'system'] } - - it 'returns the default theme' do - expect(html_links.first.attributes.symbolize_keys) - .to include( - href: have_attributes(value: match(/contrast/)) - ) - end - end - context 'when using "default" theme' do let(:theme) { ['glitch', 'default'] } it 'returns the default stylesheet' do expect(html_links.last.attributes.symbolize_keys) .to include( - href: have_attributes(value: match(/contrast/)) - ) - end - end - - context 'when using other theme' do - let(:theme) { ['glitch', 'contrast'] } - - it 'returns the theme stylesheet without color scheme information' do - expect(html_links.first.attributes.symbolize_keys) - .to include( - href: have_attributes(value: match(/contrast/)) + href: have_attributes(value: match(/default/)) ) end end @@ -122,6 +100,48 @@ RSpec.describe ThemeHelper do end end + describe '#current_theme' do + subject { helper.current_theme } + + context 'when user is not signed in' do + context 'when theme was not changed in settings' do + it { is_expected.to eq(['glitch', 'default']) } + end + end + + context 'when user is signed in' do + before { allow(helper).to receive(:current_user).and_return(current_user) } + + let(:current_user) { Fabricate :user } + + context 'when user did not set theme' do + it { is_expected.to eq(['glitch', 'default']) } + end + + context 'when user set theme' do + before { current_user.settings.update(skin: 'alternate', noindex: false) } + + context 'when theme is not valid' do + it { is_expected.to eq(['glitch', 'default']) } + end + end + end + end + + describe '#page_color_scheme' do + subject { helper.page_color_scheme } + + context 'when force_color_scheme is present' do + before { helper.content_for(:force_color_scheme) { 'value' } } + + it { is_expected.to eq('value') } + end + + context 'when force_color_scheme is absent' do + it { is_expected.to eq('auto') } + end + end + private def html_links diff --git a/spec/system/settings/preferences/appearance_spec.rb b/spec/system/settings/preferences/appearance_spec.rb index 16c14623ec..b9de36ee78 100644 --- a/spec/system/settings/preferences/appearance_spec.rb +++ b/spec/system/settings/preferences/appearance_spec.rb @@ -13,8 +13,6 @@ RSpec.describe 'Settings preferences appearance page' do expect(page) .to have_private_cache_control - # TODO: glitch-soc's option is elsewhere - # select 'contrast', from: theme_selection_field check confirm_reblog_field uncheck confirm_delete_field diff --git a/spec/validators/date_of_birth_validator_spec.rb b/spec/validators/date_of_birth_validator_spec.rb index 65b63db234..0da6b3f6f1 100644 --- a/spec/validators/date_of_birth_validator_spec.rb +++ b/spec/validators/date_of_birth_validator_spec.rb @@ -10,7 +10,7 @@ RSpec.describe DateOfBirthValidator do context 'with an invalid date' do let(:invalid_date) { '76.830.10' } - it { is_expected.to_not allow_values(invalid_date).for(:date_of_birth) } + it { is_expected.to_not allow_values(invalid_date).for(:date_of_birth).with_message(:blank) } end context 'with a date below the age limit' do diff --git a/spec/workers/purge_custom_emoji_worker_spec.rb b/spec/workers/purge_custom_emoji_worker_spec.rb new file mode 100644 index 0000000000..4317fee04a --- /dev/null +++ b/spec/workers/purge_custom_emoji_worker_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PurgeCustomEmojiWorker do + let(:worker) { described_class.new } + + let(:domain) { 'evil' } + + before do + Fabricate(:custom_emoji) + Fabricate(:custom_emoji, domain: 'example.com') + Fabricate.times(5, :custom_emoji, domain: domain) + end + + describe '#perform' do + context 'when domain is nil' do + it 'does not delete emojis' do + expect { worker.perform(nil) } + .to_not(change(CustomEmoji, :count)) + end + end + + context 'when passing a domain' do + it 'deletes emojis from this domain only' do + expect { worker.perform(domain) } + .to change { CustomEmoji.where(domain: domain).count }.to(0) + .and not_change { CustomEmoji.local.count } + .and(not_change { CustomEmoji.where(domain: 'example.com').count }) + end + end + end +end diff --git a/streaming/index.js b/streaming/index.js index 9fa444de8b..79cd3955ae 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -376,6 +376,7 @@ const startServer = async () => { req.scopes = result.rows[0].scopes.split(' '); req.accountId = result.rows[0].account_id; req.chosenLanguages = result.rows[0].chosen_languages; + req.permissions = result.rows[0].permissions; return { accessTokenId: result.rows[0].id, @@ -601,13 +602,13 @@ const startServer = async () => { /** * @param {string} kind - * @param {ResolvedAccount} account + * @param {Request} req * @returns {Promise.<{ localAccess: boolean, remoteAccess: boolean }>} */ - const getFeedAccessSettings = async (kind, account) => { + const getFeedAccessSettings = async (kind, req) => { const access = { localAccess: true, remoteAccess: true }; - if (account.permissions & PERMISSION_VIEW_FEEDS) { + if (req.permissions & PERMISSION_VIEW_FEEDS) { return access; }