diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5127a84e..5280b940ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to this project will be documented in this file. +## [4.6.1] - 2026-06-24 + +### Security + +- Update dependencies + +### Added + +- Add `avatar_description` and `header_description` to `/api/v1/accounts/update_credentials` (#39547 and #39574 by @ClearlyClaire and @mkljczk) + - This is available starting from Mastodon API version `11` and intended to provide an easier implementation path for clients implementing a similar feature in forks. + - The new `/api/v1/profile` API remains the recommended API for setting avatar and header description as well as other profile values. + +### Fixed + +- Fix combobox menu not closing after a selection (#39595 by @diondiondion) +- Fix Emoji IndexedDB upgrades when multiple tabs are open (#39576 by @ChaosExAnima) +- Fix combobox listbox not scrolling up when new suggestions have loaded (#39588 by @diondiondion) +- Fix media modal navigation in RTL languages (#39587 by @diondiondion) +- Fix accounts not visible in collection editor in advanced web interface (#39586 by @diondiondion) +- Fix error on login with certain LDAP configurations (#39571 by @oneiros) +- Fix simplified layout applying to other pages in web UI (#39570 by @Gargron) +- Fix emoji database loading in web worker (#39558 and #39562 by @ChaosExAnima) +- Fix display name length limit being incorrectly enforced in web UI (#39499 by @shleeable) +- Fix advanced UI columns not using mobile styles (#39528 by @diondiondion) +- Fix "private mention" post heading overlapping thread line (#39521 and #39554 by @diondiondion) +- Fix misattribution of remote featured collections in some cases (#39523, #39525, and #39550 by @oneiros) +- Fix custom profile field overflow (#39513 by @diondiondion) +- Fix fetching unknown key when it's not the actor's first, and add error handling for unavailable keys (#39512 by @ClearlyClaire) + ## [4.6.0] - 2026-06-17 ### Added diff --git a/app/javascript/flavours/glitch/actions/app.ts b/app/javascript/flavours/glitch/actions/app.ts index be1a5cced2..2abf21f735 100644 --- a/app/javascript/flavours/glitch/actions/app.ts +++ b/app/javascript/flavours/glitch/actions/app.ts @@ -10,3 +10,5 @@ interface ChangeLayoutPayload { } export const changeLayout = createAction('APP_LAYOUT_CHANGE'); + +export const needsReload = createAction('APP_NEED_RELOAD'); diff --git a/app/javascript/flavours/glitch/components/alerts_controller.tsx b/app/javascript/flavours/glitch/components/alerts_controller.tsx index 695834177a..c959f72340 100644 --- a/app/javascript/flavours/glitch/components/alerts_controller.tsx +++ b/app/javascript/flavours/glitch/components/alerts_controller.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; -import { useIntl } from 'react-intl'; +import { defineMessage, useIntl } from 'react-intl'; import type { IntlShape } from 'react-intl'; import { dismissAlert } from 'flavours/glitch/actions/alerts'; @@ -75,6 +75,9 @@ const TimedAlert: React.FC<{ export const AlertsController: React.FC = () => { const alerts = useAppSelector((state) => state.alerts); + const needsReload = useAppSelector( + (state) => !!state.meta.get('needsReload'), + ); return ( @@ -85,6 +88,18 @@ export const AlertsController: React.FC = () => { dismissAfter={5000 + idx * 1000} /> ))} + {needsReload && } ); }; + +const reloadMessage = defineMessage({ + id: 'alert.need_reload.message', + defaultMessage: + 'Mastodon has been updated. Some things may not work correctly until you reload the page.', +}); + +const ReloadAlert: React.FC = () => { + const intl = useIntl(); + return ; +}; diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx index b856c4b32d..a1d66c30a9 100644 --- a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx @@ -3,6 +3,7 @@ import { forwardRef, useCallback, useContext, + useEffect, useId, useMemo, useRef, @@ -238,6 +239,11 @@ const ComboboxWithRef = ( const inputRef = useRef(); const popoverRef = useRef(null); + // This ref tracks whether the menu was just closed following a + // selection, and prevents the menu from re-opening again + // when focus is returned to the input. + const wasMenuJustClosedRef = useRef(false); + const [highlightedItemId, setHighlightedItemId] = useState( null, ); @@ -276,12 +282,6 @@ const ComboboxWithRef = ( setShouldMenuOpen(false); }, []); - const resetHighlight = useCallback(() => { - const firstItem = flatItems[0]; - const firstItemId = firstItem ? getItemId(firstItem) : null; - setHighlightedItemId(firstItemId); - }, [getItemId, flatItems]); - const highlightItem = useCallback((id: string | null) => { setHighlightedItemId(id); if (id) { @@ -294,9 +294,22 @@ const ComboboxWithRef = ( } }, []); + const resetHighlight = useCallback(() => { + const firstItem = flatItems[0]; + const firstItemId = firstItem ? getItemId(firstItem) : null; + highlightItem(firstItemId); + }, [flatItems, getItemId, highlightItem]); + + // Reset scroll & highlight when menu items change + useEffect(() => { + if (flatItems.length) { + resetHighlight(); + } + }, [flatItems, resetHighlight]); + const handleFocus: React.FocusEventHandler = useCallback( (e) => { - if (openOnFocus) { + if (openOnFocus && !wasMenuJustClosedRef.current) { setShouldMenuOpen(true); } onFocus?.(e); @@ -333,6 +346,10 @@ const ComboboxWithRef = ( if (closeOnSelect) { closeMenu(); + wasMenuJustClosedRef.current = true; + setTimeout(() => { + wasMenuJustClosedRef.current = false; + }, 50); } } } diff --git a/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx b/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx index 8dd8c66eb2..78ac4951b7 100644 --- a/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx +++ b/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx @@ -27,7 +27,6 @@ import { import { Article, ItemList, - Scrollable, } from 'flavours/glitch/components/scrollable_list/components'; import { useAccount } from 'flavours/glitch/hooks/useAccount'; import { useSearchAccounts } from 'flavours/glitch/hooks/useSearchAccounts'; @@ -420,43 +419,42 @@ export const CollectionAccounts: React.FC<{ )} - - - } - message={ - - } - /> - } - > - {editorItems.map(({ account_id, state }, index) => ( -
- -
- ))} -
-
+ } + message={ + + } + /> + } + > + {editorItems.map(({ account_id, state }, index) => ( +
+ +
+ ))} + {!isEditMode && hasItems && ( diff --git a/app/javascript/flavours/glitch/features/collections/editor/styles.module.scss b/app/javascript/flavours/glitch/features/collections/editor/styles.module.scss index d1111e7469..02d719699e 100644 --- a/app/javascript/flavours/glitch/features/collections/editor/styles.module.scss +++ b/app/javascript/flavours/glitch/features/collections/editor/styles.module.scss @@ -55,7 +55,7 @@ gap: 16px; } -.scrollableWrapper { +.accountList { margin-inline: -16px; } diff --git a/app/javascript/flavours/glitch/features/emoji/constants.ts b/app/javascript/flavours/glitch/features/emoji/constants.ts index 9969a398c1..4c517599cd 100644 --- a/app/javascript/flavours/glitch/features/emoji/constants.ts +++ b/app/javascript/flavours/glitch/features/emoji/constants.ts @@ -26,8 +26,8 @@ export const EMOJI_TYPE_UNICODE = 'unicode'; export const EMOJI_TYPE_CUSTOM = 'custom'; export const EMOJI_DB_NAME_SHORTCODES = 'shortcodes'; - export const EMOJI_DB_SHORTCODE_TEST = '2122'; // 2122 is the trademark sign, which we know has shortcodes in all datasets. +export const EMOJI_DB_RELOAD_EVENT = 'emojiDbReload'; export const EMOJIS_WITH_DARK_BORDER = [ '🎱', // 1F3B1 diff --git a/app/javascript/flavours/glitch/features/emoji/database.ts b/app/javascript/flavours/glitch/features/emoji/database.ts index 7289c9fc8a..36d009f5f4 100644 --- a/app/javascript/flavours/glitch/features/emoji/database.ts +++ b/app/javascript/flavours/glitch/features/emoji/database.ts @@ -2,6 +2,7 @@ import { SUPPORTED_LOCALES } from 'emojibase'; import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase'; import type { ApiCustomEmojiJSON } from '@/flavours/glitch/api_types/custom_emoji'; +import { onceAsync } from '@/flavours/glitch/utils/promises'; import { openEmojiDB } from './db-schema'; import type { Database } from './db-schema'; @@ -22,8 +23,6 @@ const log = emojiLogger('database'); // Loads the database in a way that ensures it's only loaded once. const loadDB = (() => { - let dbPromise: Promise | null = null; - // Actually load the DB. async function initDB() { const db = await openEmojiDB(); @@ -32,17 +31,14 @@ const loadDB = (() => { return db; } + let dbPromise = onceAsync(initDB); + // Loads the database, or returns the existing promise if it hasn't resolved yet. - const loadPromise = async (): Promise => { - if (dbPromise) { - return dbPromise; - } - dbPromise = initDB(); - return dbPromise; - }; + const loadPromise = () => dbPromise(); + // Special way to reset the database, used for unit testing. loadPromise.reset = () => { - dbPromise = null; + dbPromise = onceAsync(initDB); }; return loadPromise; })(); @@ -335,13 +331,6 @@ export async function clearCache(key: CacheKey) { log('Cleared cache for %s', key); } -export async function resetDatabase() { - const db = await loadDB(); - const storeNames = [...db.objectStoreNames]; - await Promise.all(storeNames.map((storeName) => db.clear(storeName))); - log(storeNames, 'Reset emoji database stores:'); -} - export async function loadEmojiByHexcode( hexcode: string, localeString: string, diff --git a/app/javascript/flavours/glitch/features/emoji/db-schema.test.ts b/app/javascript/flavours/glitch/features/emoji/db-schema.test.ts new file mode 100644 index 0000000000..37bf86aa86 --- /dev/null +++ b/app/javascript/flavours/glitch/features/emoji/db-schema.test.ts @@ -0,0 +1,67 @@ +import { IDBFactory } from 'fake-indexeddb'; + +import { EMOJI_DB_RELOAD_EVENT } from './constants'; +import { openEmojiDB } from './db-schema'; + +describe('openEmojiDB', () => { + afterEach(() => { + indexedDB = new IDBFactory(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe('versionchange handler', () => { + test('dispatches reload event in main thread context', async () => { + const dispatchSpy = vi.fn(); + window.addEventListener(EMOJI_DB_RELOAD_EVENT, dispatchSpy); + + await openEmojiDB(); + + // Opening a higher version on the same IDBFactory triggers versionchange + // on all existing connections to that database. + indexedDB.open('mastodon-emoji', 9999); + + await vi.waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledOnce(); + }); + + window.removeEventListener(EMOJI_DB_RELOAD_EVENT, dispatchSpy); + }); + + test('posts db-blocked message in worker context', async () => { + const postMessageSpy = vi.fn(); + vi.stubGlobal('window', undefined); + vi.stubGlobal('self', { postMessage: postMessageSpy }); + + await openEmojiDB(); + + indexedDB.open('mastodon-emoji', 9999); + + await vi.waitFor(() => { + expect(postMessageSpy).toHaveBeenCalledWith({ type: 'db-blocked' }); + }); + }); + + test('closes all open connections when versionchange fires', async () => { + const dispatchSpy = vi.fn(); + window.addEventListener(EMOJI_DB_RELOAD_EVENT, dispatchSpy); + + const db1 = await openEmojiDB(); + const db2 = await openEmojiDB(); + + // Opening a higher version on the same IDBFactory triggers versionchange + // on all existing connections to that database. + indexedDB.open('mastodon-emoji', 9999); + + await vi.waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledTimes(2); + }); + + window.removeEventListener(EMOJI_DB_RELOAD_EVENT, dispatchSpy); + + // Both connections should now be closed. + expect(() => db1.transaction('etags', 'readonly')).toThrow(); + expect(() => db2.transaction('etags', 'readonly')).toThrow(); + }); + }); +}); diff --git a/app/javascript/flavours/glitch/features/emoji/db-schema.ts b/app/javascript/flavours/glitch/features/emoji/db-schema.ts index f31ac96f81..b809ef63a8 100644 --- a/app/javascript/flavours/glitch/features/emoji/db-schema.ts +++ b/app/javascript/flavours/glitch/features/emoji/db-schema.ts @@ -1,6 +1,6 @@ import { SUPPORTED_LOCALES } from 'emojibase'; import type { Locale } from 'emojibase'; -import { openDB } from 'idb'; +import { deleteDB, openDB } from 'idb'; import type { DBSchema, IDBPDatabase, @@ -10,8 +10,13 @@ import type { StoreNames, } from 'idb'; -import { resetDatabase } from './database'; -import type { CustomEmojiData, CacheKey, UnicodeEmojiData } from './types'; +import { EMOJI_DB_RELOAD_EVENT } from './constants'; +import type { + CustomEmojiData, + CacheKey, + UnicodeEmojiData, + EmojiWorkerMessage, +} from './types'; import { emojiLogger } from './utils'; const log = emojiLogger('database'); @@ -58,72 +63,95 @@ type Transaction = export type Database = IDBPDatabase; +const DATABASE_NAME = 'mastodon-emoji'; const SCHEMA_VERSION = 4; export async function openEmojiDB() { - const db = await openDB('mastodon-emoji', SCHEMA_VERSION, { - upgrade(database, oldVersion, newVersion, trx) { - if (!database.objectStoreNames.contains('custom')) { - database.createObjectStore('custom', { - keyPath: 'shortcode', - autoIncrement: false, - }); - } - maybeAddIndex({ trx, storeName: 'custom', indexName: 'category' }); - maybeAddIndex({ - trx, - storeName: 'custom', - indexName: 'tokens', - options: { multiEntry: true }, - }); - - if (!database.objectStoreNames.contains('etags')) { - database.createObjectStore('etags'); - } - - SUPPORTED_LOCALES.forEach((locale) => { - createLocaleTable(locale, database, trx); - }); - - const shortcodeTable = database.objectStoreNames.contains('shortcodes') - ? trx.objectStore('shortcodes') - : database.createObjectStore('shortcodes', { - keyPath: 'hexcode', + try { + const db = await openDB(DATABASE_NAME, SCHEMA_VERSION, { + upgrade(database, oldVersion, newVersion, trx) { + if (!database.objectStoreNames.contains('custom')) { + database.createObjectStore('custom', { + keyPath: 'shortcode', autoIncrement: false, }); - maybeAddIndex({ - trx, - storeName: 'shortcodes', - indexName: 'shortcodes', - options: { multiEntry: true }, - }); - deleteOldIndexes(shortcodeTable, ['hexcode']); + } + maybeAddIndex({ trx, storeName: 'custom', indexName: 'category' }); + maybeAddIndex({ + trx, + storeName: 'custom', + indexName: 'tokens', + options: { multiEntry: true }, + }); - void resetDatabase(); + if (!database.objectStoreNames.contains('etags')) { + database.createObjectStore('etags'); + } - log( - 'Upgraded emoji database from version %d to %d', - oldVersion, - newVersion, - ); - }, - blocked(currentVersion, blockedVersion) { - log( - 'Emoji database upgrade from version %d to %d is blocked', - currentVersion, - blockedVersion, - ); - }, - blocking(currentVersion, blockedVersion) { - log( - 'Emoji database upgrade from version %d is blocking upgrade to %d', - currentVersion, - blockedVersion, - ); - }, - }); + SUPPORTED_LOCALES.forEach((locale) => { + createLocaleTable(locale, database, trx); + }); - return db; + const shortcodeTable = database.objectStoreNames.contains('shortcodes') + ? trx.objectStore('shortcodes') + : database.createObjectStore('shortcodes', { + keyPath: 'hexcode', + autoIncrement: false, + }); + maybeAddIndex({ + trx, + storeName: 'shortcodes', + indexName: 'shortcodes', + options: { multiEntry: true }, + }); + deleteOldIndexes(shortcodeTable, ['hexcode']); + + // Reset all database stores. + for (const storeName of database.objectStoreNames) { + void trx.objectStore(storeName).clear(); + } + + log( + 'Upgraded emoji database from version %d to %d', + oldVersion, + newVersion, + ); + }, + blocked(currentVersion, blockedVersion) { + log( + 'Emoji database upgrade from version %d to %d is blocked', + currentVersion, + blockedVersion, + ); + }, + blocking(currentVersion, blockedVersion) { + log( + 'Emoji database upgrade from version %d is blocking upgrade to %d, closing connection', + currentVersion, + blockedVersion, + ); + // Close the database connection immediately. + db.close(); + + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event(EMOJI_DB_RELOAD_EVENT)); + } else { + self.postMessage({ type: 'db-blocked' } satisfies EmojiWorkerMessage); + } + }, + }); + + return db; + } catch (error) { + if (error instanceof DOMException && error.name === 'VersionError') { + log( + 'Emoji database version is newer than expected, deleting and recreating', + ); + await deleteDB(DATABASE_NAME); + return openEmojiDB(); + } + throw error; + } } function maybeAddIndex>({ diff --git a/app/javascript/flavours/glitch/features/emoji/index.ts b/app/javascript/flavours/glitch/features/emoji/index.ts index fb6523c956..bc687a0d50 100644 --- a/app/javascript/flavours/glitch/features/emoji/index.ts +++ b/app/javascript/flavours/glitch/features/emoji/index.ts @@ -1,5 +1,6 @@ import { initialState } from '@/flavours/glitch/initial_state'; +import { EMOJI_DB_RELOAD_EVENT } from './constants'; import { toSupportedLocale } from './locale'; import type { EmojiWorkerMessage } from './types'; import { emojiLogger } from './utils'; @@ -14,6 +15,12 @@ const workerLog = emojiLogger('worker'); // This is too short, but better to fallback quickly than wait. const WORKER_TIMEOUT = 2_000; +// Handle reload events +window.addEventListener( + EMOJI_DB_RELOAD_EVENT, + () => void handleEmojiDbReload(), +); + export async function initializeEmoji() { log('initializing emojis'); @@ -51,6 +58,8 @@ export async function initializeEmoji() { workerLog(message.message); } else if (type === 'done' && message.storeName === 'custom') { void loadEmojisToStore(); + } else if (type === 'db-blocked') { + window.dispatchEvent(new Event(EMOJI_DB_RELOAD_EVENT)); } if (type !== 'ready') { @@ -131,3 +140,10 @@ async function loadEmojisToStore() { log('loaded emoji data into store'); } + +async function handleEmojiDbReload() { + log('Emoji database reload needed, triggering warning'); + const { store } = await import('@/flavours/glitch/store'); + const { needsReload } = await import('@/flavours/glitch/actions/app'); + store.dispatch(needsReload()); +} diff --git a/app/javascript/flavours/glitch/features/emoji/loader.ts b/app/javascript/flavours/glitch/features/emoji/loader.ts index 3de15be670..3e129b47f0 100644 --- a/app/javascript/flavours/glitch/features/emoji/loader.ts +++ b/app/javascript/flavours/glitch/features/emoji/loader.ts @@ -1,6 +1,8 @@ import { joinShortcodes } from 'emojibase'; import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase'; +import { onceAsyncByArgs } from '@/flavours/glitch/utils/promises'; + import { putEmojiData, putCustomEmojiData, @@ -17,6 +19,12 @@ const log = emojiLogger('loader'); export async function importEmojiData(localeString: string, shortcodes = true) { const locale = toSupportedLocale(localeString); + return importEmojiDataOnce(locale, shortcodes); +} + +const importEmojiDataOnce = onceAsyncByArgs(importEmojiDataImpl); + +async function importEmojiDataImpl(locale: Locale, shortcodes: boolean) { log( 'importing emoji data for locale %s%s', locale, @@ -171,9 +179,7 @@ async function fetchAndCheckEtag({ // Use location.origin as this script may be loaded from a CDN domain. const url = new URL(path, location.origin); - const response = await fetch(url, { - headers, - }); + const response = await fetch(url, { headers }); // If not modified, return null if (response.status === 304) { diff --git a/app/javascript/flavours/glitch/features/emoji/types.ts b/app/javascript/flavours/glitch/features/emoji/types.ts index a5c408903e..6308c7d054 100644 --- a/app/javascript/flavours/glitch/features/emoji/types.ts +++ b/app/javascript/flavours/glitch/features/emoji/types.ts @@ -82,6 +82,7 @@ export type ExtraCustomEmojiMap = Record< export type EmojiWorkerMessage = | { type: 'ready' } + | { type: 'db-blocked' } | { type: 'load'; storeName: string; diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/media_modal.tsx index e035e45d68..4f518e1109 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.tsx +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.tsx @@ -47,11 +47,9 @@ interface MediaModalProps { } const MIN_SWIPE_DISTANCE = 400; +const isLtrDir = getComputedStyle(document.body).direction !== 'rtl'; -export const MediaModal: FC = forwardRef< - HTMLDivElement, - MediaModalProps ->( +export const MediaModal = forwardRef( ( { media, @@ -64,15 +62,16 @@ export const MediaModal: FC = forwardRef< statusId, onChangeBackgroundColor, }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- _ref is required to keep the ref forwarding working _ref, ) => { const [index, setIndex] = useState(startIndex); const [zoomedIn, setZoomedIn] = useState(false); const currentMedia = media.get(index); + const sign = isLtrDir ? '-' : ''; + const [wrapperStyles, api] = useSpring(() => ({ - x: `-${index * 100}%`, + x: `${sign}${index * 100}%`, })); const handleChangeIndex = useCallback( @@ -85,10 +84,12 @@ export const MediaModal: FC = forwardRef< setIndex(newIndex); setZoomedIn(false); if (animate) { - void api.start({ x: `calc(-${newIndex * 100}% + 0px)` }); + void api.start({ + x: `calc(${sign}${newIndex * 100}% + 0px)`, + }); } }, - [api, media.size], + [api, media.size, sign], ); const handlePrevClick = useCallback(() => { handleChangeIndex(index - 1, true); @@ -99,11 +100,14 @@ export const MediaModal: FC = forwardRef< const handleKeyDown = useCallback( (event: KeyboardEvent) => { - if (event.key === 'ArrowLeft') { + const prevKey = isLtrDir ? 'ArrowLeft' : 'ArrowRight'; + const nextKey = isLtrDir ? 'ArrowRight' : 'ArrowLeft'; + + if (event.key === prevKey) { handlePrevClick(); event.preventDefault(); event.stopPropagation(); - } else if (event.key === 'ArrowRight') { + } else if (event.key === nextKey) { handleNextClick(); event.preventDefault(); event.stopPropagation(); @@ -124,13 +128,14 @@ export const MediaModal: FC = forwardRef< active && Math.abs(mx) > Math.min(window.innerWidth / 4, MIN_SWIPE_DISTANCE) ) { - handleChangeIndex(index - xDir); + handleChangeIndex(isLtrDir ? index - xDir : index + xDir); cancel(); } // Set the x position via calc to ensure proper centering regardless of screen size. const x = active ? mx : 0; + const operator = isLtrDir ? '+' : '-'; void api.start({ - x: `calc(-${index * 100}% + ${x}px)`, + x: `calc(${sign}${index * 100}% ${operator} ${x}px)`, }); }, { pointer: { capture: false } }, @@ -161,14 +166,23 @@ export const MediaModal: FC = forwardRef< width: number; height: number; }>({ width: 0, height: 0 }); - const handleRef: RefCallback = useCallback((ele) => { - if (ele?.clientWidth && ele.clientHeight) { - setViewportDimensions({ - width: ele.clientWidth, - height: ele.clientHeight, - }); - } - }, []); + const handleRef: RefCallback = useCallback( + (ele) => { + if (typeof _ref === 'function') { + _ref(ele); + } else if (_ref) { + _ref.current = ele; + } + + if (ele?.clientWidth && ele.clientHeight) { + setViewportDimensions({ + width: ele.clientWidth, + height: ele.clientHeight, + }); + } + }, + [_ref], + ); const zoomable = currentMedia?.get('type') === 'image' && @@ -268,9 +282,8 @@ export const MediaModal: FC = forwardRef< const intl = useIntl(); - const leftNav = media.size > 1 && ( + const prevNav = media.size > 1 && ( ); - const rightNav = media.size > 1 && ( + const nextNav = media.size > 1 && ( ); - const rightNav = media.size > 1 && ( + const nextNav = media.size > 1 && (