From 1d081250f441fca5ea08dc2aa8fde046fa3147a7 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 14 Nov 2025 10:39:42 +0100 Subject: [PATCH 01/24] Fix Cmd/Ctrl + Enter in the composer triggering confirmation dialog action (#36870) --- .../mastodon/features/compose/components/compose_form.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 770f776049..7f86f8fd84 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -102,6 +102,7 @@ class ComposeForm extends ImmutablePureComponent { handleKeyDownPost = (e) => { if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) { this.handleSubmit(); + e.preventDefault(); } this.blurOnEscape(e); }; From 5a57c0844aed667d611126b3b63b300859edb738 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 14 Nov 2025 13:23:40 +0100 Subject: [PATCH 02/24] Fix hashtag completion not being inserted correctly (#36884) --- app/javascript/mastodon/actions/compose.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 232c4b1c19..bd1cb3ca9b 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -673,8 +673,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; From a7b45682a612e82c079948493ccdf0730ffdeea0 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 14 Nov 2025 13:39:04 +0100 Subject: [PATCH 03/24] Fix bogus quote approval policy not always being replaced correctly (#36885) --- app/javascript/mastodon/actions/importer/normalizer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 12c02dd4df..075dc84ef1 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -112,7 +112,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) { From 6486c092f631c80603e71c1fc83cb341f6855a8e Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 14 Nov 2025 15:11:41 +0100 Subject: [PATCH 04/24] Fix error with remote tags including percent signs (#36886) --- .../mastodon/components/status/handled_link.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index be816e9853..43763d9c32 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -27,12 +27,14 @@ export const HandledLink: FC> = ({ }) => { // 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 ( > = ({ return ( Date: Fri, 14 Nov 2025 16:27:43 +0100 Subject: [PATCH 05/24] chore(deps): update dependency js-yaml to v4.1.1 [security] (#36891) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index ada0629211..68fb13ff2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8946,13 +8946,13 @@ __metadata: linkType: hard "js-yaml@npm:^4.1.0": - version: 4.1.0 - resolution: "js-yaml@npm:4.1.0" + version: 4.1.1 + resolution: "js-yaml@npm:4.1.1" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f + checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 languageName: node linkType: hard From 27c67f17502e52edaeef7511bc187ba336147d24 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 14 Nov 2025 16:42:26 +0100 Subject: [PATCH 06/24] Fix cross-origin handling of CSS modules (#36890) --- lib/vite_ruby/sri_extensions.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/vite_ruby/sri_extensions.rb b/lib/vite_ruby/sri_extensions.rb index d97ab352da..31363272bf 100644 --- a/lib/vite_ruby/sri_extensions.rb +++ b/lib/vite_ruby/sri_extensions.rb @@ -107,6 +107,7 @@ module ViteRails::TagHelpers::IntegrityExtension stylesheet, integrity: vite_manifest.integrity_hash_for_file(stylesheet), media: media, + crossorigin: crossorigin, **options ) end From 44d45e57058997365691843891e171dbc2ec550c Mon Sep 17 00:00:00 2001 From: Shugo Maeda Date: Mon, 17 Nov 2025 22:34:20 +0900 Subject: [PATCH 07/24] Fix ArgumentError of tootctl upgrade storage-schema (#36914) --- lib/mastodon/cli/upgrade.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mastodon/cli/upgrade.rb b/lib/mastodon/cli/upgrade.rb index 2cb5105794..d5822cacc0 100644 --- a/lib/mastodon/cli/upgrade.rb +++ b/lib/mastodon/cli/upgrade.rb @@ -123,12 +123,12 @@ module Mastodon::CLI progress.log("Moving #{previous_path} to #{upgraded_path}") if options[:verbose] begin - move_previous_to_upgraded + move_previous_to_upgraded(previous_path, upgraded_path) rescue => e progress.log(pastel.red("Error processing #{previous_path}: #{e}")) success = false - remove_directory + remove_directory(upgraded_path) end end From c08cd6d62a128caf6509d13445829c767f2ff791 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 17 Nov 2025 16:34:18 +0100 Subject: [PATCH 08/24] Emoji: Fix path resolution for emoji worker (#36897) --- .../mastodon/features/emoji/index.ts | 32 ++++++-- .../mastodon/features/emoji/loader.ts | 82 ++++++++++--------- .../mastodon/features/emoji/render.test.ts | 2 +- .../mastodon/features/emoji/worker.ts | 25 ++++-- app/javascript/mastodon/main.tsx | 2 +- 5 files changed, 83 insertions(+), 60 deletions(-) diff --git a/app/javascript/mastodon/features/emoji/index.ts b/app/javascript/mastodon/features/emoji/index.ts index 11ee26aac2..4b0f79133c 100644 --- a/app/javascript/mastodon/features/emoji/index.ts +++ b/app/javascript/mastodon/features/emoji/index.ts @@ -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'; @@ -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) => { + worker.addEventListener('message', (event: MessageEvent) => { 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. @@ -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 }); +} diff --git a/app/javascript/mastodon/features/emoji/loader.ts b/app/javascript/mastodon/features/emoji/loader.ts index 330c5e6a2a..7251559d6b 100644 --- a/app/javascript/mastodon/features/emoji/loader.ts +++ b/app/javascript/mastodon/features/emoji/loader.ts @@ -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(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(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('custom'); + const emojis = await fetchAndCheckEtag( + 'custom', + '/api/v1/custom_emojis', + ); if (!emojis) { return; } - log('loaded %d custom emojis', emojis.length); await putCustomEmojiData(emojis); + return emojis; } -async function fetchAndCheckEtag( - localeOrCustom: LocaleOrCustom, +const modules = import.meta.glob( + '../../../../../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](); +} + +export async function fetchAndCheckEtag( + localeString: string, + path: string, ): Promise { - 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, { @@ -60,38 +80,20 @@ async function fetchAndCheckEtag( } 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( - '../../../../../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](); -} diff --git a/app/javascript/mastodon/features/emoji/render.test.ts b/app/javascript/mastodon/features/emoji/render.test.ts index 05dbc388c4..3c96cbfb55 100644 --- a/app/javascript/mastodon/features/emoji/render.test.ts +++ b/app/javascript/mastodon/features/emoji/render.test.ts @@ -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); diff --git a/app/javascript/mastodon/features/emoji/worker.ts b/app/javascript/mastodon/features/emoji/worker.ts index 6fb7d36e93..5360484d77 100644 --- a/app/javascript/mastodon/features/emoji/worker.ts +++ b/app/javascript/mastodon/features/emoji/worker.ts @@ -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) { - 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}`); } diff --git a/app/javascript/mastodon/main.tsx b/app/javascript/mastodon/main.tsx index f89baf66cd..249baf65fc 100644 --- a/app/javascript/mastodon/main.tsx +++ b/app/javascript/mastodon/main.tsx @@ -9,7 +9,6 @@ import { me, reduceMotion } from 'mastodon/initial_state'; import ready from 'mastodon/ready'; import { store } from 'mastodon/store'; -import { initializeEmoji } from './features/emoji'; import { isProduction, isDevelopment } from './utils/environment'; function main() { @@ -30,6 +29,7 @@ function main() { }); } + const { initializeEmoji } = await import('./features/emoji/index'); initializeEmoji(); const root = createRoot(mountNode); From 4ee21c2e295a0c3e456cd54159695e4b3484be92 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 18 Nov 2025 16:37:14 +0100 Subject: [PATCH 09/24] Fix double encoding in links (#36925) --- app/javascript/mastodon/components/status/handled_link.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index 43763d9c32..8b6db98fda 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -38,7 +38,7 @@ export const HandledLink: FC> = ({ return ( @@ -71,7 +71,7 @@ export const HandledLink: FC> = ({ return ( Date: Tue, 18 Nov 2025 17:17:26 +0100 Subject: [PATCH 10/24] Change private quote education modal to not show up on self-quotes (#36926) --- .../features/compose/containers/compose_form_container.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 15b1c7cc41..ef4e2b6238 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -13,6 +13,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'; @@ -36,6 +37,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']), From 6ccd9c2f1f0b8dd26465de6aa54a6b56d6cd09ae Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 18 Nov 2025 17:20:50 +0100 Subject: [PATCH 11/24] Fix scroll-to-status in threaded view being unreliable (#36927) --- .../status/components/detailed_status.tsx | 30 ++++++++++++++++++- .../mastodon/features/status/index.jsx | 30 ++----------------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx index 4e08635323..6d432e1f24 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.tsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx @@ -4,7 +4,7 @@ @typescript-eslint/no-unsafe-assignment */ import type { CSSProperties } from 'react'; -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -55,6 +55,8 @@ export const DetailedStatus: React.FC<{ pictureInPicture: any; onToggleHidden?: (status: any) => void; onToggleMediaVisibility?: () => void; + ancestors?: number; + multiColumn?: boolean; }> = ({ status, onOpenMedia, @@ -69,6 +71,8 @@ export const DetailedStatus: React.FC<{ pictureInPicture, onToggleMediaVisibility, onToggleHidden, + ancestors = 0, + multiColumn = false, }) => { const properStatus = status?.get('reblog') ?? status; const [height, setHeight] = useState(0); @@ -123,6 +127,30 @@ export const DetailedStatus: React.FC<{ if (onTranslate) onTranslate(status); }, [onTranslate, status]); + // The component is managed and will change if the status changes + // Ancestors can increase when loading a thread, in which case we want to scroll, + // or decrease if a post is deleted, in which case we don't want to mess with it + const previousAncestors = useRef(-1); + useEffect(() => { + if (nodeRef.current && previousAncestors.current < ancestors) { + nodeRef.current.scrollIntoView(true); + + // In the single-column interface, `scrollIntoView` will put the post behind the header, so compensate for that. + if (!multiColumn) { + const offset = document + .querySelector('.column-header__wrapper') + ?.getBoundingClientRect().bottom; + + if (offset) { + const scrollingElement = document.scrollingElement ?? document.body; + scrollingElement.scrollBy(0, -offset); + } + } + } + + previousAncestors.current = ancestors; + }, [ancestors, multiColumn]); + if (!properStatus) { return null; } diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 7c1f292d15..ba72ee0c70 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -164,8 +164,6 @@ class Status extends ImmutablePureComponent { componentDidMount () { attachFullscreenListener(this.onFullScreenChange); - - this._scrollStatusIntoView(); } UNSAFE_componentWillReceiveProps (nextProps) { @@ -487,35 +485,11 @@ class Status extends ImmutablePureComponent { this.statusNode = c; }; - _scrollStatusIntoView () { - const { status, multiColumn } = this.props; - - if (status) { - requestIdleCallback(() => { - this.statusNode?.scrollIntoView(true); - - // In the single-column interface, `scrollIntoView` will put the post behind the header, - // so compensate for that. - if (!multiColumn) { - const offset = document.querySelector('.column-header__wrapper')?.getBoundingClientRect()?.bottom; - if (offset) { - const scrollingElement = document.scrollingElement || document.body; - scrollingElement.scrollBy(0, -offset); - } - } - }); - } - } - componentDidUpdate (prevProps) { - const { status, ancestorsIds, descendantsIds } = this.props; + const { status, descendantsIds } = this.props; const isSameStatus = status && (prevProps.status?.get('id') === status.get('id')); - if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || !isSameStatus)) { - this._scrollStatusIntoView(); - } - // Only highlight replies after the initial load if (prevProps.descendantsIds.length && isSameStatus) { const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds); @@ -619,6 +593,8 @@ class Status extends ImmutablePureComponent { showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} pictureInPicture={pictureInPicture} + ancestors={this.props.ancestorsIds.length} + multiColumn={multiColumn} /> Date: Wed, 19 Nov 2025 11:35:10 +0100 Subject: [PATCH 12/24] Fix quoting overwriting current content warning (#36934) --- app/javascript/mastodon/reducers/compose.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index d4ed1bd505..784050d942 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -341,8 +341,8 @@ export const composeReducer = (state = initialState, action) => { const isDirect = state.get('privacy') === 'direct'; return state .set('quoted_status_id', isDirect ? null : status.get('id')) - .set('spoiler', status.get('sensitive')) - .set('spoiler_text', status.get('spoiler_text')) + .update('spoiler', spoiler => (spoiler) || !!status.get('spoiler_text')) + .update('spoiler_text', (spoiler_text) => spoiler_text || status.get('spoiler_text')) .update('privacy', (visibility) => { if (['public', 'unlisted'].includes(visibility) && status.get('visibility') === 'private') { return 'private'; From 1dbf10198d23746207b40ca58b2c5e70ded0ab30 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 19 Nov 2025 11:58:07 +0100 Subject: [PATCH 13/24] Fix `g` + `h` keyboard shortcut not working when a post is focused (#36935) --- .../mastodon/components/hotkeys/index.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx index b1484ec3ac..81ca28eb87 100644 --- a/app/javascript/mastodon/components/hotkeys/index.tsx +++ b/app/javascript/mastodon/components/hotkeys/index.tsx @@ -180,25 +180,24 @@ export function useHotkeys(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 }); } }, ); From 5fe316b2e94bccb91732a1d6675854abb60e9a4b Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 19 Nov 2025 16:29:35 +0100 Subject: [PATCH 14/24] Update dependency `glob` (#36941) --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 68fb13ff2c..2310326d8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7945,8 +7945,8 @@ __metadata: linkType: hard "glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.4.1": - version: 10.4.5 - resolution: "glob@npm:10.4.5" + version: 10.5.0 + resolution: "glob@npm:10.5.0" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" @@ -7956,7 +7956,7 @@ __metadata: path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e + checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 languageName: node linkType: hard From 553bb4673eef5e4bc611e05a94de2235cb609552 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 14 Nov 2025 10:39:42 +0100 Subject: [PATCH 15/24] [Glitch] Fix Cmd/Ctrl + Enter in the composer triggering confirmation dialog action Port 1d081250f441fca5ea08dc2aa8fde046fa3147a7 to glitch-soc Signed-off-by: Claire --- .../glitch/features/compose/components/compose_form.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx index 20f5a1fb92..be645147db 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx @@ -110,10 +110,12 @@ class ComposeForm extends ImmutablePureComponent { handleKeyDownPost = (e) => { if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) { this.handleSubmit(e); + e.preventDefault(); } if (e.key.toLowerCase() === 'enter' && e.altKey) { this.handleSecondarySubmit(e); + e.preventDefault(); } this.blurOnEscape(e); From 8ae06fdbcd095ec575d47a241fe234ee4bda9c31 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 14 Nov 2025 13:23:40 +0100 Subject: [PATCH 16/24] [Glitch] Fix hashtag completion not being inserted correctly Port 5a57c0844aed667d611126b3b63b300859edb738 to glitch-soc Signed-off-by: Claire --- app/javascript/flavours/glitch/actions/compose.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 3553378b44..0e2b2f110c 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -709,8 +709,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; From 6c7a9b8311fd4b9e5bffea3c890348510491d261 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 14 Nov 2025 13:39:04 +0100 Subject: [PATCH 17/24] [Glitch] Fix bogus quote approval policy not always being replaced correctly Port a7b45682a612e82c079948493ccdf0730ffdeea0 to glitch-soc Signed-off-by: Claire --- app/javascript/flavours/glitch/actions/importer/normalizer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index efb73d3a3f..ca26b9e055 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -104,7 +104,7 @@ export function normalizeStatus(status, normalOldStatus, { settings, bogusQuoteP } 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) { From 7141917943fac3ab65e5535fb7b41cc943d48e1e Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 14 Nov 2025 15:11:41 +0100 Subject: [PATCH 18/24] [Glitch] Fix error with remote tags including percent signs Port 6486c092f631c80603e71c1fc83cb341f6855a8e to glitch-soc Signed-off-by: Claire --- .../glitch/components/status/handled_link.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/javascript/flavours/glitch/components/status/handled_link.tsx b/app/javascript/flavours/glitch/components/status/handled_link.tsx index cd1fe0b74b..3541b09cbf 100644 --- a/app/javascript/flavours/glitch/components/status/handled_link.tsx +++ b/app/javascript/flavours/glitch/components/status/handled_link.tsx @@ -133,12 +133,14 @@ export const HandledLink: FC> = ({ // 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 ( > = ({ return ( Date: Mon, 17 Nov 2025 16:34:18 +0100 Subject: [PATCH 19/24] [Glitch] Emoji: Fix path resolution for emoji worker Port c08cd6d62a128caf6509d13445829c767f2ff791 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/features/emoji/index.ts | 32 ++++++-- .../flavours/glitch/features/emoji/loader.ts | 82 ++++++++++--------- .../glitch/features/emoji/render.test.ts | 2 +- .../flavours/glitch/features/emoji/worker.ts | 25 ++++-- app/javascript/flavours/glitch/main.tsx | 2 +- 5 files changed, 83 insertions(+), 60 deletions(-) diff --git a/app/javascript/flavours/glitch/features/emoji/index.ts b/app/javascript/flavours/glitch/features/emoji/index.ts index d6966d833e..51baef7537 100644 --- a/app/javascript/flavours/glitch/features/emoji/index.ts +++ b/app/javascript/flavours/glitch/features/emoji/index.ts @@ -1,6 +1,7 @@ import { initialState } from '@/flavours/glitch/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'; @@ -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) => { + worker.addEventListener('message', (event: MessageEvent) => { 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. @@ -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 }); +} diff --git a/app/javascript/flavours/glitch/features/emoji/loader.ts b/app/javascript/flavours/glitch/features/emoji/loader.ts index 55e4ca209f..86c879cddc 100644 --- a/app/javascript/flavours/glitch/features/emoji/loader.ts +++ b/app/javascript/flavours/glitch/features/emoji/loader.ts @@ -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(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(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('custom'); + const emojis = await fetchAndCheckEtag( + 'custom', + '/api/v1/custom_emojis', + ); if (!emojis) { return; } - log('loaded %d custom emojis', emojis.length); await putCustomEmojiData(emojis); + return emojis; } -async function fetchAndCheckEtag( - localeOrCustom: LocaleOrCustom, +const modules = import.meta.glob( + '../../../../../../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](); +} + +export async function fetchAndCheckEtag( + localeString: string, + path: string, ): Promise { - 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, { @@ -60,38 +80,20 @@ async function fetchAndCheckEtag( } 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( - '../../../../../../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](); -} diff --git a/app/javascript/flavours/glitch/features/emoji/render.test.ts b/app/javascript/flavours/glitch/features/emoji/render.test.ts index 05dbc388c4..3c96cbfb55 100644 --- a/app/javascript/flavours/glitch/features/emoji/render.test.ts +++ b/app/javascript/flavours/glitch/features/emoji/render.test.ts @@ -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); diff --git a/app/javascript/flavours/glitch/features/emoji/worker.ts b/app/javascript/flavours/glitch/features/emoji/worker.ts index 6fb7d36e93..5360484d77 100644 --- a/app/javascript/flavours/glitch/features/emoji/worker.ts +++ b/app/javascript/flavours/glitch/features/emoji/worker.ts @@ -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) { - 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}`); } diff --git a/app/javascript/flavours/glitch/main.tsx b/app/javascript/flavours/glitch/main.tsx index 8efe7cd557..167f8df476 100644 --- a/app/javascript/flavours/glitch/main.tsx +++ b/app/javascript/flavours/glitch/main.tsx @@ -9,7 +9,6 @@ import { me, reduceMotion } from 'flavours/glitch/initial_state'; import ready from 'flavours/glitch/ready'; import { store } from 'flavours/glitch/store'; -import { initializeEmoji } from './features/emoji'; import { isProduction, isDevelopment } from './utils/environment'; function main() { @@ -30,6 +29,7 @@ function main() { }); } + const { initializeEmoji } = await import('./features/emoji/index'); initializeEmoji(); const root = createRoot(mountNode); From f04b06a44f613c59f971ca026987f91e7f8b2395 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 18 Nov 2025 16:37:14 +0100 Subject: [PATCH 20/24] [Glitch] Fix double encoding in links Port 4ee21c2e295a0c3e456cd54159695e4b3484be92 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/components/status/handled_link.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/flavours/glitch/components/status/handled_link.tsx b/app/javascript/flavours/glitch/components/status/handled_link.tsx index 3541b09cbf..ac49115085 100644 --- a/app/javascript/flavours/glitch/components/status/handled_link.tsx +++ b/app/javascript/flavours/glitch/components/status/handled_link.tsx @@ -144,7 +144,7 @@ export const HandledLink: FC> = ({ return ( @@ -194,7 +194,7 @@ export const HandledLink: FC> = ({ return ( Date: Tue, 18 Nov 2025 17:17:26 +0100 Subject: [PATCH 21/24] [Glitch] Change private quote education modal to not show up on self-quotes Port 261d9b33fe7976fa67e635ea0373c3c876be4a43 to glitch-soc Signed-off-by: Claire --- .../features/compose/containers/compose_form_container.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js index eb9efb2e32..2b14bd599b 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -13,6 +13,7 @@ import { import { pasteLinkCompose } from 'flavours/glitch/actions/compose_typed'; import { openModal } from 'flavours/glitch/actions/modal'; import { PRIVATE_QUOTE_MODAL_ID } from 'flavours/glitch/features/ui/components/confirmation_modals/private_quote_notify'; +import { me } from 'flavours/glitch/initial_state'; import { privacyPreference } from 'flavours/glitch/utils/privacy_preference'; import ComposeForm from '../components/compose_form'; @@ -56,6 +57,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']), From 8c725777ed2b7143ed48b9096f73c9ad2fe32dbf Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 18 Nov 2025 17:20:50 +0100 Subject: [PATCH 22/24] [Glitch] Fix scroll-to-status in threaded view being unreliable Port 6ccd9c2f1f0b8dd26465de6aa54a6b56d6cd09ae to glitch-soc Signed-off-by: Claire --- .../status/components/detailed_status.tsx | 30 ++++++++++++++++++- .../flavours/glitch/features/status/index.jsx | 29 ++---------------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx index 87d88510fa..f8a87ac210 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx @@ -4,7 +4,7 @@ @typescript-eslint/no-unsafe-assignment */ import type { CSSProperties } from 'react'; -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -57,6 +57,8 @@ export const DetailedStatus: React.FC<{ pictureInPicture: any; onToggleHidden?: (status: any) => void; onToggleMediaVisibility?: () => void; + ancestors?: number; + multiColumn?: boolean; expanded: boolean; }> = ({ status, @@ -72,6 +74,8 @@ export const DetailedStatus: React.FC<{ pictureInPicture, onToggleMediaVisibility, onToggleHidden, + ancestors = 0, + multiColumn = false, expanded, }) => { const properStatus = status?.get('reblog') ?? status; @@ -136,6 +140,30 @@ export const DetailedStatus: React.FC<{ if (onTranslate) onTranslate(status); }, [onTranslate, status]); + // The component is managed and will change if the status changes + // Ancestors can increase when loading a thread, in which case we want to scroll, + // or decrease if a post is deleted, in which case we don't want to mess with it + const previousAncestors = useRef(-1); + useEffect(() => { + if (nodeRef.current && previousAncestors.current < ancestors) { + nodeRef.current.scrollIntoView(true); + + // In the single-column interface, `scrollIntoView` will put the post behind the header, so compensate for that. + if (!multiColumn) { + const offset = document + .querySelector('.column-header__wrapper') + ?.getBoundingClientRect().bottom; + + if (offset) { + const scrollingElement = document.scrollingElement ?? document.body; + scrollingElement.scrollBy(0, -offset); + } + } + } + + previousAncestors.current = ancestors; + }, [ancestors, multiColumn]); + if (!properStatus) { return null; } diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index 1c33cb21d0..c921bfad50 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -162,7 +162,6 @@ class Status extends ImmutablePureComponent { componentDidMount () { attachFullscreenListener(this.onFullScreenChange); this.props.dispatch(fetchStatus(this.props.params.statusId, { forceFetch: true })); - this._scrollStatusIntoView(); } static getDerivedStateFromProps(props, state) { @@ -512,35 +511,11 @@ class Status extends ImmutablePureComponent { this.statusNode = c; }; - _scrollStatusIntoView () { - const { status, multiColumn } = this.props; - - if (status) { - requestIdleCallback(() => { - this.statusNode?.scrollIntoView(true); - - // In the single-column interface, `scrollIntoView` will put the post behind the header, - // so compensate for that. - if (!multiColumn) { - const offset = document.querySelector('.column-header__wrapper')?.getBoundingClientRect()?.bottom; - if (offset) { - const scrollingElement = document.scrollingElement || document.body; - scrollingElement.scrollBy(0, -offset); - } - } - }); - } - } - componentDidUpdate (prevProps) { - const { status, ancestorsIds, descendantsIds } = this.props; + const { status, descendantsIds } = this.props; const isSameStatus = status && (prevProps.status?.get('id') === status.get('id')); - if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || !isSameStatus)) { - this._scrollStatusIntoView(); - } - // Only highlight replies after the initial load if (prevProps.descendantsIds.length && isSameStatus) { const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds); @@ -653,6 +628,8 @@ class Status extends ImmutablePureComponent { showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} pictureInPicture={pictureInPicture} + ancestors={this.props.ancestorsIds.length} + multiColumn={multiColumn} /> Date: Wed, 19 Nov 2025 11:35:10 +0100 Subject: [PATCH 23/24] [Glitch] Fix quoting overwriting current content warning Port c6ccacdf7ba0e8d4b99f9ed74c185a992808c421 to glitch-soc Signed-off-by: Claire --- app/javascript/flavours/glitch/reducers/compose.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index ed709040fa..f50a4da01c 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -418,8 +418,8 @@ export const composeReducer = (state = initialState, action) => { const isDirect = state.get('privacy') === 'direct'; return state .set('quoted_status_id', isDirect ? null : status.get('id')) - .set('spoiler', status.get('sensitive')) - .set('spoiler_text', status.get('spoiler_text')) + .update('spoiler', spoiler => (spoiler) || !!status.get('spoiler_text')) + .update('spoiler_text', (spoiler_text) => spoiler_text || status.get('spoiler_text')) .update('privacy', (visibility) => { if (['public', 'unlisted'].includes(visibility) && status.get('visibility') === 'private') { return 'private'; From 8836c4fc84629df95abc91778cb0a9fba98ac467 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 19 Nov 2025 11:58:07 +0100 Subject: [PATCH 24/24] [Glitch] Fix `g` + `h` keyboard shortcut not working when a post is focused Port 1dbf10198d23746207b40ca58b2c5e70ded0ab30 to glitch-soc Signed-off-by: Claire --- .../glitch/components/hotkeys/index.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/app/javascript/flavours/glitch/components/hotkeys/index.tsx b/app/javascript/flavours/glitch/components/hotkeys/index.tsx index d679592b02..db24038ff8 100644 --- a/app/javascript/flavours/glitch/components/hotkeys/index.tsx +++ b/app/javascript/flavours/glitch/components/hotkeys/index.tsx @@ -181,25 +181,24 @@ export function useHotkeys(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 }); } }, );