Merge commit '3de59a93441367fbca0fd22818c8411adc73a967' into glitch-soc/merge-4.5

This commit is contained in:
Claire
2025-12-28 10:51:43 +01:00
13 changed files with 105 additions and 60 deletions

View File

@@ -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}}

View File

@@ -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

View File

@@ -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'])}`;

View File

@@ -31,7 +31,7 @@ export const ContentWarning: React.FC<{
<EmojiHTML
as='span'
htmlString={text}
extraEmojis={status.get('emoji') as List<CustomEmoji>}
extraEmojis={status.get('emojis') as List<CustomEmoji>}
/>
</StatusBanner>
);

View File

@@ -0,0 +1,3 @@
.inlineIcon {
vertical-align: middle;
}

View File

@@ -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}
/>
),
}}

View File

@@ -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')

View File

@@ -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<EmojiStateCustom> | 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',

View File

@@ -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<CustomEmoji>}
/>
);
};

View File

@@ -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))

View File

@@ -14,6 +14,7 @@ admin:
permissions:
- view_dashboard
- view_audit_log
- view_feeds
- manage_users
- manage_user_access
- delete_user_data

View File

@@ -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

View File

@@ -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',
},