Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1d08125
Fix Cmd/Ctrl + Enter in the composer triggering confirmation dialog a…
diondiondion Nov 14, 2025
5a57c08
Fix hashtag completion not being inserted correctly (#36884)
ClearlyClaire Nov 14, 2025
a7b4568
Fix bogus quote approval policy not always being replaced correctly (…
ClearlyClaire Nov 14, 2025
6486c09
Fix error with remote tags including percent signs (#36886)
ChaosExAnima Nov 14, 2025
bb28552
chore(deps): update dependency js-yaml to v4.1.1 [security] (#36891)
renovate[bot] Nov 14, 2025
27c67f1
Fix cross-origin handling of CSS modules (#36890)
ClearlyClaire Nov 14, 2025
44d45e5
Fix ArgumentError of tootctl upgrade storage-schema (#36914)
shugo Nov 17, 2025
c08cd6d
Emoji: Fix path resolution for emoji worker (#36897)
ChaosExAnima Nov 17, 2025
4ee21c2
Fix double encoding in links (#36925)
ClearlyClaire Nov 18, 2025
261d9b3
Change private quote education modal to not show up on self-quotes (#…
ClearlyClaire Nov 18, 2025
6ccd9c2
Fix scroll-to-status in threaded view being unreliable (#36927)
ClearlyClaire Nov 18, 2025
c6ccacd
Fix quoting overwriting current content warning (#36934)
ClearlyClaire Nov 19, 2025
1dbf101
Fix `g` + `h` keyboard shortcut not working when a post is focused (#…
diondiondion Nov 19, 2025
5fe316b
Update dependency `glob` (#36941)
ClearlyClaire Nov 19, 2025
05c624c
Fix statuses without text disappearing on reload (#36962)
ClearlyClaire Nov 20, 2025
96eb687
Fix missing fallback link in CW-only quote posts (#36963)
ClearlyClaire Nov 20, 2025
e398ff4
New Crowdin Translations for stable-4.5 (automated) (#36945)
github-actions[bot] Nov 20, 2025
1958875
Bump version to v4.5.2 (#36944)
ClearlyClaire Nov 20, 2025
afd00a5
Bump version to 21.3-lts
kmycode Nov 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,30 @@

All notable changes to this project will be documented in this file.

## [4.5.2] - 2025-11-20

### Changed

- Change private quote education modal to not show up on self-quotes (#36926 by @ClearlyClaire)

### Fixed

- Fix missing fallback link in CW-only quote posts (#36963 by @ClearlyClaire)
- Fix statuses without text being hidden while loading (#36962 by @ClearlyClaire)
- Fix `g` + `h` keyboard shortcut not working when a post is focused (#36935 by @diondiondion)
- Fix quoting overwriting current content warning (#36934 by @ClearlyClaire)
- Fix scroll-to-status in threaded view being unreliable (#36927 by @ClearlyClaire)
- Fix path resolution for emoji worker (#36897 by @ChaosExAnima)
- Fix `tootctl upgrade storage-schema` failing with `ArgumentError` (#36914 by @shugo)
- Fix cross-origin handling of CSS modules (#36890 by @ClearlyClaire)
- Fix error with remote tags including percent signs (#36886 and #36925 by @ChaosExAnima and @ClearlyClaire)
- Fix bogus quote approval policy not always being replaced correctly (#36885 by @ClearlyClaire)
- Fix hashtag completion not being inserted correctly (#36884 by @ClearlyClaire)
- Fix Cmd/Ctrl + Enter in the composer triggering confirmation dialog action (#36870 by @diondiondion)

## [4.5.1] - 2025-11-13

### Fixes
### Fixed

- Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers (#36866 by @diondiondion)
- Fix posts coming from public/hashtag streaming being marked as unquotable (#36860 and #36869 by @ClearlyClaire)
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/mastodon/actions/compose.js
Original file line number Diff line number Diff line change
Expand Up @@ -696,8 +696,8 @@ export function selectComposeSuggestion(position, token, suggestion, path) {

dispatch(useEmoji(suggestion));
} else if (suggestion.type === 'hashtag') {
completion = suggestion.name.slice(token.length - 1);
startPosition = position + token.length;
completion = token + suggestion.name.slice(token.length - 1);
startPosition = position - 1;
} else if (suggestion.type === 'account') {
completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`;
startPosition = position - 1;
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/mastodon/actions/importer/normalizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function normalizeStatus(status, normalOldStatus, { bogusQuotePolicy = fa
}

if (normalOldStatus) {
normalStatus.quote_approval ||= normalOldStatus.quote_approval;
normalStatus.quote_approval ||= normalOldStatus.get('quote_approval');

const list = normalOldStatus.get('media_attachments');
if (normalStatus.media_attachments && list) {
Expand Down
21 changes: 10 additions & 11 deletions app/javascript/mastodon/components/hotkeys/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,25 +181,24 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {

if (shouldHandleEvent) {
const matchCandidates: {
handler: (event: KeyboardEvent) => void;
// A candidate will be have an undefined handler if it's matched,
// but handled in a parent component rather than this one.
handler: ((event: KeyboardEvent) => void) | undefined;
priority: number;
}[] = [];

(Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach(
(handlerName) => {
const handler = handlersRef.current[handlerName];
const hotkeyMatcher = hotkeyMatcherMap[handlerName];

if (handler) {
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
const { isMatch, priority } = hotkeyMatcher(
event,
bufferedKeys.current,
);

const { isMatch, priority } = hotkeyMatcher(
event,
bufferedKeys.current,
);

if (isMatch) {
matchCandidates.push({ handler, priority });
}
if (isMatch) {
matchCandidates.push({ handler, priority });
}
},
);
Expand Down
12 changes: 7 additions & 5 deletions app/javascript/mastodon/components/status/handled_link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,18 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
}) => {
// Handle hashtags
if (
text.startsWith('#') ||
prevText?.endsWith('#') ||
text.startsWith('#') ||
prevText?.endsWith('#')
(text.startsWith('#') ||
prevText?.endsWith('#') ||
text.startsWith('#') ||
prevText?.endsWith('#')) &&
!text.includes('%')
) {
const hashtag = text.slice(1).trim();

return (
<Link
className={classNames('mention hashtag', className)}
to={`/tags/${hashtag}`}
to={`/tags/${encodeURIComponent(hashtag)}`}
rel='tag'
data-menu-hashtag={hashtagAccountId}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class ComposeForm extends ImmutablePureComponent {
handleKeyDownPost = (e) => {
if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
e.preventDefault();
}
this.blurOnEscape(e);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { pasteLinkCompose } from 'mastodon/actions/compose_typed';
import { openModal } from 'mastodon/actions/modal';
import { PRIVATE_QUOTE_MODAL_ID } from 'mastodon/features/ui/components/confirmation_modals/private_quote_notify';
import { me } from 'mastodon/initial_state';

import ComposeForm from '../components/compose_form';

Expand All @@ -39,6 +40,7 @@ const mapStateToProps = state => ({
quoteToPrivate:
!!state.getIn(['compose', 'quoted_status_id'])
&& state.getIn(['compose', 'privacy']) === 'private'
&& state.getIn(['statuses', state.getIn(['compose', 'quoted_status_id']), 'account']) !== me
&& !state.getIn(['settings', 'dismissed_banners', PRIVATE_QUOTE_MODAL_ID]),
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']),
Expand Down
32 changes: 23 additions & 9 deletions app/javascript/mastodon/features/emoji/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { initialState } from '@/mastodon/initial_state';

import { toSupportedLocale } from './locale';
import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
// eslint-disable-next-line import/default -- Importing via worker loader.
import EmojiWorker from './worker?worker&inline';
Expand All @@ -24,19 +25,17 @@ export function initializeEmoji() {
}

if (worker) {
// Assign worker to const to make TS happy inside the event listener.
const thisWorker = worker;
const timeoutId = setTimeout(() => {
log('worker is not ready after timeout');
worker = null;
void fallbackLoad();
}, WORKER_TIMEOUT);
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
worker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === 'ready') {
log('worker ready, loading data');
clearTimeout(timeoutId);
thisWorker.postMessage('custom');
messageWorker('custom');
void loadEmojiLocale(userLocale);
// Load English locale as well, because people are still used to
// using it from before we supported other locales.
Expand All @@ -55,20 +54,35 @@ export function initializeEmoji() {
async function fallbackLoad() {
log('falling back to main thread for loading');
const { importCustomEmojiData } = await import('./loader');
await importCustomEmojiData();
const emojis = await importCustomEmojiData();
if (emojis) {
log('loaded %d custom emojis', emojis.length);
}
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
}
}

export async function loadEmojiLocale(localeString: string) {
async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
const { importEmojiData, localeToPath } = await import('./loader');

if (worker) {
worker.postMessage(locale);
const path = await localeToPath(locale);
log('asking worker to load locale %s from %s', locale, path);
messageWorker(locale, path);
} else {
const { importEmojiData } = await import('./loader');
await importEmojiData(locale);
const emojis = await importEmojiData(locale);
if (emojis) {
log('loaded %d emojis to locale %s', emojis.length, locale);
}
}
}

function messageWorker(locale: LocaleOrCustom, path?: string) {
if (!worker) {
return;
}
worker.postMessage({ locale, path });
}
82 changes: 42 additions & 40 deletions app/javascript/mastodon/features/emoji/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,44 +8,64 @@ import {
putLatestEtag,
} from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { CustomEmojiData, LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
import type { CustomEmojiData } from './types';

const log = emojiLogger('loader');

export async function importEmojiData(localeString: string) {
export async function importEmojiData(localeString: string, path?: string) {
const locale = toSupportedLocale(localeString);
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale);

// Validate the provided path.
if (path && !/^[/a-z]*\/packs\/assets\/compact-\w+\.json$/.test(path)) {
throw new Error('Invalid path for emoji data');
} else {
// Otherwise get the path if not provided.
path ??= await localeToPath(locale);
}

const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale, path);
if (!emojis) {
return;
}
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
log('loaded %d for %s locale', flattenedEmojis.length, locale);
await putEmojiData(flattenedEmojis, locale);
return flattenedEmojis;
}

export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>('custom');
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>(
'custom',
'/api/v1/custom_emojis',
);
if (!emojis) {
return;
}
log('loaded %d custom emojis', emojis.length);
await putCustomEmojiData(emojis);
return emojis;
}

const modules = import.meta.glob<string>(
'../../../../../node_modules/emojibase-data/**/compact.json',
{
query: '?url',
import: 'default',
},
);

export function localeToPath(locale: Locale) {
const key = `../../../../../node_modules/emojibase-data/${locale}/compact.json`;
if (!modules[key] || typeof modules[key] !== 'function') {
throw new Error(`Unsupported locale: ${locale}`);
}
return modules[key]();
}

async function fetchAndCheckEtag<ResultType extends object[]>(
localeOrCustom: LocaleOrCustom,
export async function fetchAndCheckEtag<ResultType extends object[]>(
localeString: string,
path: string,
): Promise<ResultType | null> {
const locale = toSupportedLocaleOrCustom(localeOrCustom);
const locale = toSupportedLocaleOrCustom(localeString);

// Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(location.origin);
if (locale === 'custom') {
url.pathname = '/api/v1/custom_emojis';
} else {
const modulePath = await localeToPath(locale);
url.pathname = modulePath;
}
const url = new URL(path, location.origin);

const oldEtag = await loadLatestEtag(locale);
const response = await fetch(url, {
Expand All @@ -60,38 +80,20 @@ async function fetchAndCheckEtag<ResultType extends object[]>(
}
if (!response.ok) {
throw new Error(
`Failed to fetch emoji data for ${localeOrCustom}: ${response.statusText}`,
`Failed to fetch emoji data for ${locale}: ${response.statusText}`,
);
}

const data = (await response.json()) as ResultType;
if (!Array.isArray(data)) {
throw new Error(
`Unexpected data format for ${localeOrCustom}: expected an array`,
);
throw new Error(`Unexpected data format for ${locale}: expected an array`);
}

// Store the ETag for future requests
const etag = response.headers.get('ETag');
if (etag) {
await putLatestEtag(etag, localeOrCustom);
await putLatestEtag(etag, localeString);
}

return data;
}

const modules = import.meta.glob<string>(
'../../../../../node_modules/emojibase-data/**/compact.json',
{
query: '?url',
import: 'default',
},
);

function localeToPath(locale: Locale) {
const key = `../../../../../node_modules/emojibase-data/${locale}/compact.json`;
if (!modules[key] || typeof modules[key] !== 'function') {
throw new Error(`Unsupported locale: ${locale}`);
}
return modules[key]();
}
2 changes: 1 addition & 1 deletion app/javascript/mastodon/features/emoji/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ describe('loadEmojiDataToState', () => {
const dbCall = vi
.spyOn(db, 'loadEmojiByHexcode')
.mockRejectedValue(new db.LocaleNotLoadedError('en'));
vi.spyOn(loader, 'importEmojiData').mockResolvedValueOnce();
vi.spyOn(loader, 'importEmojiData').mockResolvedValueOnce(undefined);
const consoleCall = vi
.spyOn(console, 'warn')
.mockImplementationOnce(() => null);
Expand Down
25 changes: 16 additions & 9 deletions app/javascript/mastodon/features/emoji/worker.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { importEmojiData, importCustomEmojiData } from './loader';
import { importCustomEmojiData, importEmojiData } from './loader';

addEventListener('message', handleMessage);
self.postMessage('ready'); // After the worker is ready, notify the main thread

function handleMessage(event: MessageEvent<string>) {
const { data: locale } = event;
void loadData(locale);
function handleMessage(event: MessageEvent<{ locale: string; path?: string }>) {
const {
data: { locale, path },
} = event;
void loadData(locale, path);
}

async function loadData(locale: string) {
if (locale !== 'custom') {
await importEmojiData(locale);
async function loadData(locale: string, path?: string) {
let importCount: number | undefined;
if (locale === 'custom') {
importCount = (await importCustomEmojiData())?.length;
} else if (path) {
importCount = (await importEmojiData(locale, path))?.length;
} else {
await importCustomEmojiData();
throw new Error('Path is required for loading locale emoji data');
}
if (importCount) {
self.postMessage(`loaded ${importCount} emojis into ${locale}`);
}
self.postMessage(`loaded ${locale}`);
}
Loading
Loading