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

This commit is contained in:
Claire
2026-01-10 13:12:36 +01:00
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 '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'

View File

@@ -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

View File

@@ -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)

View File

@@ -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[];
}

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 (
<img

View File

@@ -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';

View File

@@ -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',

View File

@@ -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;
}

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 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;

View File

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

View File

@@ -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,
};
}

View File

@@ -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();
},
);
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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<

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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,
}));
}
},

View File

@@ -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}",

View File

@@ -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",

View File

@@ -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>;

View File

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

View File

@@ -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,
};
}

View File

@@ -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'

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
# 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

View File

@@ -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

View File

@@ -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)])

View File

@@ -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?

View File

@@ -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')

View File

@@ -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

View File

@@ -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

View File

@@ -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

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.
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

View File

@@ -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

View File

@@ -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: Επιτρέπει στους χρήστες να περιηγούνται και να απενεργοποιούν τους συνδέσμους πρόσκλησης

View File

@@ -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ä

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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