diff --git a/Gemfile b/Gemfile index f452eca802..3cc9580fea 100644 --- a/Gemfile +++ b/Gemfile @@ -24,7 +24,7 @@ gem 'ruby-vips', '~> 2.2', require: false gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' -gem 'bootsnap', '~> 1.19.0', require: false +gem 'bootsnap', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' diff --git a/Gemfile.lock b/Gemfile.lock index d9e2419e64..bf76d1fbcd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,7 +118,7 @@ GEM rexml base64 (0.3.0) bcp47_spec (0.2.1) - bcrypt (3.1.20) + bcrypt (3.1.21) benchmark (0.5.0) better_errors (2.10.1) erubi (>= 1.0.0) @@ -129,7 +129,7 @@ GEM binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) blurhash (0.1.8) - bootsnap (1.19.0) + bootsnap (1.20.1) msgpack (~> 1.2) brakeman (7.1.2) racc @@ -240,7 +240,7 @@ GEM faraday-net_http (>= 2.0, < 3.5) json logger - faraday-follow_redirects (0.4.0) + faraday-follow_redirects (0.5.0) faraday (>= 1, < 3) faraday-httpclient (2.0.2) httpclient (>= 2.2) @@ -248,7 +248,7 @@ GEM net-http (~> 0.5) fast_blank (1.0.1) fastimage (2.4.0) - ffi (1.17.2) + ffi (1.17.3) ffi-compiler (1.3.2) ffi (>= 1.15.5) rake @@ -271,7 +271,7 @@ GEM fog-json (>= 1.0) formatador (1.2.3) reline - forwardable (1.3.3) + forwardable (1.4.0) fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) @@ -298,14 +298,15 @@ GEM rubocop (>= 1.0) sysexits (~> 1.1) hashdiff (1.2.1) - hashie (5.0.0) + hashie (5.1.0) + logger hcaptcha (7.1.0) json highline (3.1.2) reline hiredis (0.6.3) - hiredis-client (0.26.2) - redis-client (= 0.26.2) + hiredis-client (0.26.3) + redis-client (= 0.26.3) hkdf (0.3.0) htmlentities (4.3.4) http (5.3.1) @@ -427,7 +428,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.24.1) + loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -447,13 +448,14 @@ GEM mime-types-data (3.2025.0924) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.27.0) + minitest (6.0.1) + prism (~> 1.5) msgpack (1.8.0) - multi_json (1.18.0) + multi_json (1.19.1) mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.6.0) + net-imap (0.6.2) date net-protocol net-ldap (0.20.0) @@ -466,7 +468,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.18.10) + nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.13) @@ -591,7 +593,7 @@ GEM parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.6.2) + pg (1.6.3) pghero (3.7.0) activerecord (>= 7.1) playwright-ruby-client (1.57.1) @@ -609,7 +611,7 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - prism (1.6.0) + prism (1.7.0) prometheus_exporter (2.3.1) webrick propshaft (1.3.1) @@ -696,7 +698,7 @@ GEM readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.17.0) + rdoc (7.0.3) erb psych (>= 4.0.0) tsort @@ -704,7 +706,7 @@ GEM reline redcarpet (3.6.1) redis (4.8.1) - redis-client (0.26.2) + redis-client (0.26.3) connection_pool regexp_parser (2.11.3) reline (0.6.3) @@ -716,7 +718,7 @@ GEM railties (>= 7.0) rexml (3.4.4) rotp (6.3.0) - rouge (4.6.1) + rouge (4.7.0) rpam2 (4.0.2) rqrcode (3.1.1) chunky_png (~> 1.0) @@ -761,9 +763,9 @@ GEM rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.48.0) + rubocop-ast (1.49.0) parser (>= 3.3.7.2) - prism (~> 1.4) + prism (~> 1.7) rubocop-capybara (2.22.1) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) @@ -774,7 +776,7 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.34.2) + rubocop-rails (2.34.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -860,7 +862,7 @@ GEM test-prof (1.5.0) thor (1.4.0) tilt (2.6.1) - timeout (0.5.0) + timeout (0.6.0) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) @@ -888,7 +890,7 @@ GEM unf_ext (0.0.9.1) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) - unicode-emoji (4.1.0) + unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) validate_url (1.0.15) @@ -944,7 +946,7 @@ DEPENDENCIES better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.19.0) + bootsnap brakeman (~> 7.0) browser bundler-audit (~> 0.9) @@ -1091,7 +1093,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.4.1p0 + ruby 3.4.8 BUNDLED WITH - 4.0.2 + 4.0.3 diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index b1b09f2aab..3527cdaca0 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -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) diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index 1451ea82a0..d61d8ceed0 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -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[]; } diff --git a/app/javascript/mastodon/components/emoji/index.tsx b/app/javascript/mastodon/components/emoji/index.tsx index 0b1ba7fef3..9b917df6ea 100644 --- a/app/javascript/mastodon/components/emoji/index.tsx +++ b/app/javascript/mastodon/components/emoji/index.tsx @@ -88,7 +88,10 @@ export const Emoji: FC = ({ ); } - const src = unicodeHexToUrl(state.code, appState.darkTheme); + const src = unicodeHexToUrl({ + unicodeHex: state.code, + ...appState, + }); return ( = {}): 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', diff --git a/app/javascript/mastodon/features/emoji/database.ts b/app/javascript/mastodon/features/emoji/database.ts index fe4010a861..f64f3fb80d 100644 --- a/app/javascript/mastodon/features/emoji/database.ts +++ b/app/javascript/mastodon/features/emoji/database.ts @@ -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; - -type Database = IDBPDatabase; - -const SCHEMA_VERSION = 2; - const loadedLocales = new Set(); const log = emojiLogger('database'); @@ -60,75 +30,7 @@ const loadDB = (() => { // Actually load the DB. async function initDB() { - const db = await openDB('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[] = []; + 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([ + ...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(); + 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; } diff --git a/app/javascript/mastodon/features/emoji/db-schema.ts b/app/javascript/mastodon/features/emoji/db-schema.ts new file mode 100644 index 0000000000..f5582cf0c3 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/db-schema.ts @@ -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; + +type Transaction = + IDBPTransaction[], Mode>; + +export type Database = IDBPDatabase; + +const SCHEMA_VERSION = 3; + +export async function openEmojiDB() { + const db = await openDB('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>({ + trx, + storeName, + indexName, + keys, + options, +}: { + trx: Transaction; + storeName: StoreName; + indexName: IndexNames; + 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, + indexes: readonly string[], +) { + for (const index of indexes) { + if (table.indexNames.contains(index)) { + table.deleteIndex(index); + } + } +} diff --git a/app/javascript/mastodon/features/emoji/loader.ts b/app/javascript/mastodon/features/emoji/loader.ts index c6b64fe29c..7a94d604a9 100644 --- a/app/javascript/mastodon/features/emoji/loader.ts +++ b/app/javascript/mastodon/features/emoji/loader.ts @@ -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({ + let emojis = await fetchAndCheckEtag({ 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({ checkEtag?: boolean; }): Promise { 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({ }); // 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({ 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; diff --git a/app/javascript/mastodon/features/emoji/locale.ts b/app/javascript/mastodon/features/emoji/locale.ts index f39b56d47c..e8f2df340f 100644 --- a/app/javascript/mastodon/features/emoji/locale.ts +++ b/app/javascript/mastodon/features/emoji/locale.ts @@ -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); } diff --git a/app/javascript/mastodon/features/emoji/mode.ts b/app/javascript/mastodon/features/emoji/mode.ts index 6950626375..6763fc4b50 100644 --- a/app/javascript/mastodon/features/emoji/mode.ts +++ b/app/javascript/mastodon/features/emoji/mode.ts @@ -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, }; } diff --git a/app/javascript/mastodon/features/emoji/normalize.test.ts b/app/javascript/mastodon/features/emoji/normalize.test.ts index 8222ab81e5..8c6346709b 100644 --- a/app/javascript/mastodon/features/emoji/normalize.test.ts +++ b/app/javascript/mastodon/features/emoji/normalize.test.ts @@ -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(); - }, - ); -}); diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index 24df808ae1..bf06e058ef 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -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 = { + 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 = { - [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 = { - '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; } diff --git a/app/javascript/mastodon/features/emoji/render.ts b/app/javascript/mastodon/features/emoji/render.ts index 8fe311014a..4b65f3abde 100644 --- a/app/javascript/mastodon/features/emoji/render.ts +++ b/app/javascript/mastodon/features/emoji/render.ts @@ -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. diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts index 03002dda64..8ab756972a 100644 --- a/app/javascript/mastodon/features/emoji/types.ts +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -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< diff --git a/app/javascript/mastodon/features/emoji/utils.ts b/app/javascript/mastodon/features/emoji/utils.ts index c567afc2cc..d15aa96150 100644 --- a/app/javascript/mastodon/features/emoji/utils.ts +++ b/app/javascript/mastodon/features/emoji/utils.ts @@ -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; } diff --git a/app/javascript/mastodon/features/status/components/card.tsx b/app/javascript/mastodon/features/status/components/card.tsx index d060d35c2c..68eea5ea36 100644 --- a/app/javascript/mastodon/features/status/components/card.tsx +++ b/app/javascript/mastodon/features/status/components/card.tsx @@ -118,7 +118,7 @@ const Card: React.FC = ({ 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 = ({ card, sensitive }) => { {card.get('published_at') && ( <> {' '} - · + ·{' '} + )} diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js index fe87380431..2bf771e2a3 100644 --- a/app/javascript/mastodon/features/ui/containers/modal_container.js +++ b/app/javascript/mastodon/features/ui/containers/modal_container.js @@ -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, })); } }, diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index 384a152268..c2cf2a7231 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -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}", diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json index 461d728408..67b8aea8c1 100644 --- a/app/javascript/mastodon/locales/sv.json +++ b/app/javascript/mastodon/locales/sv.json @@ -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": "Det placerar dig i toppbland {domain} användare.", "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", diff --git a/app/javascript/mastodon/models/status.ts b/app/javascript/mastodon/models/status.ts index 7f9144280c..b043edb9ca 100644 --- a/app/javascript/mastodon/models/status.ts +++ b/app/javascript/mastodon/models/status.ts @@ -7,8 +7,6 @@ export type { StatusVisibility } from 'mastodon/api_types/statuses'; // Temporary until we type it correctly export type Status = Immutable.Map; -type CardShape = Required; - -export type Card = RecordOf; +export type Card = RecordOf; export type MediaAttachment = Immutable.Map; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 47f17f6e79..9546076403 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -6023,6 +6023,10 @@ a.status-card { @media screen and (width <= $mobile-breakpoint) { margin-top: auto; + + &.video-modal { + margin-top: 0; + } } } diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index 26b020d8c2..6f2a45e58f 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -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, }; } diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 64ee9acd05..d07d1c2f24 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -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' diff --git a/app/lib/activitypub/activity/quote_request.rb b/app/lib/activitypub/activity/quote_request.rb index 12f48ebb2b..46c45cde27 100644 --- a/app/lib/activitypub/activity/quote_request.rb +++ b/app/lib/activitypub/activity/quote_request.rb @@ -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 diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 43574d3657..3174d1792e 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -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 diff --git a/app/lib/connection_pool/shared_connection_pool.rb b/app/lib/connection_pool/shared_connection_pool.rb index 1cfcc5823b..c7dd747eda 100644 --- a/app/lib/connection_pool/shared_connection_pool.rb +++ b/app/lib/connection_pool/shared_connection_pool.rb @@ -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)]) diff --git a/app/lib/signature_parser.rb b/app/lib/signature_parser.rb index 7a75080d98..00a45b8251 100644 --- a/app/lib/signature_parser.rb +++ b/app/lib/signature_parser.rb @@ -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? diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml index 6682bf9788..6b5b5efbdc 100644 --- a/app/views/admin/accounts/_account.html.haml +++ b/app/views/admin/accounts/_account.html.haml @@ -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') diff --git a/app/views/admin/follow_recommendations/_account.html.haml b/app/views/admin/follow_recommendations/_account.html.haml index 00196dd01a..85ce708c1a 100644 --- a/app/views/admin/follow_recommendations/_account.html.haml +++ b/app/views/admin/follow_recommendations/_account.html.haml @@ -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 diff --git a/app/views/admin/reports/_header_card.html.haml b/app/views/admin/reports/_header_card.html.haml index 52e62b4499..2b8dfef3f3 100644 --- a/app/views/admin/reports/_header_card.html.haml +++ b/app/views/admin/reports/_header_card.html.haml @@ -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 diff --git a/app/views/relationships/_account.html.haml b/app/views/relationships/_account.html.haml index 23afcf7495..62ae0d05bd 100644 --- a/app/views/relationships/_account.html.haml +++ b/app/views/relationships/_account.html.haml @@ -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 diff --git a/config/locales/cs.yml b/config/locales/cs.yml index 9c3975cb1c..acbbf0cd44 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -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 diff --git a/config/locales/cy.yml b/config/locales/cy.yml index 83a9ab46c0..a74a51d31f 100644 --- a/config/locales/cy.yml +++ b/config/locales/cy.yml @@ -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 diff --git a/config/locales/el.yml b/config/locales/el.yml index 10a88b36b5..55db7e2919 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -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: Επιτρέπει στους χρήστες να περιηγούνται και να απενεργοποιούν τους συνδέσμους πρόσκλησης diff --git a/config/locales/fi.yml b/config/locales/fi.yml index 514a7f7783..c2e5ef0750 100644 --- a/config/locales/fi.yml +++ b/config/locales/fi.yml @@ -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ä diff --git a/config/locales/fo.yml b/config/locales/fo.yml index c69fd8306c..a531e0bc13 100644 --- a/config/locales/fo.yml +++ b/config/locales/fo.yml @@ -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 diff --git a/config/locales/gl.yml b/config/locales/gl.yml index 3de76f110f..6c9ed9b6c9 100644 --- a/config/locales/gl.yml +++ b/config/locales/gl.yml @@ -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 diff --git a/config/locales/it.yml b/config/locales/it.yml index 9e6e91e0a4..49719320e3 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -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 diff --git a/config/locales/simple_form.cy.yml b/config/locales/simple_form.cy.yml index b850cabb6e..080603d66c 100644 --- a/config/locales/simple_form.cy.yml +++ b/config/locales/simple_form.cy.yml @@ -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 diff --git a/config/locales/simple_form.sv.yml b/config/locales/simple_form.sv.yml index f3997a91c1..524ec6c8b1 100644 --- a/config/locales/simple_form.sv.yml +++ b/config/locales/simple_form.sv.yml @@ -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 diff --git a/config/locales/sv.yml b/config/locales/sv.yml index 5b50f5846d..d390baf720 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -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} diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index cad46ad903..6cbb58055e 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -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 diff --git a/yarn.lock b/yarn.lock index f1148a37ce..b97323e614 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12581,8 +12581,8 @@ __metadata: linkType: hard "sass@npm:^1.62.1, sass@npm:^1.70.0": - version: 1.97.1 - resolution: "sass@npm:1.97.1" + version: 1.97.2 + resolution: "sass@npm:1.97.2" dependencies: "@parcel/watcher": "npm:^2.4.1" chokidar: "npm:^4.0.0" @@ -12593,7 +12593,7 @@ __metadata: optional: true bin: sass: sass.js - checksum: 10c0/c389d5d6405869b49fa2291e8328500fe7936f3b72136bc2c338bee6e7fec936bb9a48d77a1310dea66aa4669ba74ae6b82a112eb32521b9b36d740138a39ea0 + checksum: 10c0/5622a4f9d0acf5cdfb72c73c448366b3ec7bfe2b6e1ecb62c6e9f8eaff9c02a6afd86a31ded246fc818fe660cedea6bd27335e82fc9bd7cce46c29f95774bece languageName: node linkType: hard @@ -15043,8 +15043,8 @@ __metadata: linkType: hard "ws@npm:^8.12.1, ws@npm:^8.18.0, ws@npm:^8.18.3": - version: 8.18.3 - resolution: "ws@npm:8.18.3" + version: 8.19.0 + resolution: "ws@npm:8.19.0" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -15053,7 +15053,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10c0/eac918213de265ef7cb3d4ca348b891a51a520d839aa51cdb8ca93d4fa7ff9f6ccb339ccee89e4075324097f0a55157c89fa3f7147bde9d8d7e90335dc087b53 + checksum: 10c0/4741d9b9bc3f9c791880882414f96e36b8b254e34d4b503279d6400d9a4b87a033834856dbdd94ee4b637944df17ea8afc4bce0ff4a1560d2166be8855da5b04 languageName: node linkType: hard