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

This commit is contained in:
Claire committed 2025-07-10 01:10:32 +02:00
commit e2a867507c
74 files changed
+1009 -208

No files matched your search

@@ -0,0 +1,110 @@
// Utility codes
export const VARIATION_SELECTOR_CODE = 0xfe0f;
export const KEYCAP_CODE = 0x20e3;
// Gender codes
export const GENDER_FEMALE_CODE = 0x2640;
export const GENDER_MALE_CODE = 0x2642;
// Skin tone codes
export const SKIN_TONE_CODES = [
0x1f3fb, // Light skin tone
0x1f3fc, // Medium-light skin tone
0x1f3fd, // Medium skin tone
0x1f3fe, // Medium-dark skin tone
0x1f3ff, // Dark skin tone
] as const;
export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1
'🐜', // 1F41C
'⚫', // 26AB
'🖤', // 1F5A4
'⬛', // 2B1B
'◼️', // 25FC-FE0F
'◾', // 25FE
'◼️', // 25FC-FE0F
'✒️', // 2712-FE0F
'▪️', // 25AA-FE0F
'💣', // 1F4A3
'🎳', // 1F3B3
'📷', // 1F4F7
'📸', // 1F4F8
'♣️', // 2663-FE0F
'🕶️', // 1F576-FE0F
'✴️', // 2734-FE0F
'🔌', // 1F50C
'💂‍♀️', // 1F482-200D-2640-FE0F
'📽️', // 1F4FD-FE0F
'🍳', // 1F373
'🦍', // 1F98D
'💂', // 1F482
'🔪', // 1F52A
'🕳️', // 1F573-FE0F
'🕹️', // 1F579-FE0F
'🕋', // 1F54B
'🖊️', // 1F58A-FE0F
'🖋️', // 1F58B-FE0F
'💂‍♂️', // 1F482-200D-2642-FE0F
'🎤', // 1F3A4
'🎓', // 1F393
'🎥', // 1F3A5
'🎼', // 1F3BC
'♠️', // 2660-FE0F
'🎩', // 1F3A9
'🦃', // 1F983
'📼', // 1F4FC
'📹', // 1F4F9
'🎮', // 1F3AE
'🐃', // 1F403
'🏴', // 1F3F4
'🐞', // 1F41E
'🕺', // 1F57A
'📱', // 1F4F1
'📲', // 1F4F2
'🚲', // 1F6B2
'🪮', // 1FAA6
'🐦‍⬛', // 1F426-200D-2B1B
];
export const EMOJIS_WITH_LIGHT_BORDER = [
'👽', // 1F47D
'⚾', // 26BE
'🐔', // 1F414
'☁️', // 2601-FE0F
'💨', // 1F4A8
'🕊️', // 1F54A-FE0F
'👀', // 1F440
'🍥', // 1F365
'👻', // 1F47B
'🐐', // 1F410
'❕', // 2755
'❔', // 2754
'⛸️', // 26F8-FE0F
'🌩️', // 1F329-FE0F
'🔊', // 1F50A
'🔇', // 1F507
'📃', // 1F4C3
'🌧️', // 1F327-FE0F
'🐏', // 1F40F
'🍚', // 1F35A
'🍙', // 1F359
'🐓', // 1F413
'🐑', // 1F411
'💀', // 1F480
'☠️', // 2620-FE0F
'🌨️', // 1F328-FE0F
'🔉', // 1F509
'🔈', // 1F508
'💬', // 1F4AC
'💭', // 1F4AD
'🏐', // 1F3D0
'🏳️', // 1F3F3-FE0F
'⚪', // 26AA
'⬜', // 2B1C
'◽', // 25FD
'◻️', // 25FB-FE0F
'▫️', // 25AB-FE0F
'🪽', // 1FAE8
'🪿', // 1FABF
];
@@ -0,0 +1,102 @@
import { SUPPORTED_LOCALES } from 'emojibase';
import type { FlatCompactEmoji, Locale } from 'emojibase';
import type { DBSchema } from 'idb';
import { openDB } from 'idb';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type { LocaleOrCustom } from './locale';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
interface EmojiDB extends LocaleTables, DBSchema {
custom: {
key: string;
value: ApiCustomEmojiJSON;
indexes: {
category: string;
};
};
etags: {
key: LocaleOrCustom;
value: string;
};
}
interface LocaleTable {
key: string;
value: FlatCompactEmoji;
indexes: {
group: number;
label: string;
order: number;
tags: string[];
};
}
type LocaleTables = Record<Locale, LocaleTable>;
const SCHEMA_VERSION = 1;
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
customTable.createIndex('category', 'category');
database.createObjectStore('etags');
for (const locale of SUPPORTED_LOCALES) {
const localeTable = database.createObjectStore(locale, {
keyPath: 'hexcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
}
},
});
export async function putEmojiData(emojis: FlatCompactEmoji[], locale: Locale) {
const trx = db.transaction(locale, 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
}
export async function putCustomEmojiData(emojis: ApiCustomEmojiJSON[]) {
const trx = db.transaction('custom', 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
}
export function putLatestEtag(etag: string, localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
return db.put('etags', etag, locale);
}
export function searchEmojiByHexcode(hexcode: string, localeString: string) {
const locale = toSupportedLocale(localeString);
return db.get(locale, hexcode);
}
export function searchEmojiByTag(tag: string, localeString: string) {
const locale = toSupportedLocale(localeString);
const range = IDBKeyRange.only(tag.toLowerCase());
return db.getAllFromIndex(locale, 'tags', range);
}
export function searchCustomEmojiByShortcode(shortcode: string) {
return db.get('custom', shortcode);
}
export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const rowCount = await db.count(locale);
if (!rowCount) {
return null; // No data for this locale, return null even if there is an etag.
}
const etag = await db.get('etags', locale);
return etag ?? null;
}
@@ -0,0 +1,38 @@
import initialState from '@/mastodon/initial_state';
import { toSupportedLocale } from './locale';
const serverLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
const worker =
'Worker' in window
? new Worker(new URL('./worker', import.meta.url), {
type: 'module',
})
: null;
export async function initializeEmoji() {
if (worker) {
worker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === 'ready') {
worker.postMessage(serverLocale);
worker.postMessage('custom');
}
});
} else {
const { importCustomEmojiData, importEmojiData } = await import('./loader');
await Promise.all([importCustomEmojiData(), importEmojiData(serverLocale)]);
}
}
export async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
if (worker) {
worker.postMessage(locale);
} else {
const { importEmojiData } = await import('./loader');
await importEmojiData(locale);
}
}
@@ -0,0 +1,77 @@
import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { isDevelopment } from '@/mastodon/utils/environment';
import {
putEmojiData,
putCustomEmojiData,
loadLatestEtag,
putLatestEtag,
} from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { LocaleOrCustom } from './locale';
export async function importEmojiData(localeString: string) {
const locale = toSupportedLocale(localeString);
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale);
if (!emojis) {
return;
}
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
await putEmojiData(flattenedEmojis, locale);
}
export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom');
if (!emojis) {
return;
}
await putCustomEmojiData(emojis);
}
async function fetchAndCheckEtag<ResultType extends object[]>(
localeOrCustom: LocaleOrCustom,
): Promise<ResultType | null> {
const locale = toSupportedLocaleOrCustom(localeOrCustom);
let uri: string;
if (locale === 'custom') {
uri = '/api/v1/custom_emojis';
} else {
uri = `/packs${isDevelopment() ? '-dev' : ''}/emoji/${locale}.json`;
}
const oldEtag = await loadLatestEtag(locale);
const response = await fetch(uri, {
headers: {
'Content-Type': 'application/json',
'If-None-Match': oldEtag ?? '', // Send the old ETag to check for modifications
},
});
// If not modified, return null
if (response.status === 304) {
return null;
}
if (!response.ok) {
throw new Error(
`Failed to fetch emoji data for ${localeOrCustom}: ${response.statusText}`,
);
}
const data = (await response.json()) as ResultType;
if (!Array.isArray(data)) {
throw new Error(
`Unexpected data format for ${localeOrCustom}: expected an array`,
);
}
// Store the ETag for future requests
const etag = response.headers.get('ETag');
if (etag) {
await putLatestEtag(etag, localeOrCustom);
}
return data;
}
@@ -1,52 +1,6 @@
import { flattenEmojiData, SUPPORTED_LOCALES } from 'emojibase';
import emojiEnData from 'emojibase-data/en/compact.json';
import emojiFrData from 'emojibase-data/fr/compact.json';
import { SUPPORTED_LOCALES } from 'emojibase';
import { toSupportedLocale, unicodeToLocaleLabel } from './locale';
describe('unicodeToLocaleLabel', () => {
const emojiTestCases = [
'1F3CB-1F3FF-200D-2640-FE0F', // 🏋🏿‍♀️ Woman weightlifter, dark skin
'1F468-1F3FB', // 👨🏻 Man, light skin
'1F469-1F3FB-200D-2695-FE0F', // 👩🏻‍⚕️ Woman health worker, light skin
'1F468-1F3FD-200D-1F692', // 👨🏽‍🚒 Man firefighter, medium skin
'1F469-1F3FE', // 👩🏾 Woman, medium-dark skin
'1F469-1F3FF-200D-1F4BB', // 👩🏿‍💻 Woman technologist, dark skin
'1F478-1F3FF', // 👸🏿 Princess with dark skin tone
'1F935-1F3FC-200D-2640-FE0F', // 🤵🏼‍♀️ Woman in tuxedo, medium-light skin
'1F9D1-1F3FC', // 🧑🏼 Person, medium-light skin
'1F9D4-1F3FE', // 🧔🏾 Person with beard, medium-dark skin
];
const flattenedEnData = flattenEmojiData(emojiEnData);
const flattenedFrData = flattenEmojiData(emojiFrData);
const emojiTestEnLabels = new Map(
emojiTestCases.map((code) => [
code,
flattenedEnData.find((emoji) => emoji.hexcode === code)?.label,
]),
);
const emojiTestFrLabels = new Map(
emojiTestCases.map((code) => [
code,
flattenedFrData.find((emoji) => emoji.hexcode === code)?.label,
]),
);
test.for(
emojiTestCases.flatMap((code) => [
[code, 'en', emojiTestEnLabels.get(code)],
[code, 'fr', emojiTestFrLabels.get(code)],
]) satisfies [string, string, string | undefined][],
)(
'returns correct label for %s for %s locale',
async ([unicodeHex, locale, expectedLabel]) => {
const label = await unicodeToLocaleLabel(unicodeHex, locale);
expect(label).toBe(expectedLabel);
},
);
});
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
describe('toSupportedLocale', () => {
test('returns the same locale if it is supported', () => {
@@ -62,3 +16,14 @@ describe('toSupportedLocale', () => {
}
});
});
describe('toSupportedLocaleOrCustom', () => {
test('returns custom for "custom" locale', () => {
expect(toSupportedLocaleOrCustom('custom')).toBe('custom');
});
test('returns supported locale for valid locales', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(toSupportedLocaleOrCustom(locale)).toBe(locale);
}
});
});
@@ -1,51 +1,23 @@
import type { CompactEmoji, Locale } from 'emojibase';
import { flattenEmojiData, SUPPORTED_LOCALES } from 'emojibase';
import type { Locale } from 'emojibase';
import { SUPPORTED_LOCALES } from 'emojibase';
// Simple cache. This will be replaced with an IndexedDB cache in the future.
const localeCache = new Map<Locale, Map<string, CompactEmoji>>();
export type LocaleOrCustom = Locale | 'custom';
export async function unicodeToLocaleLabel(
unicodeHex: string,
localeString: string,
) {
const locale = toSupportedLocale(localeString);
let hexMap = localeCache.get(locale);
if (!hexMap) {
hexMap = await loadLocaleLabels(locale);
localeCache.set(locale, hexMap);
}
const label = hexMap.get(unicodeHex)?.label;
if (!label) {
throw new Error(
`Label for unicode hex ${unicodeHex} not found in locale ${locale}`,
);
}
return label;
}
async function loadLocaleLabels(
locale: Locale,
): Promise<Map<string, CompactEmoji>> {
const { default: localeEmoji } = ((await import(
`emojibase-data/${locale}/compact.json`
)) ?? { default: [] }) as { default: CompactEmoji[] };
if (!Array.isArray(localeEmoji)) {
throw new Error(`Locale data for ${locale} not found`);
}
const hexMapEntries = flattenEmojiData(localeEmoji).map(
(emoji) => [emoji.hexcode, emoji] satisfies [string, CompactEmoji],
);
return new Map(hexMapEntries);
}
export function toSupportedLocale(locale: string): Locale {
export function toSupportedLocale(localeBase: string): Locale {
const locale = localeBase.toLowerCase();
if (isSupportedLocale(locale)) {
return locale;
}
return 'en'; // Default to English if unsupported
}
export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom {
if (locale.toLowerCase() === 'custom') {
return 'custom';
}
return toSupportedLocale(locale);
}
function isSupportedLocale(locale: string): locale is Locale {
return SUPPORTED_LOCALES.includes(locale as Locale);
return SUPPORTED_LOCALES.includes(locale.toLowerCase() as Locale);
}
@@ -1,9 +1,17 @@
import { readdir } from 'fs/promises';
import { basename, resolve } from 'path';
import unicodeEmojis from 'emojibase-data/en/data.json';
import { flattenEmojiData } from 'emojibase';
import unicodeRawEmojis from 'emojibase-data/en/data.json';
import { twemojiToUnicodeInfo, unicodeToTwemojiHex } from './normalize';
import {
twemojiHasBorder,
twemojiToUnicodeInfo,
unicodeToTwemojiHex,
CODES_WITH_DARK_BORDER,
CODES_WITH_LIGHT_BORDER,
emojiToUnicodeHex,
} from './normalize';
const emojiSVGFiles = await readdir(
// This assumes tests are run from project root
@@ -13,60 +21,81 @@ const emojiSVGFiles = await readdir(
},
);
const svgFileNames = emojiSVGFiles
.filter(
(file) =>
file.isFile() &&
file.name.endsWith('.svg') &&
!file.name.endsWith('_border.svg'),
)
.filter((file) => file.isFile() && file.name.endsWith('.svg'))
.map((file) => basename(file.name, '.svg').toUpperCase());
const svgFileNamesWithoutBorder = svgFileNames.filter(
(fileName) => !fileName.endsWith('_BORDER'),
);
describe('normalizeEmoji', () => {
describe('unicodeToSVGName', () => {
test.concurrent.for(
unicodeEmojis
// Our version of Twemoji only supports up to version 15.1
.filter((emoji) => emoji.version < 16)
.map((emoji) => [emoji.hexcode, emoji.label] as [string, string]),
)('verifying an emoji exists for %s (%s)', ([hexcode], { expect }) => {
const result = unicodeToTwemojiHex(hexcode);
expect(svgFileNames).toContain(result);
});
});
const unicodeEmojis = flattenEmojiData(unicodeRawEmojis);
describe('twemojiToUnicodeInfo', () => {
const unicodeMap = new Map(
unicodeEmojis.flatMap((emoji) => {
const base: [string, string][] = [[emoji.hexcode, emoji.label]];
if (emoji.skins) {
base.push(
...emoji.skins.map(
(skin) => [skin.hexcode, skin.label] as [string, string],
),
);
}
return base;
}),
);
describe('emojiToUnicodeHex', () => {
test.concurrent.for([
['🎱', '1F3B1'],
['🐜', '1F41C'],
['⚫', '26AB'],
['🖤', '1F5A4'],
['💀', '1F480'],
['💂‍♂️', '1F482-200D-2642-FE0F'],
] as const)(
'emojiToUnicodeHex converts %s to %s',
([emoji, hexcode], { expect }) => {
expect(emojiToUnicodeHex(emoji)).toBe(hexcode);
},
);
});
test.concurrent.for(svgFileNames)(
'verifying SVG file %s maps to Unicode emoji',
(svgFileName, { expect }) => {
assert(!!svgFileName);
const result = twemojiToUnicodeInfo(svgFileName);
const hexcode =
typeof result === 'string' ? result : result.unqualified;
if (!hexcode) {
// No hexcode means this is a special case like the Shibuya 109 emoji
expect(result).toHaveProperty('label');
return;
}
assert(!!hexcode);
expect(
unicodeMap.has(hexcode),
`${hexcode} (${svgFileName}) not found`,
).toBeTruthy();
},
);
describe('unicodeToTwemojiHex', () => {
test.concurrent.for(
unicodeEmojis
// Our version of Twemoji only supports up to version 15.1
.filter((emoji) => emoji.version < 16)
.map((emoji) => [emoji.hexcode, emoji.label] as [string, string]),
)('verifying an emoji exists for %s (%s)', ([hexcode], { expect }) => {
const result = unicodeToTwemojiHex(hexcode);
expect(svgFileNamesWithoutBorder).toContain(result);
});
});
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),
CODES_WITH_DARK_BORDER.includes(hexCode),
] 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));
test.concurrent.for(svgFileNamesWithoutBorder)(
'verifying SVG file %s maps to Unicode emoji',
(svgFileName, { expect }) => {
assert(!!svgFileName);
const result = twemojiToUnicodeInfo(svgFileName);
const hexcode = typeof result === 'string' ? result : result.unqualified;
if (!hexcode) {
// No hexcode means this is a special case like the Shibuya 109 emoji
expect(result).toHaveProperty('label');
return;
}
assert(!!hexcode);
expect(
unicodeCodeSet.has(hexcode),
`${hexcode} (${svgFileName}) not found`,
).toBeTruthy();
},
);
});
@@ -1,19 +1,12 @@
// Utility codes
const VARIATION_SELECTOR_CODE = 0xfe0f;
const KEYCAP_CODE = 0x20e3;
// Gender codes
const GENDER_FEMALE_CODE = 0x2640;
const GENDER_MALE_CODE = 0x2642;
// Skin tone codes
const SKIN_TONE_CODES = [
0x1f3fb, // Light skin tone
0x1f3fc, // Medium-light skin tone
0x1f3fd, // Medium skin tone
0x1f3fe, // Medium-dark skin tone
0x1f3ff, // Dark skin tone
] as const;
import {
VARIATION_SELECTOR_CODE,
KEYCAP_CODE,
GENDER_FEMALE_CODE,
GENDER_MALE_CODE,
SKIN_TONE_CODES,
EMOJIS_WITH_DARK_BORDER,
EMOJIS_WITH_LIGHT_BORDER,
} from './constants';
// Misc codes that have special handling
const SKIER_CODE = 0x26f7;
@@ -24,6 +17,17 @@ const LEVITATING_PERSON_CODE = 0x1f574;
const SPEECH_BUBBLE_CODE = 0x1f5e8;
const MS_CLAUS_CODE = 0x1f936;
export function emojiToUnicodeHex(emoji: string): string {
const codes: number[] = [];
for (const char of emoji) {
const code = char.codePointAt(0);
if (code !== undefined) {
codes.push(code);
}
}
return hexNumbersToString(codes);
}
export function unicodeToTwemojiHex(unicodeHex: string): string {
const codes = hexStringToNumbers(unicodeHex);
const normalizedCodes: number[] = [];
@@ -50,6 +54,35 @@ export function unicodeToTwemojiHex(unicodeHex: string): string {
return hexNumbersToString(normalizedCodes, 0);
}
interface TwemojiBorderInfo {
hexCode: string;
hasLightBorder: boolean;
hasDarkBorder: boolean;
}
export const CODES_WITH_DARK_BORDER =
EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
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;
}
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
hasDarkBorder = true;
}
return {
hexCode: normalizedHex,
hasLightBorder,
hasDarkBorder,
};
}
interface TwemojiSpecificEmoji {
unqualified?: string;
gender?: number;
@@ -84,11 +117,16 @@ export function twemojiToUnicodeInfo(
let gender: undefined | number;
let skin: undefined | number;
for (const code of codes) {
if (code in GENDER_CODES_MAP) {
if (!gender && code in GENDER_CODES_MAP) {
gender = GENDER_CODES_MAP[code];
} else if (code in SKIN_TONE_CODES) {
} else if (!skin && code in SKIN_TONE_CODES) {
skin = code;
}
// Exit if we have both skin and gender
if (skin && gender) {
break;
}
}
let mappedCodes: unknown[] = codes;
@@ -103,8 +141,8 @@ export function twemojiToUnicodeInfo(
// For key emoji, insert the variation selector
mappedCodes = [codes[0], VARIATION_SELECTOR_CODE, KEYCAP_CODE];
} else if (
codes.at(0) === SKIER_CODE ||
codes.at(0) === LEVITATING_PERSON_CODE
(codes.at(0) === SKIER_CODE || codes.at(0) === LEVITATING_PERSON_CODE) &&
codes.length > 1
) {
// Twemoji offers more gender and skin options for the skier and levitating person emoji.
return {
@@ -0,0 +1,13 @@
import { importEmojiData, importCustomEmojiData } 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;
if (locale !== 'custom') {
void importEmojiData(locale);
} else {
void importCustomEmojiData();
}
}
@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@@ -21,6 +21,9 @@ import { openModal } from 'mastodon/actions/modal';
import { IconButton } from 'mastodon/components/icon_button';
import { useIdentity } from 'mastodon/identity_context';
import { me } from 'mastodon/initial_state';
import type { Status } from 'mastodon/models/status';
import { makeGetStatus } from 'mastodon/selectors';
import type { RootState } from 'mastodon/store';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
@@ -47,6 +50,11 @@ const messages = defineMessages({
open: { id: 'status.open', defaultMessage: 'Expand this status' },
});
type GetStatusSelector = (
state: RootState,
props: { id?: string | null; contextType?: string },
) => Status | null;
export const Footer: React.FC<{
statusId: string;
withOpenButton?: boolean;
@@ -56,7 +64,8 @@ export const Footer: React.FC<{
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const status = useAppSelector((state) => state.statuses.get(statusId));
const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector;
const status = useAppSelector((state) => getStatus(state, { id: statusId }));
const accountId = status?.get('account') as string | undefined;
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
+1 -1
View File
@@ -188,7 +188,7 @@
"community.column_settings.remote_only": "Само отдалечено",
"compose.language.change": "Смяна на езика",
"compose.language.search": "Търсене на езици...",
"compose.published.body": "Публикувана публикация.",
"compose.published.body": "Публикувано.",
"compose.published.open": "Отваряне",
"compose.saved.body": "Запазена публикация.",
"compose_form.direct_message_warning_learn_more": "Още информация",
@@ -219,6 +219,13 @@
"confirmations.delete_list.confirm": "Delete",
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
"confirmations.delete_list.title": "Delete list?",
"confirmations.discard_draft.confirm": "Discard and continue",
"confirmations.discard_draft.edit.cancel": "Resume editing",
"confirmations.discard_draft.edit.message": "Continuing will discard any changes you have made to the post you are currently editing.",
"confirmations.discard_draft.edit.title": "Discard changes to your post?",
"confirmations.discard_draft.post.cancel": "Resume draft",
"confirmations.discard_draft.post.message": "Continuing will discard the post you are currently composing.",
"confirmations.discard_draft.post.title": "Discard your draft post?",
"confirmations.discard_edit_media.confirm": "Discard",
"confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
"confirmations.follow_to_list.confirm": "Follow and add to list",
@@ -330,6 +337,7 @@
"errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
"errors.unexpected_crash.report_issue": "Report issue",
"explore.suggested_follows": "People",
"explore.title": "Trending",
"explore.trending_links": "News",
"explore.trending_statuses": "Posts",
"explore.trending_tags": "Hashtags",
@@ -541,8 +549,10 @@
"mute_modal.you_wont_see_mentions": "You won't see posts that mention them.",
"mute_modal.you_wont_see_posts": "They can still see your posts, but you won't see theirs.",
"navigation_bar.about": "About",
"navigation_bar.account_settings": "Password and security",
"navigation_bar.administration": "Administration",
"navigation_bar.advanced_interface": "Open in advanced web interface",
"navigation_bar.automated_deletion": "Automated post deletion",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.bookmarks": "Bookmarks",
"navigation_bar.direct": "Private mentions",
@@ -552,13 +562,23 @@
"navigation_bar.follow_requests": "Follow requests",
"navigation_bar.followed_tags": "Followed hashtags",
"navigation_bar.follows_and_followers": "Follows and followers",
"navigation_bar.import_export": "Import and export",
"navigation_bar.lists": "Lists",
"navigation_bar.live_feed_local": "Live feed (local)",
"navigation_bar.live_feed_public": "Live feed (public)",
"navigation_bar.logout": "Logout",
"navigation_bar.moderation": "Moderation",
"navigation_bar.more": "More",
"navigation_bar.mutes": "Muted users",
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
"navigation_bar.preferences": "Preferences",
"navigation_bar.privacy_and_reach": "Privacy and reach",
"navigation_bar.search": "Search",
"navigation_bar.search_trends": "Search / Trending",
"navigation_panel.collapse_followed_tags": "Collapse followed hashtags menu",
"navigation_panel.collapse_lists": "Collapse list menu",
"navigation_panel.expand_followed_tags": "Expand followed hashtags menu",
"navigation_panel.expand_lists": "Expand list menu",
"not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.",
"notification.admin.report": "{name} reported {target}",
"notification.admin.report_account": "{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}",
@@ -785,6 +805,7 @@
"report_notification.categories.violation": "Rule violation",
"report_notification.categories.violation_sentence": "rule violation",
"report_notification.open": "Open report",
"search.clear": "Clear search",
"search.no_recent_searches": "No recent searches",
"search.placeholder": "Search",
"search.quick_action.account_search": "Profiles matching {x}",
@@ -887,7 +908,10 @@
"subscribed_languages.save": "Save changes",
"subscribed_languages.target": "Change subscribed languages for {target}",
"tabs_bar.home": "Home",
"tabs_bar.menu": "Menu",
"tabs_bar.notifications": "Notifications",
"tabs_bar.publish": "New Post",
"tabs_bar.search": "Search",
"terms_of_service.effective_as_of": "Effective as of {date}",
"terms_of_service.title": "Terms of Service",
"terms_of_service.upcoming_changes_on": "Upcoming changes on {date}",
+3
View File
@@ -564,6 +564,8 @@
"navigation_bar.follows_and_followers": "Folgers en folgjenden",
"navigation_bar.import_export": "Ymportearje en eksportearje",
"navigation_bar.lists": "Listen",
"navigation_bar.live_feed_local": "Livefeed (lokaal)",
"navigation_bar.live_feed_public": "Livefeed (iepenbier)",
"navigation_bar.logout": "Ofmelde",
"navigation_bar.moderation": "Moderaasje",
"navigation_bar.more": "Mear",
@@ -803,6 +805,7 @@
"report_notification.categories.violation": "Skeinde regels",
"report_notification.categories.violation_sentence": "skeinde regels",
"report_notification.open": "Rapport iepenje",
"search.clear": "Sykopdracht wiskje",
"search.no_recent_searches": "Gjin resinte sykopdrachten",
"search.placeholder": "Sykje",
"search.quick_action.account_search": "Accounts dy’t oerienkomme mei {x}",
+11
View File
@@ -6,6 +6,7 @@
"about.domain_blocks.preamble": "Maṣṭudun s umata yeḍmen-ak ad teẓreḍ agbur, ad tesdemreḍ akked yimseqdacen-nniḍen seg yal aqeddac deg fedivers. Ha-tent-an ɣur-k tsuraf i yellan deg uqeddac-agi.",
"about.domain_blocks.silenced.title": "Ɣur-s talast",
"about.domain_blocks.suspended.title": "Yettwaḥbes",
"about.language_label": "Tutlayt",
"about.not_available": "Talɣut-a ur tettwabder ara deg uqeddac-a.",
"about.powered_by": "Azeṭṭa inmetti yettwasɣelsen sɣur {mastodon}",
"about.rules": "Ilugan n uqeddac",
@@ -237,6 +238,10 @@
"explore.trending_links": "Isallen",
"explore.trending_statuses": "Tisuffaɣ",
"explore.trending_tags": "Ihacṭagen",
"featured_carousel.next": "Uḍfiṛ",
"featured_carousel.post": "Tasuffeɣt",
"featured_carousel.previous": "Uzwir",
"featured_carousel.slide": "{index} ɣef {total}",
"filter_modal.added.review_and_configure_title": "Iɣewwaṛen n imzizdig",
"filter_modal.added.settings_link": "asebter n yiɣewwaṛen",
"filter_modal.added.short_explanation": "Tasuffeɣt-a tettwarna ɣer taggayt-a n yimsizdegen: {title}.",
@@ -357,6 +362,7 @@
"lists.add_to_list": "Rnu ɣer tebdart",
"lists.add_to_lists": "Rnu {name} ɣer tebdarin",
"lists.create": "Snulfu-d",
"lists.create_list": "Snulfu-d tabdart",
"lists.delete": "Kkes tabdart",
"lists.done": "Immed",
"lists.edit": "Ẓreg tabdart",
@@ -397,6 +403,7 @@
"navigation_bar.lists": "Tibdarin",
"navigation_bar.logout": "Ffeɣ",
"navigation_bar.moderation": "Aseɣyed",
"navigation_bar.more": "Ugar",
"navigation_bar.mutes": "Iseqdacen yettwasusmen",
"navigation_bar.opened_in_classic_interface": "Tisuffaɣ, imiḍanen akked isebtar-nniḍen igejdanen ldin-d s wudem amezwer deg ugrudem web aklasiki.",
"navigation_bar.preferences": "Imenyafen",
@@ -610,6 +617,7 @@
"status.mute_conversation": "Sgugem adiwenni",
"status.open": "Semɣeṛ tasuffeɣt-ayi",
"status.pin": "Senteḍ-itt deg umaɣnu",
"status.quote_post_author": "Izen sɣur {name}",
"status.read_more": "Issin ugar",
"status.reblog": "Bḍu",
"status.reblogged_by": "Yebḍa-tt {name}",
@@ -635,7 +643,10 @@
"status.unpin": "Kkes asenteḍ seg umaɣnu",
"subscribed_languages.save": "Sekles ibeddilen",
"tabs_bar.home": "Agejdan",
"tabs_bar.menu": "Umuɣ",
"tabs_bar.notifications": "Ilɣa",
"tabs_bar.publish": "Tasuffeɣt tamaynut",
"tabs_bar.search": "Nadi",
"terms_of_service.title": "Tiwtilin n useqdec",
"time_remaining.days": "Mazal {number, plural, one {# wass} other {# wussan}}",
"time_remaining.hours": "Mazal {number, plural, one {# usarag} other {# yisragen}}",
@@ -218,6 +218,9 @@
"confirmations.delete_list.confirm": "删除",
"confirmations.delete_list.message": "确定要永久删除此列表吗?",
"confirmations.delete_list.title": "确定要删除列表?",
"confirmations.discard_draft.confirm": "放弃并继续",
"confirmations.discard_draft.edit.cancel": "恢复编辑",
"confirmations.discard_draft.post.cancel": "恢复草稿",
"confirmations.discard_edit_media.confirm": "丢弃",
"confirmations.discard_edit_media.message": "你还有未保存的媒体描述或预览修改,仍要丢弃吗?",
"confirmations.follow_to_list.confirm": "关注并添加到列表",