mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Merge commit 'a4b8b9fe98c677f718e4b2c1ffe1755d58e7f8d7' into glitch-soc/merge-upstream
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -24,7 +24,7 @@ gem 'ruby-vips', '~> 2.2', require: false
|
||||
|
||||
gem 'active_model_serializers', '~> 0.10'
|
||||
gem 'addressable', '~> 2.8'
|
||||
gem 'bootsnap', '~> 1.19.0', require: false
|
||||
gem 'bootsnap', require: false
|
||||
gem 'browser'
|
||||
gem 'charlock_holmes', '~> 0.7.7'
|
||||
gem 'chewy', '~> 7.3'
|
||||
|
||||
54
Gemfile.lock
54
Gemfile.lock
@@ -118,7 +118,7 @@ GEM
|
||||
rexml
|
||||
base64 (0.3.0)
|
||||
bcp47_spec (0.2.1)
|
||||
bcrypt (3.1.20)
|
||||
bcrypt (3.1.21)
|
||||
benchmark (0.5.0)
|
||||
better_errors (2.10.1)
|
||||
erubi (>= 1.0.0)
|
||||
@@ -129,7 +129,7 @@ GEM
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.19.0)
|
||||
bootsnap (1.20.1)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.2)
|
||||
racc
|
||||
@@ -240,7 +240,7 @@ GEM
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.4.0)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (2.0.2)
|
||||
httpclient (>= 2.2)
|
||||
@@ -248,7 +248,7 @@ GEM
|
||||
net-http (~> 0.5)
|
||||
fast_blank (1.0.1)
|
||||
fastimage (2.4.0)
|
||||
ffi (1.17.2)
|
||||
ffi (1.17.3)
|
||||
ffi-compiler (1.3.2)
|
||||
ffi (>= 1.15.5)
|
||||
rake
|
||||
@@ -271,7 +271,7 @@ GEM
|
||||
fog-json (>= 1.0)
|
||||
formatador (1.2.3)
|
||||
reline
|
||||
forwardable (1.3.3)
|
||||
forwardable (1.4.0)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
@@ -298,14 +298,15 @@ GEM
|
||||
rubocop (>= 1.0)
|
||||
sysexits (~> 1.1)
|
||||
hashdiff (1.2.1)
|
||||
hashie (5.0.0)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
hcaptcha (7.1.0)
|
||||
json
|
||||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.26.2)
|
||||
redis-client (= 0.26.2)
|
||||
hiredis-client (0.26.3)
|
||||
redis-client (= 0.26.3)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
@@ -427,7 +428,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.24.1)
|
||||
loofah (2.25.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
@@ -447,13 +448,14 @@ GEM
|
||||
mime-types-data (3.2025.0924)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.27.0)
|
||||
minitest (6.0.1)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.18.0)
|
||||
multi_json (1.19.1)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.6.0)
|
||||
net-imap (0.6.2)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.20.0)
|
||||
@@ -466,7 +468,7 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.18.10)
|
||||
nokogiri (1.19.0)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.13)
|
||||
@@ -591,7 +593,7 @@ GEM
|
||||
parslet (2.0.0)
|
||||
pastel (0.8.0)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.6.2)
|
||||
pg (1.6.3)
|
||||
pghero (3.7.0)
|
||||
activerecord (>= 7.1)
|
||||
playwright-ruby-client (1.57.1)
|
||||
@@ -609,7 +611,7 @@ GEM
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.6.0)
|
||||
prism (1.7.0)
|
||||
prometheus_exporter (2.3.1)
|
||||
webrick
|
||||
propshaft (1.3.1)
|
||||
@@ -696,7 +698,7 @@ GEM
|
||||
readline (~> 0.0)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.17.0)
|
||||
rdoc (7.0.3)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -704,7 +706,7 @@ GEM
|
||||
reline
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.26.2)
|
||||
redis-client (0.26.3)
|
||||
connection_pool
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
@@ -716,7 +718,7 @@ GEM
|
||||
railties (>= 7.0)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rouge (4.6.1)
|
||||
rouge (4.7.0)
|
||||
rpam2 (4.0.2)
|
||||
rqrcode (3.1.1)
|
||||
chunky_png (~> 1.0)
|
||||
@@ -761,9 +763,9 @@ GEM
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.48.0)
|
||||
rubocop-ast (1.49.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
prism (~> 1.7)
|
||||
rubocop-capybara (2.22.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
@@ -774,7 +776,7 @@ GEM
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.34.2)
|
||||
rubocop-rails (2.34.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
@@ -860,7 +862,7 @@ GEM
|
||||
test-prof (1.5.0)
|
||||
thor (1.4.0)
|
||||
tilt (2.6.1)
|
||||
timeout (0.5.0)
|
||||
timeout (0.6.0)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
@@ -888,7 +890,7 @@ GEM
|
||||
unf_ext (0.0.9.1)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
@@ -944,7 +946,7 @@ DEPENDENCIES
|
||||
better_errors (~> 2.9)
|
||||
binding_of_caller (~> 1.0)
|
||||
blurhash (~> 0.1)
|
||||
bootsnap (~> 1.19.0)
|
||||
bootsnap
|
||||
brakeman (~> 7.0)
|
||||
browser
|
||||
bundler-audit (~> 0.9)
|
||||
@@ -1091,7 +1093,7 @@ DEPENDENCIES
|
||||
xorcist (~> 1.1)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.1p0
|
||||
ruby 3.4.8
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.2
|
||||
4.0.3
|
||||
|
||||
@@ -19,7 +19,7 @@ module CacheConcern
|
||||
# from being used as cache keys, while allowing to `Vary` on them (to not serve
|
||||
# anonymous cached data to authenticated requests when authentication matters)
|
||||
def enforce_cache_control!
|
||||
vary = response.headers['Vary']&.split&.map { |x| x.strip.downcase }
|
||||
vary = response.headers['Vary'].to_s.split(',').map { |x| x.strip.downcase }.reject(&:empty?)
|
||||
return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? }
|
||||
|
||||
response.cache_control.replace(private: true, no_store: true)
|
||||
|
||||
@@ -41,11 +41,10 @@ export interface ApiPreviewCardJSON {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
language: string;
|
||||
type: string;
|
||||
language: string | null;
|
||||
type: 'video' | 'link';
|
||||
author_name: string;
|
||||
author_url: string;
|
||||
author_account?: ApiAccountJSON;
|
||||
provider_name: string;
|
||||
provider_url: string;
|
||||
html: string;
|
||||
@@ -55,7 +54,7 @@ export interface ApiPreviewCardJSON {
|
||||
image_description: string;
|
||||
embed_url: string;
|
||||
blurhash: string;
|
||||
published_at: string;
|
||||
published_at: string | null;
|
||||
authors: ApiPreviewCardAuthorJSON[];
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,10 @@ export const Emoji: FC<EmojiProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const src = unicodeHexToUrl(state.code, appState.darkTheme);
|
||||
const src = unicodeHexToUrl({
|
||||
unicodeHex: state.code,
|
||||
...appState,
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -15,6 +15,8 @@ export const SKIN_TONE_CODES = [
|
||||
0x1f3ff, // Dark skin tone
|
||||
] as const;
|
||||
|
||||
export const EMOJI_MIN_TOKEN_LENGTH = 2;
|
||||
|
||||
// Emoji rendering modes. A mode is what we are using to render emojis, a style is what the user has selected.
|
||||
export const EMOJI_MODE_NATIVE = 'native';
|
||||
export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CompactEmoji } from 'emojibase';
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
|
||||
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||
@@ -6,8 +7,6 @@ import { EMOJI_DB_SHORTCODE_TEST } from './constants';
|
||||
import {
|
||||
putEmojiData,
|
||||
loadEmojiByHexcode,
|
||||
searchEmojisByHexcodes,
|
||||
searchEmojisByTag,
|
||||
testClear,
|
||||
testGet,
|
||||
putCustomEmojiData,
|
||||
@@ -17,6 +16,14 @@ import {
|
||||
putLatestEtag,
|
||||
} from './database';
|
||||
|
||||
function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
|
||||
return {
|
||||
...unicodeEmojiFactory(),
|
||||
tags: ['test', 'emoji'],
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
describe('emoji database', () => {
|
||||
afterEach(() => {
|
||||
testClear();
|
||||
@@ -32,7 +39,7 @@ describe('emoji database', () => {
|
||||
});
|
||||
|
||||
test('loads emoji into indexedDB', async () => {
|
||||
await putEmojiData([unicodeEmojiFactory()], 'en');
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
const { db } = await testGet();
|
||||
await expect(db.get('en', 'test')).resolves.toEqual(
|
||||
unicodeEmojiFactory(),
|
||||
@@ -60,7 +67,7 @@ describe('emoji database', () => {
|
||||
});
|
||||
await expect(db.get('custom', 'emoji1')).resolves.toBeUndefined();
|
||||
await expect(db.get('custom', 'emoji2')).resolves.toEqual(
|
||||
customEmojiFactory({ shortcode: 'emoji2' }),
|
||||
customEmojiFactory({ shortcode: 'emoji2', tokens: ['emoji2'] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -79,12 +86,6 @@ describe('emoji database', () => {
|
||||
});
|
||||
|
||||
describe('loadEmojiByHexcode', () => {
|
||||
test('throws if the locale is not loaded', async () => {
|
||||
await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError(
|
||||
'Locale en',
|
||||
);
|
||||
});
|
||||
|
||||
test('retrieves the emoji', async () => {
|
||||
await putEmojiData([unicodeEmojiFactory()], 'en');
|
||||
await expect(loadEmojiByHexcode('test', 'en')).resolves.toEqual(
|
||||
@@ -98,90 +99,6 @@ describe('emoji database', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEmojisByHexcodes', () => {
|
||||
const data = [
|
||||
unicodeEmojiFactory({ hexcode: 'not a number' }),
|
||||
unicodeEmojiFactory({ hexcode: '1' }),
|
||||
unicodeEmojiFactory({ hexcode: '2' }),
|
||||
unicodeEmojiFactory({ hexcode: '3' }),
|
||||
unicodeEmojiFactory({ hexcode: 'another not a number' }),
|
||||
];
|
||||
beforeEach(async () => {
|
||||
await putEmojiData(data, 'en');
|
||||
});
|
||||
test('finds emoji in consecutive range', async () => {
|
||||
const actual = await searchEmojisByHexcodes(['1', '2', '3'], 'en');
|
||||
expect(actual).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('finds emoji in split range', async () => {
|
||||
const actual = await searchEmojisByHexcodes(['1', '3'], 'en');
|
||||
expect(actual).toHaveLength(2);
|
||||
expect(actual).toContainEqual(data.at(1));
|
||||
expect(actual).toContainEqual(data.at(3));
|
||||
});
|
||||
|
||||
test('finds emoji with non-numeric range', async () => {
|
||||
const actual = await searchEmojisByHexcodes(
|
||||
['3', 'not a number', '1'],
|
||||
'en',
|
||||
);
|
||||
expect(actual).toHaveLength(3);
|
||||
expect(actual).toContainEqual(data.at(0));
|
||||
expect(actual).toContainEqual(data.at(1));
|
||||
expect(actual).toContainEqual(data.at(3));
|
||||
});
|
||||
|
||||
test('not found emoji are not returned', async () => {
|
||||
const actual = await searchEmojisByHexcodes(['not found'], 'en');
|
||||
expect(actual).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('only found emojis are returned', async () => {
|
||||
const actual = await searchEmojisByHexcodes(
|
||||
['another not a number', 'not found'],
|
||||
'en',
|
||||
);
|
||||
expect(actual).toHaveLength(1);
|
||||
expect(actual).toContainEqual(data.at(4));
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEmojisByTag', () => {
|
||||
const data = [
|
||||
unicodeEmojiFactory({ hexcode: 'test1', tags: ['test 1'] }),
|
||||
unicodeEmojiFactory({
|
||||
hexcode: 'test2',
|
||||
tags: ['test 2', 'something else'],
|
||||
}),
|
||||
unicodeEmojiFactory({ hexcode: 'test3', tags: ['completely different'] }),
|
||||
];
|
||||
beforeEach(async () => {
|
||||
await putEmojiData(data, 'en');
|
||||
});
|
||||
test('finds emojis with tag', async () => {
|
||||
const actual = await searchEmojisByTag('test 1', 'en');
|
||||
expect(actual).toHaveLength(1);
|
||||
expect(actual).toContainEqual(data.at(0));
|
||||
});
|
||||
|
||||
test('finds emojis starting with tag', async () => {
|
||||
const actual = await searchEmojisByTag('test', 'en');
|
||||
expect(actual).toHaveLength(2);
|
||||
expect(actual).not.toContainEqual(data.at(2));
|
||||
});
|
||||
|
||||
test('does not find emojis ending with tag', async () => {
|
||||
const actual = await searchEmojisByTag('else', 'en');
|
||||
expect(actual).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('finds nothing with invalid tag', async () => {
|
||||
const actual = await searchEmojisByTag('not found', 'en');
|
||||
expect(actual).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLegacyShortcodesByShortcode', () => {
|
||||
const data = {
|
||||
hexcode: 'test_hexcode',
|
||||
|
||||
@@ -1,55 +1,25 @@
|
||||
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||
import type { Locale, ShortcodesDataset } from 'emojibase';
|
||||
import type { DBSchema, IDBPDatabase } from 'idb';
|
||||
import { openDB } from 'idb';
|
||||
import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
|
||||
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
|
||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||
import type { CustomEmojiData, UnicodeEmojiData, EtagTypes } from './types';
|
||||
import { openEmojiDB } from './db-schema';
|
||||
import type { Database } from './db-schema';
|
||||
import {
|
||||
localeToSegmenter,
|
||||
toSupportedLocale,
|
||||
toSupportedLocaleOrCustom,
|
||||
} from './locale';
|
||||
import {
|
||||
extractTokens,
|
||||
skinHexcodeToEmoji,
|
||||
transformCustomEmojiData,
|
||||
transformEmojiData,
|
||||
} from './normalize';
|
||||
import type { AnyEmojiData, EtagTypes } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
interface EmojiDB extends LocaleTables, DBSchema {
|
||||
custom: {
|
||||
key: string;
|
||||
value: CustomEmojiData;
|
||||
indexes: {
|
||||
category: string;
|
||||
};
|
||||
};
|
||||
shortcodes: {
|
||||
key: string;
|
||||
value: {
|
||||
hexcode: string;
|
||||
shortcodes: string[];
|
||||
};
|
||||
indexes: {
|
||||
hexcode: string;
|
||||
shortcodes: string[];
|
||||
};
|
||||
};
|
||||
etags: {
|
||||
key: EtagTypes;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LocaleTable {
|
||||
key: string;
|
||||
value: UnicodeEmojiData;
|
||||
indexes: {
|
||||
group: number;
|
||||
label: string;
|
||||
order: number;
|
||||
tags: string[];
|
||||
shortcodes: string[];
|
||||
};
|
||||
}
|
||||
type LocaleTables = Record<Locale, LocaleTable>;
|
||||
|
||||
type Database = IDBPDatabase<EmojiDB>;
|
||||
|
||||
const SCHEMA_VERSION = 2;
|
||||
|
||||
const loadedLocales = new Set<Locale>();
|
||||
|
||||
const log = emojiLogger('database');
|
||||
@@ -60,75 +30,7 @@ const loadDB = (() => {
|
||||
|
||||
// Actually load the DB.
|
||||
async function initDB() {
|
||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
upgrade(database, oldVersion, newVersion, trx) {
|
||||
if (!database.objectStoreNames.contains('custom')) {
|
||||
const customTable = database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
customTable.createIndex('category', 'category');
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains('etags')) {
|
||||
database.createObjectStore('etags');
|
||||
}
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
if (!database.objectStoreNames.contains(locale)) {
|
||||
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 });
|
||||
localeTable.createIndex('shortcodes', 'shortcodes', {
|
||||
multiEntry: true,
|
||||
});
|
||||
}
|
||||
// Added in version 2.
|
||||
const localeTable = trx.objectStore(locale);
|
||||
if (!localeTable.indexNames.contains('shortcodes')) {
|
||||
localeTable.createIndex('shortcodes', 'shortcodes', {
|
||||
multiEntry: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains('shortcodes')) {
|
||||
const shortcodeTable = database.createObjectStore('shortcodes', {
|
||||
keyPath: 'hexcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
shortcodeTable.createIndex('hexcode', 'hexcode');
|
||||
shortcodeTable.createIndex('shortcodes', 'shortcodes', {
|
||||
multiEntry: true,
|
||||
});
|
||||
}
|
||||
|
||||
log(
|
||||
'Upgraded emoji database from version %d to %d',
|
||||
oldVersion,
|
||||
newVersion,
|
||||
);
|
||||
},
|
||||
blocked(currentVersion, blockedVersion) {
|
||||
log(
|
||||
'Emoji database upgrade from version %d to %d is blocked',
|
||||
currentVersion,
|
||||
blockedVersion,
|
||||
);
|
||||
},
|
||||
blocking(currentVersion, blockedVersion) {
|
||||
log(
|
||||
'Emoji database upgrade from version %d is blocking upgrade to %d',
|
||||
currentVersion,
|
||||
blockedVersion,
|
||||
);
|
||||
},
|
||||
});
|
||||
const db = await openEmojiDB();
|
||||
await syncLocales(db);
|
||||
log('Loaded database version %d', db.version);
|
||||
return db;
|
||||
@@ -149,11 +51,128 @@ const loadDB = (() => {
|
||||
return loadPromise;
|
||||
})();
|
||||
|
||||
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
|
||||
export async function search({
|
||||
query,
|
||||
locale: localeString,
|
||||
limit = 0,
|
||||
}: {
|
||||
query: string;
|
||||
locale: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
performance.mark('emoji-search-start');
|
||||
|
||||
// Get the locale, and extract tokens from the query.
|
||||
const locale = await toLoadedLocale(localeString);
|
||||
const segmenter = localeToSegmenter(locale);
|
||||
const queryTokens = extractTokens(query, segmenter);
|
||||
|
||||
if (queryTokens.length === 0) {
|
||||
log('no tokens extracted from query "%s"', query);
|
||||
return [];
|
||||
}
|
||||
const lastToken = queryTokens.at(-1);
|
||||
if (!lastToken) {
|
||||
throw new Error('Missing tokens from query');
|
||||
}
|
||||
|
||||
log('searching for tokens %o in locale %s', queryTokens, locale);
|
||||
|
||||
// Create an array of emoji results
|
||||
const db = await loadDB();
|
||||
const resultArrays: Map<string, AnyEmojiData>[] = [];
|
||||
for (let i = 0; i < queryTokens.length; i++) {
|
||||
const token = queryTokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
// Only query the range for the last token to allow partial matches.
|
||||
const range =
|
||||
i === queryTokens.length - 1
|
||||
? IDBKeyRange.bound(token, token + '\uffff')
|
||||
: IDBKeyRange.only(token);
|
||||
|
||||
const [unicodeResults, customResults] = await Promise.all([
|
||||
db.getAllFromIndex(locale, 'tokens', range),
|
||||
db.getAllFromIndex('custom', 'tokens', range),
|
||||
]);
|
||||
const resultMap = new Map<string, AnyEmojiData>([
|
||||
...unicodeResults.map(
|
||||
(emoji) => [emoji.hexcode, emoji] as [string, AnyEmojiData],
|
||||
),
|
||||
...customResults.map(
|
||||
(emoji) => [emoji.shortcode, emoji] as [string, AnyEmojiData],
|
||||
),
|
||||
]);
|
||||
log('found %d results for token "%s"', resultMap.size, token);
|
||||
resultArrays.push(resultMap);
|
||||
}
|
||||
|
||||
// Utilize maps to find the intersection of all result sets.
|
||||
const results = Array.from(
|
||||
resultArrays
|
||||
.reduce((prev, curr) => {
|
||||
const intersection = new Map<string, AnyEmojiData>();
|
||||
for (const [code, emoji] of prev) {
|
||||
if (curr.has(code)) {
|
||||
intersection.set(code, emoji);
|
||||
}
|
||||
}
|
||||
return intersection;
|
||||
})
|
||||
.values(),
|
||||
);
|
||||
|
||||
results.sort((a, b) => {
|
||||
// Checks if a or b has the last token exactly, or only a prefix.
|
||||
const aHasToken = a.tokens.includes(lastToken);
|
||||
const bHasToken = b.tokens.includes(lastToken);
|
||||
if (aHasToken && !bHasToken) {
|
||||
return -1;
|
||||
} else if (!aHasToken && bHasToken) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If one is a custom emoji, prioritize it over Unicode emojis.
|
||||
if ('category' in a) {
|
||||
return -1;
|
||||
} else if ('category' in b) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If both are Unicode emojis, prioritize by order.
|
||||
if ('order' in a && 'order' in b) {
|
||||
return (a.order ?? 0) - (b.order ?? 0); // If these are both Unicode emojis, sort by order.
|
||||
}
|
||||
|
||||
// ¯\_(ツ)_/¯
|
||||
return 0;
|
||||
});
|
||||
|
||||
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
||||
log(
|
||||
'search for "%s" in locale %s returned %d results and took %dms',
|
||||
query,
|
||||
locale,
|
||||
results.length,
|
||||
time.duration,
|
||||
);
|
||||
if (limit > 0) {
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function putEmojiData(emojis: CompactEmoji[], locale: Locale) {
|
||||
loadedLocales.add(locale);
|
||||
const db = await loadDB();
|
||||
const trx = db.transaction(locale, 'readwrite');
|
||||
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||
await trx.store.clear();
|
||||
const segmenter = localeToSegmenter(locale);
|
||||
await Promise.all(
|
||||
emojis
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((emoji) => trx.store.put(transformEmojiData(emoji, segmenter))),
|
||||
);
|
||||
await trx.done;
|
||||
}
|
||||
|
||||
@@ -161,7 +180,7 @@ export async function putCustomEmojiData({
|
||||
emojis,
|
||||
clear = false,
|
||||
}: {
|
||||
emojis: CustomEmojiData[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
clear?: boolean;
|
||||
}) {
|
||||
const db = await loadDB();
|
||||
@@ -173,7 +192,9 @@ export async function putCustomEmojiData({
|
||||
log('Cleared existing custom emojis in database');
|
||||
}
|
||||
|
||||
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||
await Promise.all(
|
||||
emojis.map((emoji) => trx.store.put(transformCustomEmojiData(emoji))),
|
||||
);
|
||||
await trx.done;
|
||||
|
||||
log('Imported %d custom emojis into database', emojis.length);
|
||||
@@ -210,32 +231,25 @@ export async function loadEmojiByHexcode(
|
||||
localeString: string,
|
||||
) {
|
||||
const db = await loadDB();
|
||||
const locale = toLoadedLocale(localeString);
|
||||
return db.get(locale, hexcode);
|
||||
}
|
||||
const locale = await toLoadedLocale(localeString);
|
||||
const result = await db.get(locale, hexcode);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function searchEmojisByHexcodes(
|
||||
hexcodes: string[],
|
||||
localeString: string,
|
||||
) {
|
||||
const db = await loadDB();
|
||||
const locale = toLoadedLocale(localeString);
|
||||
const sortedCodes = hexcodes.toSorted();
|
||||
const results = await db.getAll(
|
||||
// If the emoji wasn't found, check if it's a skin tone variant.
|
||||
const skinResult = await db.getFromIndex(
|
||||
locale,
|
||||
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
|
||||
'skinHexcodes',
|
||||
IDBKeyRange.only(hexcode),
|
||||
);
|
||||
return results.filter((emoji) => hexcodes.includes(emoji.hexcode));
|
||||
}
|
||||
|
||||
export async function searchEmojisByTag(tag: string, localeString: string) {
|
||||
const db = await loadDB();
|
||||
const locale = toLoadedLocale(localeString);
|
||||
const range = IDBKeyRange.bound(
|
||||
tag.toLowerCase(),
|
||||
`${tag.toLowerCase()}\uffff`,
|
||||
);
|
||||
return db.getAllFromIndex(locale, 'tags', range);
|
||||
if (!skinResult) {
|
||||
return skinResult;
|
||||
}
|
||||
|
||||
// Reconstruct the full unicode string from the skin tone hexcode.
|
||||
return skinHexcodeToEmoji(hexcode, skinResult);
|
||||
}
|
||||
|
||||
export async function loadCustomEmojiByShortcode(shortcode: string) {
|
||||
@@ -301,13 +315,16 @@ async function syncLocales(db: Database) {
|
||||
log('Loaded %d locales: %o', loadedLocales.size, loadedLocales);
|
||||
}
|
||||
|
||||
function toLoadedLocale(localeString: string) {
|
||||
async function toLoadedLocale(localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
if (localeString !== locale) {
|
||||
log(`Locale ${locale} is different from provided ${localeString}`);
|
||||
}
|
||||
if (!loadedLocales.has(locale)) {
|
||||
throw new LocaleNotLoadedError(locale);
|
||||
log('Locale %s not loaded, importing...', locale);
|
||||
const { importEmojiData } = await import('./loader');
|
||||
await importEmojiData(locale);
|
||||
return locale;
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
198
app/javascript/mastodon/features/emoji/db-schema.ts
Normal file
198
app/javascript/mastodon/features/emoji/db-schema.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||
import type { Locale } from 'emojibase';
|
||||
import { openDB } from 'idb';
|
||||
import type {
|
||||
DBSchema,
|
||||
IDBPDatabase,
|
||||
IDBPObjectStore,
|
||||
IDBPTransaction,
|
||||
IndexNames,
|
||||
StoreNames,
|
||||
} from 'idb';
|
||||
|
||||
import type { CustomEmojiData, EtagTypes, UnicodeEmojiData } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const log = emojiLogger('database');
|
||||
|
||||
interface EmojiDB extends LocaleTables, DBSchema {
|
||||
custom: {
|
||||
key: string;
|
||||
value: CustomEmojiData;
|
||||
indexes: {
|
||||
tokens: string[];
|
||||
category: string;
|
||||
};
|
||||
};
|
||||
shortcodes: {
|
||||
key: string;
|
||||
value: {
|
||||
hexcode: string;
|
||||
shortcodes: string[];
|
||||
};
|
||||
indexes: {
|
||||
shortcodes: string[];
|
||||
};
|
||||
};
|
||||
etags: {
|
||||
key: EtagTypes;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LocaleTable {
|
||||
key: string;
|
||||
value: UnicodeEmojiData;
|
||||
indexes: {
|
||||
shortcodes: string[];
|
||||
groupOrder: [number, number];
|
||||
tokens: string[];
|
||||
skinHexcodes: string[];
|
||||
};
|
||||
}
|
||||
type LocaleTables = Record<Locale, LocaleTable>;
|
||||
|
||||
type Transaction<Mode extends IDBTransactionMode = 'versionchange'> =
|
||||
IDBPTransaction<EmojiDB, StoreNames<EmojiDB>[], Mode>;
|
||||
|
||||
export type Database = IDBPDatabase<EmojiDB>;
|
||||
|
||||
const SCHEMA_VERSION = 3;
|
||||
|
||||
export async function openEmojiDB() {
|
||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
upgrade(database, oldVersion, newVersion, trx) {
|
||||
if (!database.objectStoreNames.contains('custom')) {
|
||||
database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
}
|
||||
maybeAddIndex({ trx, storeName: 'custom', indexName: 'category' });
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: 'custom',
|
||||
indexName: 'tokens',
|
||||
options: { multiEntry: true },
|
||||
});
|
||||
|
||||
if (!database.objectStoreNames.contains('etags')) {
|
||||
database.createObjectStore('etags');
|
||||
}
|
||||
|
||||
SUPPORTED_LOCALES.forEach((locale) => {
|
||||
createLocaleTable(locale, database, trx);
|
||||
});
|
||||
|
||||
const shortcodeTable = database.objectStoreNames.contains('shortcodes')
|
||||
? trx.objectStore('shortcodes')
|
||||
: database.createObjectStore('shortcodes', {
|
||||
keyPath: 'hexcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: 'shortcodes',
|
||||
indexName: 'shortcodes',
|
||||
options: { multiEntry: true },
|
||||
});
|
||||
deleteOldIndexes(shortcodeTable, ['hexcode']);
|
||||
|
||||
log(
|
||||
'Upgraded emoji database from version %d to %d',
|
||||
oldVersion,
|
||||
newVersion,
|
||||
);
|
||||
},
|
||||
blocked(currentVersion, blockedVersion) {
|
||||
log(
|
||||
'Emoji database upgrade from version %d to %d is blocked',
|
||||
currentVersion,
|
||||
blockedVersion,
|
||||
);
|
||||
},
|
||||
blocking(currentVersion, blockedVersion) {
|
||||
log(
|
||||
'Emoji database upgrade from version %d is blocking upgrade to %d',
|
||||
currentVersion,
|
||||
blockedVersion,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function maybeAddIndex<StoreName extends StoreNames<EmojiDB>>({
|
||||
trx,
|
||||
storeName,
|
||||
indexName,
|
||||
keys,
|
||||
options,
|
||||
}: {
|
||||
trx: Transaction;
|
||||
storeName: StoreName;
|
||||
indexName: IndexNames<EmojiDB, StoreName>;
|
||||
keys?: string | string[];
|
||||
options?: IDBIndexParameters;
|
||||
}) {
|
||||
const store = trx.objectStore(storeName);
|
||||
if (!store.indexNames.contains(indexName)) {
|
||||
store.createIndex(indexName, keys ?? indexName, options);
|
||||
}
|
||||
}
|
||||
|
||||
function createLocaleTable(
|
||||
locale: Locale,
|
||||
database: Database,
|
||||
trx: Transaction,
|
||||
) {
|
||||
if (!database.objectStoreNames.contains(locale)) {
|
||||
database.createObjectStore(locale, {
|
||||
keyPath: 'hexcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
}
|
||||
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: locale,
|
||||
indexName: 'shortcodes',
|
||||
options: { multiEntry: true },
|
||||
});
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: locale,
|
||||
indexName: 'groupOrder',
|
||||
keys: ['group', 'order'],
|
||||
});
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: locale,
|
||||
indexName: 'tokens',
|
||||
keys: 'tokens',
|
||||
options: { multiEntry: true },
|
||||
});
|
||||
maybeAddIndex({
|
||||
trx,
|
||||
storeName: locale,
|
||||
indexName: 'skinHexcodes',
|
||||
keys: 'skinHexcodes',
|
||||
options: { multiEntry: true },
|
||||
});
|
||||
|
||||
const oldIndexes = ['group', 'order', 'tag', 'label'] as const;
|
||||
deleteOldIndexes(trx.objectStore(locale), oldIndexes);
|
||||
}
|
||||
|
||||
function deleteOldIndexes(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Type is too complex, so only any works here.
|
||||
table: IDBPObjectStore<any, any, any, 'versionchange'>,
|
||||
indexes: readonly string[],
|
||||
) {
|
||||
for (const index of indexes) {
|
||||
if (table.indexNames.contains(index)) {
|
||||
table.deleteIndex(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
import { flattenEmojiData } from 'emojibase';
|
||||
import type {
|
||||
CompactEmoji,
|
||||
FlatCompactEmoji,
|
||||
Locale,
|
||||
ShortcodesDataset,
|
||||
} from 'emojibase';
|
||||
import { joinShortcodes } from 'emojibase';
|
||||
import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
|
||||
|
||||
import {
|
||||
putEmojiData,
|
||||
@@ -28,7 +23,7 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
|
||||
shortcodes ? ' and shortcodes' : '',
|
||||
);
|
||||
|
||||
const emojis = await fetchAndCheckEtag<CompactEmoji[]>({
|
||||
let emojis = await fetchAndCheckEtag<CompactEmoji[]>({
|
||||
etagString: locale,
|
||||
path: localeToEmojiPath(locale),
|
||||
});
|
||||
@@ -49,12 +44,10 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
|
||||
}
|
||||
}
|
||||
|
||||
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(
|
||||
emojis,
|
||||
shortcodesData,
|
||||
);
|
||||
await putEmojiData(flattenedEmojis, locale);
|
||||
return flattenedEmojis;
|
||||
emojis = joinShortcodes(emojis, shortcodesData);
|
||||
|
||||
await putEmojiData(emojis, locale);
|
||||
return emojis;
|
||||
}
|
||||
|
||||
export async function importCustomEmojiData() {
|
||||
@@ -135,11 +128,11 @@ async function fetchAndCheckEtag<ResultType extends object[] | object>({
|
||||
checkEtag?: boolean;
|
||||
}): Promise<ResultType | null> {
|
||||
const etagName = toValidEtagName(etagString);
|
||||
const oldEtag = checkEtag ? await loadLatestEtag(etagName) : null;
|
||||
|
||||
// Use location.origin as this script may be loaded from a CDN domain.
|
||||
const url = new URL(path, location.origin);
|
||||
|
||||
const oldEtag = checkEtag ? await loadLatestEtag(etagName) : null;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -148,6 +141,7 @@ async function fetchAndCheckEtag<ResultType extends object[] | object>({
|
||||
});
|
||||
// If not modified, return null
|
||||
if (response.status === 304) {
|
||||
log('etag not modified for %s', etagName);
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
@@ -163,6 +157,8 @@ async function fetchAndCheckEtag<ResultType extends object[] | object>({
|
||||
if (etag && checkEtag) {
|
||||
log(`storing new etag for ${etagName}: ${etag}`);
|
||||
await putLatestEtag(etag, etagName);
|
||||
} else if (!etag) {
|
||||
log(`no etag found in response for ${etagName}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
@@ -32,6 +32,13 @@ export function toValidEtagName(input: string): EtagTypes {
|
||||
return toSupportedLocale(lower);
|
||||
}
|
||||
|
||||
export function localeToSegmenter(locale: Locale): Intl.Segmenter | null {
|
||||
if (typeof Intl.Segmenter === 'function') {
|
||||
return new Intl.Segmenter(locale, { granularity: 'word' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSupportedLocale(locale: string): locale is Locale {
|
||||
return SUPPORTED_LOCALES.includes(locale as Locale);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
|
||||
|
||||
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||
import { assetHost } from '@/mastodon/utils/config';
|
||||
import { isDevelopment } from '@/mastodon/utils/environment';
|
||||
import { isDarkMode } from '@/mastodon/utils/theme';
|
||||
|
||||
@@ -29,6 +30,7 @@ export function useEmojiAppState(): EmojiAppState {
|
||||
locales: [locale],
|
||||
mode,
|
||||
darkTheme: isDarkMode(),
|
||||
assetHost,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@ import { basename, resolve } from 'path';
|
||||
import { flattenEmojiData } from 'emojibase';
|
||||
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
||||
|
||||
import {
|
||||
twemojiToUnicodeInfo,
|
||||
unicodeToTwemojiHex,
|
||||
emojiToUnicodeHex,
|
||||
} from './normalize';
|
||||
import { unicodeToTwemojiHex } from './normalize';
|
||||
|
||||
const emojiSVGFiles = await readdir(
|
||||
// This assumes tests are run from project root
|
||||
@@ -26,23 +22,6 @@ const svgFileNamesWithoutBorder = svgFileNames.filter(
|
||||
|
||||
const unicodeEmojis = flattenEmojiData(unicodeRawEmojis);
|
||||
|
||||
describe('emojiToUnicodeHex', () => {
|
||||
test.concurrent.for([
|
||||
['🎱', '1F3B1'],
|
||||
['🐜', '1F41C'],
|
||||
['⚫', '26AB'],
|
||||
['🖤', '1F5A4'],
|
||||
['💀', '1F480'],
|
||||
['❤️', '2764'], // Checks for trailing variation selector removal.
|
||||
['💂♂️', '1F482-200D-2642-FE0F'],
|
||||
] as const)(
|
||||
'emojiToUnicodeHex converts %s to %s',
|
||||
([emoji, hexcode], { expect }) => {
|
||||
expect(emojiToUnicodeHex(emoji)).toBe(hexcode);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('unicodeToTwemojiHex', () => {
|
||||
test.concurrent.for(
|
||||
unicodeEmojis
|
||||
@@ -54,26 +33,3 @@ describe('unicodeToTwemojiHex', () => {
|
||||
expect(svgFileNamesWithoutBorder).toContain(result);
|
||||
});
|
||||
});
|
||||
|
||||
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,48 +1,124 @@
|
||||
import { isList } from 'immutable';
|
||||
|
||||
import { assetHost } from '@/mastodon/utils/config';
|
||||
import type { CompactEmoji, SkinTone } from 'emojibase';
|
||||
import { fromHexcodeToCodepoint } from 'emojibase';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
|
||||
import {
|
||||
VARIATION_SELECTOR_CODE,
|
||||
KEYCAP_CODE,
|
||||
GENDER_FEMALE_CODE,
|
||||
GENDER_MALE_CODE,
|
||||
SKIN_TONE_CODES,
|
||||
EMOJIS_WITH_DARK_BORDER,
|
||||
EMOJIS_WITH_LIGHT_BORDER,
|
||||
EMOJIS_REQUIRING_INVERSION_IN_LIGHT_MODE,
|
||||
EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE,
|
||||
EMOJI_MIN_TOKEN_LENGTH,
|
||||
} from './constants';
|
||||
import type { CustomEmojiMapArg, ExtraCustomEmojiMap } from './types';
|
||||
import type {
|
||||
CustomEmojiData,
|
||||
CustomEmojiMapArg,
|
||||
ExtraCustomEmojiMap,
|
||||
UnicodeEmojiData,
|
||||
} from './types';
|
||||
import { emojiToUnicodeHex } from './utils';
|
||||
|
||||
// Misc codes that have special handling
|
||||
const SKIER_CODE = 0x26f7;
|
||||
const CHRISTMAS_TREE_CODE = 0x1f384;
|
||||
const MR_CLAUS_CODE = 0x1f385;
|
||||
const EYE_CODE = 0x1f441;
|
||||
const LEVITATING_PERSON_CODE = 0x1f574;
|
||||
const SPEECH_BUBBLE_CODE = 0x1f5e8;
|
||||
const MS_CLAUS_CODE = 0x1f936;
|
||||
const SKIN_TONE_MAP: Record<number, SkinTone> = {
|
||||
0x1f3fb: 1, // Light skin tone
|
||||
0x1f3fc: 2, // Medium-light skin tone
|
||||
0x1f3fd: 3, // Medium skin tone
|
||||
0x1f3fe: 4, // Medium-dark skin tone
|
||||
0x1f3ff: 5, // Dark skin tone
|
||||
};
|
||||
|
||||
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);
|
||||
export function transformEmojiData(
|
||||
emoji: CompactEmoji,
|
||||
segmenter: Intl.Segmenter | null,
|
||||
): UnicodeEmojiData {
|
||||
const {
|
||||
shortcodes = [],
|
||||
tags = [],
|
||||
label,
|
||||
emoticon,
|
||||
hexcode,
|
||||
unicode,
|
||||
group,
|
||||
order,
|
||||
skins = [],
|
||||
} = emoji;
|
||||
const extract = (str: string) => extractTokens(str, segmenter);
|
||||
|
||||
let normalizedEmoticons: string[] | undefined = undefined;
|
||||
if (emoticon) {
|
||||
normalizedEmoticons = Array.isArray(emoticon) ? emoticon : [emoticon];
|
||||
}
|
||||
|
||||
const tokens = [
|
||||
...new Set([
|
||||
...shortcodes.map(extract).flat(),
|
||||
...tags.map(extract).flat(),
|
||||
...extract(label),
|
||||
...(normalizedEmoticons ?? []),
|
||||
]),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const res: UnicodeEmojiData = {
|
||||
tokens,
|
||||
shortcodes,
|
||||
label,
|
||||
emoticons: normalizedEmoticons,
|
||||
hexcode,
|
||||
unicode,
|
||||
group,
|
||||
order,
|
||||
};
|
||||
|
||||
for (const skin of skins) {
|
||||
res.skinHexcodes ??= [];
|
||||
res.skinHexcodes.push(skin.hexcode);
|
||||
|
||||
res.skinTones ??= [];
|
||||
for (const codePoint of skin.unicode) {
|
||||
const tone = SKIN_TONE_MAP[codePoint.codePointAt(0) ?? 0];
|
||||
if (tone) {
|
||||
res.skinTones.push(tone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handles how Emojibase removes the variation selector for single code emojis.
|
||||
// See: https://emojibase.dev/docs/spec/#merged-variation-selectors
|
||||
if (codes.at(1) === VARIATION_SELECTOR_CODE && codes.length === 2) {
|
||||
codes.pop();
|
||||
}
|
||||
return hexNumbersToString(codes);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function transformCustomEmojiData(
|
||||
emoji: ApiCustomEmojiJSON,
|
||||
): CustomEmojiData {
|
||||
const tokens = emoji.shortcode
|
||||
.split('_')
|
||||
.filter((word) => word.length >= EMOJI_MIN_TOKEN_LENGTH)
|
||||
.map((word) => word.toLowerCase());
|
||||
return {
|
||||
...emoji,
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function skinHexcodeToEmoji(
|
||||
skinHexcode: string,
|
||||
emoji: UnicodeEmojiData,
|
||||
): UnicodeEmojiData {
|
||||
return {
|
||||
...emoji,
|
||||
unicode: String.fromCodePoint(...fromHexcodeToCodepoint(skinHexcode)),
|
||||
hexcode: skinHexcode,
|
||||
};
|
||||
}
|
||||
|
||||
// Misc codes that have special handling
|
||||
const EYE_CODE = 0x1f441;
|
||||
const SPEECH_BUBBLE_CODE = 0x1f5e8;
|
||||
|
||||
export function unicodeToTwemojiHex(unicodeHex: string): string {
|
||||
const codes = hexStringToNumbers(unicodeHex);
|
||||
const codes = fromHexcodeToCodepoint(unicodeHex);
|
||||
const normalizedCodes: number[] = [];
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
const code = codes[i];
|
||||
@@ -64,19 +140,28 @@ export function unicodeToTwemojiHex(unicodeHex: string): string {
|
||||
normalizedCodes.push(code);
|
||||
}
|
||||
|
||||
return hexNumbersToString(normalizedCodes, 0).toLowerCase();
|
||||
return normalizedCodes
|
||||
.map((code) => code.toString(16))
|
||||
.join('-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export const CODES_WITH_DARK_BORDER =
|
||||
EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
|
||||
const CODES_WITH_DARK_BORDER = EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
|
||||
|
||||
export const CODES_WITH_LIGHT_BORDER =
|
||||
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
|
||||
const CODES_WITH_LIGHT_BORDER = EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
|
||||
|
||||
export function unicodeHexToUrl(unicodeHex: string, darkMode: boolean): string {
|
||||
export function unicodeHexToUrl({
|
||||
unicodeHex,
|
||||
darkTheme,
|
||||
assetHost,
|
||||
}: {
|
||||
unicodeHex: string;
|
||||
darkTheme: boolean;
|
||||
assetHost: string;
|
||||
}): string {
|
||||
const normalizedHex = unicodeToTwemojiHex(unicodeHex);
|
||||
let url = `${assetHost}/emoji/${normalizedHex}`;
|
||||
if (darkMode && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
|
||||
if (darkTheme && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
|
||||
url += '_border';
|
||||
}
|
||||
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
|
||||
@@ -86,78 +171,6 @@ export function unicodeHexToUrl(unicodeHex: string, darkMode: boolean): string {
|
||||
return url;
|
||||
}
|
||||
|
||||
interface TwemojiSpecificEmoji {
|
||||
unqualified?: string;
|
||||
gender?: number;
|
||||
skin?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Normalize man/woman to male/female
|
||||
const GENDER_CODES_MAP: Record<number, number> = {
|
||||
[GENDER_FEMALE_CODE]: GENDER_FEMALE_CODE,
|
||||
[GENDER_MALE_CODE]: GENDER_MALE_CODE,
|
||||
// These are man/woman markers, but are used for gender sometimes.
|
||||
[0x1f468]: GENDER_MALE_CODE,
|
||||
[0x1f469]: GENDER_FEMALE_CODE,
|
||||
};
|
||||
|
||||
const TWEMOJI_SPECIAL_CASES: Record<string, string | TwemojiSpecificEmoji> = {
|
||||
'1F441-200D-1F5E8': '1F441-FE0F-200D-1F5E8-FE0F', // Eye in speech bubble
|
||||
// An emoji that was never ported to the Unicode standard.
|
||||
// See: https://emojipedia.org/shibuya
|
||||
E50A: { label: 'Shibuya 109' },
|
||||
};
|
||||
|
||||
export function twemojiToUnicodeInfo(
|
||||
twemojiHex: string,
|
||||
): TwemojiSpecificEmoji | string {
|
||||
const specialCase = TWEMOJI_SPECIAL_CASES[twemojiHex.toUpperCase()];
|
||||
if (specialCase) {
|
||||
return specialCase;
|
||||
}
|
||||
const codes = hexStringToNumbers(twemojiHex);
|
||||
let gender: undefined | number;
|
||||
let skin: undefined | number;
|
||||
for (const code of codes) {
|
||||
if (!gender && code in GENDER_CODES_MAP) {
|
||||
gender = GENDER_CODES_MAP[code];
|
||||
} 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;
|
||||
|
||||
if (codes.at(-1) === CHRISTMAS_TREE_CODE && codes.length >= 3 && gender) {
|
||||
// Twemoji uses the christmas tree with a ZWJ for Mr. and Mrs. Claus,
|
||||
// but in Unicode that only works for Mx. Claus.
|
||||
const START_CODE =
|
||||
gender === GENDER_FEMALE_CODE ? MS_CLAUS_CODE : MR_CLAUS_CODE;
|
||||
mappedCodes = [START_CODE, skin];
|
||||
} else if (codes.at(-1) === KEYCAP_CODE && codes.length === 2) {
|
||||
// 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.length > 1
|
||||
) {
|
||||
// Twemoji offers more gender and skin options for the skier and levitating person emoji.
|
||||
return {
|
||||
unqualified: hexNumbersToString([codes.at(0)]),
|
||||
skin,
|
||||
gender,
|
||||
};
|
||||
}
|
||||
|
||||
return hexNumbersToString(mappedCodes);
|
||||
}
|
||||
|
||||
export function emojiToInversionClassName(emoji: string): string | null {
|
||||
if (EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE.includes(emoji)) {
|
||||
return 'invert-on-dark';
|
||||
@@ -189,19 +202,37 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
|
||||
return extraEmojis;
|
||||
}
|
||||
|
||||
function hexStringToNumbers(hexString: string): number[] {
|
||||
return hexString
|
||||
.split('-')
|
||||
.map((code) => Number.parseInt(code, 16))
|
||||
.filter((code) => !Number.isNaN(code));
|
||||
}
|
||||
/**
|
||||
* Tokenizes an input string into words, using Intl.Segmenter if available.
|
||||
* @param input Any input string.
|
||||
* @param segmenter Segmenter, if available.
|
||||
* @returns Array of tokens in lowercase.
|
||||
*/
|
||||
export function extractTokens(
|
||||
input: string,
|
||||
segmenter: Intl.Segmenter | null,
|
||||
): string[] {
|
||||
if (!input.trim()) {
|
||||
return [];
|
||||
}
|
||||
const tokens: string[] = [];
|
||||
|
||||
function hexNumbersToString(codes: unknown[], padding = 4): string {
|
||||
return codes
|
||||
.filter(
|
||||
(code): code is number =>
|
||||
typeof code === 'number' && code > 0 && !Number.isNaN(code),
|
||||
)
|
||||
.map((code) => code.toString(16).padStart(padding, '0').toUpperCase())
|
||||
.join('-');
|
||||
// Prefer to use Intl.Segmenter if available for better locale support.
|
||||
if (segmenter) {
|
||||
for (const { isWordLike, segment } of segmenter.segment(
|
||||
input.replaceAll('_', ' '), // Handle underscores from shortcodes.
|
||||
)) {
|
||||
if (isWordLike && segment.length >= EMOJI_MIN_TOKEN_LENGTH) {
|
||||
tokens.push(segment.toLowerCase());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple splitting.
|
||||
input.split(/[\s_-]+/).forEach((word) => {
|
||||
if (/\w/.test(word) && word.length >= EMOJI_MIN_TOKEN_LENGTH) {
|
||||
tokens.push(word.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
EMOJI_TYPE_UNICODE,
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
} from './constants';
|
||||
import { emojiToUnicodeHex } from './normalize';
|
||||
import type {
|
||||
EmojiLoadedState,
|
||||
EmojiMode,
|
||||
@@ -16,6 +15,7 @@ import type {
|
||||
import {
|
||||
anyEmojiRegex,
|
||||
emojiLogger,
|
||||
emojiToUnicodeHex,
|
||||
isCustomEmoji,
|
||||
isUnicodeEmoji,
|
||||
stringHasUnicodeFlags,
|
||||
@@ -140,12 +140,7 @@ export async function loadEmojiDataToState(
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
log('Could not find emoji %s for locale %s', state.code, locale);
|
||||
return null;
|
||||
} catch (err: unknown) {
|
||||
// If the locale is not loaded, load it and retry once.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { FlatCompactEmoji, Locale } from 'emojibase';
|
||||
import type { CompactEmoji, Locale, SkinTone } from 'emojibase';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
|
||||
@@ -32,10 +32,20 @@ export interface EmojiAppState {
|
||||
currentLocale: Locale;
|
||||
mode: EmojiMode;
|
||||
darkTheme: boolean;
|
||||
assetHost: string;
|
||||
}
|
||||
|
||||
export type CustomEmojiData = ApiCustomEmojiJSON;
|
||||
export type UnicodeEmojiData = FlatCompactEmoji;
|
||||
export type CustomEmojiData = ApiCustomEmojiJSON & { tokens: string[] };
|
||||
export interface UnicodeEmojiData extends Omit<
|
||||
CompactEmoji,
|
||||
'emoticon' | 'skins' | 'tags'
|
||||
> {
|
||||
shortcodes: string[];
|
||||
tokens: string[];
|
||||
emoticons?: string[];
|
||||
skinHexcodes?: string[];
|
||||
skinTones?: (SkinTone | SkinTone[])[];
|
||||
}
|
||||
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
|
||||
|
||||
type CustomEmojiRenderFields = Pick<
|
||||
|
||||
@@ -2,6 +2,8 @@ import debug from 'debug';
|
||||
|
||||
import { emojiRegexPolyfill } from '@/mastodon/polyfills';
|
||||
|
||||
import { VARIATION_SELECTOR_CODE } from './constants';
|
||||
|
||||
export function emojiLogger(segment: string) {
|
||||
return debug(`emojis:${segment}`);
|
||||
}
|
||||
@@ -44,6 +46,27 @@ export function anyEmojiRegex() {
|
||||
);
|
||||
}
|
||||
|
||||
export function emojiToUnicodeHex(emoji: string): string {
|
||||
const codes: string[] = [];
|
||||
for (const char of emoji) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code !== undefined) {
|
||||
codes.push(code.toString(16).toUpperCase().padStart(4, '0'));
|
||||
}
|
||||
}
|
||||
|
||||
// Handles how Emojibase removes the variation selector for single code emojis.
|
||||
// See: https://emojibase.dev/docs/spec/#merged-variation-selectors
|
||||
if (
|
||||
codes.at(1) === VARIATION_SELECTOR_CODE.toString(16).toUpperCase() &&
|
||||
codes.length === 2
|
||||
) {
|
||||
codes.pop();
|
||||
}
|
||||
|
||||
return codes.join('-');
|
||||
}
|
||||
|
||||
function supportsRegExpSets() {
|
||||
return 'unicodeSets' in RegExp.prototype;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
? decodeIDNA(getHostname(card.get('url')))
|
||||
: card.get('provider_name');
|
||||
const interactive = card.get('type') === 'video';
|
||||
const language = card.get('language') || '';
|
||||
const language = card.get('language') ?? '';
|
||||
const hasImage = (card.get('image')?.length ?? 0) > 0;
|
||||
const largeImage =
|
||||
(hasImage && card.get('width') > card.get('height')) || interactive;
|
||||
@@ -131,7 +131,11 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
|
||||
{card.get('published_at') && (
|
||||
<>
|
||||
{' '}
|
||||
· <RelativeTimestamp timestamp={card.get('published_at')} />
|
||||
·{' '}
|
||||
<RelativeTimestamp
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
timestamp={card.get('published_at')!}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -23,7 +23,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
confirm: confirmationMessage.confirm,
|
||||
onConfirm: () => dispatch(closeModal({
|
||||
modalType: undefined,
|
||||
ignoreFocus: { ignoreFocus },
|
||||
ignoreFocus,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
@@ -31,7 +31,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
} else {
|
||||
dispatch(closeModal({
|
||||
modalType: undefined,
|
||||
ignoreFocus: { ignoreFocus },
|
||||
ignoreFocus,
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -631,7 +631,7 @@
|
||||
"navigation_panel.expand_followed_tags": "Επέκταση μενού ετικετών που ακολουθείτε",
|
||||
"navigation_panel.expand_lists": "Επέκταση μενού λίστας",
|
||||
"not_signed_in_indicator.not_signed_in": "Πρέπει να συνδεθείς για να αποκτήσεις πρόσβαση σε αυτόν τον πόρο.",
|
||||
"notification.admin.report": "Ο/Η {name} ανέφερε τον {target}",
|
||||
"notification.admin.report": "Ο/Η {name} ανέφερε τον/την {target}",
|
||||
"notification.admin.report_account": "Ο χρήστης {name} ανέφερε {count, plural, one {μία ανάρτηση} other {# αναρτήσεις}} από {target} για {category}",
|
||||
"notification.admin.report_account_other": "Ο χρήστης {name} ανέφερε {count, plural, one {μία ανάρτηση} other {# αναρτήσεις}} από {target}",
|
||||
"notification.admin.report_statuses": "Ο χρήστης {name} ανέφερε τον χρήστη {target} για {category}",
|
||||
|
||||
@@ -122,6 +122,8 @@
|
||||
"annual_report.shared_page.donate": "Donera",
|
||||
"annual_report.shared_page.footer": "Genererad med {heart} av Mastodon-teamet",
|
||||
"annual_report.shared_page.footer_server_info": "{username} använder {domain}, en av många forum som drivs av Mastodon.",
|
||||
"annual_report.summary.archetype.booster.desc_public": "{name} jagade efter inlägg att boosta och förstärka andra skapare med perfekt sikte.",
|
||||
"annual_report.summary.archetype.booster.desc_self": "Du jagade efter inlägg att boosta och förstärka andra skapare med perfekt sikte.",
|
||||
"annual_report.summary.archetype.booster.name": "Bågskytten",
|
||||
"annual_report.summary.archetype.die_drei_fragezeichen": "???",
|
||||
"annual_report.summary.archetype.lurker.desc_public": "Vi vet att {name} var där ute någonstans och njuter av Mastodon på sitt egna tysta sätt.",
|
||||
@@ -131,13 +133,31 @@
|
||||
"annual_report.summary.archetype.oracle.desc_self": "Du skapade nya inlägg mer än svar och höll Mastodon fräscht och framtidsinriktat.",
|
||||
"annual_report.summary.archetype.oracle.name": "Oraklet",
|
||||
"annual_report.summary.archetype.pollster.desc_public": "{name} skapade fler undersökningar än andra inläggstyper och skapade nyfikenhet på Mastodon.",
|
||||
"annual_report.summary.archetype.pollster.desc_self": "Du skapade fler undersökningar än andra inläggstyper och skapade nyfikenhet på Mastodon.",
|
||||
"annual_report.summary.archetype.pollster.name": "Undraren",
|
||||
"annual_report.summary.archetype.replier.desc_public": "{name} svarade ofta på andras inlägg och pollinerade Mastodon med nya diskussioner.",
|
||||
"annual_report.summary.archetype.replier.desc_self": "Du svarade ofta på andras inlägg och pollinerade Mastodon med nya diskussioner.",
|
||||
"annual_report.summary.archetype.replier.name": "Fjärilen",
|
||||
"annual_report.summary.archetype.reveal": "Avslöja min arketyp",
|
||||
"annual_report.summary.archetype.reveal_description": "Tack för att du är en del av Mastodon! Dags att ta reda på vilken arketyp du förkroppsligade under {year}.",
|
||||
"annual_report.summary.archetype.title_public": "{name}s arketyp",
|
||||
"annual_report.summary.archetype.title_self": "Din arketyp",
|
||||
"annual_report.summary.close": "Stäng",
|
||||
"annual_report.summary.copy_link": "Kopiera länk",
|
||||
"annual_report.summary.followers.new_followers": "{count, plural, one {ny följare} other {nya följare}}",
|
||||
"annual_report.summary.highlighted_post.boost_count": "Det här inlägget förstärktes {count, plural, one {en gång} other {# gånger}}.",
|
||||
"annual_report.summary.highlighted_post.favourite_count": "Det här inlägget favoriserades {count, plural, one {en gång} other {# gånger}}.",
|
||||
"annual_report.summary.highlighted_post.reply_count": "Det här inlägget fick {count, plural, one {ett svar} other {# svar}}.",
|
||||
"annual_report.summary.highlighted_post.title": "Mest populära inlägg",
|
||||
"annual_report.summary.most_used_app.most_used_app": "mest använda app",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "mest använda hashtag",
|
||||
"annual_report.summary.most_used_hashtag.used_count": "Du inkluderade denna hashtag i {count, plural, one {ett inlägg} other {# inlägg}}.",
|
||||
"annual_report.summary.most_used_hashtag.used_count_public": "{name} inkluderade denna hashtag i {count, plural, one {ett inlägg} other {# inlägg}}.",
|
||||
"annual_report.summary.new_posts.new_posts": "nya inlägg",
|
||||
"annual_report.summary.percentile.text": "<topLabel>Det placerar dig i topp</topLabel><percentage></percentage><bottomLabel>bland {domain} användare.</bottomLabel>",
|
||||
"annual_report.summary.percentile.we_wont_tell_bernie": "Vi berättar inte för Bernie.",
|
||||
"annual_report.summary.share_elsewhere": "Dela någon annanstans",
|
||||
"annual_report.summary.share_message": "Jag fick {archetype}-arketypen!",
|
||||
"annual_report.summary.share_on_mastodon": "Dela på Mastodon",
|
||||
"attachments_list.unprocessed": "(obehandlad)",
|
||||
"audio.hide": "Dölj audio",
|
||||
|
||||
@@ -7,8 +7,6 @@ export type { StatusVisibility } from 'mastodon/api_types/statuses';
|
||||
// Temporary until we type it correctly
|
||||
export type Status = Immutable.Map<string, unknown>;
|
||||
|
||||
type CardShape = Required<ApiPreviewCardJSON>;
|
||||
|
||||
export type Card = RecordOf<CardShape>;
|
||||
export type Card = RecordOf<ApiPreviewCardJSON>;
|
||||
|
||||
export type MediaAttachment = Immutable.Map<string, unknown>;
|
||||
|
||||
@@ -6023,6 +6023,10 @@ a.status-card {
|
||||
|
||||
@media screen and (width <= $mobile-breakpoint) {
|
||||
margin-top: auto;
|
||||
|
||||
&.video-modal {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,9 @@ export function unicodeEmojiFactory(
|
||||
label: 'Test',
|
||||
unicode: '🧪',
|
||||
shortcodes: ['test_emoji'],
|
||||
tokens: ['emoji', 'test'],
|
||||
group: 1,
|
||||
order: 1,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
@@ -131,6 +134,7 @@ export function customEmojiFactory(
|
||||
static_url: '/custom-emoji/logo.svg',
|
||||
url: '/custom-emoji/logo.svg',
|
||||
visible_in_picker: true,
|
||||
tokens: ['custom'],
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,14 +21,13 @@ class ActivityPub::Activity
|
||||
|
||||
class << self
|
||||
def factory(json, account, **)
|
||||
@json = json
|
||||
klass&.new(json, account, **)
|
||||
klass_for(json)&.new(json, account, **)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def klass
|
||||
case @json['type']
|
||||
def klass_for(json)
|
||||
case json['type']
|
||||
when 'Create'
|
||||
ActivityPub::Activity::Create
|
||||
when 'Announce'
|
||||
|
||||
@@ -47,7 +47,7 @@ class ActivityPub::Activity::QuoteRequest < ActivityPub::Activity
|
||||
# NOTE: Replacing the object's context by that of the parent activity is
|
||||
# not sound, but it's consistent with the rest of the codebase
|
||||
instrument = @json['instrument'].merge({ '@context' => @json['@context'] })
|
||||
return if non_matching_uri_hosts?(instrument['id'], @account.uri)
|
||||
return if non_matching_uri_hosts?(@account.uri, instrument['id'])
|
||||
|
||||
ActivityPub::FetchRemoteStatusService.new.call(instrument['id'], prefetched_body: instrument, on_behalf_of: quoted_status.account, request_id: @options[:request_id])
|
||||
end
|
||||
|
||||
@@ -50,7 +50,7 @@ class ActivityPub::TagManager
|
||||
context_url(target) unless target.parent_account_id.nil? || target.parent_status_id.nil?
|
||||
when :note, :comment, :activity
|
||||
if target.account.numeric_ap_id?
|
||||
return activity_ap_account_status_url(target.account, target) if target.reblog?
|
||||
return activity_ap_account_status_url(target.account.id, target) if target.reblog?
|
||||
|
||||
ap_account_status_url(target.account.id, target)
|
||||
else
|
||||
|
||||
@@ -41,12 +41,17 @@ class ConnectionPool::SharedConnectionPool < ConnectionPool
|
||||
# ConnectionPool 2.4+ calls `checkin(force: true)` after fork.
|
||||
# When this happens, we should remove all connections from Thread.current
|
||||
|
||||
::Thread.current.keys.each do |name| # rubocop:disable Style/HashEachMethods
|
||||
next unless name.to_s.start_with?("#{@key}-")
|
||||
connection_keys = ::Thread.current.keys.select { |key| key.to_s.start_with?("#{@key}-") && !key.to_s.start_with?("#{@key_count}-") }
|
||||
count_keys = ::Thread.current.keys.select { |key| key.to_s.start_with?("#{@key_count}-") }
|
||||
|
||||
@available.push(::Thread.current[name])
|
||||
::Thread.current[name] = nil
|
||||
connection_keys.each do |key|
|
||||
@available.push(::Thread.current[key])
|
||||
::Thread.current[key] = nil
|
||||
end
|
||||
count_keys.each do |key|
|
||||
::Thread.current[key] = nil
|
||||
end
|
||||
|
||||
elsif ::Thread.current[key(preferred_tag)]
|
||||
if ::Thread.current[key_count(preferred_tag)] == 1
|
||||
@available.push(::Thread.current[key(preferred_tag)])
|
||||
|
||||
@@ -25,9 +25,13 @@ class SignatureParser
|
||||
|
||||
# Use `skip` instead of `scan` as we only care about the subgroups
|
||||
while scanner.skip(PARAM_RE)
|
||||
key = scanner[:key]
|
||||
# Detect a duplicate key
|
||||
raise Mastodon::SignatureVerificationError, 'Error parsing signature with duplicate keys' if params.key?(key)
|
||||
|
||||
# This is not actually correct with regards to quoted pairs, but it's consistent
|
||||
# with our previous implementation, and good enough in practice.
|
||||
params[scanner[:key]] = scanner[:value] || scanner[:quoted_value][1...-1]
|
||||
params[key] = scanner[:value] || scanner[:quoted_value][1...-1]
|
||||
|
||||
scanner.skip(/\s*/)
|
||||
return params if scanner.eos?
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
\-
|
||||
- else
|
||||
= friendly_number_to_human account.statuses_count
|
||||
%small= t('accounts.posts', count: account.statuses_count).downcase
|
||||
%small= t('accounts.posts', count: account.statuses_count)
|
||||
%td.accounts-table__count.optional
|
||||
- if account.unavailable? || account.user_pending?
|
||||
\-
|
||||
- else
|
||||
= friendly_number_to_human account.followers_count
|
||||
%small= t('accounts.followers', count: account.followers_count).downcase
|
||||
%small= t('accounts.followers', count: account.followers_count)
|
||||
%td.accounts-table__count
|
||||
= relevant_account_timestamp(account)
|
||||
%small= t('accounts.last_active')
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
%td= account_link_to account
|
||||
%td.accounts-table__count.optional
|
||||
= friendly_number_to_human account.statuses_count
|
||||
%small= t('accounts.posts', count: account.statuses_count).downcase
|
||||
%small= t('accounts.posts', count: account.statuses_count)
|
||||
%td.accounts-table__count.optional
|
||||
= friendly_number_to_human account.followers_count
|
||||
%small= t('accounts.followers', count: account.followers_count).downcase
|
||||
%small= t('accounts.followers', count: account.followers_count)
|
||||
%td.accounts-table__count
|
||||
- if account.last_status_at.present?
|
||||
%time.time-ago{ datetime: account.last_status_at.to_date.iso8601, title: l(account.last_status_at.to_date) }= l account.last_status_at
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
.account-card__counters
|
||||
.account-card__counters__item
|
||||
= friendly_number_to_human report.target_account.statuses_count
|
||||
%small= t('accounts.posts', count: report.target_account.statuses_count).downcase
|
||||
%small= t('accounts.posts', count: report.target_account.statuses_count)
|
||||
.account-card__counters__item
|
||||
= friendly_number_to_human report.target_account.followers_count
|
||||
%small= t('accounts.followers', count: report.target_account.followers_count).downcase
|
||||
%small= t('accounts.followers', count: report.target_account.followers_count)
|
||||
.account-card__counters__item
|
||||
= friendly_number_to_human report.target_account.following_count
|
||||
%small= t('accounts.following', count: report.target_account.following_count).downcase
|
||||
%small= t('accounts.following', count: report.target_account.following_count)
|
||||
.account-card__actions__button
|
||||
= link_to t('admin.reports.view_profile'), admin_account_path(report.target_account_id), class: 'button'
|
||||
.report-header__details.report-header__details--horizontal
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
%td= account_link_to account
|
||||
%td.accounts-table__count.optional
|
||||
= friendly_number_to_human account.statuses_count
|
||||
%small= t('accounts.posts', count: account.statuses_count).downcase
|
||||
%small= t('accounts.posts', count: account.statuses_count)
|
||||
%td.accounts-table__count.optional
|
||||
= friendly_number_to_human account.followers_count
|
||||
%small= t('accounts.followers', count: account.followers_count).downcase
|
||||
%small= t('accounts.followers', count: account.followers_count)
|
||||
%td.accounts-table__count
|
||||
- if account.last_status_at.present?
|
||||
%time.time-ago{ datetime: account.last_status_at.to_date.iso8601, title: l(account.last_status_at.to_date) }= l account.last_status_at.to_date
|
||||
|
||||
@@ -2278,6 +2278,7 @@ cs:
|
||||
error: Při odstraňování bezpečnostního klíče došlo k chybě. Zkuste to prosím znovu.
|
||||
success: Váš bezpečnostní klíč byl úspěšně odstraněn.
|
||||
invalid_credential: Neplatný bezpečnostní klíč
|
||||
nickname: Přezdívka
|
||||
nickname_hint: Zadejte přezdívku nového bezpečnostního klíče
|
||||
not_enabled: Zatím jste nepovolili WebAuthn
|
||||
not_supported: Tento prohlížeč nepodporuje bezpečnostní klíče
|
||||
|
||||
@@ -2144,9 +2144,11 @@ cy:
|
||||
enabled: Dileu hen bostiadau'n awtomatig
|
||||
enabled_hint: Yn dileu eich postiadau yn awtomatig ar ôl iddyn nhw gyrraedd trothwy oed penodedig, oni bai eu bod yn cyfateb i un o'r eithriadau isod
|
||||
exceptions: Eithriadau
|
||||
explanation: Caiff dileu awtomataidd ei berfformio â blaenoriaeth isel. Gall fod oedi rhwng cyrraedd y trothwy oed a chael ei ddileu.
|
||||
ignore_favs: Anwybyddu ffefrynnau
|
||||
ignore_reblogs: Anwybyddu hybiau
|
||||
interaction_exceptions: Eithriadau yn seiliedig ar ryngweithio
|
||||
interaction_exceptions_explanation: Maen bosib cadw postiadau sy'n mynd dros dro dros y trothwy ffefryn neu hwb hyd yn oed os cawn nhw eu gostwng yn ddiweddarach.
|
||||
keep_direct: Cadw negeseuon uniongyrchol
|
||||
keep_direct_hint: Nid yw'n dileu unrhyw un o'ch negeseuon uniongyrchol
|
||||
keep_media: Cadw postiadau gydag atodiadau cyfryngau
|
||||
@@ -2363,6 +2365,7 @@ cy:
|
||||
error: Bu anhawster wrth ddileu eich allwedd ddiogelwch. Ceisiwch eto, os gwelwch yn dda.
|
||||
success: Cafodd eich allwedd ddiogelwch ei dileu'n llwyddiannus.
|
||||
invalid_credential: Allwedd ddiogelwch annilys
|
||||
nickname: Llysenw
|
||||
nickname_hint: Rhowch lysenw eich allwedd ddiogelwch newydd
|
||||
not_enabled: Nid ydych wedi galluogi WebAuthn eto
|
||||
not_supported: Nid yw'r porwr hwn yn cynnal allweddi diogelwch
|
||||
|
||||
@@ -597,7 +597,7 @@ el:
|
||||
public_comment: Δημόσιο σχόλιο
|
||||
purge: Εκκαθάριση
|
||||
purge_description_html: Εάν πιστεύεις ότι αυτός ο τομέας είναι εκτός σύνδεσης μόνιμα, μπορείς να διαγράψεις όλες τις καταχωρήσεις λογαριασμών και τα σχετικά δεδομένα από αυτόν τον τομέα από τον αποθηκευτικό σου χώρο. Αυτό μπορεί να διαρκέσει λίγη ώρα.
|
||||
title: Συναλλαγές
|
||||
title: Ομοσπονδία
|
||||
total_blocked_by_us: Αποκλεισμένοι από εμάς
|
||||
total_followed_by_them: Ακολουθούνται από εκείνους
|
||||
total_followed_by_us: Ακολουθούνται από εμάς
|
||||
@@ -772,7 +772,7 @@ el:
|
||||
manage_blocks_description: Επιτρέπει στους χρήστες να αποκλείουν παρόχους email και διευθύνσεις IP
|
||||
manage_custom_emojis: Διαχείριση Προσαρμοσμένων Emojis
|
||||
manage_custom_emojis_description: Επιτρέπει στους χρήστες να διαχειρίζονται προσαρμοσμένα emojis στον διακομιστή
|
||||
manage_federation: Διαχείριση Συναλλαγών
|
||||
manage_federation: Διαχείριση Ομοσπονδίας
|
||||
manage_federation_description: Επιτρέπει στους χρήστες να αποκλείουν ή να επιτρέπουν τις συναλλαγές με άλλους τομείς και να ελέγχουν την παράδοση
|
||||
manage_invites: Διαχείριση Προσκλήσεων
|
||||
manage_invites_description: Επιτρέπει στους χρήστες να περιηγούνται και να απενεργοποιούν τους συνδέσμους πρόσκλησης
|
||||
|
||||
@@ -1973,9 +1973,11 @@ fi:
|
||||
enabled: Poista vanhat julkaisut automaattisesti
|
||||
enabled_hint: Poistaa julkaisusi automaattisesti, kun ne saavuttavat valitun ikäkynnyksen, ellei jokin alla olevista poikkeuksista tule kyseeseen
|
||||
exceptions: Poikkeukset
|
||||
explanation: Automaattipoisto suoritetaan pienellä prioriteetilla. Ikäkynnyksen saavuttamisen ja poistetuksi tulemisen välillä saattaa olla viive.
|
||||
ignore_favs: Ohita suosikit
|
||||
ignore_reblogs: Ohita tehostukset
|
||||
interaction_exceptions: Vuorovaikutuksiin perustuvat poikkeukset
|
||||
interaction_exceptions_explanation: Julkaisut, jotka ylittävät väliaikaisesti suosikki- tai tehostusrajan, voidaan säilyttää, vaikka rajat myöhemmin alittuisivat.
|
||||
keep_direct: Säilytä yksityisviestit
|
||||
keep_direct_hint: Ei poista yksityisviestejäsi
|
||||
keep_media: Säilytä julkaisut, joissa on medialiitteitä
|
||||
|
||||
@@ -2190,6 +2190,7 @@ fo:
|
||||
error: Ein trupulleiki var við at strika trygdarlykilin hjá tær. Vinarliga royn aftur.
|
||||
success: Trygdarlykilin hjá tær varð strikaður.
|
||||
invalid_credential: Ógyldugur trygdarlykil
|
||||
nickname: Kallinavn
|
||||
nickname_hint: Skriva eyknevni á tínum nýggja trygdarlykli
|
||||
not_enabled: Tú hevur ikki gjørt WebAuthn virkið enn
|
||||
not_supported: Hesin kagin stuðlar ikki uppundir trygdarlyklar
|
||||
|
||||
@@ -2190,6 +2190,7 @@ gl:
|
||||
error: Houbo un problema ó eliminar a túa chave de seguridade, inténtao outra vez.
|
||||
success: Eliminouse correctamente a chave de seguridade.
|
||||
invalid_credential: Chave de seguridade non válida
|
||||
nickname: Sobrenome
|
||||
nickname_hint: Escribe un alcume para a túa nova chave de seguridade
|
||||
not_enabled: Aínda non tes activado WebAuthn
|
||||
not_supported: Este navegador non ten soporte para chaves de seguridade
|
||||
|
||||
@@ -2190,6 +2190,7 @@ it:
|
||||
error: Si è verificato un problema durante la cancellazione della chiave di sicurezza. Dovresti riprovare.
|
||||
success: La chiave di sicurezza è stata cancellata.
|
||||
invalid_credential: Chiave di sicurezza non valida
|
||||
nickname: Soprannome
|
||||
nickname_hint: Inserisci il soprannome della tua nuova chiave di sicurezza
|
||||
not_enabled: Non hai ancora abilitato WebAuthn
|
||||
not_supported: Questo browser non supporta le chiavi di sicurezza
|
||||
|
||||
@@ -243,6 +243,8 @@ cy:
|
||||
setting_always_send_emails: Anfonwch hysbysiadau e-bost bob amser
|
||||
setting_auto_play_gif: Chwarae GIFs wedi'u hanimeiddio yn awtomatig
|
||||
setting_boost_modal: Rheoli hybu gwelededd
|
||||
setting_color_scheme: Modd
|
||||
setting_contrast: Cyferbyniad
|
||||
setting_default_language: Iaith postio
|
||||
setting_default_privacy: Gwelededd postio
|
||||
setting_default_quote_policy: Pwy sy'n gallu dyfynnu
|
||||
@@ -317,6 +319,7 @@ cy:
|
||||
thumbnail: Bawdlun y gweinydd
|
||||
trendable_by_default: Caniatáu pynciau llosg heb adolygiad
|
||||
trends: Galluogi pynciau llosg
|
||||
wrapstodon: Galluogi Wrapstodon
|
||||
interactions:
|
||||
must_be_follower: Blocio hysbysiadau o bobl nad ydynt yn eich dilyn
|
||||
must_be_following: Blocio hysbysiadau o bobl nad ydych yn eu dilyn
|
||||
|
||||
@@ -65,6 +65,7 @@ sv:
|
||||
setting_display_media_hide_all: Dölj alltid all media
|
||||
setting_display_media_show_all: Visa alltid media markerad som känslig
|
||||
setting_emoji_style: Hur emojier visas. "Automatiskt" kommer att försöka använda webbläsarens emojier, men faller tillbaka till Twemoji för äldre webbläsare.
|
||||
setting_quick_boosting_html: När aktiverad, klicka på %{boost_icon} Boost-ikonen för att omedelbart boosta istället för att öppna boost/citera-rullgardinsmenyn. Flyttar citering till %{options_icon} (Alternativ)-menyn.
|
||||
setting_system_scrollbars_ui: Gäller endast för webbläsare som är baserade på Safari och Chrome
|
||||
setting_use_blurhash: Gradienter är baserade på färgerna av de dolda objekten men fördunklar alla detaljer
|
||||
setting_use_pending_items: Dölj tidslinjeuppdateringar bakom ett klick istället för att automatiskt bläddra i flödet
|
||||
@@ -78,6 +79,7 @@ sv:
|
||||
featured_tag:
|
||||
name: 'Här är några av de hashtaggar du använt nyligen:'
|
||||
filters:
|
||||
action: Välj vilken åtgärd som ska utföras när ett inlägg matchar filtret
|
||||
actions:
|
||||
blur: Dölj media bakom en varning utan att dölja själva texten
|
||||
hide: Dölj det filtrerade innehållet helt, beter sig som om det inte fanns
|
||||
@@ -86,10 +88,12 @@ sv:
|
||||
activity_api_enabled: Antalet lokalt publicerade inlägg, aktiva användare och nya registrerade konton per vecka
|
||||
app_icon: WEBP, PNG, GIF eller JPG. Använd istället för appens egna ikon på mobila enheter.
|
||||
backups_retention_period: Användare har möjlighet att generera arkiv av sina inlägg för att ladda ned senare. När det sätts till ett positivt värde raderas dessa arkiv automatiskt från din lagring efter det angivna antalet dagar.
|
||||
bootstrap_timeline_accounts: Dessa konton kommer att fästas på toppen av nya användares följ-rekommendationer. Ange en kommaseparerad lista över konton.
|
||||
closed_registrations_message: Visas när nyregistreringar är avstängda
|
||||
content_cache_retention_period: Alla inlägg från andra servrar (inklusive booster och svar) kommer att raderas efter det angivna antalet dagar, utan hänsyn till någon lokal användarinteraktion med dessa inlägg. Detta inkluderar inlägg där en lokal användare har markerat det som bokmärke eller favoriter. Privata omnämnanden mellan användare från olika instanser kommer också att gå förlorade och blir omöjliga att återställa. Användningen av denna inställning är avsedd för specialfall och bryter många användarförväntningar när de implementeras för allmänt bruk.
|
||||
custom_css: Du kan använda anpassade stilar på webbversionen av Mastodon.
|
||||
favicon: WEBP, PNG, GIF eller JPG. Används på mobila enheter istället för appens egen ikon.
|
||||
landing_page: Väljer vilken sida nya besökare ser när de först anländer till din server. Om du väljer "Trender" måste trenderna aktiveras i Upptäckningsinställningarna. Om du väljer "Lokalt flöde" måste "Åtkomst till live-flöden med lokala inlägg" sättas till "Alla" i Upptäckningsinställningarna.
|
||||
mascot: Åsidosätter illustrationen i det avancerade webbgränssnittet.
|
||||
media_cache_retention_period: Mediafiler från inlägg som gjorts av fjärranvändare cachas på din server. När inställd på ett positivt värde kommer media att raderas efter det angivna antalet dagar. Om mediadatat begärs efter att det har raderats, kommer det att laddas ned igen om källinnehållet fortfarande är tillgängligt. På grund av begränsningar för hur ofta förhandsgranskningskort för länkar hämtas från tredjepartswebbplatser, rekommenderas det att ange detta värde till minst 14 dagar, annars kommer förhandsgranskningskorten inte att uppdateras på begäran före den tiden.
|
||||
min_age: Användare kommer att bli ombedda att bekräfta sitt födelsedatum under registreringen
|
||||
@@ -107,6 +111,7 @@ sv:
|
||||
thumbnail: En bild i cirka 2:1-proportioner som visas tillsammans med din serverinformation.
|
||||
trendable_by_default: Hoppa över manuell granskning av trendande innehåll. Enskilda objekt kan ändå raderas från trender retroaktivt.
|
||||
trends: Trender visar vilka inlägg, hashtaggar och nyheter det pratas om på din server.
|
||||
wrapstodon: Erbjud lokala användare att generera en lekfull sammanfattning av deras Mastodon-användning under året. Denna funktion är tillgänglig mellan den 10 och 31 december varje år, och erbjuds till användare som gjort minst ett Offentligt eller Tyst Offentligt inlägg och använt minst en hashtag under året.
|
||||
form_challenge:
|
||||
current_password: Du går in i ett säkert område
|
||||
imports:
|
||||
@@ -233,6 +238,7 @@ sv:
|
||||
setting_aggregate_reblogs: Gruppera boostar i tidslinjer
|
||||
setting_always_send_emails: Skicka alltid e-postnotiser
|
||||
setting_auto_play_gif: Spela upp GIF:ar automatiskt
|
||||
setting_boost_modal: Kontrollera boost-synlighet
|
||||
setting_color_scheme: Läge
|
||||
setting_contrast: Kontrast
|
||||
setting_default_language: Inläggsspråk
|
||||
|
||||
@@ -7,6 +7,8 @@ sv:
|
||||
hosted_on: Mastodon-värd på %{domain}
|
||||
title: Om
|
||||
accounts:
|
||||
errors:
|
||||
cannot_be_added_to_collections: Detta konto kan inte läggas till i samlingar.
|
||||
followers:
|
||||
one: Följare
|
||||
other: Följare
|
||||
@@ -1714,12 +1716,16 @@ sv:
|
||||
body: 'Du nämndes av %{name} i:'
|
||||
subject: Du nämndes av %{name}
|
||||
title: Ny omnämning
|
||||
moderation_warning:
|
||||
subject: Du har mottagit en modereringsvarning
|
||||
poll:
|
||||
subject: En undersökning av %{name} har avslutats
|
||||
quote:
|
||||
body: 'Ditt inlägg citerades av %{name}:'
|
||||
subject: "%{name} citerade ditt inlägg"
|
||||
title: Nytt citat
|
||||
quoted_update:
|
||||
subject: "%{name} redigerade ett inlägg du har citerat"
|
||||
reblog:
|
||||
body: 'Ditt inlägg boostades av %{name}:'
|
||||
subject: "%{name} boostade ditt inlägg"
|
||||
@@ -2192,3 +2198,4 @@ sv:
|
||||
registered_on: Registrerad den %{date}
|
||||
wrapstodon:
|
||||
description: Se hur %{name} använde Mastodon i år!
|
||||
title: Wrapstodon %{year} för %{name}
|
||||
|
||||
@@ -128,6 +128,28 @@ RSpec.describe ActivityPub::TagManager do
|
||||
.to eq("#{host_prefix}/ap/users/#{status.account.id}/statuses/#{status.id}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a reblog' do
|
||||
let(:status) { Fabricate(:status, account:, reblog: Fabricate(:status)) }
|
||||
|
||||
context 'when using a numeric ID based scheme' do
|
||||
let(:account) { Fabricate(:account, id_scheme: :numeric_ap_id) }
|
||||
|
||||
it 'returns a string starting with web domain and with the expected path' do
|
||||
expect(subject.uri_for(status))
|
||||
.to eq("#{host_prefix}/ap/users/#{status.account.id}/statuses/#{status.id}/activity")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using the legacy username based scheme' do
|
||||
let(:account) { Fabricate(:account, id_scheme: :username_ap_id) }
|
||||
|
||||
it 'returns a string starting with web domain and with the expected path' do
|
||||
expect(subject.uri_for(status))
|
||||
.to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/activity")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a remote status' do
|
||||
|
||||
12
yarn.lock
12
yarn.lock
@@ -12581,8 +12581,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"sass@npm:^1.62.1, sass@npm:^1.70.0":
|
||||
version: 1.97.1
|
||||
resolution: "sass@npm:1.97.1"
|
||||
version: 1.97.2
|
||||
resolution: "sass@npm:1.97.2"
|
||||
dependencies:
|
||||
"@parcel/watcher": "npm:^2.4.1"
|
||||
chokidar: "npm:^4.0.0"
|
||||
@@ -12593,7 +12593,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
sass: sass.js
|
||||
checksum: 10c0/c389d5d6405869b49fa2291e8328500fe7936f3b72136bc2c338bee6e7fec936bb9a48d77a1310dea66aa4669ba74ae6b82a112eb32521b9b36d740138a39ea0
|
||||
checksum: 10c0/5622a4f9d0acf5cdfb72c73c448366b3ec7bfe2b6e1ecb62c6e9f8eaff9c02a6afd86a31ded246fc818fe660cedea6bd27335e82fc9bd7cce46c29f95774bece
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15043,8 +15043,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"ws@npm:^8.12.1, ws@npm:^8.18.0, ws@npm:^8.18.3":
|
||||
version: 8.18.3
|
||||
resolution: "ws@npm:8.18.3"
|
||||
version: 8.19.0
|
||||
resolution: "ws@npm:8.19.0"
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ">=5.0.2"
|
||||
@@ -15053,7 +15053,7 @@ __metadata:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
checksum: 10c0/eac918213de265ef7cb3d4ca348b891a51a520d839aa51cdb8ca93d4fa7ff9f6ccb339ccee89e4075324097f0a55157c89fa3f7147bde9d8d7e90335dc087b53
|
||||
checksum: 10c0/4741d9b9bc3f9c791880882414f96e36b8b254e34d4b503279d6400d9a4b87a033834856dbdd94ee4b637944df17ea8afc4bce0ff4a1560d2166be8855da5b04
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user