Merge pull request #3289 from ClearlyClaire/glitch-soc/merge-4.5

Merge upstream changes up to 5fe316b2e9 into stable-4.5
This commit is contained in:
Claire
2025-11-19 22:59:47 +01:00
committed by GitHub
31 changed files with 290 additions and 225 deletions

View File

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

View File

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

View File

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

View File

@@ -133,16 +133,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}
>

View File

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

View File

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

View File

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

View File

@@ -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;
}
async function fetchAndCheckEtag<ResultType extends object[]>(
localeOrCustom: LocaleOrCustom,
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]();
}
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, {
@@ -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]();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -675,8 +675,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;

View File

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

View File

@@ -180,25 +180,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 });
}
},
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}
async function fetchAndCheckEtag<ResultType extends object[]>(
localeOrCustom: LocaleOrCustom,
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]();
}
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, {
@@ -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]();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -107,6 +107,7 @@ module ViteRails::TagHelpers::IntegrityExtension
stylesheet,
integrity: vite_manifest.integrity_hash_for_file(stylesheet),
media: media,
crossorigin: crossorigin,
**options
)
end

View File

@@ -7961,8 +7961,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"
@@ -7972,7 +7972,7 @@ __metadata:
path-scurry: "npm:^1.11.1"
bin:
glob: dist/esm/bin.mjs
checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e
checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828
languageName: node
linkType: hard
@@ -8962,13 +8962,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