diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml index 7f2875cbcb..487e3fda30 100644 --- a/.github/workflows/build-releases.yml +++ b/.github/workflows/build-releases.yml @@ -9,7 +9,44 @@ permissions: packages: write jobs: + check-latest-stable: + runs-on: ubuntu-latest + outputs: + latest: ${{ steps.check.outputs.is_latest_stable }} + steps: + # Repository needs to be cloned to list branches + - name: Clone repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check latest stable + shell: bash + id: check + run: | + ref="${GITHUB_REF#refs/tags/}" + + if [[ "$ref" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then + current="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + else + echo "tag $ref is not semver" + echo "is_latest_stable=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + latest=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/stable-*.*" \ + | sed -E 's#^origin/stable-##' \ + | sort -Vr \ + | head -n1) + + if [[ "$current" == "$latest" ]]; then + echo "is_latest_stable=true" >> "$GITHUB_OUTPUT" + else + echo "is_latest_stable=false" >> "$GITHUB_OUTPUT" + fi + build-image: + needs: check-latest-stable uses: ./.github/workflows/build-container-image.yml with: file_to_build: Dockerfile @@ -20,13 +57,14 @@ jobs: # Only tag with latest when ran against the latest stable branch # This needs to be updated after each minor version release flavor: | - latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }} + latest=${{ needs.check-latest-stable.outputs.latest }} tags: | type=pep440,pattern={{raw}} type=pep440,pattern=v{{major}}.{{minor}} secrets: inherit build-image-streaming: + needs: check-latest-stable uses: ./.github/workflows/build-container-image.yml with: file_to_build: streaming/Dockerfile @@ -37,7 +75,7 @@ jobs: # Only tag with latest when ran against the latest stable branch # This needs to be updated after each minor version release flavor: | - latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }} + latest=${{ needs.check-latest-stable.outputs.latest }} tags: | type=pep440,pattern={{raw}} type=pep440,pattern=v{{major}}.{{minor}} diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 2bdd355864..1e83ab9c69 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -72,10 +72,13 @@ module SignatureVerification rescue Mastodon::SignatureVerificationError => e fail_with! e.message rescue *Mastodon::HTTP_CONNECTION_ERRORS => e + @signature_verification_failure_code ||= 503 fail_with! "Failed to fetch remote data: #{e.message}" rescue Mastodon::UnexpectedResponseError + @signature_verification_failure_code ||= 503 fail_with! 'Failed to fetch remote data (got unexpected reply from server)' rescue Stoplight::Error::RedLight + @signature_verification_failure_code ||= 503 fail_with! 'Fetching attempt skipped because of recent connection failure' end diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index f4a3699734..b9355044fb 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -675,7 +675,16 @@ export function selectComposeSuggestion(position, token, suggestion, path) { dispatch(useEmoji(suggestion)); } else if (suggestion.type === 'hashtag') { - completion = token + suggestion.name.slice(token.length - 1); + // TODO: it could make sense to keep the “most capitalized” of the two + const tokenName = token.slice(1); // strip leading '#' + const suggestionPrefix = suggestion.name.slice(0, tokenName.length); + const prefixMatchesSuggestion = suggestionPrefix.localeCompare(tokenName, undefined, { sensitivity: 'accent' }) === 0; + if (prefixMatchesSuggestion) { + completion = token + suggestion.name.slice(tokenName.length); + } else { + completion = `${token.slice(0, 1)}${suggestion.name}`; + } + startPosition = position - 1; } else if (suggestion.type === 'account') { completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`; diff --git a/app/javascript/mastodon/components/content_warning.tsx b/app/javascript/mastodon/components/content_warning.tsx index a407ec146e..2d3222f479 100644 --- a/app/javascript/mastodon/components/content_warning.tsx +++ b/app/javascript/mastodon/components/content_warning.tsx @@ -31,7 +31,7 @@ export const ContentWarning: React.FC<{ } + extraEmojis={status.get('emojis') as List} /> ); diff --git a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.module.css b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.module.css new file mode 100644 index 0000000000..5045b6d1b9 --- /dev/null +++ b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.module.css @@ -0,0 +1,3 @@ +.inlineIcon { + vertical-align: middle; +} diff --git a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx index dec9c3ef38..bee562603b 100644 --- a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx +++ b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx @@ -12,6 +12,8 @@ import { Button } from '../button'; import { useDismissableBannerState } from '../dismissable_banner'; import { Icon } from '../icon'; +import classes from './remove_quote_hint.module.css'; + const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint'; /** @@ -93,7 +95,7 @@ export const RemoveQuoteHint: React.FC<{ id: 'status.more', defaultMessage: 'More', })} - style={{ verticalAlign: 'middle' }} + className={classes.inlineIcon} /> ), }} diff --git a/app/javascript/mastodon/features/emoji/render.test.ts b/app/javascript/mastodon/features/emoji/render.test.ts index 3c96cbfb55..af7ff75a02 100644 --- a/app/javascript/mastodon/features/emoji/render.test.ts +++ b/app/javascript/mastodon/features/emoji/render.test.ts @@ -7,6 +7,7 @@ import { stringToEmojiState, tokenizeText, } from './render'; +import type { EmojiStateCustom } from './types'; describe('tokenizeText', () => { test('returns an array of text to be a single token', () => { @@ -82,12 +83,8 @@ describe('stringToEmojiState', () => { }); }); - test('returns custom emoji state for valid custom emoji', () => { - expect(stringToEmojiState(':smile:')).toEqual({ - type: 'custom', - code: 'smile', - data: undefined, - }); + test('returns null for custom emoji without data', () => { + expect(stringToEmojiState(':smile:')).toBeNull(); }); test('returns custom emoji state with data when provided', () => { @@ -107,7 +104,6 @@ describe('stringToEmojiState', () => { test('returns null for invalid emoji strings', () => { expect(stringToEmojiState('notanemoji')).toBeNull(); - expect(stringToEmojiState(':invalid-emoji:')).toBeNull(); }); }); @@ -130,18 +126,13 @@ describe('loadEmojiDataToState', () => { }); }); - test('loads custom emoji data into state', async () => { - const dbCall = vi - .spyOn(db, 'loadCustomEmojiByShortcode') - .mockResolvedValueOnce(customEmojiFactory()); - const customState = { type: 'custom', code: 'smile' } as const; - const result = await loadEmojiDataToState(customState, 'en'); - expect(dbCall).toHaveBeenCalledWith('smile'); - expect(result).toEqual({ + test('returns null for custom emoji without data', async () => { + const customState = { type: 'custom', code: 'smile', - data: customEmojiFactory(), - }); + } as const satisfies EmojiStateCustom; + const result = await loadEmojiDataToState(customState, 'en'); + expect(result).toBeNull(); }); test('returns null if unicode emoji not found in database', async () => { @@ -151,13 +142,6 @@ describe('loadEmojiDataToState', () => { expect(result).toBeNull(); }); - test('returns null if custom emoji not found in database', async () => { - vi.spyOn(db, 'loadCustomEmojiByShortcode').mockResolvedValueOnce(undefined); - const customState = { type: 'custom', code: 'smile' } as const; - const result = await loadEmojiDataToState(customState, 'en'); - expect(result).toBeNull(); - }); - test('retries loading emoji data once if initial load fails', async () => { const dbCall = vi .spyOn(db, 'loadEmojiByHexcode') diff --git a/app/javascript/mastodon/features/emoji/render.ts b/app/javascript/mastodon/features/emoji/render.ts index 574d5ef59b..39c517b593 100644 --- a/app/javascript/mastodon/features/emoji/render.ts +++ b/app/javascript/mastodon/features/emoji/render.ts @@ -4,11 +4,7 @@ import { EMOJI_TYPE_UNICODE, EMOJI_TYPE_CUSTOM, } from './constants'; -import { - loadCustomEmojiByShortcode, - loadEmojiByHexcode, - LocaleNotLoadedError, -} from './database'; +import { loadEmojiByHexcode, LocaleNotLoadedError } from './database'; import { importEmojiData } from './loader'; import { emojiToUnicodeHex } from './normalize'; import type { @@ -79,7 +75,7 @@ export function tokenizeText(text: string): TokenizedText { export function stringToEmojiState( code: string, customEmoji: ExtraCustomEmojiMap = {}, -): EmojiState | null { +): EmojiStateUnicode | Required | null { if (isUnicodeEmoji(code)) { return { type: EMOJI_TYPE_UNICODE, @@ -89,11 +85,13 @@ export function stringToEmojiState( if (isCustomEmoji(code)) { const shortCode = code.slice(1, -1); - return { - type: EMOJI_TYPE_CUSTOM, - code: shortCode, - data: customEmoji[shortCode], - }; + if (customEmoji[shortCode]) { + return { + type: EMOJI_TYPE_CUSTOM, + code: shortCode, + data: customEmoji[shortCode], + }; + } } return null; @@ -114,26 +112,23 @@ export async function loadEmojiDataToState( return state; } + // Don't try to load data for custom emoji. + if (state.type === EMOJI_TYPE_CUSTOM) { + return null; + } + // First, try to load the data from IndexedDB. try { // This is duplicative, but that's because TS can't distinguish the state type easily. - if (state.type === EMOJI_TYPE_UNICODE) { - const data = await loadEmojiByHexcode(state.code, locale); - if (data) { - return { - ...state, - data, - }; - } - } else { - const data = await loadCustomEmojiByShortcode(state.code); - if (data) { - return { - ...state, - data, - }; - } + const data = await loadEmojiByHexcode(state.code, locale); + if (data) { + return { + ...state, + type: EMOJI_TYPE_UNICODE, + data, + }; } + // If not found, assume it's not an emoji and return null. log( 'Could not find emoji %s of type %s for locale %s', diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx index 9e7f66d112..7312a94a30 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx @@ -4,6 +4,7 @@ import type { List } from 'immutable'; import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; +import type { CustomEmoji } from '@/mastodon/models/custom_emoji'; import type { Status } from '@/mastodon/models/status'; import type { Mention } from './embedded_status'; @@ -33,6 +34,7 @@ export const EmbeddedStatusContent: React.FC<{ className={className} lang={status.get('language') as string} htmlString={status.get('contentHtml') as string} + extraEmojis={status.get('emojis') as List} /> ); }; diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 6906f77e1e..c2c33689ea 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -71,7 +71,7 @@ class ProcessMentionsService < BaseService # Make sure we never mention blocked accounts unless @current_mentions.empty? mentioned_domains = @current_mentions.filter_map { |m| m.account.domain }.uniq - blocked_domains = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains)) + blocked_domains = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains).pluck(:domain)) mentioned_account_ids = @current_mentions.map(&:account_id) blocked_account_ids = Set.new(@status.account.block_relationships.where(target_account_id: mentioned_account_ids).pluck(:target_account_id)) diff --git a/config/roles.yml b/config/roles.yml index 33d2635f4d..9729cdcdc7 100644 --- a/config/roles.yml +++ b/config/roles.yml @@ -14,6 +14,7 @@ admin: permissions: - view_dashboard - view_audit_log + - view_feeds - manage_users - manage_user_access - delete_user_data diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb index 3cc83d82f3..61faf3d04a 100644 --- a/spec/services/process_mentions_service_spec.rb +++ b/spec/services/process_mentions_service_spec.rb @@ -8,9 +8,9 @@ RSpec.describe ProcessMentionsService do let(:account) { Fabricate(:account, username: 'alice') } context 'when mentions contain blocked accounts' do - let(:non_blocked_account) { Fabricate(:account) } - let(:individually_blocked_account) { Fabricate(:account) } - let(:domain_blocked_account) { Fabricate(:account, domain: 'evil.com') } + let!(:non_blocked_account) { Fabricate(:account) } + let!(:individually_blocked_account) { Fabricate(:account) } + let!(:domain_blocked_account) { Fabricate(:account, domain: 'evil.com', protocol: :activitypub) } let(:status) { Fabricate(:status, account: account, text: "Hello @#{non_blocked_account.acct} @#{individually_blocked_account.acct} @#{domain_blocked_account.acct}", visibility: :public) } before do diff --git a/vite.config.mts b/vite.config.mts index 1bec6a40e0..e0bd15abe3 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -154,6 +154,14 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => { }, }, }, + experimental: { + /** + * Setting this causes Vite to not rely on the base config for import URLs, + * and instead uses import.meta.url, which is what we want for proper CDN support. + * @see https://github.com/mastodon/mastodon/pull/37310 + */ + renderBuiltUrl: () => undefined, + }, worker: { format: 'es', },