Merge commit 'f69ca085dbfca2253404574dcdc4dc6c2aaa35c0' into glitch-soc/merge-upstream

This commit is contained in:
Claire committed 2025-10-01 19:13:18 +02:00
commit 17e0c745c6
147 files changed
+1188 -808

No files matched your search

@@ -8,6 +8,7 @@ import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio';
import { DisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@@ -33,7 +34,6 @@ import { initMuteModal } from 'mastodon/actions/mutes';
import { initReport } from 'mastodon/actions/reports';
import { Avatar } from 'mastodon/components/avatar';
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
import { Button } from 'mastodon/components/button';
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
import {
FollowersCounter,
@@ -383,7 +383,7 @@ export const AccountHeader: React.FC<{
const isRemote = account?.acct !== account?.username;
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
const menu = useMemo(() => {
const menuItems = useMemo(() => {
const arr: MenuItem[] = [];
if (!account) {
@@ -605,6 +605,15 @@ export const AccountHeader: React.FC<{
handleUnblockDomain,
]);
const menu = accountId !== me && (
<Dropdown
disabled={menuItems.length === 0}
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
);
if (!account) {
return null;
}
@@ -718,21 +727,16 @@ export const AccountHeader: React.FC<{
);
}
if (relationship?.blocking) {
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
if (!isMovedAndUnfollowedAccount) {
actionBtn = (
<Button
text={intl.formatMessage(messages.unblock, {
name: account.username,
})}
onClick={handleBlock}
<FollowButton
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
/>
);
} else {
actionBtn = <FollowButton accountId={accountId} />;
}
if (account.moved && !relationship?.following) {
actionBtn = '';
}
if (account.locked) {
@@ -777,8 +781,8 @@ export const AccountHeader: React.FC<{
<MovedNote accountId={account.id} targetAccountId={account.moved} />
)}
<div
className={classNames('account__header animate-parent', {
<AnimateEmojiProvider
className={classNames('account__header', {
inactive: !!account.moved,
})}
>
@@ -814,18 +818,11 @@ export const AccountHeader: React.FC<{
/>
</a>
<div className='account__header__tabs__buttons'>
<div className='account__header__buttons account__header__buttons--desktop'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{!hidden && shareBtn}
{accountId !== me && (
<Dropdown
disabled={menu.length === 0}
items={menu}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
)}
{!hidden && actionBtn}
{menu}
</div>
</div>
@@ -855,6 +852,12 @@ export const AccountHeader: React.FC<{
<FamiliarFollowers accountId={accountId} />
)}
<div className='account__header__buttons account__header__buttons--mobile'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{menu}
</div>
{!(suspended || hidden) && (
<div className='account__header__extra'>
<div
@@ -967,7 +970,7 @@ export const AccountHeader: React.FC<{
</div>
)}
</div>
</div>
</AnimateEmojiProvider>
{!(hideTabs || hidden) && (
<div className='account__section-headline'>
@@ -25,6 +25,7 @@ import StatusContent from 'mastodon/components/status_content';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { makeGetStatus } from 'mastodon/selectors';
import { LinkedDisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
@@ -136,9 +137,9 @@ export const Conversation = ({ conversation, scrollKey }) => {
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div>
<div className='conversation__content__names animate-parent'>
<AnimateEmojiProvider className='conversation__content__names'>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div>
</AnimateEmojiProvider>
</div>
<StatusContent
@@ -23,8 +23,6 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji';
export const EMOJI_TYPE_UNICODE = 'unicode';
export const EMOJI_TYPE_CUSTOM = 'custom';
export const EMOJI_STATE_MISSING = 'missing';
export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1
'🐜', // 1F41C
@@ -197,11 +197,18 @@ function toLoadedLocale(localeString: string) {
log(`Locale ${locale} is different from provided ${localeString}`);
}
if (!loadedLocales.has(locale)) {
throw new Error(`Locale ${locale} is not loaded in emoji database`);
throw new LocaleNotLoadedError(locale);
}
return locale;
}
export class LocaleNotLoadedError extends Error {
constructor(locale: Locale) {
super(`Locale ${locale} is not loaded in emoji database`);
this.name = 'LocaleNotLoadedError';
}
}
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
if (loadedLocales.has(locale)) {
return true;
@@ -1,70 +0,0 @@
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import classNames from 'classnames';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { useEmojify } from './hooks';
import type { CustomEmojiMapArg } from './types';
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML' | 'className'
> & {
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
shallow?: boolean;
className?: string;
};
export const ModernEmojiHTML = ({
extraEmojis,
htmlString,
as: Wrapper = 'div', // Rename for syntax highlighting
shallow,
className = '',
...props
}: EmojiHTMLProps<ElementType>) => {
const emojifiedHtml = useEmojify({
text: htmlString,
extraEmojis,
deep: !shallow,
});
if (emojifiedHtml === null) {
return null;
}
return (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
dangerouslySetInnerHTML={{ __html: emojifiedHtml }}
/>
);
};
export const EmojiHTML = <Element extends ElementType>(
props: EmojiHTMLProps<Element>,
) => {
if (isModernEmojiEnabled()) {
return <ModernEmojiHTML {...props} />;
}
const {
as: asElement,
htmlString,
extraEmojis,
className,
shallow: _,
...rest
} = props;
const Wrapper = asElement ?? 'div';
return (
<Wrapper
{...rest}
dangerouslySetInnerHTML={{ __html: htmlString }}
className={classNames(className, 'animate-parent')}
/>
);
};
@@ -1,4 +1,4 @@
import initialState from '@/mastodon/initial_state';
import { initialState } from '@/mastodon/initial_state';
import { loadWorker } from '@/mastodon/utils/workers';
import { toSupportedLocale } from './locale';
@@ -1,8 +1,6 @@
import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import {
putEmojiData,
putCustomEmojiData,
@@ -10,7 +8,7 @@ import {
putLatestEtag,
} from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { LocaleOrCustom } from './types';
import type { CustomEmojiData, LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
const log = emojiLogger('loader');
@@ -27,7 +25,7 @@ export async function importEmojiData(localeString: string) {
}
export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom');
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>('custom');
if (!emojis) {
return;
}
@@ -5,11 +5,8 @@ import { flattenEmojiData } from 'emojibase';
import unicodeRawEmojis from 'emojibase-data/en/data.json';
import {
twemojiHasBorder,
twemojiToUnicodeInfo,
unicodeToTwemojiHex,
CODES_WITH_DARK_BORDER,
CODES_WITH_LIGHT_BORDER,
emojiToUnicodeHex,
} from './normalize';
@@ -57,26 +54,6 @@ describe('unicodeToTwemojiHex', () => {
});
});
describe('twemojiHasBorder', () => {
test.concurrent.for(
svgFileNames
.filter((file) => file.endsWith('_border'))
.map((file) => {
const hexCode = file.replace('_border', '');
return [
hexCode,
CODES_WITH_LIGHT_BORDER.includes(hexCode.toUpperCase()),
CODES_WITH_DARK_BORDER.includes(hexCode.toUpperCase()),
] as const;
}),
)('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => {
const result = twemojiHasBorder(hexCode);
expect(result).toHaveProperty('hexCode', hexCode);
expect(result).toHaveProperty('hasLightBorder', isLight);
expect(result).toHaveProperty('hasDarkBorder', isDark);
});
});
describe('twemojiToUnicodeInfo', () => {
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));
@@ -1,5 +1,7 @@
import { isList } from 'immutable';
import { assetHost } from '@/mastodon/utils/config';
import {
VARIATION_SELECTOR_CODE,
KEYCAP_CODE,
@@ -9,11 +11,7 @@ import {
EMOJIS_WITH_DARK_BORDER,
EMOJIS_WITH_LIGHT_BORDER,
} from './constants';
import type {
CustomEmojiMapArg,
ExtraCustomEmojiMap,
TwemojiBorderInfo,
} from './types';
import type { CustomEmojiMapArg, ExtraCustomEmojiMap } from './types';
// Misc codes that have special handling
const SKIER_CODE = 0x26f7;
@@ -67,21 +65,17 @@ export const CODES_WITH_DARK_BORDER =
export const CODES_WITH_LIGHT_BORDER =
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo {
const normalizedHex = twemojiHex.toUpperCase();
let hasLightBorder = false;
let hasDarkBorder = false;
if (CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
hasLightBorder = true;
export function unicodeHexToUrl(unicodeHex: string, darkMode: boolean): string {
const normalizedHex = unicodeToTwemojiHex(unicodeHex);
let url = `${assetHost}/emoji/${normalizedHex}`;
if (darkMode && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
url += '_border';
}
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
hasDarkBorder = true;
url += '_border';
}
return {
hexCode: twemojiHex,
hasLightBorder,
hasDarkBorder,
};
url += '.svg';
return url;
}
interface TwemojiSpecificEmoji {
@@ -1,10 +1,6 @@
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import { EMOJI_MODE_TWEMOJI } from './constants';
import * as db from './database';
import {
emojifyElement,
@@ -12,7 +8,7 @@ import {
testCacheClear,
tokenizeText,
} from './render';
import type { EmojiAppState, ExtraCustomEmojiMap } from './types';
import type { EmojiAppState } from './types';
function mockDatabase() {
return {
@@ -40,18 +36,6 @@ const expectedSmileImage =
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
const expectedFlagImage =
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
const expectedCustomEmojiImage =
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/custom/static" data-original="emoji/custom" data-static="emoji/custom/static">';
const expectedRemoteCustomEmojiImage =
'<img draggable="false" class="emojione custom-emoji" alt=":remote:" title=":remote:" src="remote.social/static" data-original="remote.social/custom" data-static="remote.social/static">';
const mockExtraCustom: ExtraCustomEmojiMap = {
remote: {
shortcode: 'remote',
static_url: 'remote.social/static',
url: 'remote.social/custom',
},
};
function testAppState(state: Partial<EmojiAppState> = {}) {
return {
@@ -86,64 +70,10 @@ describe('emojifyElement', () => {
'en',
);
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
'custom',
':custom:',
]);
});
test('emojifies custom emoji in native mode', async () => {
const { searchEmojisByHexcodes } = mockDatabase();
const actual = await emojifyElement(
testElement(),
testAppState({ mode: EMOJI_MODE_NATIVE }),
);
assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
});
test('emojifies flag emoji in native-with-flags mode', async () => {
const { searchEmojisByHexcodes } = mockDatabase();
const actual = await emojifyElement(
testElement(),
testAppState({ mode: EMOJI_MODE_NATIVE_WITH_FLAGS }),
);
assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
});
test('emojifies everything in twemoji mode', async () => {
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
mockDatabase();
const actual = await emojifyElement(testElement(), testAppState());
assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledOnce();
});
test('emojifies with provided custom emoji', async () => {
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
mockDatabase();
const actual = await emojifyElement(
testElement('<p>hi :remote:</p>'),
testAppState(),
mockExtraCustom,
);
assert(actual);
expect(actual.innerHTML).toBe(
`<p>hi ${expectedRemoteCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
expect(searchCustomEmojisByShortcodes).not.toHaveBeenCalled();
});
test('returns null when no emoji are found', async () => {
mockDatabase();
const actual = await emojifyElement(
@@ -165,28 +95,9 @@ describe('emojifyText', () => {
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
});
test('renders custom emojis', async () => {
mockDatabase();
const actual = await emojifyText('Hello :custom:!', testAppState());
expect(actual).toBe(`Hello ${expectedCustomEmojiImage}!`);
});
test('renders provided extra emojis', async () => {
const actual = await emojifyText(
'remote emoji :remote:',
testAppState(),
mockExtraCustom,
);
expect(actual).toBe(`remote emoji ${expectedRemoteCustomEmojiImage}`);
});
});
describe('tokenizeText', () => {
test('returns empty array for string with only whitespace', () => {
expect(tokenizeText(' \n')).toEqual([]);
});
test('returns an array of text to be a single token', () => {
expect(tokenizeText('Hello')).toEqual(['Hello']);
});
@@ -212,7 +123,7 @@ describe('tokenizeText', () => {
'Hello ',
{
type: 'custom',
code: 'smile',
code: ':smile:',
},
'!!',
]);
@@ -223,7 +134,7 @@ describe('tokenizeText', () => {
'Hello ',
{
type: 'custom',
code: 'smile_123',
code: ':smile_123:',
},
'!!',
]);
@@ -239,7 +150,7 @@ describe('tokenizeText', () => {
' ',
{
type: 'custom',
code: 'smile',
code: ':smile:',
},
'!!',
]);
+124 -42
View File
@@ -1,6 +1,5 @@
import { autoPlayGif } from '@/mastodon/initial_state';
import { createLimitedCache } from '@/mastodon/utils/cache';
import { assetHost } from '@/mastodon/utils/config';
import * as perf from '@/mastodon/utils/performance';
import {
@@ -8,38 +7,130 @@ import {
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM,
EMOJI_STATE_MISSING,
} from './constants';
import {
loadCustomEmojiByShortcode,
loadEmojiByHexcode,
LocaleNotLoadedError,
searchCustomEmojisByShortcodes,
searchEmojisByHexcodes,
} from './database';
import {
emojiToUnicodeHex,
twemojiHasBorder,
unicodeToTwemojiHex,
} from './normalize';
import { importEmojiData } from './loader';
import { emojiToUnicodeHex, unicodeHexToUrl } from './normalize';
import type {
CustomEmojiToken,
EmojiAppState,
EmojiLoadedState,
EmojiMode,
EmojiState,
EmojiStateCustom,
EmojiStateMap,
EmojiToken,
EmojiStateUnicode,
ExtraCustomEmojiMap,
LocaleOrCustom,
UnicodeEmojiToken,
} from './types';
import {
anyEmojiRegex,
emojiLogger,
isCustomEmoji,
isUnicodeEmoji,
stringHasAnyEmoji,
stringHasUnicodeFlags,
} from './utils';
const log = emojiLogger('render');
/**
* Parses emoji string to extract emoji state.
* @param code Hex code or custom shortcode.
* @param customEmoji Extra custom emojis.
*/
export function stringToEmojiState(
code: string,
customEmoji: ExtraCustomEmojiMap = {},
): EmojiState | null {
if (isUnicodeEmoji(code)) {
return {
type: EMOJI_TYPE_UNICODE,
code: emojiToUnicodeHex(code),
};
}
if (isCustomEmoji(code)) {
const shortCode = code.slice(1, -1);
return {
type: EMOJI_TYPE_CUSTOM,
code: shortCode,
data: customEmoji[shortCode],
};
}
return null;
}
/**
* Loads emoji data into the given state if not already loaded.
* @param state Emoji state to load data for.
* @param locale Locale to load data for. Only for Unicode emoji.
* @param retry Internal. Whether this is a retry after loading the locale.
*/
export async function loadEmojiDataToState(
state: EmojiState,
locale: string,
retry = false,
): Promise<EmojiLoadedState | null> {
if (isStateLoaded(state)) {
return state;
}
// First, try to load the data from IndexedDB.
try {
// This is duplicative, but that's because TS can't distinguish the state type easily.
if (state.type === EMOJI_TYPE_UNICODE) {
const data = await loadEmojiByHexcode(state.code, locale);
if (data) {
return {
...state,
data,
};
}
} else {
const data = await loadCustomEmojiByShortcode(state.code);
if (data) {
return {
...state,
data,
};
}
}
// If not found, assume it's not an emoji and return null.
log(
'Could not find emoji %s of type %s for locale %s',
state.code,
state.type,
locale,
);
return null;
} catch (err: unknown) {
// If the locale is not loaded, load it and retry once.
if (!retry && err instanceof LocaleNotLoadedError) {
log(
'Error loading emoji %s for locale %s, loading locale and retrying.',
state.code,
locale,
);
await importEmojiData(locale); // Use this from the loader file as it can be awaited.
return loadEmojiDataToState(state, locale, true);
}
console.warn('Error loading emoji data, not retrying:', state, locale, err);
return null;
}
}
export function isStateLoaded(state: EmojiState): state is EmojiLoadedState {
return !!state.data;
}
/**
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
*/
@@ -177,7 +268,11 @@ async function textToElementArray(
if (token.type === EMOJI_TYPE_CUSTOM) {
const extraEmojiData = extraEmojis[token.code];
if (extraEmojiData) {
state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData };
state = {
type: EMOJI_TYPE_CUSTOM,
data: extraEmojiData,
code: token.code,
};
} else {
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
}
@@ -189,7 +284,7 @@ async function textToElementArray(
}
// If the state is valid, create an image element. Otherwise, just append as text.
if (state && typeof state !== 'string') {
if (state && typeof state !== 'string' && isStateLoaded(state)) {
const image = stateToImage(state, appState);
renderedFragments.push(image);
continue;
@@ -202,11 +297,11 @@ async function textToElementArray(
return renderedFragments;
}
type TokenizedText = (string | EmojiToken)[];
type TokenizedText = (string | EmojiState)[];
export function tokenizeText(text: string): TokenizedText {
if (!text.trim()) {
return [];
return [text];
}
const tokens = [];
@@ -222,14 +317,14 @@ export function tokenizeText(text: string): TokenizedText {
// Custom emoji
tokens.push({
type: EMOJI_TYPE_CUSTOM,
code: code.slice(1, -1), // Remove the colons
} satisfies CustomEmojiToken);
code,
} satisfies EmojiStateCustom);
} else {
// Unicode emoji
tokens.push({
type: EMOJI_TYPE_UNICODE,
code: code,
} satisfies UnicodeEmojiToken);
} satisfies EmojiStateUnicode);
}
lastIndex = match.index + code.length;
}
@@ -304,13 +399,11 @@ async function loadMissingEmojiIntoCache(
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
const cache = cacheForLocale(currentLocale);
for (const emoji of emojis) {
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
}
const notFoundEmojis = missingEmojis.filter((code) =>
emojis.every((emoji) => emoji.hexcode !== code),
);
for (const code of notFoundEmojis) {
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
cache.set(emoji.hexcode, {
type: EMOJI_TYPE_UNICODE,
data: emoji,
code: emoji.hexcode,
});
}
localeCacheMap.set(currentLocale, cache);
}
@@ -320,19 +413,17 @@ async function loadMissingEmojiIntoCache(
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
for (const emoji of emojis) {
cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji });
}
const notFoundEmojis = missingEmojis.filter((code) =>
emojis.every((emoji) => emoji.shortcode !== code),
);
for (const code of notFoundEmojis) {
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
cache.set(emoji.shortcode, {
type: EMOJI_TYPE_CUSTOM,
data: emoji,
code: emoji.shortcode,
});
}
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
}
}
function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
export function shouldRenderImage(token: EmojiState, mode: EmojiMode): boolean {
if (token.type === EMOJI_TYPE_UNICODE) {
// If the mode is native or native with flags for non-flag emoji
// we can just append the text node directly.
@@ -354,18 +445,9 @@ function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
image.classList.add('emojione');
if (state.type === EMOJI_TYPE_UNICODE) {
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
let fileName = emojiInfo.hexCode;
if (
(appState.darkTheme && emojiInfo.hasDarkBorder) ||
(!appState.darkTheme && emojiInfo.hasLightBorder)
) {
fileName = `${emojiInfo.hexCode}_border`;
}
image.alt = state.data.unicode;
image.title = state.data.label;
image.src = `${assetHost}/emoji/${fileName}.svg`;
image.src = unicodeHexToUrl(state.data.hexcode, appState.darkTheme);
} else {
// Custom emoji
const shortCode = `:${state.data.shortcode}:`;
+17 -23
View File
@@ -10,7 +10,6 @@ import type {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
EMOJI_STATE_MISSING,
EMOJI_TYPE_CUSTOM,
EMOJI_TYPE_UNICODE,
} from './constants';
@@ -29,45 +28,40 @@ export interface EmojiAppState {
darkTheme: boolean;
}
export interface UnicodeEmojiToken {
type: typeof EMOJI_TYPE_UNICODE;
code: string;
}
export interface CustomEmojiToken {
type: typeof EMOJI_TYPE_CUSTOM;
code: string;
}
export type EmojiToken = UnicodeEmojiToken | CustomEmojiToken;
export type CustomEmojiData = ApiCustomEmojiJSON;
export type UnicodeEmojiData = FlatCompactEmoji;
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
export type EmojiStateMissing = typeof EMOJI_STATE_MISSING;
type CustomEmojiRenderFields = Pick<
CustomEmojiData,
'shortcode' | 'static_url' | 'url'
>;
export interface EmojiStateUnicode {
type: typeof EMOJI_TYPE_UNICODE;
data: UnicodeEmojiData;
code: string;
data?: UnicodeEmojiData;
}
export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM;
data: CustomEmojiRenderFields;
code: string;
data?: CustomEmojiRenderFields;
}
export type EmojiState =
| EmojiStateMissing
| EmojiStateUnicode
| EmojiStateCustom;
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiLoadedState =
| Required<EmojiStateUnicode>
| Required<EmojiStateCustom>;
export type EmojiStateMap = LimitedCache<string, EmojiState>;
export type CustomEmojiMapArg =
| ExtraCustomEmojiMap
| ImmutableList<CustomEmoji>;
export type CustomEmojiRenderFields = Pick<
CustomEmojiData,
'shortcode' | 'static_url' | 'url'
export type ExtraCustomEmojiMap = Record<
string,
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
>;
export type ExtraCustomEmojiMap = Record<string, CustomEmojiRenderFields>;
export interface TwemojiBorderInfo {
hexCode: string;
@@ -10,6 +10,13 @@ export function stringHasUnicodeEmoji(input: string): boolean {
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
}
export function isUnicodeEmoji(input: string): boolean {
return (
input.length > 0 &&
new RegExp(`^(${EMOJI_REGEX})+$`, supportedFlags()).test(input)
);
}
export function stringHasUnicodeFlags(input: string): boolean {
if (supportsRegExpSets()) {
return new RegExp(
@@ -27,6 +34,11 @@ export function stringHasUnicodeFlags(input: string): boolean {
// Constant as this is supported by all browsers.
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
export function isCustomEmoji(input: string): boolean {
return new RegExp(`^${CUSTOM_EMOJI_REGEX.source}$`, 'i').test(input);
}
export function stringHasCustomEmoji(input: string) {
return CUSTOM_EMOJI_REGEX.test(input);
}
@@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom';
import type { List as ImmutableList, RecordOf } from 'immutable';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
@@ -96,8 +97,8 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
).size;
return (
<div
className='notification-group__embedded-status animate-parent'
<AnimateEmojiProvider
className='notification-group__embedded-status'
role='button'
tabIndex={-1}
onMouseDown={handleMouseDown}
@@ -148,6 +149,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
)}
</div>
)}
</div>
</AnimateEmojiProvider>
);
};
@@ -2,25 +2,19 @@ import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { useHistory } from 'react-router-dom';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import { replyCompose } from 'mastodon/actions/compose';
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
import { toggleFavourite } from 'mastodon/actions/interactions';
import { openModal } from 'mastodon/actions/modal';
import { IconButton } from 'mastodon/components/icon_button';
import { BoostButton } from 'mastodon/components/status/boost_button';
import { useIdentity } from 'mastodon/identity_context';
import { me } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import type { Status } from 'mastodon/models/status';
import { makeGetStatus } from 'mastodon/selectors';
@@ -120,29 +114,6 @@ export const Footer: React.FC<{
}
}, [dispatch, status, signedIn]);
const handleReblogClick = useCallback(
(e: React.MouseEvent) => {
if (!status) {
return;
}
if (signedIn) {
dispatch(toggleReblog(status.get('id'), e.shiftKey));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
},
[dispatch, status, signedIn],
);
const handleOpenClick = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0 || !status) {
@@ -160,13 +131,6 @@ export const Footer: React.FC<{
return null;
}
const publicStatus = ['public', 'unlisted'].includes(
status.get('visibility') as string,
);
const reblogPrivate =
status.getIn(['account', 'id']) === me &&
status.get('visibility') === 'private';
let replyIcon, replyIconComponent, replyTitle;
if (status.get('in_reply_to_id', null) === null) {
@@ -179,24 +143,6 @@ export const Footer: React.FC<{
replyTitle = intl.formatMessage(messages.replyAll);
}
let reblogTitle, reblogIconComponent;
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus
? RepeatActiveIcon
: RepeatPrivateActiveIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
reblogIconComponent = RepeatPrivateIcon;
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
reblogIconComponent = RepeatDisabledIcon;
}
const favouriteTitle = intl.formatMessage(
status.get('favourited') ? messages.removeFavourite : messages.favourite,
);
@@ -222,19 +168,7 @@ export const Footer: React.FC<{
counter={status.get('replies_count') as number}
/>
<IconButton
className={classNames('status__action-bar-button', { reblogPrivate })}
disabled={!publicStatus && !reblogPrivate}
active={status.get('reblogged') as boolean}
title={reblogTitle}
icon='retweet'
iconComponent={reblogIconComponent}
onClick={handleReblogClick}
counter={
(status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
}
/>
<BoostButton counters status={status} />
<IconButton
className='status__action-bar-button star-icon'
@@ -12,6 +12,8 @@ import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import { useIdentity } from 'mastodon/identity_context';
import { domain } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import Column from '../ui/components/column';
@@ -31,9 +33,18 @@ export const Quotes: React.FC<{
const statusId = params?.statusId;
const { accountId: me } = useIdentity();
const isCorrectStatusId: boolean = useAppSelector(
(state) => state.status_lists.getIn(['quotes', 'statusId']) === statusId,
);
const quotedAccountId = useAppSelector(
(state) =>
state.statuses.getIn([statusId, 'account']) as string | undefined,
);
const quotedAccount = useAppSelector((state) =>
quotedAccountId ? state.accounts.get(quotedAccountId) : undefined,
);
const statusIds = useAppSelector((state) =>
state.status_lists.getIn(['quotes', 'items'], emptyList),
);
@@ -74,6 +85,32 @@ export const Quotes: React.FC<{
/>
);
let prependMessage;
if (me === quotedAccountId) {
prependMessage = null;
} else if (quotedAccount?.username === quotedAccount?.acct) {
// Local account, we know this to be exhaustive
prependMessage = (
<div className='follow_requests-unlocked_explanation'>
<FormattedMessage
id='status.quotes.local_other_disclaimer'
defaultMessage='Quotes rejected by the author will not be shown.'
/>
</div>
);
} else {
prependMessage = (
<div className='follow_requests-unlocked_explanation'>
<FormattedMessage
id='status.quotes.remote_other_disclaimer'
defaultMessage='Only quotes from {domain} are guaranteed to be shown here. Quotes rejected by the author will not be shown.'
values={{ domain: <strong>{domain}</strong> }}
/>
</div>
);
}
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
@@ -100,6 +137,7 @@ export const Quotes: React.FC<{
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
prepend={prependMessage}
/>
<Helmet>
@@ -11,7 +11,7 @@ import { hydrateStore } from 'mastodon/actions/store';
import { Router } from 'mastodon/components/router';
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
import { useRenderSignal } from 'mastodon/hooks/useRenderSignal';
import initialState from 'mastodon/initial_state';
import { initialState } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
import { store, useAppSelector, useAppDispatch } from 'mastodon/store';
@@ -31,7 +31,7 @@ import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { Audio } from 'mastodon/features/audio';
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
import { Video } from 'mastodon/features/video';
import { me } from 'mastodon/initial_state';
import { useIdentity } from 'mastodon/identity_context';
import Card from './card';
@@ -75,6 +75,8 @@ export const DetailedStatus: React.FC<{
const [showDespiteFilter, setShowDespiteFilter] = useState(false);
const nodeRef = useRef<HTMLDivElement>();
const { signedIn } = useIdentity();
const handleOpenVideo = useCallback(
(options: VideoModalOptions) => {
const lang = (status.getIn(['translation', 'language']) ||
@@ -283,7 +285,7 @@ export const DetailedStatus: React.FC<{
if (['private', 'direct'].includes(status.get('visibility') as string)) {
quotesLink = '';
} else if (status.getIn(['account', 'id']) === me) {
} else if (signedIn) {
quotesLink = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/quotes`}
@@ -11,7 +11,7 @@ export interface BaseConfirmationModalProps {
export const ConfirmationModal: React.FC<
{
title: React.ReactNode;
message: React.ReactNode;
message?: React.ReactNode;
confirm: React.ReactNode;
cancel?: React.ReactNode;
secondary?: React.ReactNode;
@@ -48,7 +48,7 @@ export const ConfirmationModal: React.FC<
<div className='safety-action-modal__top'>
<div className='safety-action-modal__confirmation'>
<h1>{title}</h1>
<p>{message}</p>
{message && <p>{message}</p>}
</div>
</div>
@@ -5,7 +5,9 @@ export {
ConfirmReplyModal,
ConfirmEditStatusModal,
} from './discard_draft_confirmation';
export { ConfirmWithdrawRequestModal } from './withdraw_follow_request';
export { ConfirmUnfollowModal } from './unfollow';
export { ConfirmUnblockModal } from './unblock';
export { ConfirmClearNotificationsModal } from './clear_notifications';
export { ConfirmLogOutModal } from './log_out';
export { ConfirmFollowToListModal } from './follow_to_list';
@@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { unblockAccount } from 'mastodon/actions/accounts';
import type { Account } from 'mastodon/models/account';
import { useAppDispatch } from 'mastodon/store';
import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
unblockConfirm: {
id: 'confirmations.unblock.confirm',
defaultMessage: 'Unblock',
},
});
export const ConfirmUnblockModal: React.FC<
{
account: Account;
} & BaseConfirmationModalProps
> = ({ account, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const onConfirm = useCallback(() => {
dispatch(unblockAccount(account.id));
}, [dispatch, account.id]);
return (
<ConfirmationModal
title={
<FormattedMessage
id='confirmations.unblock.title'
defaultMessage='Unblock {name}?'
values={{ name: `@${account.acct}` }}
/>
}
confirm={intl.formatMessage(messages.unblockConfirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};
@@ -10,10 +10,6 @@ import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
unfollowTitle: {
id: 'confirmations.unfollow.title',
defaultMessage: 'Unfollow user?',
},
unfollowConfirm: {
id: 'confirmations.unfollow.confirm',
defaultMessage: 'Unfollow',
@@ -34,12 +30,11 @@ export const ConfirmUnfollowModal: React.FC<
return (
<ConfirmationModal
title={intl.formatMessage(messages.unfollowTitle)}
message={
title={
<FormattedMessage
id='confirmations.unfollow.message'
defaultMessage='Are you sure you want to unfollow {name}?'
values={{ name: <strong>@{account.acct}</strong> }}
id='confirmations.unfollow.title'
defaultMessage='Unfollow {name}?'
values={{ name: `@${account.acct}` }}
/>
}
confirm={intl.formatMessage(messages.unfollowConfirm)}
@@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { unfollowAccount } from 'mastodon/actions/accounts';
import type { Account } from 'mastodon/models/account';
import { useAppDispatch } from 'mastodon/store';
import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
withdrawConfirm: {
id: 'confirmations.withdraw_request.confirm',
defaultMessage: 'Withdraw request',
},
});
export const ConfirmWithdrawRequestModal: React.FC<
{
account: Account;
} & BaseConfirmationModalProps
> = ({ account, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const onConfirm = useCallback(() => {
dispatch(unfollowAccount(account.id));
}, [dispatch, account.id]);
return (
<ConfirmationModal
title={
<FormattedMessage
id='confirmations.withdraw_request.title'
defaultMessage='Withdraw request to follow {name}?'
values={{ name: `@${account.acct}` }}
/>
}
confirm={intl.formatMessage(messages.withdrawConfirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};
@@ -32,7 +32,9 @@ import {
ConfirmDeleteListModal,
ConfirmReplyModal,
ConfirmEditStatusModal,
ConfirmUnblockModal,
ConfirmUnfollowModal,
ConfirmWithdrawRequestModal,
ConfirmClearNotificationsModal,
ConfirmLogOutModal,
ConfirmFollowToListModal,
@@ -57,7 +59,9 @@ export const MODAL_COMPONENTS = {
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
'CONFIRM_UNBLOCK': () => Promise.resolve({ default: ConfirmUnblockModal }),
'CONFIRM_UNFOLLOW': () => Promise.resolve({ default: ConfirmUnfollowModal }),
'CONFIRM_WITHDRAW_REQUEST': () => Promise.resolve({ default: ConfirmWithdrawRequestModal }),
'CONFIRM_CLEAR_NOTIFICATIONS': () => Promise.resolve({ default: ConfirmClearNotificationsModal }),
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
@@ -27,7 +27,7 @@ import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../act
import { clearHeight } from '../../actions/height_cache';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines';
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state';
import { initialState, me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state';
import BundleColumnError from './components/bundle_column_error';
import { NavigationBar } from './components/navigation_bar';
@@ -1,4 +1,4 @@
import initialState from '@/mastodon/initial_state';
import { initialState } from '@/mastodon/initial_state';
interface FocusColumnOptions {
index?: number;