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