mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-12 23:38:20 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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]();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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]();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ module ViteRails::TagHelpers::IntegrityExtension
|
||||
stylesheet,
|
||||
integrity: vite_manifest.integrity_hash_for_file(stylesheet),
|
||||
media: media,
|
||||
crossorigin: crossorigin,
|
||||
**options
|
||||
)
|
||||
end
|
||||
|
||||
12
yarn.lock
12
yarn.lock
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user