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

This commit is contained in:
Claire 2026-01-10 13:12:36 +01:00
commit d7254ecec4
45 changed files with 739 additions and 496 deletions

View File

@ -24,7 +24,7 @@ gem 'ruby-vips', '~> 2.2', require: false
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.8' gem 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.19.0', require: false gem 'bootsnap', require: false
gem 'browser' gem 'browser'
gem 'charlock_holmes', '~> 0.7.7' gem 'charlock_holmes', '~> 0.7.7'
gem 'chewy', '~> 7.3' gem 'chewy', '~> 7.3'

View File

@ -118,7 +118,7 @@ GEM
rexml rexml
base64 (0.3.0) base64 (0.3.0)
bcp47_spec (0.2.1) bcp47_spec (0.2.1)
bcrypt (3.1.20) bcrypt (3.1.21)
benchmark (0.5.0) benchmark (0.5.0)
better_errors (2.10.1) better_errors (2.10.1)
erubi (>= 1.0.0) erubi (>= 1.0.0)
@ -129,7 +129,7 @@ GEM
binding_of_caller (1.0.1) binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0) debug_inspector (>= 1.2.0)
blurhash (0.1.8) blurhash (0.1.8)
bootsnap (1.19.0) bootsnap (1.20.1)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.1.2) brakeman (7.1.2)
racc racc
@ -240,7 +240,7 @@ GEM
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
faraday-follow_redirects (0.4.0) faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-httpclient (2.0.2) faraday-httpclient (2.0.2)
httpclient (>= 2.2) httpclient (>= 2.2)
@ -248,7 +248,7 @@ GEM
net-http (~> 0.5) net-http (~> 0.5)
fast_blank (1.0.1) fast_blank (1.0.1)
fastimage (2.4.0) fastimage (2.4.0)
ffi (1.17.2) ffi (1.17.3)
ffi-compiler (1.3.2) ffi-compiler (1.3.2)
ffi (>= 1.15.5) ffi (>= 1.15.5)
rake rake
@ -271,7 +271,7 @@ GEM
fog-json (>= 1.0) fog-json (>= 1.0)
formatador (1.2.3) formatador (1.2.3)
reline reline
forwardable (1.3.3) forwardable (1.4.0)
fugit (1.12.1) fugit (1.12.1)
et-orbi (~> 1.4) et-orbi (~> 1.4)
raabro (~> 1.4) raabro (~> 1.4)
@ -298,14 +298,15 @@ GEM
rubocop (>= 1.0) rubocop (>= 1.0)
sysexits (~> 1.1) sysexits (~> 1.1)
hashdiff (1.2.1) hashdiff (1.2.1)
hashie (5.0.0) hashie (5.1.0)
logger
hcaptcha (7.1.0) hcaptcha (7.1.0)
json json
highline (3.1.2) highline (3.1.2)
reline reline
hiredis (0.6.3) hiredis (0.6.3)
hiredis-client (0.26.2) hiredis-client (0.26.3)
redis-client (= 0.26.2) redis-client (= 0.26.3)
hkdf (0.3.0) hkdf (0.3.0)
htmlentities (4.3.4) htmlentities (4.3.4)
http (5.3.1) http (5.3.1)
@ -427,7 +428,7 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.24.1) loofah (2.25.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.0)
@ -447,13 +448,14 @@ GEM
mime-types-data (3.2025.0924) mime-types-data (3.2025.0924)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.9) mini_portile2 (2.8.9)
minitest (5.27.0) minitest (6.0.1)
prism (~> 1.5)
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.18.0) multi_json (1.19.1)
mutex_m (0.3.0) mutex_m (0.3.0)
net-http (0.6.0) net-http (0.6.0)
uri uri
net-imap (0.6.0) net-imap (0.6.2)
date date
net-protocol net-protocol
net-ldap (0.20.0) net-ldap (0.20.0)
@ -466,7 +468,7 @@ GEM
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.18.10) nokogiri (1.19.0)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.13) oj (3.16.13)
@ -591,7 +593,7 @@ GEM
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.6.2) pg (1.6.3)
pghero (3.7.0) pghero (3.7.0)
activerecord (>= 7.1) activerecord (>= 7.1)
playwright-ruby-client (1.57.1) playwright-ruby-client (1.57.1)
@ -609,7 +611,7 @@ GEM
net-smtp net-smtp
premailer (~> 1.7, >= 1.7.9) premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.6.0) prism (1.7.0)
prometheus_exporter (2.3.1) prometheus_exporter (2.3.1)
webrick webrick
propshaft (1.3.1) propshaft (1.3.1)
@ -696,7 +698,7 @@ GEM
readline (~> 0.0) readline (~> 0.0)
rdf-normalize (0.7.0) rdf-normalize (0.7.0)
rdf (~> 3.3) rdf (~> 3.3)
rdoc (6.17.0) rdoc (7.0.3)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
@ -704,7 +706,7 @@ GEM
reline reline
redcarpet (3.6.1) redcarpet (3.6.1)
redis (4.8.1) redis (4.8.1)
redis-client (0.26.2) redis-client (0.26.3)
connection_pool connection_pool
regexp_parser (2.11.3) regexp_parser (2.11.3)
reline (0.6.3) reline (0.6.3)
@ -716,7 +718,7 @@ GEM
railties (>= 7.0) railties (>= 7.0)
rexml (3.4.4) rexml (3.4.4)
rotp (6.3.0) rotp (6.3.0)
rouge (4.6.1) rouge (4.7.0)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (3.1.1) rqrcode (3.1.1)
chunky_png (~> 1.0) chunky_png (~> 1.0)
@ -761,9 +763,9 @@ GEM
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.48.0) rubocop-ast (1.49.0)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.7)
rubocop-capybara (2.22.1) rubocop-capybara (2.22.1)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1) rubocop (~> 1.72, >= 1.72.1)
@ -774,7 +776,7 @@ GEM
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.2) rubocop-rails (2.34.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
@ -860,7 +862,7 @@ GEM
test-prof (1.5.0) test-prof (1.5.0)
thor (1.4.0) thor (1.4.0)
tilt (2.6.1) tilt (2.6.1)
timeout (0.5.0) timeout (0.6.0)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
openssl (> 2.0) openssl (> 2.0)
@ -888,7 +890,7 @@ GEM
unf_ext (0.0.9.1) unf_ext (0.0.9.1)
unicode-display_width (3.2.0) unicode-display_width (3.2.0)
unicode-emoji (~> 4.1) unicode-emoji (~> 4.1)
unicode-emoji (4.1.0) unicode-emoji (4.2.0)
uri (1.1.1) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
validate_url (1.0.15) validate_url (1.0.15)
@ -944,7 +946,7 @@ DEPENDENCIES
better_errors (~> 2.9) better_errors (~> 2.9)
binding_of_caller (~> 1.0) binding_of_caller (~> 1.0)
blurhash (~> 0.1) blurhash (~> 0.1)
bootsnap (~> 1.19.0) bootsnap
brakeman (~> 7.0) brakeman (~> 7.0)
browser browser
bundler-audit (~> 0.9) bundler-audit (~> 0.9)
@ -1091,7 +1093,7 @@ DEPENDENCIES
xorcist (~> 1.1) xorcist (~> 1.1)
RUBY VERSION RUBY VERSION
ruby 3.4.1p0 ruby 3.4.8
BUNDLED WITH BUNDLED WITH
4.0.2 4.0.3

View File

@ -19,7 +19,7 @@ module CacheConcern
# from being used as cache keys, while allowing to `Vary` on them (to not serve # from being used as cache keys, while allowing to `Vary` on them (to not serve
# anonymous cached data to authenticated requests when authentication matters) # anonymous cached data to authenticated requests when authentication matters)
def enforce_cache_control! 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? } 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) response.cache_control.replace(private: true, no_store: true)

View File

@ -41,11 +41,10 @@ export interface ApiPreviewCardJSON {
url: string; url: string;
title: string; title: string;
description: string; description: string;
language: string; language: string | null;
type: string; type: 'video' | 'link';
author_name: string; author_name: string;
author_url: string; author_url: string;
author_account?: ApiAccountJSON;
provider_name: string; provider_name: string;
provider_url: string; provider_url: string;
html: string; html: string;
@ -55,7 +54,7 @@ export interface ApiPreviewCardJSON {
image_description: string; image_description: string;
embed_url: string; embed_url: string;
blurhash: string; blurhash: string;
published_at: string; published_at: string | null;
authors: ApiPreviewCardAuthorJSON[]; authors: ApiPreviewCardAuthorJSON[];
} }

View File

@ -88,7 +88,10 @@ export const Emoji: FC<EmojiProps> = ({
); );
} }
const src = unicodeHexToUrl(state.code, appState.darkTheme); const src = unicodeHexToUrl({
unicodeHex: state.code,
...appState,
});
return ( return (
<img <img

View File

@ -15,6 +15,8 @@ export const SKIN_TONE_CODES = [
0x1f3ff, // Dark skin tone 0x1f3ff, // Dark skin tone
] as const; ] 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. // 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 = 'native';
export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags'; export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags';

View File

@ -1,3 +1,4 @@
import type { CompactEmoji } from 'emojibase';
import { IDBFactory } from 'fake-indexeddb'; import { IDBFactory } from 'fake-indexeddb';
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories'; import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
@ -6,8 +7,6 @@ import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { import {
putEmojiData, putEmojiData,
loadEmojiByHexcode, loadEmojiByHexcode,
searchEmojisByHexcodes,
searchEmojisByTag,
testClear, testClear,
testGet, testGet,
putCustomEmojiData, putCustomEmojiData,
@ -17,6 +16,14 @@ import {
putLatestEtag, putLatestEtag,
} from './database'; } from './database';
function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
return {
...unicodeEmojiFactory(),
tags: ['test', 'emoji'],
...data,
};
}
describe('emoji database', () => { describe('emoji database', () => {
afterEach(() => { afterEach(() => {
testClear(); testClear();
@ -32,7 +39,7 @@ describe('emoji database', () => {
}); });
test('loads emoji into indexedDB', async () => { test('loads emoji into indexedDB', async () => {
await putEmojiData([unicodeEmojiFactory()], 'en'); await putEmojiData([rawEmojiFactory()], 'en');
const { db } = await testGet(); const { db } = await testGet();
await expect(db.get('en', 'test')).resolves.toEqual( await expect(db.get('en', 'test')).resolves.toEqual(
unicodeEmojiFactory(), unicodeEmojiFactory(),
@ -60,7 +67,7 @@ describe('emoji database', () => {
}); });
await expect(db.get('custom', 'emoji1')).resolves.toBeUndefined(); await expect(db.get('custom', 'emoji1')).resolves.toBeUndefined();
await expect(db.get('custom', 'emoji2')).resolves.toEqual( 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', () => { 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 () => { test('retrieves the emoji', async () => {
await putEmojiData([unicodeEmojiFactory()], 'en'); await putEmojiData([unicodeEmojiFactory()], 'en');
await expect(loadEmojiByHexcode('test', 'en')).resolves.toEqual( 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', () => { describe('loadLegacyShortcodesByShortcode', () => {
const data = { const data = {
hexcode: 'test_hexcode', hexcode: 'test_hexcode',

View File

@ -1,55 +1,25 @@
import { SUPPORTED_LOCALES } from 'emojibase'; import { SUPPORTED_LOCALES } from 'emojibase';
import type { Locale, ShortcodesDataset } from 'emojibase'; import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
import type { DBSchema, IDBPDatabase } from 'idb';
import { openDB } from 'idb'; import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { EMOJI_DB_SHORTCODE_TEST } from './constants'; import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import { openEmojiDB } from './db-schema';
import type { CustomEmojiData, UnicodeEmojiData, EtagTypes } from './types'; 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'; 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 loadedLocales = new Set<Locale>();
const log = emojiLogger('database'); const log = emojiLogger('database');
@ -60,75 +30,7 @@ const loadDB = (() => {
// Actually load the DB. // Actually load the DB.
async function initDB() { async function initDB() {
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, { const db = await openEmojiDB();
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,
);
},
});
await syncLocales(db); await syncLocales(db);
log('Loaded database version %d', db.version); log('Loaded database version %d', db.version);
return db; return db;
@ -149,11 +51,128 @@ const loadDB = (() => {
return loadPromise; 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); loadedLocales.add(locale);
const db = await loadDB(); const db = await loadDB();
const trx = db.transaction(locale, 'readwrite'); 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; await trx.done;
} }
@ -161,7 +180,7 @@ export async function putCustomEmojiData({
emojis, emojis,
clear = false, clear = false,
}: { }: {
emojis: CustomEmojiData[]; emojis: ApiCustomEmojiJSON[];
clear?: boolean; clear?: boolean;
}) { }) {
const db = await loadDB(); const db = await loadDB();
@ -173,7 +192,9 @@ export async function putCustomEmojiData({
log('Cleared existing custom emojis in database'); 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; await trx.done;
log('Imported %d custom emojis into database', emojis.length); log('Imported %d custom emojis into database', emojis.length);
@ -210,32 +231,25 @@ export async function loadEmojiByHexcode(
localeString: string, localeString: string,
) { ) {
const db = await loadDB(); const db = await loadDB();
const locale = toLoadedLocale(localeString); const locale = await toLoadedLocale(localeString);
return db.get(locale, hexcode); const result = await db.get(locale, hexcode);
} if (result) {
return result;
}
export async function searchEmojisByHexcodes( // If the emoji wasn't found, check if it's a skin tone variant.
hexcodes: string[], const skinResult = await db.getFromIndex(
localeString: string,
) {
const db = await loadDB();
const locale = toLoadedLocale(localeString);
const sortedCodes = hexcodes.toSorted();
const results = await db.getAll(
locale, 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) { if (!skinResult) {
const db = await loadDB(); return skinResult;
const locale = toLoadedLocale(localeString); }
const range = IDBKeyRange.bound(
tag.toLowerCase(), // Reconstruct the full unicode string from the skin tone hexcode.
`${tag.toLowerCase()}\uffff`, return skinHexcodeToEmoji(hexcode, skinResult);
);
return db.getAllFromIndex(locale, 'tags', range);
} }
export async function loadCustomEmojiByShortcode(shortcode: string) { export async function loadCustomEmojiByShortcode(shortcode: string) {
@ -301,13 +315,16 @@ async function syncLocales(db: Database) {
log('Loaded %d locales: %o', loadedLocales.size, loadedLocales); log('Loaded %d locales: %o', loadedLocales.size, loadedLocales);
} }
function toLoadedLocale(localeString: string) { async function toLoadedLocale(localeString: string) {
const locale = toSupportedLocale(localeString); const locale = toSupportedLocale(localeString);
if (localeString !== locale) { if (localeString !== locale) {
log(`Locale ${locale} is different from provided ${localeString}`); log(`Locale ${locale} is different from provided ${localeString}`);
} }
if (!loadedLocales.has(locale)) { 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; return locale;
} }

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

View File

@ -1,10 +1,5 @@
import { flattenEmojiData } from 'emojibase'; import { joinShortcodes } from 'emojibase';
import type { import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
CompactEmoji,
FlatCompactEmoji,
Locale,
ShortcodesDataset,
} from 'emojibase';
import { import {
putEmojiData, putEmojiData,
@ -28,7 +23,7 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
shortcodes ? ' and shortcodes' : '', shortcodes ? ' and shortcodes' : '',
); );
const emojis = await fetchAndCheckEtag<CompactEmoji[]>({ let emojis = await fetchAndCheckEtag<CompactEmoji[]>({
etagString: locale, etagString: locale,
path: localeToEmojiPath(locale), path: localeToEmojiPath(locale),
}); });
@ -49,12 +44,10 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
} }
} }
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData( emojis = joinShortcodes(emojis, shortcodesData);
emojis,
shortcodesData, await putEmojiData(emojis, locale);
); return emojis;
await putEmojiData(flattenedEmojis, locale);
return flattenedEmojis;
} }
export async function importCustomEmojiData() { export async function importCustomEmojiData() {
@ -135,11 +128,11 @@ async function fetchAndCheckEtag<ResultType extends object[] | object>({
checkEtag?: boolean; checkEtag?: boolean;
}): Promise<ResultType | null> { }): Promise<ResultType | null> {
const etagName = toValidEtagName(etagString); const etagName = toValidEtagName(etagString);
const oldEtag = checkEtag ? await loadLatestEtag(etagName) : null;
// Use location.origin as this script may be loaded from a CDN domain. // Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(path, location.origin); const url = new URL(path, location.origin);
const oldEtag = checkEtag ? await loadLatestEtag(etagName) : null;
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -148,6 +141,7 @@ async function fetchAndCheckEtag<ResultType extends object[] | object>({
}); });
// If not modified, return null // If not modified, return null
if (response.status === 304) { if (response.status === 304) {
log('etag not modified for %s', etagName);
return null; return null;
} }
if (!response.ok) { if (!response.ok) {
@ -163,6 +157,8 @@ async function fetchAndCheckEtag<ResultType extends object[] | object>({
if (etag && checkEtag) { if (etag && checkEtag) {
log(`storing new etag for ${etagName}: ${etag}`); log(`storing new etag for ${etagName}: ${etag}`);
await putLatestEtag(etag, etagName); await putLatestEtag(etag, etagName);
} else if (!etag) {
log(`no etag found in response for ${etagName}`);
} }
return data; return data;

View File

@ -32,6 +32,13 @@ export function toValidEtagName(input: string): EtagTypes {
return toSupportedLocale(lower); 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 { function isSupportedLocale(locale: string): locale is Locale {
return SUPPORTED_LOCALES.includes(locale as Locale); return SUPPORTED_LOCALES.includes(locale as Locale);
} }

View File

@ -2,6 +2,7 @@
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js // See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
import { createAppSelector, useAppSelector } from '@/mastodon/store'; import { createAppSelector, useAppSelector } from '@/mastodon/store';
import { assetHost } from '@/mastodon/utils/config';
import { isDevelopment } from '@/mastodon/utils/environment'; import { isDevelopment } from '@/mastodon/utils/environment';
import { isDarkMode } from '@/mastodon/utils/theme'; import { isDarkMode } from '@/mastodon/utils/theme';
@ -29,6 +30,7 @@ export function useEmojiAppState(): EmojiAppState {
locales: [locale], locales: [locale],
mode, mode,
darkTheme: isDarkMode(), darkTheme: isDarkMode(),
assetHost,
}; };
} }

View File

@ -4,11 +4,7 @@ import { basename, resolve } from 'path';
import { flattenEmojiData } from 'emojibase'; import { flattenEmojiData } from 'emojibase';
import unicodeRawEmojis from 'emojibase-data/en/data.json'; import unicodeRawEmojis from 'emojibase-data/en/data.json';
import { import { unicodeToTwemojiHex } from './normalize';
twemojiToUnicodeInfo,
unicodeToTwemojiHex,
emojiToUnicodeHex,
} from './normalize';
const emojiSVGFiles = await readdir( const emojiSVGFiles = await readdir(
// This assumes tests are run from project root // This assumes tests are run from project root
@ -26,23 +22,6 @@ const svgFileNamesWithoutBorder = svgFileNames.filter(
const unicodeEmojis = flattenEmojiData(unicodeRawEmojis); 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', () => { describe('unicodeToTwemojiHex', () => {
test.concurrent.for( test.concurrent.for(
unicodeEmojis unicodeEmojis
@ -54,26 +33,3 @@ describe('unicodeToTwemojiHex', () => {
expect(svgFileNamesWithoutBorder).toContain(result); 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();
},
);
});

View File

@ -1,48 +1,124 @@
import { isList } from 'immutable'; 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 { import {
VARIATION_SELECTOR_CODE, VARIATION_SELECTOR_CODE,
KEYCAP_CODE, KEYCAP_CODE,
GENDER_FEMALE_CODE,
GENDER_MALE_CODE,
SKIN_TONE_CODES,
EMOJIS_WITH_DARK_BORDER, EMOJIS_WITH_DARK_BORDER,
EMOJIS_WITH_LIGHT_BORDER, EMOJIS_WITH_LIGHT_BORDER,
EMOJIS_REQUIRING_INVERSION_IN_LIGHT_MODE, EMOJIS_REQUIRING_INVERSION_IN_LIGHT_MODE,
EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE, EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE,
EMOJI_MIN_TOKEN_LENGTH,
} from './constants'; } 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 SKIN_TONE_MAP: Record<number, SkinTone> = {
const SKIER_CODE = 0x26f7; 0x1f3fb: 1, // Light skin tone
const CHRISTMAS_TREE_CODE = 0x1f384; 0x1f3fc: 2, // Medium-light skin tone
const MR_CLAUS_CODE = 0x1f385; 0x1f3fd: 3, // Medium skin tone
const EYE_CODE = 0x1f441; 0x1f3fe: 4, // Medium-dark skin tone
const LEVITATING_PERSON_CODE = 0x1f574; 0x1f3ff: 5, // Dark skin tone
const SPEECH_BUBBLE_CODE = 0x1f5e8; };
const MS_CLAUS_CODE = 0x1f936;
export function emojiToUnicodeHex(emoji: string): string { export function transformEmojiData(
const codes: number[] = []; emoji: CompactEmoji,
for (const char of emoji) { segmenter: Intl.Segmenter | null,
const code = char.codePointAt(0); ): UnicodeEmojiData {
if (code !== undefined) { const {
codes.push(code); 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. return res;
// See: https://emojibase.dev/docs/spec/#merged-variation-selectors
if (codes.at(1) === VARIATION_SELECTOR_CODE && codes.length === 2) {
codes.pop();
}
return hexNumbersToString(codes);
} }
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 { export function unicodeToTwemojiHex(unicodeHex: string): string {
const codes = hexStringToNumbers(unicodeHex); const codes = fromHexcodeToCodepoint(unicodeHex);
const normalizedCodes: number[] = []; const normalizedCodes: number[] = [];
for (let i = 0; i < codes.length; i++) { for (let i = 0; i < codes.length; i++) {
const code = codes[i]; const code = codes[i];
@ -64,19 +140,28 @@ export function unicodeToTwemojiHex(unicodeHex: string): string {
normalizedCodes.push(code); normalizedCodes.push(code);
} }
return hexNumbersToString(normalizedCodes, 0).toLowerCase(); return normalizedCodes
.map((code) => code.toString(16))
.join('-')
.toLowerCase();
} }
export const CODES_WITH_DARK_BORDER = const CODES_WITH_DARK_BORDER = EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
export const CODES_WITH_LIGHT_BORDER = const CODES_WITH_LIGHT_BORDER = EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
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); const normalizedHex = unicodeToTwemojiHex(unicodeHex);
let url = `${assetHost}/emoji/${normalizedHex}`; let url = `${assetHost}/emoji/${normalizedHex}`;
if (darkMode && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) { if (darkTheme && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
url += '_border'; url += '_border';
} }
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) { if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
@ -86,78 +171,6 @@ export function unicodeHexToUrl(unicodeHex: string, darkMode: boolean): string {
return url; 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 { export function emojiToInversionClassName(emoji: string): string | null {
if (EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE.includes(emoji)) { if (EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE.includes(emoji)) {
return 'invert-on-dark'; return 'invert-on-dark';
@ -189,19 +202,37 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
return extraEmojis; return extraEmojis;
} }
function hexStringToNumbers(hexString: string): number[] { /**
return hexString * Tokenizes an input string into words, using Intl.Segmenter if available.
.split('-') * @param input Any input string.
.map((code) => Number.parseInt(code, 16)) * @param segmenter Segmenter, if available.
.filter((code) => !Number.isNaN(code)); * @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 { // Prefer to use Intl.Segmenter if available for better locale support.
return codes if (segmenter) {
.filter( for (const { isWordLike, segment } of segmenter.segment(
(code): code is number => input.replaceAll('_', ' '), // Handle underscores from shortcodes.
typeof code === 'number' && code > 0 && !Number.isNaN(code), )) {
) if (isWordLike && segment.length >= EMOJI_MIN_TOKEN_LENGTH) {
.map((code) => code.toString(16).padStart(padding, '0').toUpperCase()) tokens.push(segment.toLowerCase());
.join('-'); }
}
} 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;
} }

View File

@ -4,7 +4,6 @@ import {
EMOJI_TYPE_UNICODE, EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM, EMOJI_TYPE_CUSTOM,
} from './constants'; } from './constants';
import { emojiToUnicodeHex } from './normalize';
import type { import type {
EmojiLoadedState, EmojiLoadedState,
EmojiMode, EmojiMode,
@ -16,6 +15,7 @@ import type {
import { import {
anyEmojiRegex, anyEmojiRegex,
emojiLogger, emojiLogger,
emojiToUnicodeHex,
isCustomEmoji, isCustomEmoji,
isUnicodeEmoji, isUnicodeEmoji,
stringHasUnicodeFlags, stringHasUnicodeFlags,
@ -140,12 +140,7 @@ export async function loadEmojiDataToState(
} }
// If not found, assume it's not an emoji and return null. // If not found, assume it's not an emoji and return null.
log( log('Could not find emoji %s for locale %s', state.code, locale);
'Could not find emoji %s of type %s for locale %s',
state.code,
state.type,
locale,
);
return null; return null;
} catch (err: unknown) { } catch (err: unknown) {
// If the locale is not loaded, load it and retry once. // If the locale is not loaded, load it and retry once.

View File

@ -1,6 +1,6 @@
import type { List as ImmutableList } from 'immutable'; 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 { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type { CustomEmoji } from '@/mastodon/models/custom_emoji'; import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
@ -32,10 +32,20 @@ export interface EmojiAppState {
currentLocale: Locale; currentLocale: Locale;
mode: EmojiMode; mode: EmojiMode;
darkTheme: boolean; darkTheme: boolean;
assetHost: string;
} }
export type CustomEmojiData = ApiCustomEmojiJSON; export type CustomEmojiData = ApiCustomEmojiJSON & { tokens: string[] };
export type UnicodeEmojiData = FlatCompactEmoji; 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; export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
type CustomEmojiRenderFields = Pick< type CustomEmojiRenderFields = Pick<

View File

@ -2,6 +2,8 @@ import debug from 'debug';
import { emojiRegexPolyfill } from '@/mastodon/polyfills'; import { emojiRegexPolyfill } from '@/mastodon/polyfills';
import { VARIATION_SELECTOR_CODE } from './constants';
export function emojiLogger(segment: string) { export function emojiLogger(segment: string) {
return debug(`emojis:${segment}`); 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() { function supportsRegExpSets() {
return 'unicodeSets' in RegExp.prototype; return 'unicodeSets' in RegExp.prototype;
} }

View File

@ -118,7 +118,7 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
? decodeIDNA(getHostname(card.get('url'))) ? decodeIDNA(getHostname(card.get('url')))
: card.get('provider_name'); : card.get('provider_name');
const interactive = card.get('type') === 'video'; 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 hasImage = (card.get('image')?.length ?? 0) > 0;
const largeImage = const largeImage =
(hasImage && card.get('width') > card.get('height')) || interactive; (hasImage && card.get('width') > card.get('height')) || interactive;
@ -131,7 +131,11 @@ const Card: React.FC<CardProps> = ({ card, sensitive }) => {
{card.get('published_at') && ( {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> </span>

View File

@ -23,7 +23,7 @@ const mapDispatchToProps = dispatch => ({
confirm: confirmationMessage.confirm, confirm: confirmationMessage.confirm,
onConfirm: () => dispatch(closeModal({ onConfirm: () => dispatch(closeModal({
modalType: undefined, modalType: undefined,
ignoreFocus: { ignoreFocus }, ignoreFocus,
})), })),
}, },
}), }),
@ -31,7 +31,7 @@ const mapDispatchToProps = dispatch => ({
} else { } else {
dispatch(closeModal({ dispatch(closeModal({
modalType: undefined, modalType: undefined,
ignoreFocus: { ignoreFocus }, ignoreFocus,
})); }));
} }
}, },

View File

@ -631,7 +631,7 @@
"navigation_panel.expand_followed_tags": "Επέκταση μενού ετικετών που ακολουθείτε", "navigation_panel.expand_followed_tags": "Επέκταση μενού ετικετών που ακολουθείτε",
"navigation_panel.expand_lists": "Επέκταση μενού λίστας", "navigation_panel.expand_lists": "Επέκταση μενού λίστας",
"not_signed_in_indicator.not_signed_in": "Πρέπει να συνδεθείς για να αποκτήσεις πρόσβαση σε αυτόν τον πόρο.", "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": "Ο χρήστης {name} ανέφερε {count, plural, one {μία ανάρτηση} other {# αναρτήσεις}} από {target} για {category}",
"notification.admin.report_account_other": "Ο χρήστης {name} ανέφερε {count, plural, one {μία ανάρτηση} other {# αναρτήσεις}} από {target}", "notification.admin.report_account_other": "Ο χρήστης {name} ανέφερε {count, plural, one {μία ανάρτηση} other {# αναρτήσεις}} από {target}",
"notification.admin.report_statuses": "Ο χρήστης {name} ανέφερε τον χρήστη {target} για {category}", "notification.admin.report_statuses": "Ο χρήστης {name} ανέφερε τον χρήστη {target} για {category}",

View File

@ -122,6 +122,8 @@
"annual_report.shared_page.donate": "Donera", "annual_report.shared_page.donate": "Donera",
"annual_report.shared_page.footer": "Genererad med {heart} av Mastodon-teamet", "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.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.booster.name": "Bågskytten",
"annual_report.summary.archetype.die_drei_fragezeichen": "???", "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.", "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.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.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_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.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_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.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.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.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.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_elsewhere": "Dela någon annanstans",
"annual_report.summary.share_message": "Jag fick {archetype}-arketypen!",
"annual_report.summary.share_on_mastodon": "Dela på Mastodon", "annual_report.summary.share_on_mastodon": "Dela på Mastodon",
"attachments_list.unprocessed": "(obehandlad)", "attachments_list.unprocessed": "(obehandlad)",
"audio.hide": "Dölj audio", "audio.hide": "Dölj audio",

View File

@ -7,8 +7,6 @@ export type { StatusVisibility } from 'mastodon/api_types/statuses';
// Temporary until we type it correctly // Temporary until we type it correctly
export type Status = Immutable.Map<string, unknown>; export type Status = Immutable.Map<string, unknown>;
type CardShape = Required<ApiPreviewCardJSON>; export type Card = RecordOf<ApiPreviewCardJSON>;
export type Card = RecordOf<CardShape>;
export type MediaAttachment = Immutable.Map<string, unknown>; export type MediaAttachment = Immutable.Map<string, unknown>;

View File

@ -6023,6 +6023,10 @@ a.status-card {
@media screen and (width <= $mobile-breakpoint) { @media screen and (width <= $mobile-breakpoint) {
margin-top: auto; margin-top: auto;
&.video-modal {
margin-top: 0;
}
} }
} }

View File

@ -119,6 +119,9 @@ export function unicodeEmojiFactory(
label: 'Test', label: 'Test',
unicode: '🧪', unicode: '🧪',
shortcodes: ['test_emoji'], shortcodes: ['test_emoji'],
tokens: ['emoji', 'test'],
group: 1,
order: 1,
...data, ...data,
}; };
} }
@ -131,6 +134,7 @@ export function customEmojiFactory(
static_url: '/custom-emoji/logo.svg', static_url: '/custom-emoji/logo.svg',
url: '/custom-emoji/logo.svg', url: '/custom-emoji/logo.svg',
visible_in_picker: true, visible_in_picker: true,
tokens: ['custom'],
...data, ...data,
}; };
} }

View File

@ -21,14 +21,13 @@ class ActivityPub::Activity
class << self class << self
def factory(json, account, **) def factory(json, account, **)
@json = json klass_for(json)&.new(json, account, **)
klass&.new(json, account, **)
end end
private private
def klass def klass_for(json)
case @json['type'] case json['type']
when 'Create' when 'Create'
ActivityPub::Activity::Create ActivityPub::Activity::Create
when 'Announce' when 'Announce'

View File

@ -47,7 +47,7 @@ class ActivityPub::Activity::QuoteRequest < ActivityPub::Activity
# NOTE: Replacing the object's context by that of the parent activity is # 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 # not sound, but it's consistent with the rest of the codebase
instrument = @json['instrument'].merge({ '@context' => @json['@context'] }) 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]) ActivityPub::FetchRemoteStatusService.new.call(instrument['id'], prefetched_body: instrument, on_behalf_of: quoted_status.account, request_id: @options[:request_id])
end end

View File

@ -50,7 +50,7 @@ class ActivityPub::TagManager
context_url(target) unless target.parent_account_id.nil? || target.parent_status_id.nil? context_url(target) unless target.parent_account_id.nil? || target.parent_status_id.nil?
when :note, :comment, :activity when :note, :comment, :activity
if target.account.numeric_ap_id? 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) ap_account_status_url(target.account.id, target)
else else

View File

@ -41,12 +41,17 @@ class ConnectionPool::SharedConnectionPool < ConnectionPool
# ConnectionPool 2.4+ calls `checkin(force: true)` after fork. # ConnectionPool 2.4+ calls `checkin(force: true)` after fork.
# When this happens, we should remove all connections from Thread.current # When this happens, we should remove all connections from Thread.current
::Thread.current.keys.each do |name| # rubocop:disable Style/HashEachMethods connection_keys = ::Thread.current.keys.select { |key| key.to_s.start_with?("#{@key}-") && !key.to_s.start_with?("#{@key_count}-") }
next unless name.to_s.start_with?("#{@key}-") count_keys = ::Thread.current.keys.select { |key| key.to_s.start_with?("#{@key_count}-") }
@available.push(::Thread.current[name]) connection_keys.each do |key|
::Thread.current[name] = nil @available.push(::Thread.current[key])
::Thread.current[key] = nil
end end
count_keys.each do |key|
::Thread.current[key] = nil
end
elsif ::Thread.current[key(preferred_tag)] elsif ::Thread.current[key(preferred_tag)]
if ::Thread.current[key_count(preferred_tag)] == 1 if ::Thread.current[key_count(preferred_tag)] == 1
@available.push(::Thread.current[key(preferred_tag)]) @available.push(::Thread.current[key(preferred_tag)])

View File

@ -25,9 +25,13 @@ class SignatureParser
# Use `skip` instead of `scan` as we only care about the subgroups # Use `skip` instead of `scan` as we only care about the subgroups
while scanner.skip(PARAM_RE) 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 # This is not actually correct with regards to quoted pairs, but it's consistent
# with our previous implementation, and good enough in practice. # 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*/) scanner.skip(/\s*/)
return params if scanner.eos? return params if scanner.eos?

View File

@ -12,13 +12,13 @@
\- \-
- else - else
= friendly_number_to_human account.statuses_count = 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 %td.accounts-table__count.optional
- if account.unavailable? || account.user_pending? - if account.unavailable? || account.user_pending?
\- \-
- else - else
= friendly_number_to_human account.followers_count = 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 %td.accounts-table__count
= relevant_account_timestamp(account) = relevant_account_timestamp(account)
%small= t('accounts.last_active') %small= t('accounts.last_active')

View File

@ -8,10 +8,10 @@
%td= account_link_to account %td= account_link_to account
%td.accounts-table__count.optional %td.accounts-table__count.optional
= friendly_number_to_human account.statuses_count = 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 %td.accounts-table__count.optional
= friendly_number_to_human account.followers_count = 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 %td.accounts-table__count
- if account.last_status_at.present? - 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 %time.time-ago{ datetime: account.last_status_at.to_date.iso8601, title: l(account.last_status_at.to_date) }= l account.last_status_at

View File

@ -24,13 +24,13 @@
.account-card__counters .account-card__counters
.account-card__counters__item .account-card__counters__item
= friendly_number_to_human report.target_account.statuses_count = 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 .account-card__counters__item
= friendly_number_to_human report.target_account.followers_count = 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 .account-card__counters__item
= friendly_number_to_human report.target_account.following_count = 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 .account-card__actions__button
= link_to t('admin.reports.view_profile'), admin_account_path(report.target_account_id), class: 'button' = link_to t('admin.reports.view_profile'), admin_account_path(report.target_account_id), class: 'button'
.report-header__details.report-header__details--horizontal .report-header__details.report-header__details--horizontal

View File

@ -10,10 +10,10 @@
%td= account_link_to account %td= account_link_to account
%td.accounts-table__count.optional %td.accounts-table__count.optional
= friendly_number_to_human account.statuses_count = 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 %td.accounts-table__count.optional
= friendly_number_to_human account.followers_count = 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 %td.accounts-table__count
- if account.last_status_at.present? - 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 %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

View File

@ -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. 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. success: Váš bezpečnostní klíč byl úspěšně odstraněn.
invalid_credential: Neplatný bezpečnostní klíč invalid_credential: Neplatný bezpečnostní klíč
nickname: Přezdívka
nickname_hint: Zadejte přezdívku nového bezpečnostního klíče nickname_hint: Zadejte přezdívku nového bezpečnostního klíče
not_enabled: Zatím jste nepovolili WebAuthn not_enabled: Zatím jste nepovolili WebAuthn
not_supported: Tento prohlížeč nepodporuje bezpečnostní klíče not_supported: Tento prohlížeč nepodporuje bezpečnostní klíče

View File

@ -2144,9 +2144,11 @@ cy:
enabled: Dileu hen bostiadau'n awtomatig 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 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 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_favs: Anwybyddu ffefrynnau
ignore_reblogs: Anwybyddu hybiau ignore_reblogs: Anwybyddu hybiau
interaction_exceptions: Eithriadau yn seiliedig ar ryngweithio 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: Cadw negeseuon uniongyrchol
keep_direct_hint: Nid yw'n dileu unrhyw un o'ch negeseuon uniongyrchol keep_direct_hint: Nid yw'n dileu unrhyw un o'ch negeseuon uniongyrchol
keep_media: Cadw postiadau gydag atodiadau cyfryngau 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. error: Bu anhawster wrth ddileu eich allwedd ddiogelwch. Ceisiwch eto, os gwelwch yn dda.
success: Cafodd eich allwedd ddiogelwch ei dileu'n llwyddiannus. success: Cafodd eich allwedd ddiogelwch ei dileu'n llwyddiannus.
invalid_credential: Allwedd ddiogelwch annilys invalid_credential: Allwedd ddiogelwch annilys
nickname: Llysenw
nickname_hint: Rhowch lysenw eich allwedd ddiogelwch newydd nickname_hint: Rhowch lysenw eich allwedd ddiogelwch newydd
not_enabled: Nid ydych wedi galluogi WebAuthn eto not_enabled: Nid ydych wedi galluogi WebAuthn eto
not_supported: Nid yw'r porwr hwn yn cynnal allweddi diogelwch not_supported: Nid yw'r porwr hwn yn cynnal allweddi diogelwch

View File

@ -597,7 +597,7 @@ el:
public_comment: Δημόσιο σχόλιο public_comment: Δημόσιο σχόλιο
purge: Εκκαθάριση purge: Εκκαθάριση
purge_description_html: Εάν πιστεύεις ότι αυτός ο τομέας είναι εκτός σύνδεσης μόνιμα, μπορείς να διαγράψεις όλες τις καταχωρήσεις λογαριασμών και τα σχετικά δεδομένα από αυτόν τον τομέα από τον αποθηκευτικό σου χώρο. Αυτό μπορεί να διαρκέσει λίγη ώρα. purge_description_html: Εάν πιστεύεις ότι αυτός ο τομέας είναι εκτός σύνδεσης μόνιμα, μπορείς να διαγράψεις όλες τις καταχωρήσεις λογαριασμών και τα σχετικά δεδομένα από αυτόν τον τομέα από τον αποθηκευτικό σου χώρο. Αυτό μπορεί να διαρκέσει λίγη ώρα.
title: Συναλλαγές title: Ομοσπονδία
total_blocked_by_us: Αποκλεισμένοι από εμάς total_blocked_by_us: Αποκλεισμένοι από εμάς
total_followed_by_them: Ακολουθούνται από εκείνους total_followed_by_them: Ακολουθούνται από εκείνους
total_followed_by_us: Ακολουθούνται από εμάς total_followed_by_us: Ακολουθούνται από εμάς
@ -772,7 +772,7 @@ el:
manage_blocks_description: Επιτρέπει στους χρήστες να αποκλείουν παρόχους email και διευθύνσεις IP manage_blocks_description: Επιτρέπει στους χρήστες να αποκλείουν παρόχους email και διευθύνσεις IP
manage_custom_emojis: Διαχείριση Προσαρμοσμένων Emojis manage_custom_emojis: Διαχείριση Προσαρμοσμένων Emojis
manage_custom_emojis_description: Επιτρέπει στους χρήστες να διαχειρίζονται προσαρμοσμένα emojis στον διακομιστή manage_custom_emojis_description: Επιτρέπει στους χρήστες να διαχειρίζονται προσαρμοσμένα emojis στον διακομιστή
manage_federation: Διαχείριση Συναλλαγών manage_federation: Διαχείριση Ομοσπονδίας
manage_federation_description: Επιτρέπει στους χρήστες να αποκλείουν ή να επιτρέπουν τις συναλλαγές με άλλους τομείς και να ελέγχουν την παράδοση manage_federation_description: Επιτρέπει στους χρήστες να αποκλείουν ή να επιτρέπουν τις συναλλαγές με άλλους τομείς και να ελέγχουν την παράδοση
manage_invites: Διαχείριση Προσκλήσεων manage_invites: Διαχείριση Προσκλήσεων
manage_invites_description: Επιτρέπει στους χρήστες να περιηγούνται και να απενεργοποιούν τους συνδέσμους πρόσκλησης manage_invites_description: Επιτρέπει στους χρήστες να περιηγούνται και να απενεργοποιούν τους συνδέσμους πρόσκλησης

View File

@ -1973,9 +1973,11 @@ fi:
enabled: Poista vanhat julkaisut automaattisesti enabled: Poista vanhat julkaisut automaattisesti
enabled_hint: Poistaa julkaisusi automaattisesti, kun ne saavuttavat valitun ikäkynnyksen, ellei jokin alla olevista poikkeuksista tule kyseeseen enabled_hint: Poistaa julkaisusi automaattisesti, kun ne saavuttavat valitun ikäkynnyksen, ellei jokin alla olevista poikkeuksista tule kyseeseen
exceptions: Poikkeukset exceptions: Poikkeukset
explanation: Automaattipoisto suoritetaan pienellä prioriteetilla. Ikäkynnyksen saavuttamisen ja poistetuksi tulemisen välillä saattaa olla viive.
ignore_favs: Ohita suosikit ignore_favs: Ohita suosikit
ignore_reblogs: Ohita tehostukset ignore_reblogs: Ohita tehostukset
interaction_exceptions: Vuorovaikutuksiin perustuvat poikkeukset 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: Säilytä yksityisviestit
keep_direct_hint: Ei poista yksityisviestejäsi keep_direct_hint: Ei poista yksityisviestejäsi
keep_media: Säilytä julkaisut, joissa on medialiitteitä keep_media: Säilytä julkaisut, joissa on medialiitteitä

View File

@ -2190,6 +2190,7 @@ fo:
error: Ein trupulleiki var við at strika trygdarlykilin hjá tær. Vinarliga royn aftur. error: Ein trupulleiki var við at strika trygdarlykilin hjá tær. Vinarliga royn aftur.
success: Trygdarlykilin hjá tær varð strikaður. success: Trygdarlykilin hjá tær varð strikaður.
invalid_credential: Ógyldugur trygdarlykil invalid_credential: Ógyldugur trygdarlykil
nickname: Kallinavn
nickname_hint: Skriva eyknevni á tínum nýggja trygdarlykli nickname_hint: Skriva eyknevni á tínum nýggja trygdarlykli
not_enabled: Tú hevur ikki gjørt WebAuthn virkið enn not_enabled: Tú hevur ikki gjørt WebAuthn virkið enn
not_supported: Hesin kagin stuðlar ikki uppundir trygdarlyklar not_supported: Hesin kagin stuðlar ikki uppundir trygdarlyklar

View File

@ -2190,6 +2190,7 @@ gl:
error: Houbo un problema ó eliminar a túa chave de seguridade, inténtao outra vez. error: Houbo un problema ó eliminar a túa chave de seguridade, inténtao outra vez.
success: Eliminouse correctamente a chave de seguridade. success: Eliminouse correctamente a chave de seguridade.
invalid_credential: Chave de seguridade non válida invalid_credential: Chave de seguridade non válida
nickname: Sobrenome
nickname_hint: Escribe un alcume para a túa nova chave de seguridade nickname_hint: Escribe un alcume para a túa nova chave de seguridade
not_enabled: Aínda non tes activado WebAuthn not_enabled: Aínda non tes activado WebAuthn
not_supported: Este navegador non ten soporte para chaves de seguridade not_supported: Este navegador non ten soporte para chaves de seguridade

View File

@ -2190,6 +2190,7 @@ it:
error: Si è verificato un problema durante la cancellazione della chiave di sicurezza. Dovresti riprovare. error: Si è verificato un problema durante la cancellazione della chiave di sicurezza. Dovresti riprovare.
success: La chiave di sicurezza è stata cancellata. success: La chiave di sicurezza è stata cancellata.
invalid_credential: Chiave di sicurezza non valida invalid_credential: Chiave di sicurezza non valida
nickname: Soprannome
nickname_hint: Inserisci il soprannome della tua nuova chiave di sicurezza nickname_hint: Inserisci il soprannome della tua nuova chiave di sicurezza
not_enabled: Non hai ancora abilitato WebAuthn not_enabled: Non hai ancora abilitato WebAuthn
not_supported: Questo browser non supporta le chiavi di sicurezza not_supported: Questo browser non supporta le chiavi di sicurezza

View File

@ -243,6 +243,8 @@ cy:
setting_always_send_emails: Anfonwch hysbysiadau e-bost bob amser setting_always_send_emails: Anfonwch hysbysiadau e-bost bob amser
setting_auto_play_gif: Chwarae GIFs wedi'u hanimeiddio yn awtomatig setting_auto_play_gif: Chwarae GIFs wedi'u hanimeiddio yn awtomatig
setting_boost_modal: Rheoli hybu gwelededd setting_boost_modal: Rheoli hybu gwelededd
setting_color_scheme: Modd
setting_contrast: Cyferbyniad
setting_default_language: Iaith postio setting_default_language: Iaith postio
setting_default_privacy: Gwelededd postio setting_default_privacy: Gwelededd postio
setting_default_quote_policy: Pwy sy'n gallu dyfynnu setting_default_quote_policy: Pwy sy'n gallu dyfynnu
@ -317,6 +319,7 @@ cy:
thumbnail: Bawdlun y gweinydd thumbnail: Bawdlun y gweinydd
trendable_by_default: Caniatáu pynciau llosg heb adolygiad trendable_by_default: Caniatáu pynciau llosg heb adolygiad
trends: Galluogi pynciau llosg trends: Galluogi pynciau llosg
wrapstodon: Galluogi Wrapstodon
interactions: interactions:
must_be_follower: Blocio hysbysiadau o bobl nad ydynt yn eich dilyn must_be_follower: Blocio hysbysiadau o bobl nad ydynt yn eich dilyn
must_be_following: Blocio hysbysiadau o bobl nad ydych yn eu dilyn must_be_following: Blocio hysbysiadau o bobl nad ydych yn eu dilyn

View File

@ -65,6 +65,7 @@ sv:
setting_display_media_hide_all: Dölj alltid all media setting_display_media_hide_all: Dölj alltid all media
setting_display_media_show_all: Visa alltid media markerad som känslig 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_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_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_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 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: featured_tag:
name: 'Här är några av de hashtaggar du använt nyligen:' name: 'Här är några av de hashtaggar du använt nyligen:'
filters: filters:
action: Välj vilken åtgärd som ska utföras när ett inlägg matchar filtret
actions: actions:
blur: Dölj media bakom en varning utan att dölja själva texten 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 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 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. 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. 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 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. 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. 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. 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. 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. 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 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. 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. 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. 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: form_challenge:
current_password: Du går in i ett säkert område current_password: Du går in i ett säkert område
imports: imports:
@ -233,6 +238,7 @@ sv:
setting_aggregate_reblogs: Gruppera boostar i tidslinjer setting_aggregate_reblogs: Gruppera boostar i tidslinjer
setting_always_send_emails: Skicka alltid e-postnotiser setting_always_send_emails: Skicka alltid e-postnotiser
setting_auto_play_gif: Spela upp GIF:ar automatiskt setting_auto_play_gif: Spela upp GIF:ar automatiskt
setting_boost_modal: Kontrollera boost-synlighet
setting_color_scheme: Läge setting_color_scheme: Läge
setting_contrast: Kontrast setting_contrast: Kontrast
setting_default_language: Inläggsspråk setting_default_language: Inläggsspråk

View File

@ -7,6 +7,8 @@ sv:
hosted_on: Mastodon-värd på %{domain} hosted_on: Mastodon-värd på %{domain}
title: Om title: Om
accounts: accounts:
errors:
cannot_be_added_to_collections: Detta konto kan inte läggas till i samlingar.
followers: followers:
one: Följare one: Följare
other: Följare other: Följare
@ -1714,12 +1716,16 @@ sv:
body: 'Du nämndes av %{name} i:' body: 'Du nämndes av %{name} i:'
subject: Du nämndes av %{name} subject: Du nämndes av %{name}
title: Ny omnämning title: Ny omnämning
moderation_warning:
subject: Du har mottagit en modereringsvarning
poll: poll:
subject: En undersökning av %{name} har avslutats subject: En undersökning av %{name} har avslutats
quote: quote:
body: 'Ditt inlägg citerades av %{name}:' body: 'Ditt inlägg citerades av %{name}:'
subject: "%{name} citerade ditt inlägg" subject: "%{name} citerade ditt inlägg"
title: Nytt citat title: Nytt citat
quoted_update:
subject: "%{name} redigerade ett inlägg du har citerat"
reblog: reblog:
body: 'Ditt inlägg boostades av %{name}:' body: 'Ditt inlägg boostades av %{name}:'
subject: "%{name} boostade ditt inlägg" subject: "%{name} boostade ditt inlägg"
@ -2192,3 +2198,4 @@ sv:
registered_on: Registrerad den %{date} registered_on: Registrerad den %{date}
wrapstodon: wrapstodon:
description: Se hur %{name} använde Mastodon i år! description: Se hur %{name} använde Mastodon i år!
title: Wrapstodon %{year} för %{name}

View File

@ -128,6 +128,28 @@ RSpec.describe ActivityPub::TagManager do
.to eq("#{host_prefix}/ap/users/#{status.account.id}/statuses/#{status.id}") .to eq("#{host_prefix}/ap/users/#{status.account.id}/statuses/#{status.id}")
end end
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 end
context 'with a remote status' do context 'with a remote status' do

View File

@ -12581,8 +12581,8 @@ __metadata:
linkType: hard linkType: hard
"sass@npm:^1.62.1, sass@npm:^1.70.0": "sass@npm:^1.62.1, sass@npm:^1.70.0":
version: 1.97.1 version: 1.97.2
resolution: "sass@npm:1.97.1" resolution: "sass@npm:1.97.2"
dependencies: dependencies:
"@parcel/watcher": "npm:^2.4.1" "@parcel/watcher": "npm:^2.4.1"
chokidar: "npm:^4.0.0" chokidar: "npm:^4.0.0"
@ -12593,7 +12593,7 @@ __metadata:
optional: true optional: true
bin: bin:
sass: sass.js sass: sass.js
checksum: 10c0/c389d5d6405869b49fa2291e8328500fe7936f3b72136bc2c338bee6e7fec936bb9a48d77a1310dea66aa4669ba74ae6b82a112eb32521b9b36d740138a39ea0 checksum: 10c0/5622a4f9d0acf5cdfb72c73c448366b3ec7bfe2b6e1ecb62c6e9f8eaff9c02a6afd86a31ded246fc818fe660cedea6bd27335e82fc9bd7cce46c29f95774bece
languageName: node languageName: node
linkType: hard linkType: hard
@ -15043,8 +15043,8 @@ __metadata:
linkType: hard linkType: hard
"ws@npm:^8.12.1, ws@npm:^8.18.0, ws@npm:^8.18.3": "ws@npm:^8.12.1, ws@npm:^8.18.0, ws@npm:^8.18.3":
version: 8.18.3 version: 8.19.0
resolution: "ws@npm:8.18.3" resolution: "ws@npm:8.19.0"
peerDependencies: peerDependencies:
bufferutil: ^4.0.1 bufferutil: ^4.0.1
utf-8-validate: ">=5.0.2" utf-8-validate: ">=5.0.2"
@ -15053,7 +15053,7 @@ __metadata:
optional: true optional: true
utf-8-validate: utf-8-validate:
optional: true optional: true
checksum: 10c0/eac918213de265ef7cb3d4ca348b891a51a520d839aa51cdb8ca93d4fa7ff9f6ccb339ccee89e4075324097f0a55157c89fa3f7147bde9d8d7e90335dc087b53 checksum: 10c0/4741d9b9bc3f9c791880882414f96e36b8b254e34d4b503279d6400d9a4b87a033834856dbdd94ee4b637944df17ea8afc4bce0ff4a1560d2166be8855da5b04
languageName: node languageName: node
linkType: hard linkType: hard