mirror of
https://github.com/mastodon/mastodon.git
synced 2025-09-05 17:31:12 +00:00
Merge branch 'main' into feature/require-mfa-by-admin
This commit is contained in:
commit
2b98d29942
2
Gemfile
2
Gemfile
|
@ -84,7 +84,7 @@ gem 'sanitize', '~> 7.0'
|
||||||
gem 'scenic', '~> 1.7'
|
gem 'scenic', '~> 1.7'
|
||||||
gem 'sidekiq', '< 8'
|
gem 'sidekiq', '< 8'
|
||||||
gem 'sidekiq-bulk', '~> 0.2.0'
|
gem 'sidekiq-bulk', '~> 0.2.0'
|
||||||
gem 'sidekiq-scheduler', '~> 5.0'
|
gem 'sidekiq-scheduler', '~> 6.0'
|
||||||
gem 'sidekiq-unique-jobs', '> 8'
|
gem 'sidekiq-unique-jobs', '> 8'
|
||||||
gem 'simple_form', '~> 5.2'
|
gem 'simple_form', '~> 5.2'
|
||||||
gem 'simple-navigation', '~> 4.4'
|
gem 'simple-navigation', '~> 4.4'
|
||||||
|
|
17
Gemfile.lock
17
Gemfile.lock
|
@ -175,9 +175,9 @@ GEM
|
||||||
css_parser (1.21.1)
|
css_parser (1.21.1)
|
||||||
addressable
|
addressable
|
||||||
csv (3.3.5)
|
csv (3.3.5)
|
||||||
database_cleaner-active_record (2.2.1)
|
database_cleaner-active_record (2.2.2)
|
||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
debug (1.11.0)
|
debug (1.11.0)
|
||||||
|
@ -635,7 +635,7 @@ GEM
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (6.6.0)
|
puma (6.6.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.0)
|
pundit (2.5.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
|
@ -765,7 +765,7 @@ GEM
|
||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.4)
|
rspec-support (3.13.4)
|
||||||
rubocop (1.79.0)
|
rubocop (1.79.1)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
|
@ -775,7 +775,6 @@ GEM
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.46.0, < 2.0)
|
rubocop-ast (>= 1.46.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
tsort (>= 0.2.0)
|
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.46.0)
|
rubocop-ast (1.46.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
|
@ -834,10 +833,9 @@ GEM
|
||||||
redis-client (>= 0.22.2)
|
redis-client (>= 0.22.2)
|
||||||
sidekiq-bulk (0.2.0)
|
sidekiq-bulk (0.2.0)
|
||||||
sidekiq
|
sidekiq
|
||||||
sidekiq-scheduler (5.0.6)
|
sidekiq-scheduler (6.0.1)
|
||||||
rufus-scheduler (~> 3.2)
|
rufus-scheduler (~> 3.2)
|
||||||
sidekiq (>= 6, < 8)
|
sidekiq (>= 7.3, < 9)
|
||||||
tilt (>= 1.4.0, < 3)
|
|
||||||
sidekiq-unique-jobs (8.0.11)
|
sidekiq-unique-jobs (8.0.11)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||||
sidekiq (>= 7.0.0, < 9.0.0)
|
sidekiq (>= 7.0.0, < 9.0.0)
|
||||||
|
@ -881,7 +879,6 @@ GEM
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
tsort (0.2.0)
|
|
||||||
tty-color (0.6.0)
|
tty-color (0.6.0)
|
||||||
tty-cursor (0.7.1)
|
tty-cursor (0.7.1)
|
||||||
tty-prompt (0.23.1)
|
tty-prompt (0.23.1)
|
||||||
|
@ -1084,7 +1081,7 @@ DEPENDENCIES
|
||||||
shoulda-matchers
|
shoulda-matchers
|
||||||
sidekiq (< 8)
|
sidekiq (< 8)
|
||||||
sidekiq-bulk (~> 0.2.0)
|
sidekiq-bulk (~> 0.2.0)
|
||||||
sidekiq-scheduler (~> 5.0)
|
sidekiq-scheduler (~> 6.0)
|
||||||
sidekiq-unique-jobs (> 8)
|
sidekiq-unique-jobs (> 8)
|
||||||
simple-navigation (~> 4.4)
|
simple-navigation (~> 4.4)
|
||||||
simple_form (~> 5.2)
|
simple_form (~> 5.2)
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||||
|
|
||||||
|
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||||
|
before_action :set_quote_authorization
|
||||||
|
|
||||||
|
def show
|
||||||
|
expires_in 0, public: @quote.status.distributable? && public_fetch_mode?
|
||||||
|
render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def pundit_user
|
||||||
|
signed_request_account
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_quote_authorization
|
||||||
|
@quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
|
||||||
|
authorize @quote.status, :show?
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
not_found
|
||||||
|
end
|
||||||
|
end
|
72
app/controllers/api/v1/statuses/quotes_controller.rb
Normal file
72
app/controllers/api/v1/statuses/quotes_controller.rb
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
|
||||||
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke
|
||||||
|
|
||||||
|
before_action :check_owner!
|
||||||
|
before_action :set_quote, only: :revoke
|
||||||
|
after_action :insert_pagination_headers, only: :index
|
||||||
|
|
||||||
|
def index
|
||||||
|
cache_if_unauthenticated!
|
||||||
|
@statuses = load_statuses
|
||||||
|
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke
|
||||||
|
authorize @quote, :revoke?
|
||||||
|
|
||||||
|
RevokeQuoteService.new.call(@quote)
|
||||||
|
|
||||||
|
render_empty # TODO: do we want to return something? an updated status?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_owner!
|
||||||
|
authorize @status, :list_quotes?
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_quote
|
||||||
|
@quote = @status.quotes.find_by!(status_id: params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_statuses
|
||||||
|
scope = default_statuses
|
||||||
|
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
|
||||||
|
scope.merge(paginated_quotes).to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_statuses
|
||||||
|
Status.includes(:quote).references(:quote)
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginated_quotes
|
||||||
|
@status.quotes.accepted.paginate_by_max_id(
|
||||||
|
limit_param(DEFAULT_STATUSES_LIMIT),
|
||||||
|
params[:max_id],
|
||||||
|
params[:since_id]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_path
|
||||||
|
api_v1_status_quotes_url pagination_params(max_id: pagination_max_id) if records_continue?
|
||||||
|
end
|
||||||
|
|
||||||
|
def prev_path
|
||||||
|
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_max_id
|
||||||
|
@statuses.last.quote.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_since_id
|
||||||
|
@statuses.first.quote.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def records_continue?
|
||||||
|
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
||||||
|
end
|
||||||
|
end
|
|
@ -39,6 +39,12 @@ module ContextHelper
|
||||||
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
||||||
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
||||||
},
|
},
|
||||||
|
quote_authorizations: {
|
||||||
|
'gts' => 'https://gotosocial.org/ns#',
|
||||||
|
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||||
|
'interactingObject' => { '@id' => 'gts:interactingObject' },
|
||||||
|
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
|
||||||
|
},
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def full_context
|
def full_context
|
||||||
|
|
|
@ -39,16 +39,6 @@ module HomeHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def obscured_counter(count)
|
|
||||||
if count <= 0
|
|
||||||
'0'
|
|
||||||
elsif count == 1
|
|
||||||
'1'
|
|
||||||
else
|
|
||||||
'1+'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def field_verified_class(verified)
|
def field_verified_class(verified)
|
||||||
if verified
|
if verified
|
||||||
'verified'
|
'verified'
|
||||||
|
|
|
@ -15,6 +15,17 @@ export const SKIN_TONE_CODES = [
|
||||||
0x1f3ff, // Dark skin tone
|
0x1f3ff, // Dark skin tone
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// TODO: Test and create fallback for browsers that do not handle the /v flag.
|
||||||
|
export const UNICODE_EMOJI_REGEX = /\p{RGI_Emoji}/v;
|
||||||
|
// See: https://www.unicode.org/reports/tr51/#valid-emoji-tag-sequences
|
||||||
|
export const UNICODE_FLAG_EMOJI_REGEX =
|
||||||
|
/\p{RGI_Emoji_Flag_Sequence}|\p{RGI_Emoji_Tag_Sequence}/v;
|
||||||
|
export const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
||||||
|
export const ANY_EMOJI_REGEX = new RegExp(
|
||||||
|
`(${UNICODE_EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`,
|
||||||
|
'gv',
|
||||||
|
);
|
||||||
|
|
||||||
// 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';
|
||||||
|
|
139
app/javascript/mastodon/features/emoji/database.test.ts
Normal file
139
app/javascript/mastodon/features/emoji/database.test.ts
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import { IDBFactory } from 'fake-indexeddb';
|
||||||
|
|
||||||
|
import { unicodeEmojiFactory } from '@/testing/factories';
|
||||||
|
|
||||||
|
import {
|
||||||
|
putEmojiData,
|
||||||
|
loadEmojiByHexcode,
|
||||||
|
searchEmojisByHexcodes,
|
||||||
|
searchEmojisByTag,
|
||||||
|
testClear,
|
||||||
|
testGet,
|
||||||
|
} from './database';
|
||||||
|
|
||||||
|
describe('emoji database', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
testClear();
|
||||||
|
indexedDB = new IDBFactory();
|
||||||
|
});
|
||||||
|
describe('putEmojiData', () => {
|
||||||
|
test('adds to loaded locales', async () => {
|
||||||
|
const { loadedLocales } = await testGet();
|
||||||
|
expect(loadedLocales).toHaveLength(0);
|
||||||
|
await putEmojiData([], 'en');
|
||||||
|
expect(loadedLocales).toContain('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads emoji into indexedDB', async () => {
|
||||||
|
await putEmojiData([unicodeEmojiFactory()], 'en');
|
||||||
|
const { db } = await testGet();
|
||||||
|
await expect(db.get('en', 'test')).resolves.toEqual(
|
||||||
|
unicodeEmojiFactory(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadEmojiByHexcode', () => {
|
||||||
|
test('throws if the locale is not loaded', async () => {
|
||||||
|
await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError(
|
||||||
|
'Locale en',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retrieves the emoji', async () => {
|
||||||
|
await putEmojiData([unicodeEmojiFactory()], 'en');
|
||||||
|
await expect(loadEmojiByHexcode('test', 'en')).resolves.toEqual(
|
||||||
|
unicodeEmojiFactory(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if not found', async () => {
|
||||||
|
await putEmojiData([], 'en');
|
||||||
|
await expect(loadEmojiByHexcode('test', 'en')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
UnicodeEmojiData,
|
UnicodeEmojiData,
|
||||||
LocaleOrCustom,
|
LocaleOrCustom,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
interface EmojiDB extends LocaleTables, DBSchema {
|
interface EmojiDB extends LocaleTables, DBSchema {
|
||||||
custom: {
|
custom: {
|
||||||
|
@ -36,15 +37,21 @@ interface LocaleTable {
|
||||||
}
|
}
|
||||||
type LocaleTables = Record<Locale, LocaleTable>;
|
type LocaleTables = Record<Locale, LocaleTable>;
|
||||||
|
|
||||||
|
type Database = IDBPDatabase<EmojiDB>;
|
||||||
|
|
||||||
const SCHEMA_VERSION = 1;
|
const SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
let db: IDBPDatabase<EmojiDB> | null = null;
|
const loadedLocales = new Set<Locale>();
|
||||||
|
|
||||||
async function loadDB() {
|
const log = emojiLogger('database');
|
||||||
if (db) {
|
|
||||||
return db;
|
// Loads the database in a way that ensures it's only loaded once.
|
||||||
}
|
const loadDB = (() => {
|
||||||
db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
let dbPromise: Promise<Database> | null = null;
|
||||||
|
|
||||||
|
// Actually load the DB.
|
||||||
|
async function initDB() {
|
||||||
|
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||||
upgrade(database) {
|
upgrade(database) {
|
||||||
const customTable = database.createObjectStore('custom', {
|
const customTable = database.createObjectStore('custom', {
|
||||||
keyPath: 'shortcode',
|
keyPath: 'shortcode',
|
||||||
|
@ -66,10 +73,27 @@ async function loadDB() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await syncLocales(db);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loads the database, or returns the existing promise if it hasn't resolved yet.
|
||||||
|
const loadPromise = async (): Promise<Database> => {
|
||||||
|
if (dbPromise) {
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
dbPromise = initDB();
|
||||||
|
return dbPromise;
|
||||||
|
};
|
||||||
|
// Special way to reset the database, used for unit testing.
|
||||||
|
loadPromise.reset = () => {
|
||||||
|
dbPromise = null;
|
||||||
|
};
|
||||||
|
return loadPromise;
|
||||||
|
})();
|
||||||
|
|
||||||
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
|
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: 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 Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||||
|
@ -86,15 +110,15 @@ export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
|
||||||
export async function putLatestEtag(etag: string, localeString: string) {
|
export async function putLatestEtag(etag: string, localeString: string) {
|
||||||
const locale = toSupportedLocaleOrCustom(localeString);
|
const locale = toSupportedLocaleOrCustom(localeString);
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
return db.put('etags', etag, locale);
|
await db.put('etags', etag, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchEmojiByHexcode(
|
export async function loadEmojiByHexcode(
|
||||||
hexcode: string,
|
hexcode: string,
|
||||||
localeString: string,
|
localeString: string,
|
||||||
) {
|
) {
|
||||||
const locale = toSupportedLocale(localeString);
|
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
|
const locale = toLoadedLocale(localeString);
|
||||||
return db.get(locale, hexcode);
|
return db.get(locale, hexcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,45 +126,39 @@ export async function searchEmojisByHexcodes(
|
||||||
hexcodes: string[],
|
hexcodes: string[],
|
||||||
localeString: string,
|
localeString: string,
|
||||||
) {
|
) {
|
||||||
const locale = toSupportedLocale(localeString);
|
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
return db.getAll(
|
const locale = toLoadedLocale(localeString);
|
||||||
|
const sortedCodes = hexcodes.toSorted();
|
||||||
|
const results = await db.getAll(
|
||||||
locale,
|
locale,
|
||||||
IDBKeyRange.bound(hexcodes[0], hexcodes[hexcodes.length - 1]),
|
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
|
||||||
);
|
);
|
||||||
|
return results.filter((emoji) => hexcodes.includes(emoji.hexcode));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchEmojiByTag(tag: string, localeString: string) {
|
export async function searchEmojisByTag(tag: string, localeString: string) {
|
||||||
const locale = toSupportedLocale(localeString);
|
|
||||||
const range = IDBKeyRange.only(tag.toLowerCase());
|
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
|
const locale = toLoadedLocale(localeString);
|
||||||
|
const range = IDBKeyRange.bound(
|
||||||
|
tag.toLowerCase(),
|
||||||
|
`${tag.toLowerCase()}\uffff`,
|
||||||
|
);
|
||||||
return db.getAllFromIndex(locale, 'tags', range);
|
return db.getAllFromIndex(locale, 'tags', range);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchCustomEmojiByShortcode(shortcode: string) {
|
export async function loadCustomEmojiByShortcode(shortcode: string) {
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
return db.get('custom', shortcode);
|
return db.get('custom', shortcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
return db.getAll(
|
const sortedCodes = shortcodes.toSorted();
|
||||||
|
const results = await db.getAll(
|
||||||
'custom',
|
'custom',
|
||||||
IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 1]),
|
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
|
||||||
);
|
);
|
||||||
}
|
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
|
||||||
|
|
||||||
export async function findMissingLocales(localeStrings: string[]) {
|
|
||||||
const locales = new Set(localeStrings.map(toSupportedLocale));
|
|
||||||
const missingLocales: Locale[] = [];
|
|
||||||
const db = await loadDB();
|
|
||||||
for (const locale of locales) {
|
|
||||||
const rowCount = await db.count(locale);
|
|
||||||
if (!rowCount) {
|
|
||||||
missingLocales.push(locale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return missingLocales;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLatestEtag(localeString: string) {
|
export async function loadLatestEtag(localeString: string) {
|
||||||
|
@ -153,3 +171,51 @@ export async function loadLatestEtag(localeString: string) {
|
||||||
const etag = await db.get('etags', locale);
|
const etag = await db.get('etags', locale);
|
||||||
return etag ?? null;
|
return etag ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Private functions
|
||||||
|
|
||||||
|
async function syncLocales(db: Database) {
|
||||||
|
const locales = await Promise.all(
|
||||||
|
SUPPORTED_LOCALES.map(
|
||||||
|
async (locale) =>
|
||||||
|
[locale, await hasLocale(locale, db)] satisfies [Locale, boolean],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const [locale, loaded] of locales) {
|
||||||
|
if (loaded) {
|
||||||
|
loadedLocales.add(locale);
|
||||||
|
} else {
|
||||||
|
loadedLocales.delete(locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('Loaded %d locales: %o', loadedLocales.size, loadedLocales);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLoadedLocale(localeString: string) {
|
||||||
|
const locale = toSupportedLocale(localeString);
|
||||||
|
if (localeString !== locale) {
|
||||||
|
log(`Locale ${locale} is different from provided ${localeString}`);
|
||||||
|
}
|
||||||
|
if (!loadedLocales.has(locale)) {
|
||||||
|
throw new Error(`Locale ${locale} is not loaded in emoji database`);
|
||||||
|
}
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
|
||||||
|
if (loadedLocales.has(locale)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const rowCount = await db.count(locale);
|
||||||
|
return !!rowCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testing helpers
|
||||||
|
export async function testGet() {
|
||||||
|
const db = await loadDB();
|
||||||
|
return { db, loadedLocales };
|
||||||
|
}
|
||||||
|
export function testClear() {
|
||||||
|
loadedLocales.clear();
|
||||||
|
loadDB.reset();
|
||||||
|
}
|
||||||
|
|
|
@ -1,81 +1,31 @@
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import { useEmojify } from './hooks';
|
||||||
import { isList } from 'immutable';
|
import type { CustomEmojiMapArg } from './types';
|
||||||
|
|
||||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||||
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
|
ComponentPropsWithoutRef<Element>,
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
|
||||||
|
|
||||||
import { useEmojiAppState } from './hooks';
|
|
||||||
import { emojifyElement } from './render';
|
|
||||||
import type { ExtraCustomEmojiMap } from './types';
|
|
||||||
|
|
||||||
type EmojiHTMLProps = Omit<
|
|
||||||
HTMLAttributes<HTMLDivElement>,
|
|
||||||
'dangerouslySetInnerHTML'
|
'dangerouslySetInnerHTML'
|
||||||
> & {
|
> & {
|
||||||
htmlString: string;
|
htmlString: string;
|
||||||
extraEmojis?: ExtraCustomEmojiMap | ImmutableList<CustomEmoji>;
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
|
as?: Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmojiHTML: React.FC<EmojiHTMLProps> = ({
|
export const EmojiHTML = <Element extends ElementType>({
|
||||||
htmlString,
|
|
||||||
extraEmojis,
|
extraEmojis,
|
||||||
|
htmlString,
|
||||||
|
as: asElement, // Rename for syntax highlighting
|
||||||
...props
|
...props
|
||||||
}) => {
|
}: EmojiHTMLProps<Element>) => {
|
||||||
if (isModernEmojiEnabled()) {
|
const Wrapper = asElement ?? 'div';
|
||||||
return (
|
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
|
||||||
<ModernEmojiHTML
|
|
||||||
htmlString={htmlString}
|
|
||||||
extraEmojis={extraEmojis}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <div dangerouslySetInnerHTML={{ __html: htmlString }} {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ModernEmojiHTML: React.FC<EmojiHTMLProps> = ({
|
if (emojifiedHtml === null) {
|
||||||
extraEmojis: rawEmojis,
|
|
||||||
htmlString: text,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const appState = useEmojiAppState();
|
|
||||||
const [innerHTML, setInnerHTML] = useState('');
|
|
||||||
|
|
||||||
const extraEmojis: ExtraCustomEmojiMap = useMemo(() => {
|
|
||||||
if (!rawEmojis) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (isList(rawEmojis)) {
|
|
||||||
return (
|
|
||||||
rawEmojis.toJS() as ApiCustomEmojiJSON[]
|
|
||||||
).reduce<ExtraCustomEmojiMap>(
|
|
||||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return rawEmojis;
|
|
||||||
}, [rawEmojis]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!text) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cb = async () => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.innerHTML = text;
|
|
||||||
const ele = await emojifyElement(div, appState, extraEmojis);
|
|
||||||
setInnerHTML(ele.innerHTML);
|
|
||||||
};
|
|
||||||
void cb();
|
|
||||||
}, [text, appState, extraEmojis]);
|
|
||||||
|
|
||||||
if (!innerHTML) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div {...props} dangerouslySetInnerHTML={{ __html: innerHTML }} />;
|
return (
|
||||||
|
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useEmojiAppState } from './hooks';
|
|
||||||
import { emojifyText } from './render';
|
|
||||||
|
|
||||||
interface EmojiTextProps {
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EmojiText: React.FC<EmojiTextProps> = ({ text }) => {
|
|
||||||
const appState = useEmojiAppState();
|
|
||||||
const [rendered, setRendered] = useState<(string | HTMLImageElement)[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cb = async () => {
|
|
||||||
const rendered = await emojifyText(text, appState);
|
|
||||||
setRendered(rendered ?? []);
|
|
||||||
};
|
|
||||||
void cb();
|
|
||||||
}, [text, appState]);
|
|
||||||
|
|
||||||
if (rendered.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{rendered.map((fragment, index) => {
|
|
||||||
if (typeof fragment === 'string') {
|
|
||||||
return <span key={index}>{fragment}</span>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
key={index}
|
|
||||||
draggable='false'
|
|
||||||
src={fragment.src}
|
|
||||||
alt={fragment.alt}
|
|
||||||
title={fragment.title}
|
|
||||||
className={fragment.className}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,8 +1,64 @@
|
||||||
|
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { isList } from 'immutable';
|
||||||
|
|
||||||
|
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||||
import { useAppSelector } from '@/mastodon/store';
|
import { useAppSelector } from '@/mastodon/store';
|
||||||
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
import { determineEmojiMode } from './mode';
|
import { determineEmojiMode } from './mode';
|
||||||
import type { EmojiAppState } from './types';
|
import { emojifyElement } from './render';
|
||||||
|
import type {
|
||||||
|
CustomEmojiMapArg,
|
||||||
|
EmojiAppState,
|
||||||
|
ExtraCustomEmojiMap,
|
||||||
|
} from './types';
|
||||||
|
import { stringHasAnyEmoji } from './utils';
|
||||||
|
|
||||||
|
export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
|
||||||
|
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const appState = useEmojiAppState();
|
||||||
|
const extra: ExtraCustomEmojiMap = useMemo(() => {
|
||||||
|
if (!extraEmojis) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (isList(extraEmojis)) {
|
||||||
|
return (
|
||||||
|
extraEmojis.toJS() as ApiCustomEmojiJSON[]
|
||||||
|
).reduce<ExtraCustomEmojiMap>(
|
||||||
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return extraEmojis;
|
||||||
|
}, [extraEmojis]);
|
||||||
|
|
||||||
|
const emojify = useCallback(
|
||||||
|
async (input: string) => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = input;
|
||||||
|
const result = await emojifyElement(wrapper, appState, extra);
|
||||||
|
if (result) {
|
||||||
|
setEmojifiedText(result.innerHTML);
|
||||||
|
} else {
|
||||||
|
setEmojifiedText(input);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appState, extra],
|
||||||
|
);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
||||||
|
void emojify(text);
|
||||||
|
} else {
|
||||||
|
// If no emoji or we don't want to render, fall back.
|
||||||
|
setEmojifiedText(text);
|
||||||
|
}
|
||||||
|
}, [emojify, text]);
|
||||||
|
|
||||||
|
return emojifiedText;
|
||||||
|
}
|
||||||
|
|
||||||
export function useEmojiAppState(): EmojiAppState {
|
export function useEmojiAppState(): EmojiAppState {
|
||||||
const locale = useAppSelector((state) =>
|
const locale = useAppSelector((state) =>
|
||||||
|
@ -12,5 +68,10 @@ export function useEmojiAppState(): EmojiAppState {
|
||||||
determineEmojiMode(state.meta.get('emoji_style') as string),
|
determineEmojiMode(state.meta.get('emoji_style') as string),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { currentLocale: locale, locales: [locale], mode };
|
return {
|
||||||
|
currentLocale: locale,
|
||||||
|
locales: [locale],
|
||||||
|
mode,
|
||||||
|
darkTheme: document.body.classList.contains('theme-default'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,12 +2,16 @@ import initialState from '@/mastodon/initial_state';
|
||||||
import { loadWorker } from '@/mastodon/utils/workers';
|
import { loadWorker } from '@/mastodon/utils/workers';
|
||||||
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||||
|
|
||||||
let worker: Worker | null = null;
|
let worker: Worker | null = null;
|
||||||
|
|
||||||
export async function initializeEmoji() {
|
const log = emojiLogger('index');
|
||||||
|
|
||||||
|
export function initializeEmoji() {
|
||||||
|
log('initializing emojis');
|
||||||
if (!worker && 'Worker' in window) {
|
if (!worker && 'Worker' in window) {
|
||||||
try {
|
try {
|
||||||
worker = loadWorker(new URL('./worker', import.meta.url), {
|
worker = loadWorker(new URL('./worker', import.meta.url), {
|
||||||
|
@ -21,9 +25,16 @@ export async function initializeEmoji() {
|
||||||
if (worker) {
|
if (worker) {
|
||||||
// Assign worker to const to make TS happy inside the event listener.
|
// Assign worker to const to make TS happy inside the event listener.
|
||||||
const thisWorker = worker;
|
const thisWorker = worker;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
log('worker is not ready after timeout');
|
||||||
|
worker = null;
|
||||||
|
void fallbackLoad();
|
||||||
|
}, 500);
|
||||||
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||||
const { data: message } = event;
|
const { data: message } = event;
|
||||||
if (message === 'ready') {
|
if (message === 'ready') {
|
||||||
|
log('worker ready, loading data');
|
||||||
|
clearTimeout(timeoutId);
|
||||||
thisWorker.postMessage('custom');
|
thisWorker.postMessage('custom');
|
||||||
void loadEmojiLocale(userLocale);
|
void loadEmojiLocale(userLocale);
|
||||||
// Load English locale as well, because people are still used to
|
// Load English locale as well, because people are still used to
|
||||||
|
@ -31,16 +42,23 @@ export async function initializeEmoji() {
|
||||||
if (userLocale !== 'en') {
|
if (userLocale !== 'en') {
|
||||||
void loadEmojiLocale('en');
|
void loadEmojiLocale('en');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
log('got worker message: %s', message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
void fallbackLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackLoad() {
|
||||||
|
log('falling back to main thread for loading');
|
||||||
const { importCustomEmojiData } = await import('./loader');
|
const { importCustomEmojiData } = await import('./loader');
|
||||||
await importCustomEmojiData();
|
await importCustomEmojiData();
|
||||||
await loadEmojiLocale(userLocale);
|
await loadEmojiLocale(userLocale);
|
||||||
if (userLocale !== 'en') {
|
if (userLocale !== 'en') {
|
||||||
await loadEmojiLocale('en');
|
await loadEmojiLocale('en');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadEmojiLocale(localeString: string) {
|
export async function loadEmojiLocale(localeString: string) {
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { flattenEmojiData } from 'emojibase';
|
||||||
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
||||||
|
|
||||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||||
import { isDevelopment } from '@/mastodon/utils/environment';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
putEmojiData,
|
putEmojiData,
|
||||||
|
@ -12,6 +11,9 @@ import {
|
||||||
} from './database';
|
} from './database';
|
||||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||||
import type { LocaleOrCustom } from './types';
|
import type { LocaleOrCustom } from './types';
|
||||||
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
|
const log = emojiLogger('loader');
|
||||||
|
|
||||||
export async function importEmojiData(localeString: string) {
|
export async function importEmojiData(localeString: string) {
|
||||||
const locale = toSupportedLocale(localeString);
|
const locale = toSupportedLocale(localeString);
|
||||||
|
@ -20,6 +22,7 @@ export async function importEmojiData(localeString: string) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
|
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
|
||||||
|
log('loaded %d for %s locale', flattenedEmojis.length, locale);
|
||||||
await putEmojiData(flattenedEmojis, locale);
|
await putEmojiData(flattenedEmojis, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,6 +31,7 @@ export async function importCustomEmojiData() {
|
||||||
if (!emojis) {
|
if (!emojis) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
log('loaded %d custom emojis', emojis.length);
|
||||||
await putCustomEmojiData(emojis);
|
await putCustomEmojiData(emojis);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +45,9 @@ async function fetchAndCheckEtag<ResultType extends object[]>(
|
||||||
if (locale === 'custom') {
|
if (locale === 'custom') {
|
||||||
url.pathname = '/api/v1/custom_emojis';
|
url.pathname = '/api/v1/custom_emojis';
|
||||||
} else {
|
} else {
|
||||||
url.pathname = `/packs${isDevelopment() ? '-dev' : ''}/emoji/${locale}.json`;
|
// This doesn't use isDevelopment() as that module loads initial state
|
||||||
|
// which breaks workers, as they cannot access the DOM.
|
||||||
|
url.pathname = `/packs${import.meta.env.DEV ? '-dev' : ''}/emoji/${locale}.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldEtag = await loadLatestEtag(locale);
|
const oldEtag = await loadLatestEtag(locale);
|
||||||
|
|
|
@ -1,94 +1,184 @@
|
||||||
|
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EMOJI_MODE_NATIVE,
|
EMOJI_MODE_NATIVE,
|
||||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||||
EMOJI_MODE_TWEMOJI,
|
EMOJI_MODE_TWEMOJI,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { emojifyElement, tokenizeText } from './render';
|
import * as db from './database';
|
||||||
import type { CustomEmojiData, UnicodeEmojiData } from './types';
|
import {
|
||||||
|
emojifyElement,
|
||||||
|
emojifyText,
|
||||||
|
testCacheClear,
|
||||||
|
tokenizeText,
|
||||||
|
} from './render';
|
||||||
|
import type { EmojiAppState, ExtraCustomEmojiMap } from './types';
|
||||||
|
|
||||||
vitest.mock('./database', () => ({
|
function mockDatabase() {
|
||||||
searchCustomEmojisByShortcodes: vitest.fn(
|
return {
|
||||||
() =>
|
searchCustomEmojisByShortcodes: vi
|
||||||
[
|
.spyOn(db, 'searchCustomEmojisByShortcodes')
|
||||||
{
|
.mockResolvedValue([customEmojiFactory()]),
|
||||||
shortcode: 'custom',
|
searchEmojisByHexcodes: vi
|
||||||
static_url: 'emoji/static',
|
.spyOn(db, 'searchEmojisByHexcodes')
|
||||||
url: 'emoji/custom',
|
.mockResolvedValue([
|
||||||
category: 'test',
|
unicodeEmojiFactory({
|
||||||
visible_in_picker: true,
|
|
||||||
},
|
|
||||||
] satisfies CustomEmojiData[],
|
|
||||||
),
|
|
||||||
searchEmojisByHexcodes: vitest.fn(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
{
|
|
||||||
hexcode: '1F60A',
|
hexcode: '1F60A',
|
||||||
group: 0,
|
|
||||||
label: 'smiling face with smiling eyes',
|
label: 'smiling face with smiling eyes',
|
||||||
order: 0,
|
|
||||||
tags: ['smile', 'happy'],
|
|
||||||
unicode: '😊',
|
unicode: '😊',
|
||||||
},
|
}),
|
||||||
{
|
unicodeEmojiFactory({
|
||||||
hexcode: '1F1EA-1F1FA',
|
hexcode: '1F1EA-1F1FA',
|
||||||
group: 0,
|
|
||||||
label: 'flag-eu',
|
label: 'flag-eu',
|
||||||
order: 0,
|
|
||||||
tags: ['flag', 'european union'],
|
|
||||||
unicode: '🇪🇺',
|
unicode: '🇪🇺',
|
||||||
},
|
}),
|
||||||
] satisfies UnicodeEmojiData[],
|
]),
|
||||||
),
|
};
|
||||||
findMissingLocales: vitest.fn(() => []),
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
describe('emojifyElement', () => {
|
const expectedSmileImage =
|
||||||
const testElement = document.createElement('div');
|
|
||||||
testElement.innerHTML = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>';
|
|
||||||
|
|
||||||
const expectedSmileImage =
|
|
||||||
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
||||||
const expectedFlagImage =
|
const expectedFlagImage =
|
||||||
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
||||||
const expectedCustomEmojiImage =
|
const expectedCustomEmojiImage =
|
||||||
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/static" data-original="emoji/custom" data-static="emoji/static">';
|
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/custom/static" data-original="emoji/custom" data-static="emoji/custom/static">';
|
||||||
|
const expectedRemoteCustomEmojiImage =
|
||||||
|
'<img draggable="false" class="emojione custom-emoji" alt=":remote:" title=":remote:" src="remote.social/static" data-original="remote.social/custom" data-static="remote.social/static">';
|
||||||
|
|
||||||
function cloneTestElement() {
|
const mockExtraCustom: ExtraCustomEmojiMap = {
|
||||||
return testElement.cloneNode(true) as HTMLElement;
|
remote: {
|
||||||
}
|
shortcode: 'remote',
|
||||||
|
static_url: 'remote.social/static',
|
||||||
|
url: 'remote.social/custom',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
test('emojifies custom emoji in native mode', async () => {
|
function testAppState(state: Partial<EmojiAppState> = {}) {
|
||||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
return {
|
||||||
locales: ['en'],
|
|
||||||
mode: EMOJI_MODE_NATIVE,
|
|
||||||
currentLocale: 'en',
|
|
||||||
});
|
|
||||||
expect(emojifiedElement.innerHTML).toBe(
|
|
||||||
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emojifies flag emoji in native-with-flags mode', async () => {
|
|
||||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
|
||||||
locales: ['en'],
|
|
||||||
mode: EMOJI_MODE_NATIVE_WITH_FLAGS,
|
|
||||||
currentLocale: 'en',
|
|
||||||
});
|
|
||||||
expect(emojifiedElement.innerHTML).toBe(
|
|
||||||
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emojifies everything in twemoji mode', async () => {
|
|
||||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
|
||||||
locales: ['en'],
|
locales: ['en'],
|
||||||
mode: EMOJI_MODE_TWEMOJI,
|
mode: EMOJI_MODE_TWEMOJI,
|
||||||
currentLocale: 'en',
|
currentLocale: 'en',
|
||||||
|
darkTheme: false,
|
||||||
|
...state,
|
||||||
|
} satisfies EmojiAppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('emojifyElement', () => {
|
||||||
|
function testElement(text = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>') {
|
||||||
|
const testElement = document.createElement('div');
|
||||||
|
testElement.innerHTML = text;
|
||||||
|
return testElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
testCacheClear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
expect(emojifiedElement.innerHTML).toBe(
|
|
||||||
|
test('caches element rendering results', async () => {
|
||||||
|
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||||
|
mockDatabase();
|
||||||
|
await emojifyElement(testElement(), testAppState());
|
||||||
|
await emojifyElement(testElement(), testAppState());
|
||||||
|
await emojifyElement(testElement(), testAppState());
|
||||||
|
expect(searchEmojisByHexcodes).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
['1F1EA-1F1FA', '1F60A'],
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
|
||||||
|
'custom',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emojifies custom emoji in native mode', async () => {
|
||||||
|
const { searchEmojisByHexcodes } = mockDatabase();
|
||||||
|
const actual = await emojifyElement(
|
||||||
|
testElement(),
|
||||||
|
testAppState({ mode: EMOJI_MODE_NATIVE }),
|
||||||
|
);
|
||||||
|
assert(actual);
|
||||||
|
expect(actual.innerHTML).toBe(
|
||||||
|
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||||
|
);
|
||||||
|
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emojifies flag emoji in native-with-flags mode', async () => {
|
||||||
|
const { searchEmojisByHexcodes } = mockDatabase();
|
||||||
|
const actual = await emojifyElement(
|
||||||
|
testElement(),
|
||||||
|
testAppState({ mode: EMOJI_MODE_NATIVE_WITH_FLAGS }),
|
||||||
|
);
|
||||||
|
assert(actual);
|
||||||
|
expect(actual.innerHTML).toBe(
|
||||||
|
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||||
|
);
|
||||||
|
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emojifies everything in twemoji mode', async () => {
|
||||||
|
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||||
|
mockDatabase();
|
||||||
|
const actual = await emojifyElement(testElement(), testAppState());
|
||||||
|
assert(actual);
|
||||||
|
expect(actual.innerHTML).toBe(
|
||||||
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||||
);
|
);
|
||||||
|
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
|
||||||
|
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emojifies with provided custom emoji', async () => {
|
||||||
|
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||||
|
mockDatabase();
|
||||||
|
const actual = await emojifyElement(
|
||||||
|
testElement('<p>hi :remote:</p>'),
|
||||||
|
testAppState(),
|
||||||
|
mockExtraCustom,
|
||||||
|
);
|
||||||
|
assert(actual);
|
||||||
|
expect(actual.innerHTML).toBe(
|
||||||
|
`<p>hi ${expectedRemoteCustomEmojiImage}</p>`,
|
||||||
|
);
|
||||||
|
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
|
||||||
|
expect(searchCustomEmojisByShortcodes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when no emoji are found', async () => {
|
||||||
|
mockDatabase();
|
||||||
|
const actual = await emojifyElement(
|
||||||
|
testElement('<p>here is just text :)</p>'),
|
||||||
|
testAppState(),
|
||||||
|
);
|
||||||
|
expect(actual).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emojifyText', () => {
|
||||||
|
test('returns original input when no emoji are in string', async () => {
|
||||||
|
const actual = await emojifyText('nothing here', testAppState());
|
||||||
|
expect(actual).toBe('nothing here');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders Unicode emojis to twemojis', async () => {
|
||||||
|
mockDatabase();
|
||||||
|
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
|
||||||
|
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders custom emojis', async () => {
|
||||||
|
mockDatabase();
|
||||||
|
const actual = await emojifyText('Hello :custom:!', testAppState());
|
||||||
|
expect(actual).toBe(`Hello ${expectedCustomEmojiImage}!`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders provided extra emojis', async () => {
|
||||||
|
const actual = await emojifyText(
|
||||||
|
'remote emoji :remote:',
|
||||||
|
testAppState(),
|
||||||
|
mockExtraCustom,
|
||||||
|
);
|
||||||
|
expect(actual).toBe(`remote emoji ${expectedRemoteCustomEmojiImage}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import type { Locale } from 'emojibase';
|
|
||||||
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
|
|
||||||
|
|
||||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||||
|
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||||
import { assetHost } from '@/mastodon/utils/config';
|
import { assetHost } from '@/mastodon/utils/config';
|
||||||
|
import * as perf from '@/mastodon/utils/performance';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EMOJI_MODE_NATIVE,
|
EMOJI_MODE_NATIVE,
|
||||||
|
@ -10,13 +9,12 @@ import {
|
||||||
EMOJI_TYPE_UNICODE,
|
EMOJI_TYPE_UNICODE,
|
||||||
EMOJI_TYPE_CUSTOM,
|
EMOJI_TYPE_CUSTOM,
|
||||||
EMOJI_STATE_MISSING,
|
EMOJI_STATE_MISSING,
|
||||||
|
ANY_EMOJI_REGEX,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import {
|
import {
|
||||||
findMissingLocales,
|
|
||||||
searchCustomEmojisByShortcodes,
|
searchCustomEmojisByShortcodes,
|
||||||
searchEmojisByHexcodes,
|
searchEmojisByHexcodes,
|
||||||
} from './database';
|
} from './database';
|
||||||
import { loadEmojiLocale } from './index';
|
|
||||||
import {
|
import {
|
||||||
emojiToUnicodeHex,
|
emojiToUnicodeHex,
|
||||||
twemojiHasBorder,
|
twemojiHasBorder,
|
||||||
|
@ -34,18 +32,33 @@ import type {
|
||||||
LocaleOrCustom,
|
LocaleOrCustom,
|
||||||
UnicodeEmojiToken,
|
UnicodeEmojiToken,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { stringHasUnicodeFlags } from './utils';
|
import { emojiLogger, stringHasAnyEmoji, stringHasUnicodeFlags } from './utils';
|
||||||
|
|
||||||
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
const log = emojiLogger('render');
|
||||||
[EMOJI_TYPE_CUSTOM, new Map()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
/**
|
||||||
|
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
||||||
|
*/
|
||||||
export async function emojifyElement<Element extends HTMLElement>(
|
export async function emojifyElement<Element extends HTMLElement>(
|
||||||
element: Element,
|
element: Element,
|
||||||
appState: EmojiAppState,
|
appState: EmojiAppState,
|
||||||
extraEmojis: ExtraCustomEmojiMap = {},
|
extraEmojis: ExtraCustomEmojiMap = {},
|
||||||
): Promise<Element> {
|
): Promise<Element | null> {
|
||||||
|
const cacheKey = createCacheKey(element, appState, extraEmojis);
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
log('Cache hit on %s', element.outerHTML);
|
||||||
|
if (cached === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
element.innerHTML = cached;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
if (!stringHasAnyEmoji(element.innerHTML)) {
|
||||||
|
updateCache(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
perf.start('emojifyElement()');
|
||||||
const queue: (HTMLElement | Text)[] = [element];
|
const queue: (HTMLElement | Text)[] = [element];
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const current = queue.shift();
|
const current = queue.shift();
|
||||||
|
@ -61,7 +74,7 @@ export async function emojifyElement<Element extends HTMLElement>(
|
||||||
current.textContent &&
|
current.textContent &&
|
||||||
(current instanceof Text || !current.hasChildNodes())
|
(current instanceof Text || !current.hasChildNodes())
|
||||||
) {
|
) {
|
||||||
const renderedContent = await emojifyText(
|
const renderedContent = await textToElementArray(
|
||||||
current.textContent,
|
current.textContent,
|
||||||
appState,
|
appState,
|
||||||
extraEmojis,
|
extraEmojis,
|
||||||
|
@ -70,7 +83,7 @@ export async function emojifyElement<Element extends HTMLElement>(
|
||||||
if (!(current instanceof Text)) {
|
if (!(current instanceof Text)) {
|
||||||
current.textContent = null; // Clear the text content if it's not a Text node.
|
current.textContent = null; // Clear the text content if it's not a Text node.
|
||||||
}
|
}
|
||||||
current.replaceWith(renderedToHTMLFragment(renderedContent));
|
current.replaceWith(renderedToHTML(renderedContent));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -81,6 +94,8 @@ export async function emojifyElement<Element extends HTMLElement>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateCache(cacheKey, element.innerHTML);
|
||||||
|
perf.stop('emojifyElement()');
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,7 +103,54 @@ export async function emojifyText(
|
||||||
text: string,
|
text: string,
|
||||||
appState: EmojiAppState,
|
appState: EmojiAppState,
|
||||||
extraEmojis: ExtraCustomEmojiMap = {},
|
extraEmojis: ExtraCustomEmojiMap = {},
|
||||||
|
): Promise<string | null> {
|
||||||
|
const cacheKey = createCacheKey(text, appState, extraEmojis);
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
log('Cache hit on %s', text);
|
||||||
|
return cached ?? text;
|
||||||
|
}
|
||||||
|
if (!stringHasAnyEmoji(text)) {
|
||||||
|
updateCache(cacheKey, null);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const eleArray = await textToElementArray(text, appState, extraEmojis);
|
||||||
|
if (!eleArray) {
|
||||||
|
updateCache(cacheKey, null);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const rendered = renderedToHTML(eleArray, document.createElement('div'));
|
||||||
|
updateCache(cacheKey, rendered.innerHTML);
|
||||||
|
return rendered.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private functions
|
||||||
|
|
||||||
|
const {
|
||||||
|
set: updateCache,
|
||||||
|
get: getCached,
|
||||||
|
clear: cacheClear,
|
||||||
|
} = createLimitedCache<string | null>({ log: log.extend('cache') });
|
||||||
|
|
||||||
|
function createCacheKey(
|
||||||
|
input: HTMLElement | string,
|
||||||
|
appState: EmojiAppState,
|
||||||
|
extraEmojis: ExtraCustomEmojiMap,
|
||||||
) {
|
) {
|
||||||
|
return JSON.stringify([
|
||||||
|
input instanceof HTMLElement ? input.outerHTML : input,
|
||||||
|
appState,
|
||||||
|
extraEmojis,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmojifiedTextArray = (string | HTMLImageElement)[];
|
||||||
|
|
||||||
|
async function textToElementArray(
|
||||||
|
text: string,
|
||||||
|
appState: EmojiAppState,
|
||||||
|
extraEmojis: ExtraCustomEmojiMap = {},
|
||||||
|
): Promise<EmojifiedTextArray | null> {
|
||||||
// Exit if no text to convert.
|
// Exit if no text to convert.
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -102,10 +164,9 @@ export async function emojifyText(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all emoji from the state map, loading any missing ones.
|
// Get all emoji from the state map, loading any missing ones.
|
||||||
await ensureLocalesAreLoaded(appState.locales);
|
await loadMissingEmojiIntoCache(tokens, appState, extraEmojis);
|
||||||
await loadMissingEmojiIntoCache(tokens, appState.locales);
|
|
||||||
|
|
||||||
const renderedFragments: (string | HTMLImageElement)[] = [];
|
const renderedFragments: EmojifiedTextArray = [];
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
|
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
|
||||||
let state: EmojiState | undefined;
|
let state: EmojiState | undefined;
|
||||||
|
@ -125,7 +186,7 @@ export async function emojifyText(
|
||||||
|
|
||||||
// If the state is valid, create an image element. Otherwise, just append as text.
|
// If the state is valid, create an image element. Otherwise, just append as text.
|
||||||
if (state && typeof state !== 'string') {
|
if (state && typeof state !== 'string') {
|
||||||
const image = stateToImage(state);
|
const image = stateToImage(state, appState);
|
||||||
renderedFragments.push(image);
|
renderedFragments.push(image);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -137,21 +198,6 @@ export async function emojifyText(
|
||||||
return renderedFragments;
|
return renderedFragments;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private functions
|
|
||||||
|
|
||||||
async function ensureLocalesAreLoaded(locales: Locale[]) {
|
|
||||||
const missingLocales = await findMissingLocales(locales);
|
|
||||||
for (const locale of missingLocales) {
|
|
||||||
await loadEmojiLocale(locale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
|
||||||
const TOKENIZE_REGEX = new RegExp(
|
|
||||||
`(${EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`,
|
|
||||||
'g',
|
|
||||||
);
|
|
||||||
|
|
||||||
type TokenizedText = (string | EmojiToken)[];
|
type TokenizedText = (string | EmojiToken)[];
|
||||||
|
|
||||||
export function tokenizeText(text: string): TokenizedText {
|
export function tokenizeText(text: string): TokenizedText {
|
||||||
|
@ -161,7 +207,7 @@ export function tokenizeText(text: string): TokenizedText {
|
||||||
|
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
for (const match of text.matchAll(TOKENIZE_REGEX)) {
|
for (const match of text.matchAll(ANY_EMOJI_REGEX)) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
tokens.push(text.slice(lastIndex, match.index));
|
tokens.push(text.slice(lastIndex, match.index));
|
||||||
}
|
}
|
||||||
|
@ -189,8 +235,18 @@ export function tokenizeText(text: string): TokenizedText {
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
||||||
|
[
|
||||||
|
EMOJI_TYPE_CUSTOM,
|
||||||
|
createLimitedCache<EmojiState>({ log: log.extend('custom') }),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
|
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
|
||||||
return localeCacheMap.get(locale) ?? (new Map() as EmojiStateMap);
|
return (
|
||||||
|
localeCacheMap.get(locale) ??
|
||||||
|
createLimitedCache<EmojiState>({ log: log.extend(locale) })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emojiForLocale(
|
function emojiForLocale(
|
||||||
|
@ -203,7 +259,8 @@ function emojiForLocale(
|
||||||
|
|
||||||
async function loadMissingEmojiIntoCache(
|
async function loadMissingEmojiIntoCache(
|
||||||
tokens: TokenizedText,
|
tokens: TokenizedText,
|
||||||
locales: Locale[],
|
{ mode, currentLocale }: EmojiAppState,
|
||||||
|
extraEmojis: ExtraCustomEmojiMap,
|
||||||
) {
|
) {
|
||||||
const missingUnicodeEmoji = new Set<string>();
|
const missingUnicodeEmoji = new Set<string>();
|
||||||
const missingCustomEmoji = new Set<string>();
|
const missingCustomEmoji = new Set<string>();
|
||||||
|
@ -217,31 +274,31 @@ async function loadMissingEmojiIntoCache(
|
||||||
// If this is a custom emoji, check it separately.
|
// If this is a custom emoji, check it separately.
|
||||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||||
const code = token.code;
|
const code = token.code;
|
||||||
|
if (code in extraEmojis) {
|
||||||
|
continue; // We don't care about extra emoji.
|
||||||
|
}
|
||||||
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
|
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
|
||||||
if (!emojiState) {
|
if (!emojiState) {
|
||||||
missingCustomEmoji.add(code);
|
missingCustomEmoji.add(code);
|
||||||
}
|
}
|
||||||
// Otherwise this is a unicode emoji, so check it against all locales.
|
// Otherwise this is a unicode emoji, so check it against all locales.
|
||||||
} else {
|
} else if (shouldRenderImage(token, mode)) {
|
||||||
const code = emojiToUnicodeHex(token.code);
|
const code = emojiToUnicodeHex(token.code);
|
||||||
if (missingUnicodeEmoji.has(code)) {
|
if (missingUnicodeEmoji.has(code)) {
|
||||||
continue; // Already marked as missing.
|
continue; // Already marked as missing.
|
||||||
}
|
}
|
||||||
for (const locale of locales) {
|
const emojiState = emojiForLocale(code, currentLocale);
|
||||||
const emojiState = emojiForLocale(code, locale);
|
|
||||||
if (!emojiState) {
|
if (!emojiState) {
|
||||||
// If it's missing in one locale, we consider it missing for all.
|
// If it's missing in one locale, we consider it missing for all.
|
||||||
missingUnicodeEmoji.add(code);
|
missingUnicodeEmoji.add(code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (missingUnicodeEmoji.size > 0) {
|
if (missingUnicodeEmoji.size > 0) {
|
||||||
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
|
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
|
||||||
for (const locale of locales) {
|
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
|
||||||
const emojis = await searchEmojisByHexcodes(missingEmojis, locale);
|
const cache = cacheForLocale(currentLocale);
|
||||||
const cache = cacheForLocale(locale);
|
|
||||||
for (const emoji of emojis) {
|
for (const emoji of emojis) {
|
||||||
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
|
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
|
||||||
}
|
}
|
||||||
|
@ -251,8 +308,7 @@ async function loadMissingEmojiIntoCache(
|
||||||
for (const code of notFoundEmojis) {
|
for (const code of notFoundEmojis) {
|
||||||
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
||||||
}
|
}
|
||||||
localeCacheMap.set(locale, cache);
|
localeCacheMap.set(currentLocale, cache);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingCustomEmoji.size > 0) {
|
if (missingCustomEmoji.size > 0) {
|
||||||
|
@ -288,22 +344,24 @@ function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateToImage(state: EmojiLoadedState) {
|
function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
|
||||||
const image = document.createElement('img');
|
const image = document.createElement('img');
|
||||||
image.draggable = false;
|
image.draggable = false;
|
||||||
image.classList.add('emojione');
|
image.classList.add('emojione');
|
||||||
|
|
||||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||||
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
|
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
|
||||||
if (emojiInfo.hasLightBorder) {
|
let fileName = emojiInfo.hexCode;
|
||||||
image.dataset.lightCode = `${emojiInfo.hexCode}_BORDER`;
|
if (
|
||||||
} else if (emojiInfo.hasDarkBorder) {
|
(appState.darkTheme && emojiInfo.hasDarkBorder) ||
|
||||||
image.dataset.darkCode = `${emojiInfo.hexCode}_BORDER`;
|
(!appState.darkTheme && emojiInfo.hasLightBorder)
|
||||||
|
) {
|
||||||
|
fileName = `${emojiInfo.hexCode}_border`;
|
||||||
}
|
}
|
||||||
|
|
||||||
image.alt = state.data.unicode;
|
image.alt = state.data.unicode;
|
||||||
image.title = state.data.label;
|
image.title = state.data.label;
|
||||||
image.src = `${assetHost}/emoji/${emojiInfo.hexCode}.svg`;
|
image.src = `${assetHost}/emoji/${fileName}.svg`;
|
||||||
} else {
|
} else {
|
||||||
// Custom emoji
|
// Custom emoji
|
||||||
const shortCode = `:${state.data.shortcode}:`;
|
const shortCode = `:${state.data.shortcode}:`;
|
||||||
|
@ -318,8 +376,16 @@ function stateToImage(state: EmojiLoadedState) {
|
||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
|
function renderedToHTML(renderedArray: EmojifiedTextArray): DocumentFragment;
|
||||||
const fragment = document.createDocumentFragment();
|
function renderedToHTML<ParentType extends ParentNode>(
|
||||||
|
renderedArray: EmojifiedTextArray,
|
||||||
|
parent: ParentType,
|
||||||
|
): ParentType;
|
||||||
|
function renderedToHTML(
|
||||||
|
renderedArray: EmojifiedTextArray,
|
||||||
|
parent: ParentNode | null = null,
|
||||||
|
) {
|
||||||
|
const fragment = parent ?? document.createDocumentFragment();
|
||||||
for (const fragmentItem of renderedArray) {
|
for (const fragmentItem of renderedArray) {
|
||||||
if (typeof fragmentItem === 'string') {
|
if (typeof fragmentItem === 'string') {
|
||||||
fragment.appendChild(document.createTextNode(fragmentItem));
|
fragment.appendChild(document.createTextNode(fragmentItem));
|
||||||
|
@ -329,3 +395,9 @@ function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
|
||||||
}
|
}
|
||||||
return fragment;
|
return fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Testing helpers
|
||||||
|
export const testCacheClear = () => {
|
||||||
|
cacheClear();
|
||||||
|
localeCacheMap.clear();
|
||||||
|
};
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
import type { FlatCompactEmoji, Locale } from 'emojibase';
|
import type { FlatCompactEmoji, Locale } 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 { LimitedCache } from '@/mastodon/utils/cache';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
EMOJI_MODE_NATIVE,
|
EMOJI_MODE_NATIVE,
|
||||||
|
@ -22,6 +26,7 @@ export interface EmojiAppState {
|
||||||
locales: Locale[];
|
locales: Locale[];
|
||||||
currentLocale: Locale;
|
currentLocale: Locale;
|
||||||
mode: EmojiMode;
|
mode: EmojiMode;
|
||||||
|
darkTheme: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnicodeEmojiToken {
|
export interface UnicodeEmojiToken {
|
||||||
|
@ -45,7 +50,7 @@ export interface EmojiStateUnicode {
|
||||||
}
|
}
|
||||||
export interface EmojiStateCustom {
|
export interface EmojiStateCustom {
|
||||||
type: typeof EMOJI_TYPE_CUSTOM;
|
type: typeof EMOJI_TYPE_CUSTOM;
|
||||||
data: CustomEmojiData;
|
data: CustomEmojiRenderFields;
|
||||||
}
|
}
|
||||||
export type EmojiState =
|
export type EmojiState =
|
||||||
| EmojiStateMissing
|
| EmojiStateMissing
|
||||||
|
@ -53,9 +58,16 @@ export type EmojiState =
|
||||||
| EmojiStateCustom;
|
| EmojiStateCustom;
|
||||||
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
|
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
|
||||||
|
|
||||||
export type EmojiStateMap = Map<string, EmojiState>;
|
export type EmojiStateMap = LimitedCache<string, EmojiState>;
|
||||||
|
|
||||||
export type ExtraCustomEmojiMap = Record<string, ApiCustomEmojiJSON>;
|
export type CustomEmojiMapArg =
|
||||||
|
| ExtraCustomEmojiMap
|
||||||
|
| ImmutableList<CustomEmoji>;
|
||||||
|
export type CustomEmojiRenderFields = Pick<
|
||||||
|
CustomEmojiData,
|
||||||
|
'shortcode' | 'static_url' | 'url'
|
||||||
|
>;
|
||||||
|
export type ExtraCustomEmojiMap = Record<string, CustomEmojiRenderFields>;
|
||||||
|
|
||||||
export interface TwemojiBorderInfo {
|
export interface TwemojiBorderInfo {
|
||||||
hexCode: string;
|
hexCode: string;
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
import { stringHasUnicodeEmoji, stringHasUnicodeFlags } from './utils';
|
import {
|
||||||
|
stringHasAnyEmoji,
|
||||||
|
stringHasCustomEmoji,
|
||||||
|
stringHasUnicodeEmoji,
|
||||||
|
stringHasUnicodeFlags,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
describe('stringHasEmoji', () => {
|
describe('stringHasUnicodeEmoji', () => {
|
||||||
test.concurrent.for([
|
test.concurrent.for([
|
||||||
['only text', false],
|
['only text', false],
|
||||||
|
['text with non-emoji symbols ™©', false],
|
||||||
['text with emoji 😀', true],
|
['text with emoji 😀', true],
|
||||||
['multiple emojis 😀😃😄', true],
|
['multiple emojis 😀😃😄', true],
|
||||||
['emoji with skin tone 👍🏽', true],
|
['emoji with skin tone 👍🏽', true],
|
||||||
|
@ -19,14 +25,14 @@ describe('stringHasEmoji', () => {
|
||||||
['emoji with enclosing keycap #️⃣', true],
|
['emoji with enclosing keycap #️⃣', true],
|
||||||
['emoji with no visible glyph \u200D', false],
|
['emoji with no visible glyph \u200D', false],
|
||||||
] as const)(
|
] as const)(
|
||||||
'stringHasEmoji has emojis in "%s": %o',
|
'stringHasUnicodeEmoji has emojis in "%s": %o',
|
||||||
([text, expected], { expect }) => {
|
([text, expected], { expect }) => {
|
||||||
expect(stringHasUnicodeEmoji(text)).toBe(expected);
|
expect(stringHasUnicodeEmoji(text)).toBe(expected);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('stringHasFlags', () => {
|
describe('stringHasUnicodeFlags', () => {
|
||||||
test.concurrent.for([
|
test.concurrent.for([
|
||||||
['EU 🇪🇺', true],
|
['EU 🇪🇺', true],
|
||||||
['Germany 🇩🇪', true],
|
['Germany 🇩🇪', true],
|
||||||
|
@ -45,3 +51,27 @@ describe('stringHasFlags', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('stringHasCustomEmoji', () => {
|
||||||
|
test('string with custom emoji returns true', () => {
|
||||||
|
expect(stringHasCustomEmoji(':custom: :test:')).toBeTruthy();
|
||||||
|
});
|
||||||
|
test('string without custom emoji returns false', () => {
|
||||||
|
expect(stringHasCustomEmoji('🏳️🌈 :🏳️🌈: text ™')).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stringHasAnyEmoji', () => {
|
||||||
|
test('string without any emoji or characters', () => {
|
||||||
|
expect(stringHasAnyEmoji('normal text. 12356?!')).toBeFalsy();
|
||||||
|
});
|
||||||
|
test('string with non-emoji characters', () => {
|
||||||
|
expect(stringHasAnyEmoji('™©')).toBeFalsy();
|
||||||
|
});
|
||||||
|
test('has unicode emoji', () => {
|
||||||
|
expect(stringHasAnyEmoji('🏳️🌈🔥🇸🇹 👩🔬')).toBeTruthy();
|
||||||
|
});
|
||||||
|
test('has custom emoji', () => {
|
||||||
|
expect(stringHasAnyEmoji(':test: :custom:')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -1,13 +1,27 @@
|
||||||
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
|
import debug from 'debug';
|
||||||
|
|
||||||
export function stringHasUnicodeEmoji(text: string): boolean {
|
import {
|
||||||
return EMOJI_REGEX.test(text);
|
CUSTOM_EMOJI_REGEX,
|
||||||
|
UNICODE_EMOJI_REGEX,
|
||||||
|
UNICODE_FLAG_EMOJI_REGEX,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
export function emojiLogger(segment: string) {
|
||||||
|
return debug(`emojis:${segment}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// From https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L49-L50
|
export function stringHasUnicodeEmoji(input: string): boolean {
|
||||||
const EMOJIS_FLAGS_REGEX =
|
return UNICODE_EMOJI_REGEX.test(input);
|
||||||
/[\u{1F1E6}-\u{1F1FF}|\u{E0062}-\u{E0063}|\u{E0065}|\u{E0067}|\u{E006C}|\u{E006E}|\u{E0073}-\u{E0074}|\u{E0077}|\u{E007F}]+/u;
|
}
|
||||||
|
|
||||||
export function stringHasUnicodeFlags(text: string): boolean {
|
export function stringHasUnicodeFlags(input: string): boolean {
|
||||||
return EMOJIS_FLAGS_REGEX.test(text);
|
return UNICODE_FLAG_EMOJI_REGEX.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringHasCustomEmoji(input: string) {
|
||||||
|
return CUSTOM_EMOJI_REGEX.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringHasAnyEmoji(input: string) {
|
||||||
|
return stringHasUnicodeEmoji(input) || stringHasCustomEmoji(input);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,14 @@ self.postMessage('ready'); // After the worker is ready, notify the main thread
|
||||||
|
|
||||||
function handleMessage(event: MessageEvent<string>) {
|
function handleMessage(event: MessageEvent<string>) {
|
||||||
const { data: locale } = event;
|
const { data: locale } = event;
|
||||||
if (locale !== 'custom') {
|
void loadData(locale);
|
||||||
void importEmojiData(locale);
|
}
|
||||||
} else {
|
|
||||||
void importCustomEmojiData();
|
async function loadData(locale: string) {
|
||||||
}
|
if (locale !== 'custom') {
|
||||||
|
await importEmojiData(locale);
|
||||||
|
} else {
|
||||||
|
await importCustomEmojiData();
|
||||||
|
}
|
||||||
|
self.postMessage(`loaded ${locale}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -872,12 +872,6 @@
|
||||||
"status.open": "وسّع هذا المنشور",
|
"status.open": "وسّع هذا المنشور",
|
||||||
"status.pin": "دبّسه على الصفحة التعريفية",
|
"status.pin": "دبّسه على الصفحة التعريفية",
|
||||||
"status.quote_error.filtered": "مُخفي بسبب إحدى إعدادات التصفية خاصتك",
|
"status.quote_error.filtered": "مُخفي بسبب إحدى إعدادات التصفية خاصتك",
|
||||||
"status.quote_error.not_found": "لا يمكن عرض هذا المنشور.",
|
|
||||||
"status.quote_error.pending_approval": "هذا المنشور ينتظر موافقة صاحب المنشور الأصلي.",
|
|
||||||
"status.quote_error.rejected": "لا يمكن عرض هذا المنشور لأن صاحب المنشور الأصلي لا يسمح له بأن يكون مقتبس.",
|
|
||||||
"status.quote_error.removed": "تمت إزالة المنشور من قبل صاحبه.",
|
|
||||||
"status.quote_error.unauthorized": "لا يمكن عرض هذا المنشور لأنك لست مخولاً برؤيته.",
|
|
||||||
"status.quote_post_author": "منشور من {name}",
|
|
||||||
"status.read_more": "اقرأ المزيد",
|
"status.read_more": "اقرأ المزيد",
|
||||||
"status.reblog": "إعادة النشر",
|
"status.reblog": "إعادة النشر",
|
||||||
"status.reblog_private": "إعادة النشر إلى الجمهور الأصلي",
|
"status.reblog_private": "إعادة النشر إلى الجمهور الأصلي",
|
||||||
|
|
|
@ -821,7 +821,6 @@
|
||||||
"status.mute_conversation": "Ігнараваць размову",
|
"status.mute_conversation": "Ігнараваць размову",
|
||||||
"status.open": "Разгарнуць гэты допіс",
|
"status.open": "Разгарнуць гэты допіс",
|
||||||
"status.pin": "Замацаваць у профілі",
|
"status.pin": "Замацаваць у профілі",
|
||||||
"status.quote_post_author": "Допіс карыстальніка @{name}",
|
|
||||||
"status.read_more": "Чытаць болей",
|
"status.read_more": "Чытаць болей",
|
||||||
"status.reblog": "Пашырыць",
|
"status.reblog": "Пашырыць",
|
||||||
"status.reblog_private": "Пашырыць з першапачатковай бачнасцю",
|
"status.reblog_private": "Пашырыць з першапачатковай бачнасцю",
|
||||||
|
|
|
@ -860,12 +860,6 @@
|
||||||
"status.open": "Разширяване на публикацията",
|
"status.open": "Разширяване на публикацията",
|
||||||
"status.pin": "Закачане в профила",
|
"status.pin": "Закачане в профила",
|
||||||
"status.quote_error.filtered": "Скрито поради един от филтрите ви",
|
"status.quote_error.filtered": "Скрито поради един от филтрите ви",
|
||||||
"status.quote_error.not_found": "Публикацията не може да се показва.",
|
|
||||||
"status.quote_error.pending_approval": "Публикацията чака одобрение от първоначалния автор.",
|
|
||||||
"status.quote_error.rejected": "Публикацията не може да се показва като първоначалния автор не позволява цитирането ѝ.",
|
|
||||||
"status.quote_error.removed": "Публикацията е премахната от автора ѝ.",
|
|
||||||
"status.quote_error.unauthorized": "Публикацията не може да се показва, тъй като не сте упълномощени да я гледате.",
|
|
||||||
"status.quote_post_author": "Публикация от {name}",
|
|
||||||
"status.read_more": "Още за четене",
|
"status.read_more": "Още за четене",
|
||||||
"status.reblog": "Подсилване",
|
"status.reblog": "Подсилване",
|
||||||
"status.reblog_private": "Подсилване с оригиналната видимост",
|
"status.reblog_private": "Подсилване с оригиналната видимост",
|
||||||
|
|
|
@ -582,7 +582,6 @@
|
||||||
"status.mute_conversation": "Kuzhat ar gaozeadenn",
|
"status.mute_conversation": "Kuzhat ar gaozeadenn",
|
||||||
"status.open": "Digeriñ ar c'hannad-mañ",
|
"status.open": "Digeriñ ar c'hannad-mañ",
|
||||||
"status.pin": "Spilhennañ d'ar profil",
|
"status.pin": "Spilhennañ d'ar profil",
|
||||||
"status.quote_post_author": "Embannadenn gant {name}",
|
|
||||||
"status.read_more": "Lenn muioc'h",
|
"status.read_more": "Lenn muioc'h",
|
||||||
"status.reblog": "Skignañ",
|
"status.reblog": "Skignañ",
|
||||||
"status.reblog_private": "Skignañ gant ar weledenn gentañ",
|
"status.reblog_private": "Skignañ gant ar weledenn gentañ",
|
||||||
|
|
|
@ -872,12 +872,6 @@
|
||||||
"status.open": "Amplia el tut",
|
"status.open": "Amplia el tut",
|
||||||
"status.pin": "Fixa en el perfil",
|
"status.pin": "Fixa en el perfil",
|
||||||
"status.quote_error.filtered": "No es mostra a causa d'un dels vostres filtres",
|
"status.quote_error.filtered": "No es mostra a causa d'un dels vostres filtres",
|
||||||
"status.quote_error.not_found": "No es pot mostrar aquesta publicació.",
|
|
||||||
"status.quote_error.pending_approval": "Aquesta publicació està pendent d'aprovació per l'autor original.",
|
|
||||||
"status.quote_error.rejected": "No es pot mostrar aquesta publicació perquè l'autor original no en permet la citació.",
|
|
||||||
"status.quote_error.removed": "Aquesta publicació ha estat eliminada per l'autor.",
|
|
||||||
"status.quote_error.unauthorized": "No es pot mostrar aquesta publicació perquè no teniu autorització per a veure-la.",
|
|
||||||
"status.quote_post_author": "Publicació de {name}",
|
|
||||||
"status.read_more": "Més informació",
|
"status.read_more": "Més informació",
|
||||||
"status.reblog": "Impulsa",
|
"status.reblog": "Impulsa",
|
||||||
"status.reblog_private": "Impulsa amb la visibilitat original",
|
"status.reblog_private": "Impulsa amb la visibilitat original",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "k přeložení příspěvku",
|
"keyboard_shortcuts.translate": "k přeložení příspěvku",
|
||||||
"keyboard_shortcuts.unfocus": "Zrušit zaměření na nový příspěvek/hledání",
|
"keyboard_shortcuts.unfocus": "Zrušit zaměření na nový příspěvek/hledání",
|
||||||
"keyboard_shortcuts.up": "Posunout v seznamu nahoru",
|
"keyboard_shortcuts.up": "Posunout v seznamu nahoru",
|
||||||
|
"learn_more_link.got_it": "Rozumím",
|
||||||
|
"learn_more_link.learn_more": "Zjistit více",
|
||||||
"lightbox.close": "Zavřít",
|
"lightbox.close": "Zavřít",
|
||||||
"lightbox.next": "Další",
|
"lightbox.next": "Další",
|
||||||
"lightbox.previous": "Předchozí",
|
"lightbox.previous": "Předchozí",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Rozbalit tento příspěvek",
|
"status.open": "Rozbalit tento příspěvek",
|
||||||
"status.pin": "Připnout na profil",
|
"status.pin": "Připnout na profil",
|
||||||
"status.quote_error.filtered": "Skryté kvůli jednomu z vašich filtrů",
|
"status.quote_error.filtered": "Skryté kvůli jednomu z vašich filtrů",
|
||||||
"status.quote_error.not_found": "Tento příspěvek nelze zobrazit.",
|
"status.quote_error.not_available": "Příspěvek není dostupný",
|
||||||
"status.quote_error.pending_approval": "Tento příspěvek čeká na schválení od původního autora.",
|
"status.quote_error.pending_approval": "Příspěvek čeká na schválení",
|
||||||
"status.quote_error.rejected": "Tento příspěvek nemůže být zobrazen, protože původní autor neumožňuje, aby byl citován.",
|
"status.quote_error.pending_approval_popout.body": "Zobrazení citátů sdílených napříč Fediversem může chvíli trvat, protože různé servery používají různé protokoly.",
|
||||||
"status.quote_error.removed": "Tento příspěvek byl odstraněn jeho autorem.",
|
"status.quote_error.pending_approval_popout.title": "Příspěvek čeká na schválení? Buďte klidní",
|
||||||
"status.quote_error.unauthorized": "Tento příspěvek nelze zobrazit, protože nemáte oprávnění k jeho zobrazení.",
|
"status.quote_post_author": "Citovali příspěvek od @{name}",
|
||||||
"status.quote_post_author": "Příspěvek od {name}",
|
|
||||||
"status.read_more": "Číst více",
|
"status.read_more": "Číst více",
|
||||||
"status.reblog": "Boostnout",
|
"status.reblog": "Boostnout",
|
||||||
"status.reblog_private": "Boostnout s původní viditelností",
|
"status.reblog_private": "Boostnout s původní viditelností",
|
||||||
|
|
|
@ -871,12 +871,6 @@
|
||||||
"status.open": "Ehangu'r post hwn",
|
"status.open": "Ehangu'r post hwn",
|
||||||
"status.pin": "Pinio ar y proffil",
|
"status.pin": "Pinio ar y proffil",
|
||||||
"status.quote_error.filtered": "Wedi'i guddio oherwydd un o'ch hidlwyr",
|
"status.quote_error.filtered": "Wedi'i guddio oherwydd un o'ch hidlwyr",
|
||||||
"status.quote_error.not_found": "Does dim modd dangos y postiad hwn.",
|
|
||||||
"status.quote_error.pending_approval": "Mae'r postiad hwn yn aros am gymeradwyaeth yr awdur gwreiddiol.",
|
|
||||||
"status.quote_error.rejected": "Does dim modd dangos y postiad hwn gan nad yw'r awdur gwreiddiol yn caniatáu iddo gael ei ddyfynnu.",
|
|
||||||
"status.quote_error.removed": "Cafodd y postiad hwn ei ddileu gan ei awdur.",
|
|
||||||
"status.quote_error.unauthorized": "Does dim modd dangos y postiad hwn gan nad oes gennych awdurdod i'w weld.",
|
|
||||||
"status.quote_post_author": "Postiad gan {name}",
|
|
||||||
"status.read_more": "Darllen rhagor",
|
"status.read_more": "Darllen rhagor",
|
||||||
"status.reblog": "Hybu",
|
"status.reblog": "Hybu",
|
||||||
"status.reblog_private": "Hybu i'r gynulleidfa wreiddiol",
|
"status.reblog_private": "Hybu i'r gynulleidfa wreiddiol",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "for at oversætte et indlæg",
|
"keyboard_shortcuts.translate": "for at oversætte et indlæg",
|
||||||
"keyboard_shortcuts.unfocus": "Fjern fokus fra tekstskrivningsområde/søgning",
|
"keyboard_shortcuts.unfocus": "Fjern fokus fra tekstskrivningsområde/søgning",
|
||||||
"keyboard_shortcuts.up": "Flyt opad på listen",
|
"keyboard_shortcuts.up": "Flyt opad på listen",
|
||||||
|
"learn_more_link.got_it": "Forstået",
|
||||||
|
"learn_more_link.learn_more": "Få mere at vide",
|
||||||
"lightbox.close": "Luk",
|
"lightbox.close": "Luk",
|
||||||
"lightbox.next": "Næste",
|
"lightbox.next": "Næste",
|
||||||
"lightbox.previous": "Forrige",
|
"lightbox.previous": "Forrige",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Udvid dette indlæg",
|
"status.open": "Udvid dette indlæg",
|
||||||
"status.pin": "Fastgør til profil",
|
"status.pin": "Fastgør til profil",
|
||||||
"status.quote_error.filtered": "Skjult grundet et af filterne",
|
"status.quote_error.filtered": "Skjult grundet et af filterne",
|
||||||
"status.quote_error.not_found": "Dette indlæg kan ikke vises.",
|
"status.quote_error.not_available": "Indlæg utilgængeligt",
|
||||||
"status.quote_error.pending_approval": "Dette indlæg afventer godkendelse fra den oprindelige forfatter.",
|
"status.quote_error.pending_approval": "Afventende indlæg",
|
||||||
"status.quote_error.rejected": "Dette indlæg kan ikke vises, da den oprindelige forfatter ikke tillader citering heraf.",
|
"status.quote_error.pending_approval_popout.body": "Citater delt på tværs af Fediverset kan tage tid at vise, da forskellige servere har forskellige protokoller.",
|
||||||
"status.quote_error.removed": "Dette indlæg er fjernet af forfatteren.",
|
"status.quote_error.pending_approval_popout.title": "Afventende citat? Tag det roligt",
|
||||||
"status.quote_error.unauthorized": "Dette indlæg kan ikke vises, da man ikke har tilladelse til at se det.",
|
"status.quote_post_author": "Citerede et indlæg fra @{name}",
|
||||||
"status.quote_post_author": "Indlæg fra {name}",
|
|
||||||
"status.read_more": "Læs mere",
|
"status.read_more": "Læs mere",
|
||||||
"status.reblog": "Fremhæv",
|
"status.reblog": "Fremhæv",
|
||||||
"status.reblog_private": "Fremhæv med oprindelig synlighed",
|
"status.reblog_private": "Fremhæv med oprindelig synlighed",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "Beitrag übersetzen",
|
"keyboard_shortcuts.translate": "Beitrag übersetzen",
|
||||||
"keyboard_shortcuts.unfocus": "Eingabefeld/Suche nicht mehr fokussieren",
|
"keyboard_shortcuts.unfocus": "Eingabefeld/Suche nicht mehr fokussieren",
|
||||||
"keyboard_shortcuts.up": "Ansicht nach oben bewegen",
|
"keyboard_shortcuts.up": "Ansicht nach oben bewegen",
|
||||||
|
"learn_more_link.got_it": "Verstanden",
|
||||||
|
"learn_more_link.learn_more": "Mehr erfahren",
|
||||||
"lightbox.close": "Schließen",
|
"lightbox.close": "Schließen",
|
||||||
"lightbox.next": "Vor",
|
"lightbox.next": "Vor",
|
||||||
"lightbox.previous": "Zurück",
|
"lightbox.previous": "Zurück",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Beitrag öffnen",
|
"status.open": "Beitrag öffnen",
|
||||||
"status.pin": "Im Profil anheften",
|
"status.pin": "Im Profil anheften",
|
||||||
"status.quote_error.filtered": "Ausgeblendet wegen eines deiner Filter",
|
"status.quote_error.filtered": "Ausgeblendet wegen eines deiner Filter",
|
||||||
"status.quote_error.not_found": "Dieser Beitrag kann nicht angezeigt werden.",
|
"status.quote_error.not_available": "Beitrag nicht verfügbar",
|
||||||
"status.quote_error.pending_approval": "Dieser Beitrag muss noch durch das ursprüngliche Profil genehmigt werden.",
|
"status.quote_error.pending_approval": "Beitragsveröffentlichung ausstehend",
|
||||||
"status.quote_error.rejected": "Dieser Beitrag kann nicht angezeigt werden, weil das ursprüngliche Profil das Zitieren nicht erlaubt.",
|
"status.quote_error.pending_approval_popout.body": "Zitierte Beiträge, die im Fediverse geteilt werden, benötigen einige Zeit, bis sie überall angezeigt werden, da die verschiedenen Server unterschiedliche Protokolle nutzen.",
|
||||||
"status.quote_error.removed": "Dieser Beitrag wurde durch das Profil entfernt.",
|
"status.quote_error.pending_approval_popout.title": "Zitierter Beitrag noch nicht freigegeben? Immer mit der Ruhe",
|
||||||
"status.quote_error.unauthorized": "Dieser Beitrag kann nicht angezeigt werden, weil du zum Ansehen nicht berechtigt bist.",
|
"status.quote_post_author": "Zitierte einen Beitrag von @{name}",
|
||||||
"status.quote_post_author": "Beitrag von {name}",
|
|
||||||
"status.read_more": "Gesamten Beitrag anschauen",
|
"status.read_more": "Gesamten Beitrag anschauen",
|
||||||
"status.reblog": "Teilen",
|
"status.reblog": "Teilen",
|
||||||
"status.reblog_private": "Mit der ursprünglichen Zielgruppe teilen",
|
"status.reblog_private": "Mit der ursprünglichen Zielgruppe teilen",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "για να μεταφραστεί μια ανάρτηση",
|
"keyboard_shortcuts.translate": "για να μεταφραστεί μια ανάρτηση",
|
||||||
"keyboard_shortcuts.unfocus": "Αποεστίαση του πεδίου σύνθεσης/αναζήτησης",
|
"keyboard_shortcuts.unfocus": "Αποεστίαση του πεδίου σύνθεσης/αναζήτησης",
|
||||||
"keyboard_shortcuts.up": "Μετακίνηση προς τα πάνω στη λίστα",
|
"keyboard_shortcuts.up": "Μετακίνηση προς τα πάνω στη λίστα",
|
||||||
|
"learn_more_link.got_it": "Το κατάλαβα",
|
||||||
|
"learn_more_link.learn_more": "Μάθε περισσότερα",
|
||||||
"lightbox.close": "Κλείσιμο",
|
"lightbox.close": "Κλείσιμο",
|
||||||
"lightbox.next": "Επόμενο",
|
"lightbox.next": "Επόμενο",
|
||||||
"lightbox.previous": "Προηγούμενο",
|
"lightbox.previous": "Προηγούμενο",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Επέκταση ανάρτησης",
|
"status.open": "Επέκταση ανάρτησης",
|
||||||
"status.pin": "Καρφίτσωσε στο προφίλ",
|
"status.pin": "Καρφίτσωσε στο προφίλ",
|
||||||
"status.quote_error.filtered": "Κρυφό λόγω ενός από τα φίλτρα σου",
|
"status.quote_error.filtered": "Κρυφό λόγω ενός από τα φίλτρα σου",
|
||||||
"status.quote_error.not_found": "Αυτή η ανάρτηση δεν μπορεί να εμφανιστεί.",
|
"status.quote_error.not_available": "Ανάρτηση μη διαθέσιμη",
|
||||||
"status.quote_error.pending_approval": "Αυτή η ανάρτηση εκκρεμεί έγκριση από τον αρχικό συντάκτη.",
|
"status.quote_error.pending_approval": "Ανάρτηση σε αναμονή",
|
||||||
"status.quote_error.rejected": "Αυτή η ανάρτηση δεν μπορεί να εμφανιστεί καθώς ο αρχικός συντάκτης δεν επιτρέπει τις παραθέσεις.",
|
"status.quote_error.pending_approval_popout.body": "Οι παραθέσεις που μοιράζονται στο Fediverse μπορεί να χρειαστούν χρόνο για να εμφανιστούν, καθώς διαφορετικοί διακομιστές έχουν διαφορετικά πρωτόκολλα.",
|
||||||
"status.quote_error.removed": "Αυτή η ανάρτηση αφαιρέθηκε από τον συντάκτη της.",
|
"status.quote_error.pending_approval_popout.title": "Παράθεση σε εκκρεμότητα; Μείνετε ψύχραιμοι",
|
||||||
"status.quote_error.unauthorized": "Αυτή η ανάρτηση δεν μπορεί να εμφανιστεί καθώς δεν έχεις εξουσιοδότηση για να τη δεις.",
|
"status.quote_post_author": "Παρατίθεται μια ανάρτηση από @{name}",
|
||||||
"status.quote_post_author": "Ανάρτηση από {name}",
|
|
||||||
"status.read_more": "Διάβασε περισότερα",
|
"status.read_more": "Διάβασε περισότερα",
|
||||||
"status.reblog": "Ενίσχυση",
|
"status.reblog": "Ενίσχυση",
|
||||||
"status.reblog_private": "Ενίσχυση με αρχική ορατότητα",
|
"status.reblog_private": "Ενίσχυση με αρχική ορατότητα",
|
||||||
|
|
|
@ -871,12 +871,6 @@
|
||||||
"status.open": "Expand this post",
|
"status.open": "Expand this post",
|
||||||
"status.pin": "Pin on profile",
|
"status.pin": "Pin on profile",
|
||||||
"status.quote_error.filtered": "Hidden due to one of your filters",
|
"status.quote_error.filtered": "Hidden due to one of your filters",
|
||||||
"status.quote_error.not_found": "This post cannot be displayed.",
|
|
||||||
"status.quote_error.pending_approval": "This post is pending approval from the original author.",
|
|
||||||
"status.quote_error.rejected": "This post cannot be displayed as the original author does not allow it to be quoted.",
|
|
||||||
"status.quote_error.removed": "This post was removed by its author.",
|
|
||||||
"status.quote_error.unauthorized": "This post cannot be displayed as you are not authorised",
|
|
||||||
"status.quote_post_author": "Post by {name}",
|
|
||||||
"status.read_more": "Read more",
|
"status.read_more": "Read more",
|
||||||
"status.reblog": "Boost",
|
"status.reblog": "Boost",
|
||||||
"status.reblog_private": "Boost with original visibility",
|
"status.reblog_private": "Boost with original visibility",
|
||||||
|
|
|
@ -851,9 +851,6 @@
|
||||||
"status.mute_conversation": "Silentigi konversacion",
|
"status.mute_conversation": "Silentigi konversacion",
|
||||||
"status.open": "Pligrandigu ĉi tiun afiŝon",
|
"status.open": "Pligrandigu ĉi tiun afiŝon",
|
||||||
"status.pin": "Alpingli al la profilo",
|
"status.pin": "Alpingli al la profilo",
|
||||||
"status.quote_error.not_found": "Ĉi tiu afiŝo ne povas esti montrata.",
|
|
||||||
"status.quote_error.rejected": "Ĉi tiu afiŝo ne povas esti montrata ĉar la originala aŭtoro ne permesas ĝian citadon.",
|
|
||||||
"status.quote_error.removed": "Ĉi tiu afiŝo estis forigita de ĝia aŭtoro.",
|
|
||||||
"status.read_more": "Legi pli",
|
"status.read_more": "Legi pli",
|
||||||
"status.reblog": "Diskonigi",
|
"status.reblog": "Diskonigi",
|
||||||
"status.reblog_private": "Diskonigi kun la sama videbleco",
|
"status.reblog_private": "Diskonigi kun la sama videbleco",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "para traducir un mensaje",
|
"keyboard_shortcuts.translate": "para traducir un mensaje",
|
||||||
"keyboard_shortcuts.unfocus": "Quitar el foco del área de texto de redacción o de búsqueda",
|
"keyboard_shortcuts.unfocus": "Quitar el foco del área de texto de redacción o de búsqueda",
|
||||||
"keyboard_shortcuts.up": "Subir en la lista",
|
"keyboard_shortcuts.up": "Subir en la lista",
|
||||||
|
"learn_more_link.got_it": "Entendido",
|
||||||
|
"learn_more_link.learn_more": "Aprendé más",
|
||||||
"lightbox.close": "Cerrar",
|
"lightbox.close": "Cerrar",
|
||||||
"lightbox.next": "Siguiente",
|
"lightbox.next": "Siguiente",
|
||||||
"lightbox.previous": "Anterior",
|
"lightbox.previous": "Anterior",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Expandir este mensaje",
|
"status.open": "Expandir este mensaje",
|
||||||
"status.pin": "Fijar en el perfil",
|
"status.pin": "Fijar en el perfil",
|
||||||
"status.quote_error.filtered": "Oculto debido a uno de tus filtros",
|
"status.quote_error.filtered": "Oculto debido a uno de tus filtros",
|
||||||
"status.quote_error.not_found": "No se puede mostrar este mensaje.",
|
"status.quote_error.not_available": "Mensaje no disponible",
|
||||||
"status.quote_error.pending_approval": "Este mensaje está pendiente de aprobación del autor original.",
|
"status.quote_error.pending_approval": "Mensaje pendiente",
|
||||||
"status.quote_error.rejected": "No se puede mostrar este mensaje, ya que el autor original no permite que se cite.",
|
"status.quote_error.pending_approval_popout.body": "Las citas compartidas a través del Fediverso pueden tardar en mostrarse, ya que diferentes servidores tienen diferentes protocolos.",
|
||||||
"status.quote_error.removed": "Este mensaje fue eliminado por su autor.",
|
"status.quote_error.pending_approval_popout.title": "¿Cita pendiente? Esperá un momento",
|
||||||
"status.quote_error.unauthorized": "No se puede mostrar este mensaje, ya que no tenés autorización para verlo.",
|
"status.quote_post_author": "Se citó un mensaje de @{name}",
|
||||||
"status.quote_post_author": "Mensaje de @{name}",
|
|
||||||
"status.read_more": "Leé más",
|
"status.read_more": "Leé más",
|
||||||
"status.reblog": "Adherir",
|
"status.reblog": "Adherir",
|
||||||
"status.reblog_private": "Adherir a la audiencia original",
|
"status.reblog_private": "Adherir a la audiencia original",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "para traducir una publicación",
|
"keyboard_shortcuts.translate": "para traducir una publicación",
|
||||||
"keyboard_shortcuts.unfocus": "Desenfocar área de redacción/búsqueda",
|
"keyboard_shortcuts.unfocus": "Desenfocar área de redacción/búsqueda",
|
||||||
"keyboard_shortcuts.up": "Ascender en la lista",
|
"keyboard_shortcuts.up": "Ascender en la lista",
|
||||||
|
"learn_more_link.got_it": "Entendido",
|
||||||
|
"learn_more_link.learn_more": "Más información",
|
||||||
"lightbox.close": "Cerrar",
|
"lightbox.close": "Cerrar",
|
||||||
"lightbox.next": "Siguiente",
|
"lightbox.next": "Siguiente",
|
||||||
"lightbox.previous": "Anterior",
|
"lightbox.previous": "Anterior",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Expandir estado",
|
"status.open": "Expandir estado",
|
||||||
"status.pin": "Fijar",
|
"status.pin": "Fijar",
|
||||||
"status.quote_error.filtered": "Oculto debido a uno de tus filtros",
|
"status.quote_error.filtered": "Oculto debido a uno de tus filtros",
|
||||||
"status.quote_error.not_found": "No se puede mostrar esta publicación.",
|
"status.quote_error.not_available": "Publicación no disponible",
|
||||||
"status.quote_error.pending_approval": "Esta publicación está pendiente de aprobación del autor original.",
|
"status.quote_error.pending_approval": "Publicación pendiente",
|
||||||
"status.quote_error.rejected": "No se puede mostrar esta publicación, puesto que el autor original no permite que sea citado.",
|
"status.quote_error.pending_approval_popout.body": "Las citas compartidas a través del Fediverso pueden tardar en mostrarse, ya que los diferentes servidores tienen diferentes protocolos.",
|
||||||
"status.quote_error.removed": "Esta publicación fue eliminada por su autor.",
|
"status.quote_error.pending_approval_popout.title": "¿Cita pendiente? Mantén la calma",
|
||||||
"status.quote_error.unauthorized": "No se puede mostrar esta publicación, puesto que no estás autorizado a verla.",
|
"status.quote_post_author": "Ha citado una publicación de @{name}",
|
||||||
"status.quote_post_author": "Publicado por {name}",
|
|
||||||
"status.read_more": "Leer más",
|
"status.read_more": "Leer más",
|
||||||
"status.reblog": "Impulsar",
|
"status.reblog": "Impulsar",
|
||||||
"status.reblog_private": "Implusar a la audiencia original",
|
"status.reblog_private": "Implusar a la audiencia original",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "para traducir una publicación",
|
"keyboard_shortcuts.translate": "para traducir una publicación",
|
||||||
"keyboard_shortcuts.unfocus": "Quitar el foco de la caja de redacción/búsqueda",
|
"keyboard_shortcuts.unfocus": "Quitar el foco de la caja de redacción/búsqueda",
|
||||||
"keyboard_shortcuts.up": "Moverse hacia arriba en la lista",
|
"keyboard_shortcuts.up": "Moverse hacia arriba en la lista",
|
||||||
|
"learn_more_link.got_it": "Entendido",
|
||||||
|
"learn_more_link.learn_more": "Más información",
|
||||||
"lightbox.close": "Cerrar",
|
"lightbox.close": "Cerrar",
|
||||||
"lightbox.next": "Siguiente",
|
"lightbox.next": "Siguiente",
|
||||||
"lightbox.previous": "Anterior",
|
"lightbox.previous": "Anterior",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Expandir publicación",
|
"status.open": "Expandir publicación",
|
||||||
"status.pin": "Fijar",
|
"status.pin": "Fijar",
|
||||||
"status.quote_error.filtered": "Oculto debido a uno de tus filtros",
|
"status.quote_error.filtered": "Oculto debido a uno de tus filtros",
|
||||||
"status.quote_error.not_found": "No se puede mostrar esta publicación.",
|
"status.quote_error.not_available": "Publicación no disponible",
|
||||||
"status.quote_error.pending_approval": "Esta publicación está pendiente de aprobación del autor original.",
|
"status.quote_error.pending_approval": "Publicación pendiente",
|
||||||
"status.quote_error.rejected": "Esta publicación no puede mostrarse porque el autor original no permite que se cite.",
|
"status.quote_error.pending_approval_popout.body": "Las citas compartidas a través del Fediverso pueden tardar en mostrarse, ya que los diferentes servidores tienen diferentes protocolos.",
|
||||||
"status.quote_error.removed": "Esta publicación fue eliminada por su autor.",
|
"status.quote_error.pending_approval_popout.title": "¿Cita pendiente? Mantén la calma",
|
||||||
"status.quote_error.unauthorized": "Esta publicación no puede mostrarse, ya que no estás autorizado a verla.",
|
"status.quote_post_author": "Ha citado una publicación de @{name}",
|
||||||
"status.quote_post_author": "Publicación de {name}",
|
|
||||||
"status.read_more": "Leer más",
|
"status.read_more": "Leer más",
|
||||||
"status.reblog": "Impulsar",
|
"status.reblog": "Impulsar",
|
||||||
"status.reblog_private": "Impulsar a la audiencia original",
|
"status.reblog_private": "Impulsar a la audiencia original",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "postituse tõlkimiseks",
|
"keyboard_shortcuts.translate": "postituse tõlkimiseks",
|
||||||
"keyboard_shortcuts.unfocus": "Fookus tekstialalt/otsingult ära",
|
"keyboard_shortcuts.unfocus": "Fookus tekstialalt/otsingult ära",
|
||||||
"keyboard_shortcuts.up": "Liigu loetelus üles",
|
"keyboard_shortcuts.up": "Liigu loetelus üles",
|
||||||
|
"learn_more_link.got_it": "Sain aru",
|
||||||
|
"learn_more_link.learn_more": "Lisateave",
|
||||||
"lightbox.close": "Sulge",
|
"lightbox.close": "Sulge",
|
||||||
"lightbox.next": "Järgmine",
|
"lightbox.next": "Järgmine",
|
||||||
"lightbox.previous": "Eelmine",
|
"lightbox.previous": "Eelmine",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Laienda postitus",
|
"status.open": "Laienda postitus",
|
||||||
"status.pin": "Kinnita profiilile",
|
"status.pin": "Kinnita profiilile",
|
||||||
"status.quote_error.filtered": "Peidetud mõne kasutatud filtri tõttu",
|
"status.quote_error.filtered": "Peidetud mõne kasutatud filtri tõttu",
|
||||||
"status.quote_error.not_found": "Seda postitust ei saa näidata.",
|
"status.quote_error.not_available": "Postitus pole saadaval",
|
||||||
"status.quote_error.pending_approval": "See postitus on algse autori kinnituse ootel.",
|
"status.quote_error.pending_approval": "Postitus on ootel",
|
||||||
"status.quote_error.rejected": "Seda postitust ei saa näidata, kuina algne autor ei luba teda tsiteerida.",
|
"status.quote_error.pending_approval_popout.body": "Kuna erinevates serverites on erinevad reeglid, siis üle Födiversumi jagatud tsitaatide kuvamine võib võtta aega.",
|
||||||
"status.quote_error.removed": "Autor kustutas selle postituse.",
|
"status.quote_error.pending_approval_popout.title": "Tsiteerimine on ootel? Palun jää rahulikuks",
|
||||||
"status.quote_error.unauthorized": "Kuna sul pole luba selle postituse nägemiseks, siis seda ei saa kuvada.",
|
"status.quote_post_author": "Tsiteeris kasutaja @{name} postitust",
|
||||||
"status.quote_post_author": "Postitajaks {name}",
|
|
||||||
"status.read_more": "Loe veel",
|
"status.read_more": "Loe veel",
|
||||||
"status.reblog": "Jaga",
|
"status.reblog": "Jaga",
|
||||||
"status.reblog_private": "Jaga algse nähtavusega",
|
"status.reblog_private": "Jaga algse nähtavusega",
|
||||||
|
|
|
@ -844,8 +844,6 @@
|
||||||
"status.mute_conversation": "Mututu elkarrizketa",
|
"status.mute_conversation": "Mututu elkarrizketa",
|
||||||
"status.open": "Hedatu bidalketa hau",
|
"status.open": "Hedatu bidalketa hau",
|
||||||
"status.pin": "Finkatu profilean",
|
"status.pin": "Finkatu profilean",
|
||||||
"status.quote_error.not_found": "Bidalketa hau ezin da erakutsi.",
|
|
||||||
"status.quote_error.pending_approval": "Bidalketa hau egile originalak onartzeko zain dago.",
|
|
||||||
"status.read_more": "Irakurri gehiago",
|
"status.read_more": "Irakurri gehiago",
|
||||||
"status.reblog": "Bultzada",
|
"status.reblog": "Bultzada",
|
||||||
"status.reblog_private": "Bultzada jatorrizko hartzaileei",
|
"status.reblog_private": "Bultzada jatorrizko hartzaileei",
|
||||||
|
|
|
@ -873,12 +873,6 @@
|
||||||
"status.open": "گسترش این فرسته",
|
"status.open": "گسترش این فرسته",
|
||||||
"status.pin": "سنجاق به نمایه",
|
"status.pin": "سنجاق به نمایه",
|
||||||
"status.quote_error.filtered": "نهفته بنا بر یکی از پالایههایتان",
|
"status.quote_error.filtered": "نهفته بنا بر یکی از پالایههایتان",
|
||||||
"status.quote_error.not_found": "این فرسته قابل نمایش نیست.",
|
|
||||||
"status.quote_error.pending_approval": "این فرسته منظر تأیید نگارندهٔ اصلی است.",
|
|
||||||
"status.quote_error.rejected": "از آنجا که نگارندهٔ اصلی این فرسته اجازهٔ نقلش را نمیدهد قابل نمایش نیست.",
|
|
||||||
"status.quote_error.removed": "این فرسته به دست نگارندهاش برداشته شده.",
|
|
||||||
"status.quote_error.unauthorized": "از آنجا که اجازهٔ دیدن این فرسته را ندارید قابل نمایش نیست.",
|
|
||||||
"status.quote_post_author": "فرسته توسط {name}",
|
|
||||||
"status.read_more": "بیشتر بخوانید",
|
"status.read_more": "بیشتر بخوانید",
|
||||||
"status.reblog": "تقویت",
|
"status.reblog": "تقویت",
|
||||||
"status.reblog_private": "تقویت برای مخاطبان نخستین",
|
"status.reblog_private": "تقویت برای مخاطبان نخستین",
|
||||||
|
|
|
@ -311,7 +311,7 @@
|
||||||
"empty_column.account_featured_other.unknown": "Tämä tili ei suosittele vielä mitään.",
|
"empty_column.account_featured_other.unknown": "Tämä tili ei suosittele vielä mitään.",
|
||||||
"empty_column.account_hides_collections": "Käyttäjä on päättänyt pitää nämä tiedot yksityisinä",
|
"empty_column.account_hides_collections": "Käyttäjä on päättänyt pitää nämä tiedot yksityisinä",
|
||||||
"empty_column.account_suspended": "Tili jäädytetty",
|
"empty_column.account_suspended": "Tili jäädytetty",
|
||||||
"empty_column.account_timeline": "Ei viestejä täällä.",
|
"empty_column.account_timeline": "Ei julkaisuja täällä!",
|
||||||
"empty_column.account_unavailable": "Profiilia ei ole saatavilla",
|
"empty_column.account_unavailable": "Profiilia ei ole saatavilla",
|
||||||
"empty_column.blocks": "Et ole vielä estänyt käyttäjiä.",
|
"empty_column.blocks": "Et ole vielä estänyt käyttäjiä.",
|
||||||
"empty_column.bookmarked_statuses": "Et ole vielä lisännyt julkaisuja kirjanmerkkeihisi. Kun lisäät yhden, se näkyy tässä.",
|
"empty_column.bookmarked_statuses": "Et ole vielä lisännyt julkaisuja kirjanmerkkeihisi. Kun lisäät yhden, se näkyy tässä.",
|
||||||
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "Käännä julkaisu",
|
"keyboard_shortcuts.translate": "Käännä julkaisu",
|
||||||
"keyboard_shortcuts.unfocus": "Poistu kirjoitus- tai hakukentästä",
|
"keyboard_shortcuts.unfocus": "Poistu kirjoitus- tai hakukentästä",
|
||||||
"keyboard_shortcuts.up": "Siirry luettelossa taaksepäin",
|
"keyboard_shortcuts.up": "Siirry luettelossa taaksepäin",
|
||||||
|
"learn_more_link.got_it": "Selvä",
|
||||||
|
"learn_more_link.learn_more": "Lue lisää",
|
||||||
"lightbox.close": "Sulje",
|
"lightbox.close": "Sulje",
|
||||||
"lightbox.next": "Seuraava",
|
"lightbox.next": "Seuraava",
|
||||||
"lightbox.previous": "Edellinen",
|
"lightbox.previous": "Edellinen",
|
||||||
|
@ -754,7 +756,7 @@
|
||||||
"reply_indicator.cancel": "Peruuta",
|
"reply_indicator.cancel": "Peruuta",
|
||||||
"reply_indicator.poll": "Äänestys",
|
"reply_indicator.poll": "Äänestys",
|
||||||
"report.block": "Estä",
|
"report.block": "Estä",
|
||||||
"report.block_explanation": "Et näe hänen viestejään, eikä hän voi nähdä viestejäsi tai seurata sinua. Hän näkee, että olet estänyt hänet.",
|
"report.block_explanation": "Et näe hänen julkaisujaan. Hän voi nähdä julkaisujasi eikä seurata sinua. Hän näkee, että olet estänyt hänet.",
|
||||||
"report.categories.legal": "Lakiseikat",
|
"report.categories.legal": "Lakiseikat",
|
||||||
"report.categories.other": "Muu",
|
"report.categories.other": "Muu",
|
||||||
"report.categories.spam": "Roskaposti",
|
"report.categories.spam": "Roskaposti",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Laajenna julkaisu",
|
"status.open": "Laajenna julkaisu",
|
||||||
"status.pin": "Kiinnitä profiiliin",
|
"status.pin": "Kiinnitä profiiliin",
|
||||||
"status.quote_error.filtered": "Piilotettu jonkin asettamasi suodattimen takia",
|
"status.quote_error.filtered": "Piilotettu jonkin asettamasi suodattimen takia",
|
||||||
"status.quote_error.not_found": "Tätä julkaisua ei voi näyttää.",
|
"status.quote_error.not_available": "Julkaisu ei saatavilla",
|
||||||
"status.quote_error.pending_approval": "Tämä julkaisu odottaa alkuperäisen tekijänsä hyväksyntää.",
|
"status.quote_error.pending_approval": "Julkaisu odottaa",
|
||||||
"status.quote_error.rejected": "Tätä julkaisua ei voi näyttää, sillä sen alkuperäinen tekijä ei salli lainattavan julkaisua.",
|
"status.quote_error.pending_approval_popout.body": "Saattaa viedä jonkin ainaa ennen kuin fediversumin kautta jaetut julkaisut tulevat näkyviin, sillä eri palvelimet käyttävät eri protokollia.",
|
||||||
"status.quote_error.removed": "Tekijä on poistanut julkaisun.",
|
"status.quote_error.pending_approval_popout.title": "Odottava lainaus? Pysy rauhallisena",
|
||||||
"status.quote_error.unauthorized": "Tätä julkaisua ei voi näyttää, koska sinulla ei ole oikeutta tarkastella sitä.",
|
"status.quote_post_author": "Lainaa käyttäjän @{name} julkaisua",
|
||||||
"status.quote_post_author": "Julkaisu käyttäjältä {name}",
|
|
||||||
"status.read_more": "Näytä enemmän",
|
"status.read_more": "Näytä enemmän",
|
||||||
"status.reblog": "Tehosta",
|
"status.reblog": "Tehosta",
|
||||||
"status.reblog_private": "Tehosta alkuperäiselle yleisölle",
|
"status.reblog_private": "Tehosta alkuperäiselle yleisölle",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "at umseta ein post",
|
"keyboard_shortcuts.translate": "at umseta ein post",
|
||||||
"keyboard_shortcuts.unfocus": "Tak skrivi-/leiti-økið úr miðdeplinum",
|
"keyboard_shortcuts.unfocus": "Tak skrivi-/leiti-økið úr miðdeplinum",
|
||||||
"keyboard_shortcuts.up": "Flyt upp á listanum",
|
"keyboard_shortcuts.up": "Flyt upp á listanum",
|
||||||
|
"learn_more_link.got_it": "Eg skilji",
|
||||||
|
"learn_more_link.learn_more": "Lær meira",
|
||||||
"lightbox.close": "Lat aftur",
|
"lightbox.close": "Lat aftur",
|
||||||
"lightbox.next": "Fram",
|
"lightbox.next": "Fram",
|
||||||
"lightbox.previous": "Aftur",
|
"lightbox.previous": "Aftur",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Víðka henda postin",
|
"status.open": "Víðka henda postin",
|
||||||
"status.pin": "Ger fastan í vangan",
|
"status.pin": "Ger fastan í vangan",
|
||||||
"status.quote_error.filtered": "Eitt av tínum filtrum fjalir hetta",
|
"status.quote_error.filtered": "Eitt av tínum filtrum fjalir hetta",
|
||||||
"status.quote_error.not_found": "Tað ber ikki til at vísa hendan postin.",
|
"status.quote_error.not_available": "Postur ikki tøkur",
|
||||||
"status.quote_error.pending_approval": "Hesin posturin bíðar eftir góðkenning frá upprunahøvundinum.",
|
"status.quote_error.pending_approval": "Postur bíðar",
|
||||||
"status.quote_error.rejected": "Hesin posturin kann ikki vísast, tí upprunahøvundurin loyvir ikki at posturin verður siteraður.",
|
"status.quote_error.pending_approval_popout.body": "Sitatir, sum eru deild tvørtur um fediversið, kunnu taka nakað av tíð at vísast, tí ymiskir ambætarar hava ymiskar protokollir.",
|
||||||
"status.quote_error.removed": "Hesin posturin var strikaður av høvundinum.",
|
"status.quote_error.pending_approval_popout.title": "Bíðar eftir sitati? Tak tað róligt",
|
||||||
"status.quote_error.unauthorized": "Hesin posturin kann ikki vísast, tí tú hevur ikki rættindi at síggja hann.",
|
"status.quote_post_author": "Siteraði ein post hjá @{name}",
|
||||||
"status.quote_post_author": "Postur hjá @{name}",
|
|
||||||
"status.read_more": "Les meira",
|
"status.read_more": "Les meira",
|
||||||
"status.reblog": "Stimbra",
|
"status.reblog": "Stimbra",
|
||||||
"status.reblog_private": "Stimbra við upprunasýni",
|
"status.reblog_private": "Stimbra við upprunasýni",
|
||||||
|
|
|
@ -864,9 +864,6 @@
|
||||||
"status.mute_conversation": "Masquer la conversation",
|
"status.mute_conversation": "Masquer la conversation",
|
||||||
"status.open": "Afficher la publication entière",
|
"status.open": "Afficher la publication entière",
|
||||||
"status.pin": "Épingler sur profil",
|
"status.pin": "Épingler sur profil",
|
||||||
"status.quote_error.removed": "Ce message a été retiré par son auteur·ice.",
|
|
||||||
"status.quote_error.unauthorized": "Ce message ne peut pas être affiché car vous n'êtes pas autorisé·e à le voir.",
|
|
||||||
"status.quote_post_author": "Message par {name}",
|
|
||||||
"status.read_more": "En savoir plus",
|
"status.read_more": "En savoir plus",
|
||||||
"status.reblog": "Booster",
|
"status.reblog": "Booster",
|
||||||
"status.reblog_private": "Booster avec visibilité originale",
|
"status.reblog_private": "Booster avec visibilité originale",
|
||||||
|
|
|
@ -864,9 +864,6 @@
|
||||||
"status.mute_conversation": "Masquer la conversation",
|
"status.mute_conversation": "Masquer la conversation",
|
||||||
"status.open": "Afficher le message entier",
|
"status.open": "Afficher le message entier",
|
||||||
"status.pin": "Épingler sur le profil",
|
"status.pin": "Épingler sur le profil",
|
||||||
"status.quote_error.removed": "Ce message a été retiré par son auteur·ice.",
|
|
||||||
"status.quote_error.unauthorized": "Ce message ne peut pas être affiché car vous n'êtes pas autorisé·e à le voir.",
|
|
||||||
"status.quote_post_author": "Message par {name}",
|
|
||||||
"status.read_more": "En savoir plus",
|
"status.read_more": "En savoir plus",
|
||||||
"status.reblog": "Partager",
|
"status.reblog": "Partager",
|
||||||
"status.reblog_private": "Partager à l’audience originale",
|
"status.reblog_private": "Partager à l’audience originale",
|
||||||
|
|
|
@ -873,12 +873,6 @@
|
||||||
"status.open": "Dit berjocht útklappe",
|
"status.open": "Dit berjocht útklappe",
|
||||||
"status.pin": "Op profylside fêstsette",
|
"status.pin": "Op profylside fêstsette",
|
||||||
"status.quote_error.filtered": "Ferburgen troch ien fan jo filters",
|
"status.quote_error.filtered": "Ferburgen troch ien fan jo filters",
|
||||||
"status.quote_error.not_found": "Dit berjocht kin net toand wurde.",
|
|
||||||
"status.quote_error.pending_approval": "Dit berjocht is yn ôfwachting fan goedkarring troch de oarspronklike auteur.",
|
|
||||||
"status.quote_error.rejected": "Dit berjocht kin net toand wurde, omdat de oarspronklike auteur net tastiet dat it sitearre wurdt.",
|
|
||||||
"status.quote_error.removed": "Dit berjocht is fuotsmiten troch de auteur.",
|
|
||||||
"status.quote_error.unauthorized": "Dit berjocht kin net toand wurde, omdat jo net it foech hawwe om it te besjen.",
|
|
||||||
"status.quote_post_author": "Berjocht fan {name}",
|
|
||||||
"status.read_more": "Mear ynfo",
|
"status.read_more": "Mear ynfo",
|
||||||
"status.reblog": "Booste",
|
"status.reblog": "Booste",
|
||||||
"status.reblog_private": "Boost nei oarspronklike ûntfangers",
|
"status.reblog_private": "Boost nei oarspronklike ûntfangers",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "post a aistriú",
|
"keyboard_shortcuts.translate": "post a aistriú",
|
||||||
"keyboard_shortcuts.unfocus": "Unfocus cum textarea/search",
|
"keyboard_shortcuts.unfocus": "Unfocus cum textarea/search",
|
||||||
"keyboard_shortcuts.up": "Bog suas ar an liosta",
|
"keyboard_shortcuts.up": "Bog suas ar an liosta",
|
||||||
|
"learn_more_link.got_it": "Tuigim é",
|
||||||
|
"learn_more_link.learn_more": "Foghlaim níos mó",
|
||||||
"lightbox.close": "Dún",
|
"lightbox.close": "Dún",
|
||||||
"lightbox.next": "An céad eile",
|
"lightbox.next": "An céad eile",
|
||||||
"lightbox.previous": "Roimhe seo",
|
"lightbox.previous": "Roimhe seo",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Leathnaigh an post seo",
|
"status.open": "Leathnaigh an post seo",
|
||||||
"status.pin": "Pionnáil ar do phróifíl",
|
"status.pin": "Pionnáil ar do phróifíl",
|
||||||
"status.quote_error.filtered": "I bhfolach mar gheall ar cheann de do scagairí",
|
"status.quote_error.filtered": "I bhfolach mar gheall ar cheann de do scagairí",
|
||||||
"status.quote_error.not_found": "Ní féidir an post seo a thaispeáint.",
|
"status.quote_error.not_available": "Níl an postáil ar fáil",
|
||||||
"status.quote_error.pending_approval": "Tá an post seo ag feitheamh ar cheadú ón údar bunaidh.",
|
"status.quote_error.pending_approval": "Post ar feitheamh",
|
||||||
"status.quote_error.rejected": "Ní féidir an post seo a thaispeáint mar ní cheadaíonn an t-údar bunaidh é a lua.",
|
"status.quote_error.pending_approval_popout.body": "D’fhéadfadh sé go dtógfadh sé tamall le Sleachta a roinntear ar fud Fediverse a thaispeáint, toisc go mbíonn prótacail éagsúla ag freastalaithe éagsúla.",
|
||||||
"status.quote_error.removed": "Baineadh an post seo ag a údar.",
|
"status.quote_error.pending_approval_popout.title": "Ag fanacht le luachan? Fan socair",
|
||||||
"status.quote_error.unauthorized": "Ní féidir an post seo a thaispeáint mar níl údarú agat é a fheiceáil.",
|
"status.quote_post_author": "Luaigh mé post le @{name}",
|
||||||
"status.quote_post_author": "Postáil le {name}",
|
|
||||||
"status.read_more": "Léan a thuilleadh",
|
"status.read_more": "Léan a thuilleadh",
|
||||||
"status.reblog": "Treisiú",
|
"status.reblog": "Treisiú",
|
||||||
"status.reblog_private": "Mol le léargas bunúsach",
|
"status.reblog_private": "Mol le léargas bunúsach",
|
||||||
|
|
|
@ -871,12 +871,6 @@
|
||||||
"status.open": "Leudaich am post seo",
|
"status.open": "Leudaich am post seo",
|
||||||
"status.pin": "Prìnich ris a’ phròifil",
|
"status.pin": "Prìnich ris a’ phròifil",
|
||||||
"status.quote_error.filtered": "Falaichte le criathrag a th’ agad",
|
"status.quote_error.filtered": "Falaichte le criathrag a th’ agad",
|
||||||
"status.quote_error.not_found": "Chan urrainn dhuinn am post seo a shealltainn.",
|
|
||||||
"status.quote_error.pending_approval": "Tha am post seo a’ feitheamh air aontachadh leis an ùghdar tùsail.",
|
|
||||||
"status.quote_error.rejected": "Chan urrainn dhuinn am post seo a shealltainn air sgàth ’s nach ceadaich an t-ùghdar tùsail aige gun dèid a luaidh.",
|
|
||||||
"status.quote_error.removed": "Chaidh am post seo a thoirt air falbh le ùghdar.",
|
|
||||||
"status.quote_error.unauthorized": "Chan urrainn dhuinn am post seo a shealltainn air sgàth ’s nach eil cead agad fhaicinn.",
|
|
||||||
"status.quote_post_author": "Post le {name}",
|
|
||||||
"status.read_more": "Leugh an còrr",
|
"status.read_more": "Leugh an còrr",
|
||||||
"status.reblog": "Brosnaich",
|
"status.reblog": "Brosnaich",
|
||||||
"status.reblog_private": "Brosnaich leis an t-so-fhaicsinneachd tùsail",
|
"status.reblog_private": "Brosnaich leis an t-so-fhaicsinneachd tùsail",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "para traducir unha publicación",
|
"keyboard_shortcuts.translate": "para traducir unha publicación",
|
||||||
"keyboard_shortcuts.unfocus": "Para deixar de destacar a área de escritura/procura",
|
"keyboard_shortcuts.unfocus": "Para deixar de destacar a área de escritura/procura",
|
||||||
"keyboard_shortcuts.up": "Para mover cara arriba na listaxe",
|
"keyboard_shortcuts.up": "Para mover cara arriba na listaxe",
|
||||||
|
"learn_more_link.got_it": "Entendo",
|
||||||
|
"learn_more_link.learn_more": "Saber máis",
|
||||||
"lightbox.close": "Fechar",
|
"lightbox.close": "Fechar",
|
||||||
"lightbox.next": "Seguinte",
|
"lightbox.next": "Seguinte",
|
||||||
"lightbox.previous": "Anterior",
|
"lightbox.previous": "Anterior",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Estender esta publicación",
|
"status.open": "Estender esta publicación",
|
||||||
"status.pin": "Fixar no perfil",
|
"status.pin": "Fixar no perfil",
|
||||||
"status.quote_error.filtered": "Oculto debido a un dos teus filtros",
|
"status.quote_error.filtered": "Oculto debido a un dos teus filtros",
|
||||||
"status.quote_error.not_found": "Non se pode mostrar a publicación.",
|
"status.quote_error.not_available": "Publicación non dispoñible",
|
||||||
"status.quote_error.pending_approval": "A publicación está pendente da aprobación pola autora orixinal.",
|
"status.quote_error.pending_approval": "Publicación pendente",
|
||||||
"status.quote_error.rejected": "Non se pode mostrar esta publicación xa que a autora orixinal non permite que se cite.",
|
"status.quote_error.pending_approval_popout.body": "As citas compartidas no Fediverso poderían tardar en mostrarse, xa que os diferentes servidores teñen diferentes protocolos.",
|
||||||
"status.quote_error.removed": "Publicación eliminada pola autora.",
|
"status.quote_error.pending_approval_popout.title": "Cita pendente? Non te apures",
|
||||||
"status.quote_error.unauthorized": "Non se pode mostrar esta publicación porque non tes permiso para vela.",
|
"status.quote_post_author": "Citou unha publicación de @{name}",
|
||||||
"status.quote_post_author": "Publicación de {name}",
|
|
||||||
"status.read_more": "Ler máis",
|
"status.read_more": "Ler máis",
|
||||||
"status.reblog": "Promover",
|
"status.reblog": "Promover",
|
||||||
"status.reblog_private": "Compartir coa audiencia orixinal",
|
"status.reblog_private": "Compartir coa audiencia orixinal",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "לתרגם הודעה",
|
"keyboard_shortcuts.translate": "לתרגם הודעה",
|
||||||
"keyboard_shortcuts.unfocus": "לצאת מתיבת חיבור/חיפוש",
|
"keyboard_shortcuts.unfocus": "לצאת מתיבת חיבור/חיפוש",
|
||||||
"keyboard_shortcuts.up": "לנוע במעלה הרשימה",
|
"keyboard_shortcuts.up": "לנוע במעלה הרשימה",
|
||||||
|
"learn_more_link.got_it": "הבנתי",
|
||||||
|
"learn_more_link.learn_more": "למידע נוסף",
|
||||||
"lightbox.close": "סגירה",
|
"lightbox.close": "סגירה",
|
||||||
"lightbox.next": "הבא",
|
"lightbox.next": "הבא",
|
||||||
"lightbox.previous": "הקודם",
|
"lightbox.previous": "הקודם",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "הרחבת הודעה זו",
|
"status.open": "הרחבת הודעה זו",
|
||||||
"status.pin": "הצמדה לפרופיל שלי",
|
"status.pin": "הצמדה לפרופיל שלי",
|
||||||
"status.quote_error.filtered": "מוסתר בהתאם לסננים שלך",
|
"status.quote_error.filtered": "מוסתר בהתאם לסננים שלך",
|
||||||
"status.quote_error.not_found": "לא ניתן להציג הודעה זו.",
|
"status.quote_error.not_available": "ההודעה לא זמינה",
|
||||||
"status.quote_error.pending_approval": "הודעה זו מחכה לאישור מידי היוצר המקורי.",
|
"status.quote_error.pending_approval": "ההודעה בהמתנה לאישור",
|
||||||
"status.quote_error.rejected": "לא ניתן להציג הודעה זו שכן המחבר.ת המקוריים לא הרשו לצטט אותה.",
|
"status.quote_error.pending_approval_popout.body": "ציטוטים ששותפו בפדיוורס עשויים להתפרסם אחרי עיכוב קל, כיוון ששרתים שונים משתמשים בפרוטוקולים שונים.",
|
||||||
"status.quote_error.removed": "הודעה זו הוסרה על ידי השולחים המקוריים.",
|
"status.quote_error.pending_approval_popout.title": "ההודעה בהמתנה? המתינו ברוגע",
|
||||||
"status.quote_error.unauthorized": "הודעה זו לא מוצגת כיוון שאין לך רשות לראותה.",
|
"status.quote_post_author": "ההודעה צוטטה על ידי @{name}",
|
||||||
"status.quote_post_author": "פרסום מאת {name}",
|
|
||||||
"status.read_more": "לקרוא עוד",
|
"status.read_more": "לקרוא עוד",
|
||||||
"status.reblog": "הדהוד",
|
"status.reblog": "הדהוד",
|
||||||
"status.reblog_private": "להדהד ברמת הנראות המקורית",
|
"status.reblog_private": "להדהד ברמת הנראות המקורית",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "Bejegyzés lefordítása",
|
"keyboard_shortcuts.translate": "Bejegyzés lefordítása",
|
||||||
"keyboard_shortcuts.unfocus": "Szerkesztés/keresés fókuszból való kivétele",
|
"keyboard_shortcuts.unfocus": "Szerkesztés/keresés fókuszból való kivétele",
|
||||||
"keyboard_shortcuts.up": "Mozgás felfelé a listában",
|
"keyboard_shortcuts.up": "Mozgás felfelé a listában",
|
||||||
|
"learn_more_link.got_it": "Rendben",
|
||||||
|
"learn_more_link.learn_more": "További tudnivalók",
|
||||||
"lightbox.close": "Bezárás",
|
"lightbox.close": "Bezárás",
|
||||||
"lightbox.next": "Következő",
|
"lightbox.next": "Következő",
|
||||||
"lightbox.previous": "Előző",
|
"lightbox.previous": "Előző",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Bejegyzés kibontása",
|
"status.open": "Bejegyzés kibontása",
|
||||||
"status.pin": "Kitűzés a profilodra",
|
"status.pin": "Kitűzés a profilodra",
|
||||||
"status.quote_error.filtered": "A szűrőid miatt rejtett",
|
"status.quote_error.filtered": "A szűrőid miatt rejtett",
|
||||||
"status.quote_error.not_found": "Ez a bejegyzés nem jeleníthető meg.",
|
"status.quote_error.not_available": "A bejegyzés nem érhető el",
|
||||||
"status.quote_error.pending_approval": "Ez a bejegyzés az eredeti szerző jóváhagyására vár.",
|
"status.quote_error.pending_approval": "A bejegyzés függőben van",
|
||||||
"status.quote_error.rejected": "Ez a bejegyzés nem jeleníthető meg, mert az eredeti szerzője nem engedélyezi az idézését.",
|
"status.quote_error.pending_approval_popout.body": "A Födiverzumon keresztül megosztott idézetek megjelenítése eltarthat egy darabig, mivel a különböző kiszolgálók különböző protokollokat használnak.",
|
||||||
"status.quote_error.removed": "Ezt a bejegyzés eltávolította a szerzője.",
|
"status.quote_error.pending_approval_popout.title": "Függőben lévő idézet? Maradj nyugodt.",
|
||||||
"status.quote_error.unauthorized": "Ez a bejegyzés nem jeleníthető meg, mert nem jogosult a megtekintésére.",
|
"status.quote_post_author": "Idézte @{name} bejegyzését",
|
||||||
"status.quote_post_author": "Szerző: {name}",
|
|
||||||
"status.read_more": "Bővebben",
|
"status.read_more": "Bővebben",
|
||||||
"status.reblog": "Megtolás",
|
"status.reblog": "Megtolás",
|
||||||
"status.reblog_private": "Megtolás az eredeti közönségnek",
|
"status.reblog_private": "Megtolás az eredeti közönségnek",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "að þýða færslu",
|
"keyboard_shortcuts.translate": "að þýða færslu",
|
||||||
"keyboard_shortcuts.unfocus": "Taka virkni úr textainnsetningarreit eða leit",
|
"keyboard_shortcuts.unfocus": "Taka virkni úr textainnsetningarreit eða leit",
|
||||||
"keyboard_shortcuts.up": "Fara ofar í listanum",
|
"keyboard_shortcuts.up": "Fara ofar í listanum",
|
||||||
|
"learn_more_link.got_it": "Náði því",
|
||||||
|
"learn_more_link.learn_more": "Kanna nánar",
|
||||||
"lightbox.close": "Loka",
|
"lightbox.close": "Loka",
|
||||||
"lightbox.next": "Næsta",
|
"lightbox.next": "Næsta",
|
||||||
"lightbox.previous": "Fyrra",
|
"lightbox.previous": "Fyrra",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Opna þessa færslu",
|
"status.open": "Opna þessa færslu",
|
||||||
"status.pin": "Festa á notandasnið",
|
"status.pin": "Festa á notandasnið",
|
||||||
"status.quote_error.filtered": "Falið vegna einnar síu sem er virk",
|
"status.quote_error.filtered": "Falið vegna einnar síu sem er virk",
|
||||||
"status.quote_error.not_found": "Þessa færslu er ekki hægt að birta.",
|
"status.quote_error.not_available": "Færsla ekki tiltæk",
|
||||||
"status.quote_error.pending_approval": "Þessi færsla bíður eftir samþykki frá upprunalegum höfundi hennar.",
|
"status.quote_error.pending_approval": "Færsla í bið",
|
||||||
"status.quote_error.rejected": "Þessa færslu er ekki hægt að birta þar sem upphaflegur höfundur hennar leyfir ekki að vitnað sé til hennar.",
|
"status.quote_error.pending_approval_popout.body": "Tilvitnanir sem deilt er út um samfélagsnetið geta þurft nokkurn tíma áður en þær birtast, því mismunandi netþjónar geta haft mismunandi samskiptareglur.",
|
||||||
"status.quote_error.removed": "Þessi færsla var fjarlægð af höfundi hennar.",
|
"status.quote_error.pending_approval_popout.title": "Færsla í bið? Verum róleg",
|
||||||
"status.quote_error.unauthorized": "Þessa færslu er ekki hægt að birta þar sem þú hefur ekki heimild til að skoða hana.",
|
"status.quote_post_author": "Vitnaði í færslu frá @{name}",
|
||||||
"status.quote_post_author": "Færsla frá {name}",
|
|
||||||
"status.read_more": "Lesa meira",
|
"status.read_more": "Lesa meira",
|
||||||
"status.reblog": "Endurbirting",
|
"status.reblog": "Endurbirting",
|
||||||
"status.reblog_private": "Endurbirta til upphaflegra lesenda",
|
"status.reblog_private": "Endurbirta til upphaflegra lesenda",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "Traduce un post",
|
"keyboard_shortcuts.translate": "Traduce un post",
|
||||||
"keyboard_shortcuts.unfocus": "Rimuove il focus sull'area di composizione testuale/ricerca",
|
"keyboard_shortcuts.unfocus": "Rimuove il focus sull'area di composizione testuale/ricerca",
|
||||||
"keyboard_shortcuts.up": "Scorre in su nell'elenco",
|
"keyboard_shortcuts.up": "Scorre in su nell'elenco",
|
||||||
|
"learn_more_link.got_it": "Ho capito",
|
||||||
|
"learn_more_link.learn_more": "Scopri di più",
|
||||||
"lightbox.close": "Chiudi",
|
"lightbox.close": "Chiudi",
|
||||||
"lightbox.next": "Successivo",
|
"lightbox.next": "Successivo",
|
||||||
"lightbox.previous": "Precedente",
|
"lightbox.previous": "Precedente",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Espandi questo post",
|
"status.open": "Espandi questo post",
|
||||||
"status.pin": "Fissa in cima sul profilo",
|
"status.pin": "Fissa in cima sul profilo",
|
||||||
"status.quote_error.filtered": "Nascosto a causa di uno dei tuoi filtri",
|
"status.quote_error.filtered": "Nascosto a causa di uno dei tuoi filtri",
|
||||||
"status.quote_error.not_found": "Questo post non può essere visualizzato.",
|
"status.quote_error.not_available": "Post non disponibile",
|
||||||
"status.quote_error.pending_approval": "Questo post è in attesa di approvazione dell'autore originale.",
|
"status.quote_error.pending_approval": "Post in attesa",
|
||||||
"status.quote_error.rejected": "Questo post non può essere visualizzato perché l'autore originale non consente che venga citato.",
|
"status.quote_error.pending_approval_popout.body": "Le citazioni condivise in tutto il Fediverso possono richiedere del tempo per la visualizzazione, poiché server diversi hanno protocolli diversi.",
|
||||||
"status.quote_error.removed": "Questo post è stato rimosso dal suo autore.",
|
"status.quote_error.pending_approval_popout.title": "Citazione in attesa? Resta calmo",
|
||||||
"status.quote_error.unauthorized": "Questo post non può essere visualizzato in quanto non sei autorizzato a visualizzarlo.",
|
"status.quote_post_author": "Citato un post di @{name}",
|
||||||
"status.quote_post_author": "Post di @{name}",
|
|
||||||
"status.read_more": "Leggi di più",
|
"status.read_more": "Leggi di più",
|
||||||
"status.reblog": "Reblog",
|
"status.reblog": "Reblog",
|
||||||
"status.reblog_private": "Reblog con visibilità originale",
|
"status.reblog_private": "Reblog con visibilità originale",
|
||||||
|
|
|
@ -868,12 +868,6 @@
|
||||||
"status.open": "詳細を表示",
|
"status.open": "詳細を表示",
|
||||||
"status.pin": "プロフィールに固定表示",
|
"status.pin": "プロフィールに固定表示",
|
||||||
"status.quote_error.filtered": "あなたのフィルター設定によって非表示になっています",
|
"status.quote_error.filtered": "あなたのフィルター設定によって非表示になっています",
|
||||||
"status.quote_error.not_found": "この投稿は表示できません。",
|
|
||||||
"status.quote_error.pending_approval": "この投稿は投稿者の承認待ちです。",
|
|
||||||
"status.quote_error.rejected": "この投稿は、オリジナルの投稿者が引用することを許可していないため、表示できません。",
|
|
||||||
"status.quote_error.removed": "この投稿は投稿者によって削除されました。",
|
|
||||||
"status.quote_error.unauthorized": "この投稿を表示する権限がないため、表示できません。",
|
|
||||||
"status.quote_post_author": "{name} の投稿",
|
|
||||||
"status.read_more": "もっと見る",
|
"status.read_more": "もっと見る",
|
||||||
"status.reblog": "ブースト",
|
"status.reblog": "ブースト",
|
||||||
"status.reblog_private": "ブースト",
|
"status.reblog_private": "ブースト",
|
||||||
|
|
|
@ -623,7 +623,6 @@
|
||||||
"status.mute_conversation": "Sgugem adiwenni",
|
"status.mute_conversation": "Sgugem adiwenni",
|
||||||
"status.open": "Semɣeṛ tasuffeɣt-ayi",
|
"status.open": "Semɣeṛ tasuffeɣt-ayi",
|
||||||
"status.pin": "Senteḍ-itt deg umaɣnu",
|
"status.pin": "Senteḍ-itt deg umaɣnu",
|
||||||
"status.quote_post_author": "Izen sɣur {name}",
|
|
||||||
"status.read_more": "Issin ugar",
|
"status.read_more": "Issin ugar",
|
||||||
"status.reblog": "Bḍu",
|
"status.reblog": "Bḍu",
|
||||||
"status.reblogged_by": "Yebḍa-tt {name}",
|
"status.reblogged_by": "Yebḍa-tt {name}",
|
||||||
|
|
|
@ -871,12 +871,6 @@
|
||||||
"status.open": "상세 정보 표시",
|
"status.open": "상세 정보 표시",
|
||||||
"status.pin": "고정",
|
"status.pin": "고정",
|
||||||
"status.quote_error.filtered": "필터에 의해 가려짐",
|
"status.quote_error.filtered": "필터에 의해 가려짐",
|
||||||
"status.quote_error.not_found": "이 게시물은 표시할 수 없습니다.",
|
|
||||||
"status.quote_error.pending_approval": "이 게시물은 원작자의 승인을 기다리고 있습니다.",
|
|
||||||
"status.quote_error.rejected": "이 게시물은 원작자가 인용을 허용하지 않았기 때문에 표시할 수 없습니다.",
|
|
||||||
"status.quote_error.removed": "이 게시물은 작성자에 의해 삭제되었습니다.",
|
|
||||||
"status.quote_error.unauthorized": "이 게시물은 권한이 없기 때문에 볼 수 없습니다.",
|
|
||||||
"status.quote_post_author": "{name} 님의 게시물",
|
|
||||||
"status.read_more": "더 보기",
|
"status.read_more": "더 보기",
|
||||||
"status.reblog": "부스트",
|
"status.reblog": "부스트",
|
||||||
"status.reblog_private": "원래의 수신자들에게 부스트",
|
"status.reblog_private": "원래의 수신자들에게 부스트",
|
||||||
|
|
|
@ -738,12 +738,6 @@
|
||||||
"status.mute_conversation": "Apklusināt sarunu",
|
"status.mute_conversation": "Apklusināt sarunu",
|
||||||
"status.open": "Izvērst šo ierakstu",
|
"status.open": "Izvērst šo ierakstu",
|
||||||
"status.pin": "Piespraust profilam",
|
"status.pin": "Piespraust profilam",
|
||||||
"status.quote_error.not_found": "Šo ierakstu nevar parādīt.",
|
|
||||||
"status.quote_error.pending_approval": "Šis ieraksts gaida apstiprinājumu no tā autora.",
|
|
||||||
"status.quote_error.rejected": "Šo ierakstu nevar parādīt, jo tā autors neļauj to citēt.",
|
|
||||||
"status.quote_error.removed": "Šo ierakstu noņēma tā autors.",
|
|
||||||
"status.quote_error.unauthorized": "Šo ierakstu nevar parādīt, jo jums nav atļaujas to skatīt.",
|
|
||||||
"status.quote_post_author": "Publicēja {name}",
|
|
||||||
"status.read_more": "Lasīt vairāk",
|
"status.read_more": "Lasīt vairāk",
|
||||||
"status.reblog": "Pastiprināt",
|
"status.reblog": "Pastiprināt",
|
||||||
"status.reblog_private": "Pastiprināt ar sākotnējo redzamību",
|
"status.reblog_private": "Pastiprināt ar sākotnējo redzamību",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "kā PO文翻譯",
|
"keyboard_shortcuts.translate": "kā PO文翻譯",
|
||||||
"keyboard_shortcuts.unfocus": "離開輸入框仔/tshiau-tshuē格仔",
|
"keyboard_shortcuts.unfocus": "離開輸入框仔/tshiau-tshuē格仔",
|
||||||
"keyboard_shortcuts.up": "佇列單內kā suá khah面頂",
|
"keyboard_shortcuts.up": "佇列單內kā suá khah面頂",
|
||||||
|
"learn_more_link.got_it": "知矣",
|
||||||
|
"learn_more_link.learn_more": "看詳細",
|
||||||
"lightbox.close": "關",
|
"lightbox.close": "關",
|
||||||
"lightbox.next": "下tsi̍t ê",
|
"lightbox.next": "下tsi̍t ê",
|
||||||
"lightbox.previous": "頂tsi̍t ê",
|
"lightbox.previous": "頂tsi̍t ê",
|
||||||
|
@ -872,12 +874,8 @@
|
||||||
"status.mute_conversation": "Kā對話消音",
|
"status.mute_conversation": "Kā對話消音",
|
||||||
"status.open": "Kā PO文展開",
|
"status.open": "Kā PO文展開",
|
||||||
"status.quote_error.filtered": "Lí所設定ê過濾器kā tse khàm起來",
|
"status.quote_error.filtered": "Lí所設定ê過濾器kā tse khàm起來",
|
||||||
"status.quote_error.not_found": "Tsit篇PO文bē當顯示。",
|
"status.quote_error.not_available": "鋪文bē當看",
|
||||||
"status.quote_error.pending_approval": "Tsit篇PO文teh等原作者審查。",
|
"status.quote_error.pending_approval": "鋪文當咧送",
|
||||||
"status.quote_error.rejected": "因為原作者無允准引用,tsit篇PO文bē當顯示。",
|
|
||||||
"status.quote_error.removed": "Tsit篇hōo作者thâi掉ah。",
|
|
||||||
"status.quote_error.unauthorized": "因為lí無得著讀tse ê權限,tsit篇PO文bē當顯示。",
|
|
||||||
"status.quote_post_author": "{name} 所PO ê",
|
|
||||||
"status.read_more": "讀詳細",
|
"status.read_more": "讀詳細",
|
||||||
"status.reblog": "轉送",
|
"status.reblog": "轉送",
|
||||||
"status.reblog_private": "照原PO ê通看見ê範圍轉送",
|
"status.reblog_private": "照原PO ê通看見ê範圍轉送",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "om een bericht te vertalen",
|
"keyboard_shortcuts.translate": "om een bericht te vertalen",
|
||||||
"keyboard_shortcuts.unfocus": "Tekst- en zoekveld ontfocussen",
|
"keyboard_shortcuts.unfocus": "Tekst- en zoekveld ontfocussen",
|
||||||
"keyboard_shortcuts.up": "Naar boven in de lijst bewegen",
|
"keyboard_shortcuts.up": "Naar boven in de lijst bewegen",
|
||||||
|
"learn_more_link.got_it": "Begrepen",
|
||||||
|
"learn_more_link.learn_more": "Meer informatie",
|
||||||
"lightbox.close": "Sluiten",
|
"lightbox.close": "Sluiten",
|
||||||
"lightbox.next": "Volgende",
|
"lightbox.next": "Volgende",
|
||||||
"lightbox.previous": "Vorige",
|
"lightbox.previous": "Vorige",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Volledig bericht tonen",
|
"status.open": "Volledig bericht tonen",
|
||||||
"status.pin": "Aan profielpagina vastmaken",
|
"status.pin": "Aan profielpagina vastmaken",
|
||||||
"status.quote_error.filtered": "Verborgen door een van je filters",
|
"status.quote_error.filtered": "Verborgen door een van je filters",
|
||||||
"status.quote_error.not_found": "Dit bericht kan niet worden weergegeven.",
|
"status.quote_error.not_available": "Bericht niet beschikbaar",
|
||||||
"status.quote_error.pending_approval": "Dit bericht is in afwachting van goedkeuring door de oorspronkelijke auteur.",
|
"status.quote_error.pending_approval": "Bericht in afwachting",
|
||||||
"status.quote_error.rejected": "Dit bericht kan niet worden weergegeven omdat de oorspronkelijke auteur niet toestaat dat het wordt geciteerd.",
|
"status.quote_error.pending_approval_popout.body": "Het kan even duren voordat citaten die in de Fediverse gedeeld worden, worden weergegeven. Omdat verschillende servers niet allemaal hetzelfde protocol gebruiken.",
|
||||||
"status.quote_error.removed": "Dit bericht is verwijderd door de auteur.",
|
"status.quote_error.pending_approval_popout.title": "Even geduld wanneer het citaat nog moet worden goedgekeurd.",
|
||||||
"status.quote_error.unauthorized": "Dit bericht kan niet worden weergegeven omdat je niet bevoegd bent om het te bekijken.",
|
"status.quote_post_author": "Citeerde een bericht van @{name}",
|
||||||
"status.quote_post_author": "Bericht van {name}",
|
|
||||||
"status.read_more": "Meer lezen",
|
"status.read_more": "Meer lezen",
|
||||||
"status.reblog": "Boosten",
|
"status.reblog": "Boosten",
|
||||||
"status.reblog_private": "Boost naar oorspronkelijke ontvangers",
|
"status.reblog_private": "Boost naar oorspronkelijke ontvangers",
|
||||||
|
|
|
@ -871,12 +871,6 @@
|
||||||
"status.open": "Utvid denne statusen",
|
"status.open": "Utvid denne statusen",
|
||||||
"status.pin": "Fest på profil",
|
"status.pin": "Fest på profil",
|
||||||
"status.quote_error.filtered": "Gøymt på grunn av eitt av filtra dine",
|
"status.quote_error.filtered": "Gøymt på grunn av eitt av filtra dine",
|
||||||
"status.quote_error.not_found": "Du kan ikkje visa dette innlegget.",
|
|
||||||
"status.quote_error.pending_approval": "Dette innlegget ventar på at skribenten skal godkjenna det.",
|
|
||||||
"status.quote_error.rejected": "Du kan ikkje visa dette innlegget fordi skribenten ikkje vil at det skal siterast.",
|
|
||||||
"status.quote_error.removed": "Skribenten sletta dette innlegget.",
|
|
||||||
"status.quote_error.unauthorized": "Du kan ikkje visa dette innlegget fordi du ikkje har løyve til det.",
|
|
||||||
"status.quote_post_author": "Innlegg av {name}",
|
|
||||||
"status.read_more": "Les meir",
|
"status.read_more": "Les meir",
|
||||||
"status.reblog": "Framhev",
|
"status.reblog": "Framhev",
|
||||||
"status.reblog_private": "Framhev til dei originale mottakarane",
|
"status.reblog_private": "Framhev til dei originale mottakarane",
|
||||||
|
|
|
@ -839,9 +839,6 @@
|
||||||
"status.open": "Utvid dette innlegget",
|
"status.open": "Utvid dette innlegget",
|
||||||
"status.pin": "Fest på profilen",
|
"status.pin": "Fest på profilen",
|
||||||
"status.quote_error.filtered": "Skjult på grunn av et av filterne dine",
|
"status.quote_error.filtered": "Skjult på grunn av et av filterne dine",
|
||||||
"status.quote_error.not_found": "Dette innlegget kan ikke vises.",
|
|
||||||
"status.quote_error.pending_approval": "Dette innlegget venter på godkjenning fra den opprinnelige forfatteren.",
|
|
||||||
"status.quote_error.rejected": "Dette innlegget kan ikke vises fordi den opprinnelige forfatteren ikke har tillatt at det blir sitert.",
|
|
||||||
"status.read_more": "Les mer",
|
"status.read_more": "Les mer",
|
||||||
"status.reblog": "Fremhev",
|
"status.reblog": "Fremhev",
|
||||||
"status.reblog_private": "Fremhev til det opprinnelige publikummet",
|
"status.reblog_private": "Fremhev til det opprinnelige publikummet",
|
||||||
|
|
|
@ -854,12 +854,6 @@
|
||||||
"status.open": "Abrir toot",
|
"status.open": "Abrir toot",
|
||||||
"status.pin": "Fixar",
|
"status.pin": "Fixar",
|
||||||
"status.quote_error.filtered": "Oculto devido a um dos seus filtros",
|
"status.quote_error.filtered": "Oculto devido a um dos seus filtros",
|
||||||
"status.quote_error.not_found": "Esta postagem não pode ser exibida.",
|
|
||||||
"status.quote_error.pending_approval": "Esta postagem está pendente de aprovação do autor original.",
|
|
||||||
"status.quote_error.rejected": "Esta publicação não pode ser exibida porque o autor original não permite que seja citada.",
|
|
||||||
"status.quote_error.removed": "Esta postagem foi removida pelo autor.",
|
|
||||||
"status.quote_error.unauthorized": "Esta publicação não pode ser exibida, pois, você não está autorizado a vê-la.",
|
|
||||||
"status.quote_post_author": "Publicação por {name}",
|
|
||||||
"status.read_more": "Ler mais",
|
"status.read_more": "Ler mais",
|
||||||
"status.reblog": "Dar boost",
|
"status.reblog": "Dar boost",
|
||||||
"status.reblog_private": "Dar boost para o mesmo público",
|
"status.reblog_private": "Dar boost para o mesmo público",
|
||||||
|
|
|
@ -873,12 +873,6 @@
|
||||||
"status.open": "Expandir esta publicação",
|
"status.open": "Expandir esta publicação",
|
||||||
"status.pin": "Afixar no perfil",
|
"status.pin": "Afixar no perfil",
|
||||||
"status.quote_error.filtered": "Oculto devido a um dos seus filtros",
|
"status.quote_error.filtered": "Oculto devido a um dos seus filtros",
|
||||||
"status.quote_error.not_found": "Esta publicação não pode ser exibida.",
|
|
||||||
"status.quote_error.pending_approval": "Esta publicação está a aguardar a aprovação do autor original.",
|
|
||||||
"status.quote_error.rejected": "Esta publicação não pode ser exibida porque o autor original não permite que seja citada.",
|
|
||||||
"status.quote_error.removed": "Esta publicação foi removida pelo seu autor.",
|
|
||||||
"status.quote_error.unauthorized": "Esta publicação não pode ser exibida porque o utilizador não está autorizado a visualizá-la.",
|
|
||||||
"status.quote_post_author": "Publicação de {name}",
|
|
||||||
"status.read_more": "Ler mais",
|
"status.read_more": "Ler mais",
|
||||||
"status.reblog": "Impulsionar",
|
"status.reblog": "Impulsionar",
|
||||||
"status.reblog_private": "Impulsionar com a visibilidade original",
|
"status.reblog_private": "Impulsionar com a visibilidade original",
|
||||||
|
|
|
@ -871,12 +871,6 @@
|
||||||
"status.open": "Открыть пост",
|
"status.open": "Открыть пост",
|
||||||
"status.pin": "Закрепить в профиле",
|
"status.pin": "Закрепить в профиле",
|
||||||
"status.quote_error.filtered": "Скрыто одним из ваших фильтров",
|
"status.quote_error.filtered": "Скрыто одним из ваших фильтров",
|
||||||
"status.quote_error.not_found": "Пост не может быть показан.",
|
|
||||||
"status.quote_error.pending_approval": "Разрешение на цитирование от автора оригинального поста пока не получено.",
|
|
||||||
"status.quote_error.rejected": "Автор оригинального поста запретил его цитировать.",
|
|
||||||
"status.quote_error.removed": "Пост был удалён его автором.",
|
|
||||||
"status.quote_error.unauthorized": "Этот пост для вас недоступен.",
|
|
||||||
"status.quote_post_author": "Пост пользователя {name}",
|
|
||||||
"status.read_more": "Читать далее",
|
"status.read_more": "Читать далее",
|
||||||
"status.reblog": "Продвинуть",
|
"status.reblog": "Продвинуть",
|
||||||
"status.reblog_private": "Продвинуть для своей аудитории",
|
"status.reblog_private": "Продвинуть для своей аудитории",
|
||||||
|
|
|
@ -864,12 +864,6 @@
|
||||||
"status.open": "Zgjeroje këtë mesazh",
|
"status.open": "Zgjeroje këtë mesazh",
|
||||||
"status.pin": "Fiksoje në profil",
|
"status.pin": "Fiksoje në profil",
|
||||||
"status.quote_error.filtered": "Fshehur për shkak të njërit nga filtrat tuaj",
|
"status.quote_error.filtered": "Fshehur për shkak të njërit nga filtrat tuaj",
|
||||||
"status.quote_error.not_found": "Ky postim s’mund të shfaqet.",
|
|
||||||
"status.quote_error.pending_approval": "Ky postim është në pritje të miratimit nga autori origjinal.",
|
|
||||||
"status.quote_error.rejected": "Ky postim s’mund të shfaqet, ngaqë autori origjinal nuk lejon citim të tij.",
|
|
||||||
"status.quote_error.removed": "Ky postim u hoq nga autori i tij.",
|
|
||||||
"status.quote_error.unauthorized": "Ky postim s’mund të shfaqet, ngaqë s’jeni i autorizuar ta shihni.",
|
|
||||||
"status.quote_post_author": "Postim nga {name}",
|
|
||||||
"status.read_more": "Lexoni më tepër",
|
"status.read_more": "Lexoni më tepër",
|
||||||
"status.reblog": "Përforcojeni",
|
"status.reblog": "Përforcojeni",
|
||||||
"status.reblog_private": "Përforcim për publikun origjinal",
|
"status.reblog_private": "Përforcim për publikun origjinal",
|
||||||
|
|
|
@ -873,12 +873,6 @@
|
||||||
"status.open": "Utvidga detta inlägg",
|
"status.open": "Utvidga detta inlägg",
|
||||||
"status.pin": "Fäst i profil",
|
"status.pin": "Fäst i profil",
|
||||||
"status.quote_error.filtered": "Dolt på grund av ett av dina filter",
|
"status.quote_error.filtered": "Dolt på grund av ett av dina filter",
|
||||||
"status.quote_error.not_found": "Detta inlägg kan inte boostas.",
|
|
||||||
"status.quote_error.pending_approval": "Det här inlägget väntar på godkännande från originalförfattaren.",
|
|
||||||
"status.quote_error.rejected": "Det här inlägget kan inte visas eftersom originalförfattaren inte tillåter att det citeras.",
|
|
||||||
"status.quote_error.removed": "Detta inlägg har tagits bort av författaren.",
|
|
||||||
"status.quote_error.unauthorized": "Det här inlägget kan inte visas eftersom du inte har behörighet att se det.",
|
|
||||||
"status.quote_post_author": "Inlägg av @{name}",
|
|
||||||
"status.read_more": "Läs mer",
|
"status.read_more": "Läs mer",
|
||||||
"status.reblog": "Boosta",
|
"status.reblog": "Boosta",
|
||||||
"status.reblog_private": "Boosta med ursprunglig synlighet",
|
"status.reblog_private": "Boosta med ursprunglig synlighet",
|
||||||
|
|
|
@ -832,7 +832,6 @@
|
||||||
"status.mute_conversation": "ซ่อนการสนทนา",
|
"status.mute_conversation": "ซ่อนการสนทนา",
|
||||||
"status.open": "ขยายโพสต์นี้",
|
"status.open": "ขยายโพสต์นี้",
|
||||||
"status.pin": "ปักหมุดในโปรไฟล์",
|
"status.pin": "ปักหมุดในโปรไฟล์",
|
||||||
"status.quote_post_author": "โพสต์โดย {name}",
|
|
||||||
"status.read_more": "อ่านเพิ่มเติม",
|
"status.read_more": "อ่านเพิ่มเติม",
|
||||||
"status.reblog": "ดัน",
|
"status.reblog": "ดัน",
|
||||||
"status.reblog_private": "ดันด้วยการมองเห็นดั้งเดิม",
|
"status.reblog_private": "ดันด้วยการมองเห็นดั้งเดิม",
|
||||||
|
|
|
@ -873,12 +873,6 @@
|
||||||
"status.open": "Bu gönderiyi genişlet",
|
"status.open": "Bu gönderiyi genişlet",
|
||||||
"status.pin": "Profile sabitle",
|
"status.pin": "Profile sabitle",
|
||||||
"status.quote_error.filtered": "Bazı filtrelerinizden dolayı gizlenmiştir",
|
"status.quote_error.filtered": "Bazı filtrelerinizden dolayı gizlenmiştir",
|
||||||
"status.quote_error.not_found": "Bu gönderi görüntülenemez.",
|
|
||||||
"status.quote_error.pending_approval": "Bu gönderi özgün yazarın onayını bekliyor.",
|
|
||||||
"status.quote_error.rejected": "Bu gönderi, özgün yazar alıntılanmasına izin vermediği için görüntülenemez.",
|
|
||||||
"status.quote_error.removed": "Bu gönderi yazarı tarafından kaldırıldı.",
|
|
||||||
"status.quote_error.unauthorized": "Bu gönderiyi, yetkiniz olmadığı için görüntüleyemiyorsunuz.",
|
|
||||||
"status.quote_post_author": "{name} gönderisi",
|
|
||||||
"status.read_more": "Devamını okuyun",
|
"status.read_more": "Devamını okuyun",
|
||||||
"status.reblog": "Yeniden paylaş",
|
"status.reblog": "Yeniden paylaş",
|
||||||
"status.reblog_private": "Özgün görünürlük ile yeniden paylaş",
|
"status.reblog_private": "Özgün görünürlük ile yeniden paylaş",
|
||||||
|
|
|
@ -468,6 +468,8 @@
|
||||||
"keyboard_shortcuts.translate": "перекласти допис",
|
"keyboard_shortcuts.translate": "перекласти допис",
|
||||||
"keyboard_shortcuts.unfocus": "Розфокусуватися з нового допису чи пошуку",
|
"keyboard_shortcuts.unfocus": "Розфокусуватися з нового допису чи пошуку",
|
||||||
"keyboard_shortcuts.up": "Рухатися вгору списком",
|
"keyboard_shortcuts.up": "Рухатися вгору списком",
|
||||||
|
"learn_more_link.got_it": "Зрозуміло",
|
||||||
|
"learn_more_link.learn_more": "Докладніше",
|
||||||
"lightbox.close": "Закрити",
|
"lightbox.close": "Закрити",
|
||||||
"lightbox.next": "Далі",
|
"lightbox.next": "Далі",
|
||||||
"lightbox.previous": "Назад",
|
"lightbox.previous": "Назад",
|
||||||
|
@ -843,7 +845,8 @@
|
||||||
"status.open": "Розгорнути допис",
|
"status.open": "Розгорнути допис",
|
||||||
"status.pin": "Закріпити у профілі",
|
"status.pin": "Закріпити у профілі",
|
||||||
"status.quote_error.filtered": "Приховано через один з ваших фільтрів",
|
"status.quote_error.filtered": "Приховано через один з ваших фільтрів",
|
||||||
"status.quote_post_author": "@{name} опублікував допис",
|
"status.quote_error.not_available": "Пост недоступний",
|
||||||
|
"status.quote_post_author": "Цитований допис @{name}",
|
||||||
"status.read_more": "Дізнатися більше",
|
"status.read_more": "Дізнатися більше",
|
||||||
"status.reblog": "Поширити",
|
"status.reblog": "Поширити",
|
||||||
"status.reblog_private": "Поширити для початкової аудиторії",
|
"status.reblog_private": "Поширити для початкової аудиторії",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "dịch tút",
|
"keyboard_shortcuts.translate": "dịch tút",
|
||||||
"keyboard_shortcuts.unfocus": "đưa con trỏ ra khỏi ô soạn thảo hoặc ô tìm kiếm",
|
"keyboard_shortcuts.unfocus": "đưa con trỏ ra khỏi ô soạn thảo hoặc ô tìm kiếm",
|
||||||
"keyboard_shortcuts.up": "di chuyển lên trên danh sách",
|
"keyboard_shortcuts.up": "di chuyển lên trên danh sách",
|
||||||
|
"learn_more_link.got_it": "Đã hiểu",
|
||||||
|
"learn_more_link.learn_more": "Tìm hiểu thêm",
|
||||||
"lightbox.close": "Đóng",
|
"lightbox.close": "Đóng",
|
||||||
"lightbox.next": "Tiếp",
|
"lightbox.next": "Tiếp",
|
||||||
"lightbox.previous": "Trước",
|
"lightbox.previous": "Trước",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "Mở tút",
|
"status.open": "Mở tút",
|
||||||
"status.pin": "Ghim lên hồ sơ",
|
"status.pin": "Ghim lên hồ sơ",
|
||||||
"status.quote_error.filtered": "Bị ẩn vì một bộ lọc của bạn",
|
"status.quote_error.filtered": "Bị ẩn vì một bộ lọc của bạn",
|
||||||
"status.quote_error.not_found": "Tút này không thể hiển thị.",
|
"status.quote_error.not_available": "Tút không khả dụng",
|
||||||
"status.quote_error.pending_approval": "Tút này cần chờ cho phép từ người đăng.",
|
"status.quote_error.pending_approval": "Tút đang chờ duyệt",
|
||||||
"status.quote_error.rejected": "Tút này không thể hiển thị vì người đăng không cho phép trích dẫn nó.",
|
"status.quote_error.pending_approval_popout.body": "Các trích dẫn được chia sẻ trên Fediverse có thể mất thời gian để hiển thị vì các máy chủ khác nhau có giao thức khác nhau.",
|
||||||
"status.quote_error.removed": "Tút này đã bị người đăng xóa.",
|
"status.quote_error.pending_approval_popout.title": "Đang chờ trích dẫn? Hãy bình tĩnh",
|
||||||
"status.quote_error.unauthorized": "Tút này không thể hiển thị vì bạn không được cấp quyền truy cập nó.",
|
"status.quote_post_author": "Trích dẫn từ tút của @{name}",
|
||||||
"status.quote_post_author": "Tút của {name}",
|
|
||||||
"status.read_more": "Đọc tiếp",
|
"status.read_more": "Đọc tiếp",
|
||||||
"status.reblog": "Đăng lại",
|
"status.reblog": "Đăng lại",
|
||||||
"status.reblog_private": "Đăng lại (Riêng tư)",
|
"status.reblog_private": "Đăng lại (Riêng tư)",
|
||||||
|
|
|
@ -862,12 +862,6 @@
|
||||||
"status.open": "展开嘟文",
|
"status.open": "展开嘟文",
|
||||||
"status.pin": "在个人资料页面置顶",
|
"status.pin": "在个人资料页面置顶",
|
||||||
"status.quote_error.filtered": "已根据你的筛选器过滤",
|
"status.quote_error.filtered": "已根据你的筛选器过滤",
|
||||||
"status.quote_error.not_found": "无法显示这篇贴文。",
|
|
||||||
"status.quote_error.pending_approval": "此嘟文正在等待原作者批准。",
|
|
||||||
"status.quote_error.rejected": "由于原作者不允许引用转发,无法显示这篇贴文。",
|
|
||||||
"status.quote_error.removed": "该帖子已被作者删除。",
|
|
||||||
"status.quote_error.unauthorized": "你无权查看此嘟文,因此无法显示。",
|
|
||||||
"status.quote_post_author": "{name} 的嘟文",
|
|
||||||
"status.read_more": "查看更多",
|
"status.read_more": "查看更多",
|
||||||
"status.reblog": "转嘟",
|
"status.reblog": "转嘟",
|
||||||
"status.reblog_private": "以相同可见性转嘟",
|
"status.reblog_private": "以相同可见性转嘟",
|
||||||
|
|
|
@ -498,6 +498,8 @@
|
||||||
"keyboard_shortcuts.translate": "翻譯嘟文",
|
"keyboard_shortcuts.translate": "翻譯嘟文",
|
||||||
"keyboard_shortcuts.unfocus": "跳離文字撰寫區塊或搜尋框",
|
"keyboard_shortcuts.unfocus": "跳離文字撰寫區塊或搜尋框",
|
||||||
"keyboard_shortcuts.up": "向上移動",
|
"keyboard_shortcuts.up": "向上移動",
|
||||||
|
"learn_more_link.got_it": "了解",
|
||||||
|
"learn_more_link.learn_more": "了解更多",
|
||||||
"lightbox.close": "關閉",
|
"lightbox.close": "關閉",
|
||||||
"lightbox.next": "下一步",
|
"lightbox.next": "下一步",
|
||||||
"lightbox.previous": "上一步",
|
"lightbox.previous": "上一步",
|
||||||
|
@ -873,12 +875,11 @@
|
||||||
"status.open": "展開此嘟文",
|
"status.open": "展開此嘟文",
|
||||||
"status.pin": "釘選至個人檔案頁面",
|
"status.pin": "釘選至個人檔案頁面",
|
||||||
"status.quote_error.filtered": "由於您的過濾器,該嘟文被隱藏",
|
"status.quote_error.filtered": "由於您的過濾器,該嘟文被隱藏",
|
||||||
"status.quote_error.not_found": "這則嘟文無法被顯示。",
|
"status.quote_error.not_available": "無法取得該嘟文",
|
||||||
"status.quote_error.pending_approval": "此嘟文正在等待原作者審核。",
|
"status.quote_error.pending_approval": "嘟文正在發送中",
|
||||||
"status.quote_error.rejected": "由於原作者不允許引用,此嘟文無法被顯示。",
|
"status.quote_error.pending_approval_popout.body": "因為伺服器間可能運行不同協定,顯示聯邦宇宙間之引用嘟文會有些許延遲。",
|
||||||
"status.quote_error.removed": "此嘟文已被其作者移除。",
|
"status.quote_error.pending_approval_popout.title": "引用嘟文正在發送中?別著急,請稍候片刻",
|
||||||
"status.quote_error.unauthorized": "由於您未被授權檢視,此嘟文無法被顯示。",
|
"status.quote_post_author": "已引用 @{name} 之嘟文",
|
||||||
"status.quote_post_author": "由 {name} 發嘟",
|
|
||||||
"status.read_more": "閱讀更多",
|
"status.read_more": "閱讀更多",
|
||||||
"status.reblog": "轉嘟",
|
"status.reblog": "轉嘟",
|
||||||
"status.reblog_private": "依照原嘟可見性轉嘟",
|
"status.reblog_private": "依照原嘟可見性轉嘟",
|
||||||
|
|
|
@ -2,10 +2,10 @@ import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { Globals } from '@react-spring/web';
|
import { Globals } from '@react-spring/web';
|
||||||
|
|
||||||
|
import * as perf from '@/mastodon/utils/performance';
|
||||||
import { setupBrowserNotifications } from 'mastodon/actions/notifications';
|
import { setupBrowserNotifications } from 'mastodon/actions/notifications';
|
||||||
import Mastodon from 'mastodon/containers/mastodon';
|
import Mastodon from 'mastodon/containers/mastodon';
|
||||||
import { me, reduceMotion } from 'mastodon/initial_state';
|
import { me, reduceMotion } from 'mastodon/initial_state';
|
||||||
import * as perf from 'mastodon/performance';
|
|
||||||
import ready from 'mastodon/ready';
|
import ready from 'mastodon/ready';
|
||||||
import { store } from 'mastodon/store';
|
import { store } from 'mastodon/store';
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ function main() {
|
||||||
|
|
||||||
if (isModernEmojiEnabled()) {
|
if (isModernEmojiEnabled()) {
|
||||||
const { initializeEmoji } = await import('@/mastodon/features/emoji');
|
const { initializeEmoji } = await import('@/mastodon/features/emoji');
|
||||||
await initializeEmoji();
|
initializeEmoji();
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = createRoot(mountNode);
|
const root = createRoot(mountNode);
|
||||||
|
|
78
app/javascript/mastodon/utils/__tests__/cache.test.ts
Normal file
78
app/javascript/mastodon/utils/__tests__/cache.test.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { createLimitedCache } from '../cache';
|
||||||
|
|
||||||
|
describe('createCache', () => {
|
||||||
|
test('returns expected methods', () => {
|
||||||
|
const actual = createLimitedCache();
|
||||||
|
expect(actual).toBeTypeOf('object');
|
||||||
|
expect(actual).toHaveProperty('get');
|
||||||
|
expect(actual).toHaveProperty('has');
|
||||||
|
expect(actual).toHaveProperty('delete');
|
||||||
|
expect(actual).toHaveProperty('set');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caches values provided to it', () => {
|
||||||
|
const cache = createLimitedCache();
|
||||||
|
cache.set('test', 'result');
|
||||||
|
expect(cache.get('test')).toBe('result');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has returns expected values', () => {
|
||||||
|
const cache = createLimitedCache();
|
||||||
|
cache.set('test', 'result');
|
||||||
|
expect(cache.has('test')).toBeTruthy();
|
||||||
|
expect(cache.has('not found')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates a value if keys are the same', () => {
|
||||||
|
const cache = createLimitedCache();
|
||||||
|
cache.set('test1', 1);
|
||||||
|
cache.set('test1', 2);
|
||||||
|
expect(cache.get('test1')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete removes an item', () => {
|
||||||
|
const cache = createLimitedCache();
|
||||||
|
cache.set('test', 'result');
|
||||||
|
expect(cache.has('test')).toBeTruthy();
|
||||||
|
cache.delete('test');
|
||||||
|
expect(cache.has('test')).toBeFalsy();
|
||||||
|
expect(cache.get('test')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes oldest item cached if it exceeds a set size', () => {
|
||||||
|
const cache = createLimitedCache({ maxSize: 1 });
|
||||||
|
cache.set('test1', 1);
|
||||||
|
cache.set('test2', 2);
|
||||||
|
expect(cache.get('test1')).toBeUndefined();
|
||||||
|
expect(cache.get('test2')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retrieving a value bumps up last access', () => {
|
||||||
|
const cache = createLimitedCache({ maxSize: 2 });
|
||||||
|
cache.set('test1', 1);
|
||||||
|
cache.set('test2', 2);
|
||||||
|
expect(cache.get('test1')).toBe(1);
|
||||||
|
cache.set('test3', 3);
|
||||||
|
expect(cache.get('test1')).toBe(1);
|
||||||
|
expect(cache.get('test2')).toBeUndefined();
|
||||||
|
expect(cache.get('test3')).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs when cache is added to and removed', () => {
|
||||||
|
const log = vi.fn();
|
||||||
|
const cache = createLimitedCache({ maxSize: 1, log });
|
||||||
|
cache.set('test1', 1);
|
||||||
|
expect(log).toHaveBeenLastCalledWith(
|
||||||
|
'Added %s to cache, now size %d',
|
||||||
|
'test1',
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
cache.set('test2', 1);
|
||||||
|
expect(log).toHaveBeenLastCalledWith(
|
||||||
|
'Added %s and deleted %s from cache, now size %d',
|
||||||
|
'test2',
|
||||||
|
'test1',
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
60
app/javascript/mastodon/utils/cache.ts
Normal file
60
app/javascript/mastodon/utils/cache.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
export interface LimitedCache<CacheKey, CacheValue> {
|
||||||
|
has: (key: CacheKey) => boolean;
|
||||||
|
get: (key: CacheKey) => CacheValue | undefined;
|
||||||
|
delete: (key: CacheKey) => void;
|
||||||
|
set: (key: CacheKey, value: CacheValue) => void;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LimitedCacheArguments {
|
||||||
|
maxSize?: number;
|
||||||
|
log?: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLimitedCache<CacheValue, CacheKey = string>({
|
||||||
|
maxSize = 100,
|
||||||
|
log = () => null,
|
||||||
|
}: LimitedCacheArguments = {}): LimitedCache<CacheKey, CacheValue> {
|
||||||
|
const cacheMap = new Map<CacheKey, CacheValue>();
|
||||||
|
const cacheKeys = new Set<CacheKey>();
|
||||||
|
|
||||||
|
function touchKey(key: CacheKey) {
|
||||||
|
if (cacheKeys.has(key)) {
|
||||||
|
cacheKeys.delete(key);
|
||||||
|
}
|
||||||
|
cacheKeys.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
has: (key) => cacheMap.has(key),
|
||||||
|
get: (key) => {
|
||||||
|
if (cacheMap.has(key)) {
|
||||||
|
touchKey(key);
|
||||||
|
}
|
||||||
|
return cacheMap.get(key);
|
||||||
|
},
|
||||||
|
delete: (key) => cacheMap.delete(key) && cacheKeys.delete(key),
|
||||||
|
set: (key, value) => {
|
||||||
|
cacheMap.set(key, value);
|
||||||
|
touchKey(key);
|
||||||
|
|
||||||
|
const lastKey = cacheKeys.values().toArray().shift();
|
||||||
|
if (cacheMap.size > maxSize && lastKey) {
|
||||||
|
cacheMap.delete(lastKey);
|
||||||
|
cacheKeys.delete(lastKey);
|
||||||
|
log(
|
||||||
|
'Added %s and deleted %s from cache, now size %d',
|
||||||
|
key,
|
||||||
|
lastKey,
|
||||||
|
cacheMap.size,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log('Added %s to cache, now size %d', key, cacheMap.size);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
cacheMap.clear();
|
||||||
|
cacheKeys.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -19,5 +19,12 @@ export function isFeatureEnabled(feature: Features) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isModernEmojiEnabled() {
|
export function isModernEmojiEnabled() {
|
||||||
return isFeatureEnabled('modern_emojis') && isDevelopment();
|
try {
|
||||||
|
return (
|
||||||
|
isFeatureEnabled('modern_emojis') &&
|
||||||
|
localStorage.getItem('experiments')?.split(',').includes('modern_emojis')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,15 +4,15 @@
|
||||||
|
|
||||||
import * as marky from 'marky';
|
import * as marky from 'marky';
|
||||||
|
|
||||||
import { isDevelopment } from './utils/environment';
|
import { isDevelopment } from './environment';
|
||||||
|
|
||||||
export function start(name) {
|
export function start(name: string) {
|
||||||
if (isDevelopment()) {
|
if (isDevelopment()) {
|
||||||
marky.mark(name);
|
marky.mark(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stop(name) {
|
export function stop(name: string) {
|
||||||
if (isDevelopment()) {
|
if (isDevelopment()) {
|
||||||
marky.stop(name);
|
marky.stop(name);
|
||||||
}
|
}
|
|
@ -1,4 +1,8 @@
|
||||||
import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships';
|
import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships';
|
||||||
|
import type {
|
||||||
|
CustomEmojiData,
|
||||||
|
UnicodeEmojiData,
|
||||||
|
} from '@/mastodon/features/emoji/types';
|
||||||
import { createAccountFromServerJSON } from '@/mastodon/models/account';
|
import { createAccountFromServerJSON } from '@/mastodon/models/account';
|
||||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||||
|
|
||||||
|
@ -68,3 +72,26 @@ export const relationshipsFactory: FactoryFunction<ApiRelationshipJSON> = ({
|
||||||
showing_reblogs: true,
|
showing_reblogs: true,
|
||||||
...data,
|
...data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function unicodeEmojiFactory(
|
||||||
|
data: Partial<UnicodeEmojiData> = {},
|
||||||
|
): UnicodeEmojiData {
|
||||||
|
return {
|
||||||
|
hexcode: 'test',
|
||||||
|
label: 'Test',
|
||||||
|
unicode: '🧪',
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customEmojiFactory(
|
||||||
|
data: Partial<CustomEmojiData> = {},
|
||||||
|
): CustomEmojiData {
|
||||||
|
return {
|
||||||
|
shortcode: 'custom',
|
||||||
|
static_url: 'emoji/custom/static',
|
||||||
|
url: 'emoji/custom',
|
||||||
|
visible_in_picker: true,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -230,7 +230,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
return if @quote_uri.blank?
|
return if @quote_uri.blank?
|
||||||
|
|
||||||
approval_uri = @status_parser.quote_approval_uri
|
approval_uri = @status_parser.quote_approval_uri
|
||||||
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
|
approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri)
|
||||||
@quote = Quote.new(account: @account, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?)
|
@quote = Quote.new(account: @account, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -152,9 +152,6 @@ class ActivityPub::Parser::StatusParser
|
||||||
# Remove the special-meaning actor URI
|
# Remove the special-meaning actor URI
|
||||||
allowed_actors.delete(@options[:actor_uri])
|
allowed_actors.delete(@options[:actor_uri])
|
||||||
|
|
||||||
# Tagged users are always allowed, so remove them
|
|
||||||
allowed_actors -= as_array(@object['tag']).filter_map { |tag| tag['href'] if equals_or_includes?(tag['type'], 'Mention') }
|
|
||||||
|
|
||||||
# Any unrecognized actor is marked as unknown
|
# Any unrecognized actor is marked as unknown
|
||||||
flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:unknown] unless allowed_actors.empty?
|
flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:unknown] unless allowed_actors.empty?
|
||||||
|
|
||||||
|
|
|
@ -51,6 +51,13 @@ class ActivityPub::TagManager
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def approval_uri_for(quote, check_approval: true)
|
||||||
|
return quote.approval_uri unless quote.quoted_account&.local?
|
||||||
|
return if check_approval && !quote.accepted?
|
||||||
|
|
||||||
|
account_quote_authorization_url(quote.quoted_account, quote)
|
||||||
|
end
|
||||||
|
|
||||||
def key_uri_for(target)
|
def key_uri_for(target)
|
||||||
[uri_for(target), '#main-key'].join
|
[uri_for(target), '#main-key'].join
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,14 +3,18 @@
|
||||||
class DeliveryFailureTracker
|
class DeliveryFailureTracker
|
||||||
include Redisable
|
include Redisable
|
||||||
|
|
||||||
FAILURE_DAYS_THRESHOLD = 7
|
FAILURE_THRESHOLDS = {
|
||||||
|
days: 7,
|
||||||
|
minutes: 5,
|
||||||
|
}.freeze
|
||||||
|
|
||||||
def initialize(url_or_host)
|
def initialize(url_or_host, resolution: :days)
|
||||||
@host = url_or_host.start_with?('https://', 'http://') ? Addressable::URI.parse(url_or_host).normalized_host : url_or_host
|
@host = url_or_host.start_with?('https://', 'http://') ? Addressable::URI.parse(url_or_host).normalized_host : url_or_host
|
||||||
|
@resolution = resolution
|
||||||
end
|
end
|
||||||
|
|
||||||
def track_failure!
|
def track_failure!
|
||||||
redis.sadd(exhausted_deliveries_key, today)
|
redis.sadd(exhausted_deliveries_key, failure_time)
|
||||||
UnavailableDomain.create(domain: @host) if reached_failure_threshold?
|
UnavailableDomain.create(domain: @host) if reached_failure_threshold?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -24,6 +28,12 @@ class DeliveryFailureTracker
|
||||||
end
|
end
|
||||||
|
|
||||||
def days
|
def days
|
||||||
|
raise TypeError, 'resolution is not in days' unless @resolution == :days
|
||||||
|
|
||||||
|
failures
|
||||||
|
end
|
||||||
|
|
||||||
|
def failures
|
||||||
redis.scard(exhausted_deliveries_key) || 0
|
redis.scard(exhausted_deliveries_key) || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -32,7 +42,7 @@ class DeliveryFailureTracker
|
||||||
end
|
end
|
||||||
|
|
||||||
def exhausted_deliveries_days
|
def exhausted_deliveries_days
|
||||||
@exhausted_deliveries_days ||= redis.smembers(exhausted_deliveries_key).sort.map { |date| Date.new(date.slice(0, 4).to_i, date.slice(4, 2).to_i, date.slice(6, 2).to_i) }
|
@exhausted_deliveries_days ||= redis.smembers(exhausted_deliveries_key).sort.map { |date| Date.new(date.slice(0, 4).to_i, date.slice(4, 2).to_i, date.slice(6, 2).to_i) }.uniq
|
||||||
end
|
end
|
||||||
|
|
||||||
alias reset! track_success!
|
alias reset! track_success!
|
||||||
|
@ -89,11 +99,16 @@ class DeliveryFailureTracker
|
||||||
"exhausted_deliveries:#{@host}"
|
"exhausted_deliveries:#{@host}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def today
|
def failure_time
|
||||||
|
case @resolution
|
||||||
|
when :days
|
||||||
Time.now.utc.strftime('%Y%m%d')
|
Time.now.utc.strftime('%Y%m%d')
|
||||||
|
when :minutes
|
||||||
|
Time.now.utc.strftime('%Y%m%d%H%M')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def reached_failure_threshold?
|
def reached_failure_threshold?
|
||||||
days >= FAILURE_DAYS_THRESHOLD
|
failures >= FAILURE_THRESHOLDS[@resolution]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -33,16 +33,8 @@ module Status::InteractionPolicyConcern
|
||||||
automatic_policy = quote_approval_policy >> 16
|
automatic_policy = quote_approval_policy >> 16
|
||||||
manual_policy = quote_approval_policy & 0xFFFF
|
manual_policy = quote_approval_policy & 0xFFFF
|
||||||
|
|
||||||
# Checking for public policy first because it's less expensive than looking at mentions
|
|
||||||
return :automatic if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public])
|
return :automatic if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public])
|
||||||
|
|
||||||
# Mentioned users are always allowed to quote
|
|
||||||
if active_mentions.loaded?
|
|
||||||
return :automatic if active_mentions.any? { |mention| mention.account_id == other_account.id }
|
|
||||||
elsif active_mentions.exists?(account: other_account)
|
|
||||||
return :automatic
|
|
||||||
end
|
|
||||||
|
|
||||||
if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers])
|
if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers])
|
||||||
following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil?
|
following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil?
|
||||||
return :automatic if following_author
|
return :automatic if following_author
|
||||||
|
|
|
@ -73,6 +73,9 @@ class Notification < ApplicationRecord
|
||||||
'admin.report': {
|
'admin.report': {
|
||||||
filterable: false,
|
filterable: false,
|
||||||
}.freeze,
|
}.freeze,
|
||||||
|
quote: {
|
||||||
|
filterable: true,
|
||||||
|
}.freeze,
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
TYPES = PROPERTIES.keys.freeze
|
TYPES = PROPERTIES.keys.freeze
|
||||||
|
@ -81,6 +84,7 @@ class Notification < ApplicationRecord
|
||||||
status: :status,
|
status: :status,
|
||||||
reblog: [status: :reblog],
|
reblog: [status: :reblog],
|
||||||
mention: [mention: :status],
|
mention: [mention: :status],
|
||||||
|
quote: [quote: :status],
|
||||||
favourite: [favourite: :status],
|
favourite: [favourite: :status],
|
||||||
poll: [poll: :status],
|
poll: [poll: :status],
|
||||||
update: :status,
|
update: :status,
|
||||||
|
@ -102,6 +106,7 @@ class Notification < ApplicationRecord
|
||||||
belongs_to :account_relationship_severance_event, inverse_of: false
|
belongs_to :account_relationship_severance_event, inverse_of: false
|
||||||
belongs_to :account_warning, inverse_of: false
|
belongs_to :account_warning, inverse_of: false
|
||||||
belongs_to :generated_annual_report, inverse_of: false
|
belongs_to :generated_annual_report, inverse_of: false
|
||||||
|
belongs_to :quote, inverse_of: :notification
|
||||||
end
|
end
|
||||||
|
|
||||||
validates :type, inclusion: { in: TYPES }
|
validates :type, inclusion: { in: TYPES }
|
||||||
|
@ -122,6 +127,8 @@ class Notification < ApplicationRecord
|
||||||
favourite&.status
|
favourite&.status
|
||||||
when :mention
|
when :mention
|
||||||
mention&.status
|
mention&.status
|
||||||
|
when :quote
|
||||||
|
quote&.status
|
||||||
when :poll
|
when :poll
|
||||||
poll&.status
|
poll&.status
|
||||||
end
|
end
|
||||||
|
@ -174,6 +181,8 @@ class Notification < ApplicationRecord
|
||||||
notification.mention.status = cached_status
|
notification.mention.status = cached_status
|
||||||
when :poll
|
when :poll
|
||||||
notification.poll.status = cached_status
|
notification.poll.status = cached_status
|
||||||
|
when :quote
|
||||||
|
notification.quote.status = cached_status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -192,7 +201,7 @@ class Notification < ApplicationRecord
|
||||||
return unless new_record?
|
return unless new_record?
|
||||||
|
|
||||||
case activity_type
|
case activity_type
|
||||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report'
|
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report', 'Quote'
|
||||||
self.from_account_id = activity&.account_id
|
self.from_account_id = activity&.account_id
|
||||||
when 'Mention'
|
when 'Mention'
|
||||||
self.from_account_id = activity&.status&.account_id
|
self.from_account_id = activity&.status&.account_id
|
||||||
|
|
|
@ -49,6 +49,6 @@ class NotificationRequest < ApplicationRecord
|
||||||
private
|
private
|
||||||
|
|
||||||
def prepare_notifications_count
|
def prepare_notifications_count
|
||||||
self.notifications_count = Notification.where(account: account, from_account: from_account, type: :mention, filtered: true).limit(MAX_MEANINGFUL_COUNT).count
|
self.notifications_count = Notification.where(account: account, from_account: from_account, type: [:mention, :quote], filtered: true).limit(MAX_MEANINGFUL_COUNT).count
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,6 +17,10 @@
|
||||||
# status_id :bigint(8) not null
|
# status_id :bigint(8) not null
|
||||||
#
|
#
|
||||||
class Quote < ApplicationRecord
|
class Quote < ApplicationRecord
|
||||||
|
include Paginable
|
||||||
|
|
||||||
|
has_one :notification, as: :activity, dependent: :destroy
|
||||||
|
|
||||||
BACKGROUND_REFRESH_INTERVAL = 1.week.freeze
|
BACKGROUND_REFRESH_INTERVAL = 1.week.freeze
|
||||||
REFRESH_DEADLINE = 6.hours
|
REFRESH_DEADLINE = 6.hours
|
||||||
|
|
||||||
|
@ -33,6 +37,7 @@ class Quote < ApplicationRecord
|
||||||
before_validation :set_accounts
|
before_validation :set_accounts
|
||||||
before_validation :set_activity_uri, only: :create, if: -> { account.local? && quoted_account&.remote? }
|
before_validation :set_activity_uri, only: :create, if: -> { account.local? && quoted_account&.remote? }
|
||||||
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
|
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
|
||||||
|
validates :approval_uri, absence: true, if: -> { quoted_account&.local? }
|
||||||
validate :validate_visibility
|
validate :validate_visibility
|
||||||
|
|
||||||
def accept!
|
def accept!
|
||||||
|
|
7
app/policies/quote_policy.rb
Normal file
7
app/policies/quote_policy.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class QuotePolicy < ApplicationPolicy
|
||||||
|
def revoke?
|
||||||
|
record.quoted_account_id.present? && record.quoted_account_id == current_account&.id
|
||||||
|
end
|
||||||
|
end
|
|
@ -21,7 +21,7 @@ class StatusPolicy < ApplicationPolicy
|
||||||
|
|
||||||
# This is about requesting a quote post, not validating it
|
# This is about requesting a quote post, not validating it
|
||||||
def quote?
|
def quote?
|
||||||
record.quote_policy_for_account(current_account, preloaded_relations: @preloaded_relations) != :denied
|
show? && record.quote_policy_for_account(current_account, preloaded_relations: @preloaded_relations) != :denied
|
||||||
end
|
end
|
||||||
|
|
||||||
def reblog?
|
def reblog?
|
||||||
|
@ -36,6 +36,10 @@ class StatusPolicy < ApplicationPolicy
|
||||||
owned?
|
owned?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def list_quotes?
|
||||||
|
owned?
|
||||||
|
end
|
||||||
|
|
||||||
alias unreblog? destroy?
|
alias unreblog? destroy?
|
||||||
|
|
||||||
def update?
|
def update?
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::DeleteQuoteAuthorizationSerializer < ActivityPub::Serializer
|
||||||
|
attributes :id, :type, :actor, :to
|
||||||
|
|
||||||
|
# TODO: change the `object` to a `QuoteAuthorization` object instead of just the URI?
|
||||||
|
attribute :virtual_object, key: :object
|
||||||
|
|
||||||
|
def id
|
||||||
|
[ActivityPub::TagManager.instance.approval_uri_for(object, check_approval: false), '#delete'].join
|
||||||
|
end
|
||||||
|
|
||||||
|
def virtual_object
|
||||||
|
ActivityPub::TagManager.instance.approval_uri_for(object, check_approval: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def type
|
||||||
|
'Delete'
|
||||||
|
end
|
||||||
|
|
||||||
|
def actor
|
||||||
|
ActivityPub::TagManager.instance.uri_for(object.quoted_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to
|
||||||
|
[ActivityPub::TagManager::COLLECTIONS[:public]]
|
||||||
|
end
|
||||||
|
end
|
|
@ -204,7 +204,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def quote_authorization?
|
def quote_authorization?
|
||||||
object.quote&.approval_uri.present?
|
object.quote.present? && ActivityPub::TagManager.instance.approval_uri_for(object.quote).present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def quote
|
def quote
|
||||||
|
@ -213,8 +213,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def quote_authorization
|
def quote_authorization
|
||||||
# TODO: approval of local quotes may work differently, perhaps?
|
ActivityPub::TagManager.instance.approval_uri_for(object.quote)
|
||||||
object.quote.approval_uri
|
|
||||||
end
|
end
|
||||||
|
|
||||||
class MediaAttachmentSerializer < ActivityPub::Serializer
|
class MediaAttachmentSerializer < ActivityPub::Serializer
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::QuoteAuthorizationSerializer < ActivityPub::Serializer
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
|
context_extensions :quote_authorizations
|
||||||
|
|
||||||
|
attributes :id, :type, :attributed_to, :interacting_object, :interaction_target
|
||||||
|
|
||||||
|
def id
|
||||||
|
ActivityPub::TagManager.instance.approval_uri_for(object)
|
||||||
|
end
|
||||||
|
|
||||||
|
def type
|
||||||
|
'QuoteAuthorization'
|
||||||
|
end
|
||||||
|
|
||||||
|
def attributed_to
|
||||||
|
ActivityPub::TagManager.instance.uri_for(object.quoted_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def interaction_target
|
||||||
|
ActivityPub::TagManager.instance.uri_for(object.quoted_status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def interacting_object
|
||||||
|
ActivityPub::TagManager.instance.uri_for(object.status)
|
||||||
|
end
|
||||||
|
end
|
|
@ -21,7 +21,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_type?
|
def status_type?
|
||||||
[:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
|
[:favourite, :reblog, :status, :mention, :poll, :update, :quote].include?(object.type)
|
||||||
end
|
end
|
||||||
|
|
||||||
def report_type?
|
def report_type?
|
||||||
|
|
|
@ -278,10 +278,10 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
||||||
return unless quote_uri.present? && @status.quote.present?
|
return unless quote_uri.present? && @status.quote.present?
|
||||||
|
|
||||||
quote = @status.quote
|
quote = @status.quote
|
||||||
return if quote.quoted_status.present? && ActivityPub::TagManager.instance.uri_for(quote.quoted_status) != quote_uri
|
return if quote.quoted_status.present? && (ActivityPub::TagManager.instance.uri_for(quote.quoted_status) != quote_uri || quote.quoted_status.local?)
|
||||||
|
|
||||||
approval_uri = @status_parser.quote_approval_uri
|
approval_uri = @status_parser.quote_approval_uri
|
||||||
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
|
approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri)
|
||||||
|
|
||||||
quote.update(approval_uri: approval_uri, state: :pending, legacy: @status_parser.legacy_quote?) if quote.approval_uri != @status_parser.quote_approval_uri
|
quote.update(approval_uri: approval_uri, state: :pending, legacy: @status_parser.legacy_quote?) if quote.approval_uri != @status_parser.quote_approval_uri
|
||||||
|
|
||||||
|
@ -293,11 +293,13 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
||||||
|
|
||||||
if quote_uri.present?
|
if quote_uri.present?
|
||||||
approval_uri = @status_parser.quote_approval_uri
|
approval_uri = @status_parser.quote_approval_uri
|
||||||
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
|
approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri)
|
||||||
|
|
||||||
if @status.quote.present?
|
if @status.quote.present?
|
||||||
# If the quoted post has changed, discard the old object and create a new one
|
# If the quoted post has changed, discard the old object and create a new one
|
||||||
if @status.quote.quoted_status.present? && ActivityPub::TagManager.instance.uri_for(@status.quote.quoted_status) != quote_uri
|
if @status.quote.quoted_status.present? && ActivityPub::TagManager.instance.uri_for(@status.quote.quoted_status) != quote_uri
|
||||||
|
# Revoke the quote while we get a chance… maybe this should be a `before_destroy` hook?
|
||||||
|
RevokeQuoteService.new.call(@status.quote) if @status.quote.quoted_account&.local? && @status.quote.accepted?
|
||||||
@status.quote.destroy
|
@status.quote.destroy
|
||||||
quote = Quote.create(status: @status, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?)
|
quote = Quote.create(status: @status, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?)
|
||||||
@quote_changed = true
|
@quote_changed = true
|
||||||
|
|
|
@ -13,6 +13,7 @@ class ActivityPub::VerifyQuoteService < BaseService
|
||||||
@fetching_error = nil
|
@fetching_error = nil
|
||||||
|
|
||||||
fetch_quoted_post_if_needed!(fetchable_quoted_uri, prefetched_body: prefetched_quoted_object)
|
fetch_quoted_post_if_needed!(fetchable_quoted_uri, prefetched_body: prefetched_quoted_object)
|
||||||
|
return handle_local_quote! if quote.quoted_account&.local?
|
||||||
return if fast_track_approval! || quote.approval_uri.blank?
|
return if fast_track_approval! || quote.approval_uri.blank?
|
||||||
|
|
||||||
@json = fetch_approval_object(quote.approval_uri, prefetched_body: prefetched_approval)
|
@json = fetch_approval_object(quote.approval_uri, prefetched_body: prefetched_approval)
|
||||||
|
@ -34,6 +35,15 @@ class ActivityPub::VerifyQuoteService < BaseService
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def handle_local_quote!
|
||||||
|
@quote.update!(approval_uri: nil)
|
||||||
|
if StatusPolicy.new(@quote.account, @quote.quoted_status).quote?
|
||||||
|
@quote.accept!
|
||||||
|
else
|
||||||
|
@quote.reject!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# FEP-044f defines rules that don't require the approval flow
|
# FEP-044f defines rules that don't require the approval flow
|
||||||
def fast_track_approval!
|
def fast_track_approval!
|
||||||
return false if @quote.quoted_status_id.blank?
|
return false if @quote.quoted_status_id.blank?
|
||||||
|
@ -45,15 +55,8 @@ class ActivityPub::VerifyQuoteService < BaseService
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
# Always allow someone to quote posts in which they are mentioned
|
|
||||||
if @quote.quoted_status.active_mentions.exists?(mentions: { account_id: @quote.account_id })
|
|
||||||
@quote.accept!
|
|
||||||
|
|
||||||
true
|
|
||||||
else
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_approval_object(uri, prefetched_body: nil)
|
def fetch_approval_object(uri, prefetched_body: nil)
|
||||||
if prefetched_body.nil?
|
if prefetched_body.nil?
|
||||||
|
|
|
@ -40,6 +40,7 @@ class FanOutOnWriteService < BaseService
|
||||||
deliver_to_self!
|
deliver_to_self!
|
||||||
|
|
||||||
unless @options[:skip_notifications]
|
unless @options[:skip_notifications]
|
||||||
|
notify_quoted_account!
|
||||||
notify_mentioned_accounts!
|
notify_mentioned_accounts!
|
||||||
notify_about_update! if update?
|
notify_about_update! if update?
|
||||||
end
|
end
|
||||||
|
@ -69,6 +70,12 @@ class FanOutOnWriteService < BaseService
|
||||||
FeedManager.instance.push_to_home(@account, @status, update: update?) if @account.local?
|
FeedManager.instance.push_to_home(@account, @status, update: update?) if @account.local?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def notify_quoted_account!
|
||||||
|
return unless @status.quote&.quoted_account&.local? && @status.quote&.accepted?
|
||||||
|
|
||||||
|
LocalNotificationWorker.perform_async(@status.quote.quoted_account_id, @status.quote.id, 'Quote', 'quote')
|
||||||
|
end
|
||||||
|
|
||||||
def notify_mentioned_accounts!
|
def notify_mentioned_accounts!
|
||||||
@status.active_mentions.where.not(id: @options[:silenced_account_ids] || []).joins(:account).merge(Account.local).select(:id, :account_id).reorder(nil).find_in_batches do |mentions|
|
@status.active_mentions.where.not(id: @options[:silenced_account_ids] || []).joins(:account).merge(Account.local).select(:id, :account_id).reorder(nil).find_in_batches do |mentions|
|
||||||
LocalNotificationWorker.push_bulk(mentions) do |mention|
|
LocalNotificationWorker.push_bulk(mentions) do |mention|
|
||||||
|
|
|
@ -247,7 +247,7 @@ class NotifyService < BaseService
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_notification_request!
|
def update_notification_request!
|
||||||
return unless @notification.type == :mention
|
return unless %i(mention quote).include?(@notification.type)
|
||||||
|
|
||||||
notification_request = NotificationRequest.find_or_initialize_by(account_id: @recipient.id, from_account_id: @notification.from_account_id)
|
notification_request = NotificationRequest.find_or_initialize_by(account_id: @recipient.id, from_account_id: @notification.from_account_id)
|
||||||
notification_request.last_status_id = @notification.target_status.id
|
notification_request.last_status_id = @notification.target_status.id
|
||||||
|
|
|
@ -47,6 +47,9 @@ class RemoveStatusService < BaseService
|
||||||
remove_media
|
remove_media
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Revoke the quote while we get a chance… maybe this should be a `before_destroy` hook?
|
||||||
|
RevokeQuoteService.new.call(@status.quote) if @status.quote&.quoted_account&.local? && @status.quote&.accepted?
|
||||||
|
|
||||||
@status.destroy! if permanently?
|
@status.destroy! if permanently?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
32
app/services/revoke_quote_service.rb
Normal file
32
app/services/revoke_quote_service.rb
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class RevokeQuoteService < BaseService
|
||||||
|
include Payloadable
|
||||||
|
|
||||||
|
def call(quote)
|
||||||
|
@quote = quote
|
||||||
|
@account = quote.quoted_account
|
||||||
|
|
||||||
|
@quote.reject!
|
||||||
|
distribute_stamp_deletion!
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def distribute_stamp_deletion!
|
||||||
|
ActivityPub::DeliveryWorker.push_bulk(inboxes, limit: 1_000) do |inbox_url|
|
||||||
|
[signed_activity_json, @account.id, inbox_url]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def inboxes
|
||||||
|
[
|
||||||
|
@quote.status,
|
||||||
|
@quote.quoted_status,
|
||||||
|
].compact.map { |status| StatusReachFinder.new(status, unsafe: true).inboxes }.flatten.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed_activity_json
|
||||||
|
@signed_activity_json ||= Oj.dump(serialize_payload(@quote, ActivityPub::DeleteQuoteAuthorizationSerializer, signer: @account, always_sign: true))
|
||||||
|
end
|
||||||
|
end
|
|
@ -78,7 +78,7 @@
|
||||||
%h3= t('admin.instances.availability.title')
|
%h3= t('admin.instances.availability.title')
|
||||||
|
|
||||||
%p
|
%p
|
||||||
= t('admin.instances.availability.description_html', count: DeliveryFailureTracker::FAILURE_DAYS_THRESHOLD)
|
= t('admin.instances.availability.description_html', count: DeliveryFailureTracker::FAILURE_THRESHOLDS[:days])
|
||||||
|
|
||||||
.availability-indicator
|
.availability-indicator
|
||||||
%ul.availability-indicator__graphic
|
%ul.availability-indicator__graphic
|
||||||
|
|
|
@ -2047,8 +2047,6 @@ ar:
|
||||||
ownership: لا يمكن تثبيت منشور نشره شخص آخر
|
ownership: لا يمكن تثبيت منشور نشره شخص آخر
|
||||||
reblog: لا يمكن تثبيت إعادة نشر
|
reblog: لا يمكن تثبيت إعادة نشر
|
||||||
quote_policies:
|
quote_policies:
|
||||||
followers: المتابعين والمستخدمين المذكورين
|
|
||||||
nobody: المستخدمين المذكورين فقط
|
|
||||||
public: الجميع
|
public: الجميع
|
||||||
title: '%{name}: "%{quote}"'
|
title: '%{name}: "%{quote}"'
|
||||||
visibilities:
|
visibilities:
|
||||||
|
|
|
@ -1842,8 +1842,6 @@ be:
|
||||||
ownership: Немагчыма замацаваць чужы допіс
|
ownership: Немагчыма замацаваць чужы допіс
|
||||||
reblog: Немагчыма замацаваць пашырэнне
|
reblog: Немагчыма замацаваць пашырэнне
|
||||||
quote_policies:
|
quote_policies:
|
||||||
followers: Падпісчыкі і згаданыя карыстальнікі
|
|
||||||
nobody: Толькі згаданыя карыстальнікі
|
|
||||||
public: Усе
|
public: Усе
|
||||||
title: '%{name}: "%{quote}"'
|
title: '%{name}: "%{quote}"'
|
||||||
visibilities:
|
visibilities:
|
||||||
|
|
|
@ -1857,8 +1857,6 @@ bg:
|
||||||
ownership: Публикация на някого другиго не може да бъде закачена
|
ownership: Публикация на някого другиго не може да бъде закачена
|
||||||
reblog: Раздуване не може да бъде закачано
|
reblog: Раздуване не може да бъде закачано
|
||||||
quote_policies:
|
quote_policies:
|
||||||
followers: Последователи и споменати потребители
|
|
||||||
nobody: Само споменатите потребители
|
|
||||||
public: Всеки
|
public: Всеки
|
||||||
title: "%{name}: „%{quote}“"
|
title: "%{name}: „%{quote}“"
|
||||||
visibilities:
|
visibilities:
|
||||||
|
|
|
@ -1878,8 +1878,8 @@ ca:
|
||||||
ownership: No es pot fixar el tut d'algú altre
|
ownership: No es pot fixar el tut d'algú altre
|
||||||
reblog: No es pot fixar un impuls
|
reblog: No es pot fixar un impuls
|
||||||
quote_policies:
|
quote_policies:
|
||||||
followers: Seguidors i usuaris mencionats
|
followers: Només els vostres seguidors
|
||||||
nobody: Només usuaris mencionats
|
nobody: Ningú
|
||||||
public: Tothom
|
public: Tothom
|
||||||
title: '%{name}: "%{quote}"'
|
title: '%{name}: "%{quote}"'
|
||||||
visibilities:
|
visibilities:
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user