Merge branch 'main' into feature/require-mfa-by-admin

This commit is contained in:
FredysFonseca 2025-08-03 12:23:16 -04:00 committed by GitHub
commit 2b98d29942
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
202 changed files with 2578 additions and 1290 deletions

View File

@ -84,7 +84,7 @@ gem 'sanitize', '~> 7.0'
gem 'scenic', '~> 1.7'
gem 'sidekiq', '< 8'
gem 'sidekiq-bulk', '~> 0.2.0'
gem 'sidekiq-scheduler', '~> 5.0'
gem 'sidekiq-scheduler', '~> 6.0'
gem 'sidekiq-unique-jobs', '> 8'
gem 'simple_form', '~> 5.2'
gem 'simple-navigation', '~> 4.4'

View File

@ -175,9 +175,9 @@ GEM
css_parser (1.21.1)
addressable
csv (3.3.5)
database_cleaner-active_record (2.2.1)
database_cleaner-active_record (2.2.2)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
date (3.4.1)
debug (1.11.0)
@ -635,7 +635,7 @@ GEM
date
stringio
public_suffix (6.0.2)
puma (6.6.0)
puma (6.6.1)
nio4r (~> 2.0)
pundit (2.5.0)
activesupport (>= 3.0.0)
@ -765,7 +765,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.4)
rubocop (1.79.0)
rubocop (1.79.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -775,7 +775,6 @@ GEM
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.46.0, < 2.0)
ruby-progressbar (~> 1.7)
tsort (>= 0.2.0)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.46.0)
parser (>= 3.3.7.2)
@ -834,10 +833,9 @@ GEM
redis-client (>= 0.22.2)
sidekiq-bulk (0.2.0)
sidekiq
sidekiq-scheduler (5.0.6)
sidekiq-scheduler (6.0.1)
rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8)
tilt (>= 1.4.0, < 3)
sidekiq (>= 7.3, < 9)
sidekiq-unique-jobs (8.0.11)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 7.0.0, < 9.0.0)
@ -881,7 +879,6 @@ GEM
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
tsort (0.2.0)
tty-color (0.6.0)
tty-cursor (0.7.1)
tty-prompt (0.23.1)
@ -1084,7 +1081,7 @@ DEPENDENCIES
shoulda-matchers
sidekiq (< 8)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 5.0)
sidekiq-scheduler (~> 6.0)
sidekiq-unique-jobs (> 8)
simple-navigation (~> 4.4)
simple_form (~> 5.2)

View File

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

View 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

View File

@ -39,6 +39,12 @@ module ContextHelper
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@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
def full_context

View File

@ -39,16 +39,6 @@ module HomeHelper
end
end
def obscured_counter(count)
if count <= 0
'0'
elsif count == 1
'1'
else
'1+'
end
end
def field_verified_class(verified)
if verified
'verified'

View File

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

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

View File

@ -9,6 +9,7 @@ import type {
UnicodeEmojiData,
LocaleOrCustom,
} from './types';
import { emojiLogger } from './utils';
interface EmojiDB extends LocaleTables, DBSchema {
custom: {
@ -36,40 +37,63 @@ interface LocaleTable {
}
type LocaleTables = Record<Locale, LocaleTable>;
type Database = IDBPDatabase<EmojiDB>;
const SCHEMA_VERSION = 1;
let db: IDBPDatabase<EmojiDB> | null = null;
const loadedLocales = new Set<Locale>();
async function loadDB() {
if (db) {
return db;
}
db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
customTable.createIndex('category', 'category');
const log = emojiLogger('database');
database.createObjectStore('etags');
// Loads the database in a way that ensures it's only loaded once.
const loadDB = (() => {
let dbPromise: Promise<Database> | null = null;
for (const locale of SUPPORTED_LOCALES) {
const localeTable = database.createObjectStore(locale, {
keyPath: 'hexcode',
// Actually load the DB.
async function initDB() {
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
}
},
});
return db;
}
customTable.createIndex('category', 'category');
database.createObjectStore('etags');
for (const locale of SUPPORTED_LOCALES) {
const localeTable = database.createObjectStore(locale, {
keyPath: 'hexcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
}
},
});
await syncLocales(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) {
loadedLocales.add(locale);
const db = await loadDB();
const trx = db.transaction(locale, 'readwrite');
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) {
const locale = toSupportedLocaleOrCustom(localeString);
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,
localeString: string,
) {
const locale = toSupportedLocale(localeString);
const db = await loadDB();
const locale = toLoadedLocale(localeString);
return db.get(locale, hexcode);
}
@ -102,45 +126,39 @@ export async function searchEmojisByHexcodes(
hexcodes: string[],
localeString: string,
) {
const locale = toSupportedLocale(localeString);
const db = await loadDB();
return db.getAll(
const locale = toLoadedLocale(localeString);
const sortedCodes = hexcodes.toSorted();
const results = await db.getAll(
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) {
const locale = toSupportedLocale(localeString);
const range = IDBKeyRange.only(tag.toLowerCase());
export async function searchEmojisByTag(tag: string, localeString: string) {
const db = await loadDB();
const locale = toLoadedLocale(localeString);
const range = IDBKeyRange.bound(
tag.toLowerCase(),
`${tag.toLowerCase()}\uffff`,
);
return db.getAllFromIndex(locale, 'tags', range);
}
export async function searchCustomEmojiByShortcode(shortcode: string) {
export async function loadCustomEmojiByShortcode(shortcode: string) {
const db = await loadDB();
return db.get('custom', shortcode);
}
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
const db = await loadDB();
return db.getAll(
const sortedCodes = shortcodes.toSorted();
const results = await db.getAll(
'custom',
IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 1]),
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
);
}
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;
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
}
export async function loadLatestEtag(localeString: string) {
@ -153,3 +171,51 @@ export async function loadLatestEtag(localeString: string) {
const etag = await db.get('etags', locale);
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();
}

View File

@ -1,81 +1,31 @@
import type { HTMLAttributes } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import type { List as ImmutableList } from 'immutable';
import { isList } from 'immutable';
import { useEmojify } from './hooks';
import type { CustomEmojiMapArg } from './types';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { useEmojiAppState } from './hooks';
import { emojifyElement } from './render';
import type { ExtraCustomEmojiMap } from './types';
type EmojiHTMLProps = Omit<
HTMLAttributes<HTMLDivElement>,
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML'
> & {
htmlString: string;
extraEmojis?: ExtraCustomEmojiMap | ImmutableList<CustomEmoji>;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
};
export const EmojiHTML: React.FC<EmojiHTMLProps> = ({
htmlString,
export const EmojiHTML = <Element extends ElementType>({
extraEmojis,
htmlString,
as: asElement, // Rename for syntax highlighting
...props
}) => {
if (isModernEmojiEnabled()) {
return (
<ModernEmojiHTML
htmlString={htmlString}
extraEmojis={extraEmojis}
{...props}
/>
);
}
return <div dangerouslySetInnerHTML={{ __html: htmlString }} {...props} />;
};
}: EmojiHTMLProps<Element>) => {
const Wrapper = asElement ?? 'div';
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
const ModernEmojiHTML: React.FC<EmojiHTMLProps> = ({
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) {
if (emojifiedHtml === null) {
return null;
}
return <div {...props} dangerouslySetInnerHTML={{ __html: innerHTML }} />;
return (
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
);
};

View File

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

View File

@ -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 { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { toSupportedLocale } from './locale';
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 {
const locale = useAppSelector((state) =>
@ -12,5 +68,10 @@ export function useEmojiAppState(): EmojiAppState {
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'),
};
}

View File

@ -2,12 +2,16 @@ import initialState from '@/mastodon/initial_state';
import { loadWorker } from '@/mastodon/utils/workers';
import { toSupportedLocale } from './locale';
import { emojiLogger } from './utils';
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
let worker: Worker | null = null;
export async function initializeEmoji() {
const log = emojiLogger('index');
export function initializeEmoji() {
log('initializing emojis');
if (!worker && 'Worker' in window) {
try {
worker = loadWorker(new URL('./worker', import.meta.url), {
@ -21,9 +25,16 @@ export async function initializeEmoji() {
if (worker) {
// Assign worker to const to make TS happy inside the event listener.
const thisWorker = worker;
const timeoutId = setTimeout(() => {
log('worker is not ready after timeout');
worker = null;
void fallbackLoad();
}, 500);
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === 'ready') {
log('worker ready, loading data');
clearTimeout(timeoutId);
thisWorker.postMessage('custom');
void loadEmojiLocale(userLocale);
// Load English locale as well, because people are still used to
@ -31,15 +42,22 @@ export async function initializeEmoji() {
if (userLocale !== 'en') {
void loadEmojiLocale('en');
}
} else {
log('got worker message: %s', message);
}
});
} else {
const { importCustomEmojiData } = await import('./loader');
await importCustomEmojiData();
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
}
void fallbackLoad();
}
}
async function fallbackLoad() {
log('falling back to main thread for loading');
const { importCustomEmojiData } = await import('./loader');
await importCustomEmojiData();
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
}
}

View File

@ -2,7 +2,6 @@ import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { isDevelopment } from '@/mastodon/utils/environment';
import {
putEmojiData,
@ -12,6 +11,9 @@ import {
} from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
const log = emojiLogger('loader');
export async function importEmojiData(localeString: string) {
const locale = toSupportedLocale(localeString);
@ -20,6 +22,7 @@ export async function importEmojiData(localeString: string) {
return;
}
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
log('loaded %d for %s locale', flattenedEmojis.length, locale);
await putEmojiData(flattenedEmojis, locale);
}
@ -28,6 +31,7 @@ export async function importCustomEmojiData() {
if (!emojis) {
return;
}
log('loaded %d custom emojis', emojis.length);
await putCustomEmojiData(emojis);
}
@ -41,7 +45,9 @@ async function fetchAndCheckEtag<ResultType extends object[]>(
if (locale === 'custom') {
url.pathname = '/api/v1/custom_emojis';
} 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);

View File

@ -1,94 +1,184 @@
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import { emojifyElement, tokenizeText } from './render';
import type { CustomEmojiData, UnicodeEmojiData } from './types';
import * as db from './database';
import {
emojifyElement,
emojifyText,
testCacheClear,
tokenizeText,
} from './render';
import type { EmojiAppState, ExtraCustomEmojiMap } from './types';
vitest.mock('./database', () => ({
searchCustomEmojisByShortcodes: vitest.fn(
() =>
[
{
shortcode: 'custom',
static_url: 'emoji/static',
url: 'emoji/custom',
category: 'test',
visible_in_picker: true,
},
] satisfies CustomEmojiData[],
),
searchEmojisByHexcodes: vitest.fn(
() =>
[
{
function mockDatabase() {
return {
searchCustomEmojisByShortcodes: vi
.spyOn(db, 'searchCustomEmojisByShortcodes')
.mockResolvedValue([customEmojiFactory()]),
searchEmojisByHexcodes: vi
.spyOn(db, 'searchEmojisByHexcodes')
.mockResolvedValue([
unicodeEmojiFactory({
hexcode: '1F60A',
group: 0,
label: 'smiling face with smiling eyes',
order: 0,
tags: ['smile', 'happy'],
unicode: '😊',
},
{
}),
unicodeEmojiFactory({
hexcode: '1F1EA-1F1FA',
group: 0,
label: 'flag-eu',
order: 0,
tags: ['flag', 'european union'],
unicode: '🇪🇺',
},
] satisfies UnicodeEmojiData[],
),
findMissingLocales: vitest.fn(() => []),
}));
}),
]),
};
}
const expectedSmileImage =
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
const expectedFlagImage =
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
const expectedCustomEmojiImage =
'<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">';
const mockExtraCustom: ExtraCustomEmojiMap = {
remote: {
shortcode: 'remote',
static_url: 'remote.social/static',
url: 'remote.social/custom',
},
};
function testAppState(state: Partial<EmojiAppState> = {}) {
return {
locales: ['en'],
mode: EMOJI_MODE_TWEMOJI,
currentLocale: 'en',
darkTheme: false,
...state,
} satisfies EmojiAppState;
}
describe('emojifyElement', () => {
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">';
const expectedFlagImage =
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
const expectedCustomEmojiImage =
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/static" data-original="emoji/custom" data-static="emoji/static">';
function cloneTestElement() {
return testElement.cloneNode(true) as HTMLElement;
function testElement(text = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>') {
const testElement = document.createElement('div');
testElement.innerHTML = text;
return testElement;
}
afterEach(() => {
testCacheClear();
vi.restoreAllMocks();
});
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 emojifiedElement = await emojifyElement(cloneTestElement(), {
locales: ['en'],
mode: EMOJI_MODE_NATIVE,
currentLocale: 'en',
});
expect(emojifiedElement.innerHTML).toBe(
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 emojifiedElement = await emojifyElement(cloneTestElement(), {
locales: ['en'],
mode: EMOJI_MODE_NATIVE_WITH_FLAGS,
currentLocale: 'en',
});
expect(emojifiedElement.innerHTML).toBe(
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 emojifiedElement = await emojifyElement(cloneTestElement(), {
locales: ['en'],
mode: EMOJI_MODE_TWEMOJI,
currentLocale: 'en',
});
expect(emojifiedElement.innerHTML).toBe(
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
mockDatabase();
const actual = await emojifyElement(testElement(), testAppState());
assert(actual);
expect(actual.innerHTML).toBe(
`<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}`);
});
});

View File

@ -1,8 +1,7 @@
import type { Locale } from 'emojibase';
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
import { autoPlayGif } from '@/mastodon/initial_state';
import { createLimitedCache } from '@/mastodon/utils/cache';
import { assetHost } from '@/mastodon/utils/config';
import * as perf from '@/mastodon/utils/performance';
import {
EMOJI_MODE_NATIVE,
@ -10,13 +9,12 @@ import {
EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM,
EMOJI_STATE_MISSING,
ANY_EMOJI_REGEX,
} from './constants';
import {
findMissingLocales,
searchCustomEmojisByShortcodes,
searchEmojisByHexcodes,
} from './database';
import { loadEmojiLocale } from './index';
import {
emojiToUnicodeHex,
twemojiHasBorder,
@ -34,18 +32,33 @@ import type {
LocaleOrCustom,
UnicodeEmojiToken,
} from './types';
import { stringHasUnicodeFlags } from './utils';
import { emojiLogger, stringHasAnyEmoji, stringHasUnicodeFlags } from './utils';
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
[EMOJI_TYPE_CUSTOM, new Map()],
]);
const log = emojiLogger('render');
// 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>(
element: Element,
appState: EmojiAppState,
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];
while (queue.length > 0) {
const current = queue.shift();
@ -61,7 +74,7 @@ export async function emojifyElement<Element extends HTMLElement>(
current.textContent &&
(current instanceof Text || !current.hasChildNodes())
) {
const renderedContent = await emojifyText(
const renderedContent = await textToElementArray(
current.textContent,
appState,
extraEmojis,
@ -70,7 +83,7 @@ export async function emojifyElement<Element extends HTMLElement>(
if (!(current instanceof Text)) {
current.textContent = null; // Clear the text content if it's not a Text node.
}
current.replaceWith(renderedToHTMLFragment(renderedContent));
current.replaceWith(renderedToHTML(renderedContent));
}
continue;
}
@ -81,6 +94,8 @@ export async function emojifyElement<Element extends HTMLElement>(
}
}
}
updateCache(cacheKey, element.innerHTML);
perf.stop('emojifyElement()');
return element;
}
@ -88,7 +103,54 @@ export async function emojifyText(
text: string,
appState: EmojiAppState,
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.
if (!text.trim()) {
return null;
@ -102,10 +164,9 @@ export async function emojifyText(
}
// Get all emoji from the state map, loading any missing ones.
await ensureLocalesAreLoaded(appState.locales);
await loadMissingEmojiIntoCache(tokens, appState.locales);
await loadMissingEmojiIntoCache(tokens, appState, extraEmojis);
const renderedFragments: (string | HTMLImageElement)[] = [];
const renderedFragments: EmojifiedTextArray = [];
for (const token of tokens) {
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
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 (state && typeof state !== 'string') {
const image = stateToImage(state);
const image = stateToImage(state, appState);
renderedFragments.push(image);
continue;
}
@ -137,21 +198,6 @@ export async function emojifyText(
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)[];
export function tokenizeText(text: string): TokenizedText {
@ -161,7 +207,7 @@ export function tokenizeText(text: string): TokenizedText {
const tokens = [];
let lastIndex = 0;
for (const match of text.matchAll(TOKENIZE_REGEX)) {
for (const match of text.matchAll(ANY_EMOJI_REGEX)) {
if (match.index > lastIndex) {
tokens.push(text.slice(lastIndex, match.index));
}
@ -189,8 +235,18 @@ export function tokenizeText(text: string): TokenizedText {
return tokens;
}
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
[
EMOJI_TYPE_CUSTOM,
createLimitedCache<EmojiState>({ log: log.extend('custom') }),
],
]);
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(
@ -203,7 +259,8 @@ function emojiForLocale(
async function loadMissingEmojiIntoCache(
tokens: TokenizedText,
locales: Locale[],
{ mode, currentLocale }: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap,
) {
const missingUnicodeEmoji = new Set<string>();
const missingCustomEmoji = new Set<string>();
@ -217,42 +274,41 @@ async function loadMissingEmojiIntoCache(
// If this is a custom emoji, check it separately.
if (token.type === EMOJI_TYPE_CUSTOM) {
const code = token.code;
if (code in extraEmojis) {
continue; // We don't care about extra emoji.
}
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
if (!emojiState) {
missingCustomEmoji.add(code);
}
// Otherwise this is a unicode emoji, so check it against all locales.
} else {
} else if (shouldRenderImage(token, mode)) {
const code = emojiToUnicodeHex(token.code);
if (missingUnicodeEmoji.has(code)) {
continue; // Already marked as missing.
}
for (const locale of locales) {
const emojiState = emojiForLocale(code, locale);
if (!emojiState) {
// If it's missing in one locale, we consider it missing for all.
missingUnicodeEmoji.add(code);
}
const emojiState = emojiForLocale(code, currentLocale);
if (!emojiState) {
// If it's missing in one locale, we consider it missing for all.
missingUnicodeEmoji.add(code);
}
}
}
if (missingUnicodeEmoji.size > 0) {
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
for (const locale of locales) {
const emojis = await searchEmojisByHexcodes(missingEmojis, locale);
const cache = cacheForLocale(locale);
for (const emoji of emojis) {
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
}
const notFoundEmojis = missingEmojis.filter((code) =>
emojis.every((emoji) => emoji.hexcode !== code),
);
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.
}
localeCacheMap.set(locale, cache);
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
const cache = cacheForLocale(currentLocale);
for (const emoji of emojis) {
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
}
const notFoundEmojis = missingEmojis.filter((code) =>
emojis.every((emoji) => emoji.hexcode !== code),
);
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.
}
localeCacheMap.set(currentLocale, cache);
}
if (missingCustomEmoji.size > 0) {
@ -288,22 +344,24 @@ function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
return true;
}
function stateToImage(state: EmojiLoadedState) {
function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
const image = document.createElement('img');
image.draggable = false;
image.classList.add('emojione');
if (state.type === EMOJI_TYPE_UNICODE) {
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
if (emojiInfo.hasLightBorder) {
image.dataset.lightCode = `${emojiInfo.hexCode}_BORDER`;
} else if (emojiInfo.hasDarkBorder) {
image.dataset.darkCode = `${emojiInfo.hexCode}_BORDER`;
let fileName = emojiInfo.hexCode;
if (
(appState.darkTheme && emojiInfo.hasDarkBorder) ||
(!appState.darkTheme && emojiInfo.hasLightBorder)
) {
fileName = `${emojiInfo.hexCode}_border`;
}
image.alt = state.data.unicode;
image.title = state.data.label;
image.src = `${assetHost}/emoji/${emojiInfo.hexCode}.svg`;
image.src = `${assetHost}/emoji/${fileName}.svg`;
} else {
// Custom emoji
const shortCode = `:${state.data.shortcode}:`;
@ -318,8 +376,16 @@ function stateToImage(state: EmojiLoadedState) {
return image;
}
function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
const fragment = document.createDocumentFragment();
function renderedToHTML(renderedArray: EmojifiedTextArray): DocumentFragment;
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) {
if (typeof fragmentItem === 'string') {
fragment.appendChild(document.createTextNode(fragmentItem));
@ -329,3 +395,9 @@ function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
}
return fragment;
}
// Testing helpers
export const testCacheClear = () => {
cacheClear();
localeCacheMap.clear();
};

View File

@ -1,6 +1,10 @@
import type { List as ImmutableList } from 'immutable';
import type { FlatCompactEmoji, Locale } from 'emojibase';
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 {
EMOJI_MODE_NATIVE,
@ -22,6 +26,7 @@ export interface EmojiAppState {
locales: Locale[];
currentLocale: Locale;
mode: EmojiMode;
darkTheme: boolean;
}
export interface UnicodeEmojiToken {
@ -45,7 +50,7 @@ export interface EmojiStateUnicode {
}
export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM;
data: CustomEmojiData;
data: CustomEmojiRenderFields;
}
export type EmojiState =
| EmojiStateMissing
@ -53,9 +58,16 @@ export type EmojiState =
| 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 {
hexCode: string;

View File

@ -1,8 +1,14 @@
import { stringHasUnicodeEmoji, stringHasUnicodeFlags } from './utils';
import {
stringHasAnyEmoji,
stringHasCustomEmoji,
stringHasUnicodeEmoji,
stringHasUnicodeFlags,
} from './utils';
describe('stringHasEmoji', () => {
describe('stringHasUnicodeEmoji', () => {
test.concurrent.for([
['only text', false],
['text with non-emoji symbols ™©', false],
['text with emoji 😀', true],
['multiple emojis 😀😃😄', true],
['emoji with skin tone 👍🏽', true],
@ -19,14 +25,14 @@ describe('stringHasEmoji', () => {
['emoji with enclosing keycap #️⃣', true],
['emoji with no visible glyph \u200D', false],
] as const)(
'stringHasEmoji has emojis in "%s": %o',
'stringHasUnicodeEmoji has emojis in "%s": %o',
([text, expected], { expect }) => {
expect(stringHasUnicodeEmoji(text)).toBe(expected);
},
);
});
describe('stringHasFlags', () => {
describe('stringHasUnicodeFlags', () => {
test.concurrent.for([
['EU 🇪🇺', 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();
});
});

View File

@ -1,13 +1,27 @@
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
import debug from 'debug';
export function stringHasUnicodeEmoji(text: string): boolean {
return EMOJI_REGEX.test(text);
import {
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
const EMOJIS_FLAGS_REGEX =
/[\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 {
return EMOJIS_FLAGS_REGEX.test(text);
export function stringHasUnicodeEmoji(input: string): boolean {
return UNICODE_EMOJI_REGEX.test(input);
}
export function stringHasUnicodeFlags(input: string): boolean {
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);
}

View File

@ -5,9 +5,14 @@ self.postMessage('ready'); // After the worker is ready, notify the main thread
function handleMessage(event: MessageEvent<string>) {
const { data: locale } = event;
if (locale !== 'custom') {
void importEmojiData(locale);
} else {
void importCustomEmojiData();
}
void loadData(locale);
}
async function loadData(locale: string) {
if (locale !== 'custom') {
await importEmojiData(locale);
} else {
await importCustomEmojiData();
}
self.postMessage(`loaded ${locale}`);
}

View File

@ -872,12 +872,6 @@
"status.open": "وسّع هذا المنشور",
"status.pin": "دبّسه على الصفحة التعريفية",
"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.reblog": "إعادة النشر",
"status.reblog_private": "إعادة النشر إلى الجمهور الأصلي",

View File

@ -821,7 +821,6 @@
"status.mute_conversation": "Ігнараваць размову",
"status.open": "Разгарнуць гэты допіс",
"status.pin": "Замацаваць у профілі",
"status.quote_post_author": "Допіс карыстальніка @{name}",
"status.read_more": "Чытаць болей",
"status.reblog": "Пашырыць",
"status.reblog_private": "Пашырыць з першапачатковай бачнасцю",

View File

@ -860,12 +860,6 @@
"status.open": "Разширяване на публикацията",
"status.pin": "Закачане в профила",
"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.reblog": "Подсилване",
"status.reblog_private": "Подсилване с оригиналната видимост",

View File

@ -582,7 +582,6 @@
"status.mute_conversation": "Kuzhat ar gaozeadenn",
"status.open": "Digeriñ ar c'hannad-mañ",
"status.pin": "Spilhennañ d'ar profil",
"status.quote_post_author": "Embannadenn gant {name}",
"status.read_more": "Lenn muioc'h",
"status.reblog": "Skignañ",
"status.reblog_private": "Skignañ gant ar weledenn gentañ",

View File

@ -872,12 +872,6 @@
"status.open": "Amplia el tut",
"status.pin": "Fixa en el perfil",
"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.reblog": "Impulsa",
"status.reblog_private": "Impulsa amb la visibilitat original",

View File

@ -498,6 +498,8 @@
"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.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.next": "Další",
"lightbox.previous": "Předchozí",
@ -873,12 +875,11 @@
"status.open": "Rozbalit tento příspěvek",
"status.pin": "Připnout na profil",
"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.pending_approval": "Tento příspěvek čeká na schválení od původního autora.",
"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.removed": "Tento příspěvek byl odstraněn jeho autorem.",
"status.quote_error.unauthorized": "Tento příspěvek nelze zobrazit, protože nemáte oprávnění k jeho zobrazení.",
"status.quote_post_author": "Příspěvek od {name}",
"status.quote_error.not_available": "Příspěvek není dostupný",
"status.quote_error.pending_approval": "Příspěvek čeká na schválení",
"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.pending_approval_popout.title": "Příspěvek čeká na schválení? Buďte klidní",
"status.quote_post_author": "Citovali příspěvek od @{name}",
"status.read_more": "Číst více",
"status.reblog": "Boostnout",
"status.reblog_private": "Boostnout s původní viditelností",

View File

@ -871,12 +871,6 @@
"status.open": "Ehangu'r post hwn",
"status.pin": "Pinio ar y proffil",
"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.reblog": "Hybu",
"status.reblog_private": "Hybu i'r gynulleidfa wreiddiol",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "for at oversætte et indlæg",
"keyboard_shortcuts.unfocus": "Fjern fokus fra tekstskrivningsområde/søgning",
"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.next": "Næste",
"lightbox.previous": "Forrige",
@ -873,12 +875,11 @@
"status.open": "Udvid dette indlæg",
"status.pin": "Fastgør til profil",
"status.quote_error.filtered": "Skjult grundet et af filterne",
"status.quote_error.not_found": "Dette indlæg kan ikke vises.",
"status.quote_error.pending_approval": "Dette indlæg afventer godkendelse fra den oprindelige forfatter.",
"status.quote_error.rejected": "Dette indlæg kan ikke vises, da den oprindelige forfatter ikke tillader citering heraf.",
"status.quote_error.removed": "Dette indlæg er fjernet af forfatteren.",
"status.quote_error.unauthorized": "Dette indlæg kan ikke vises, da man ikke har tilladelse til at se det.",
"status.quote_post_author": "Indlæg fra {name}",
"status.quote_error.not_available": "Indlæg utilgængeligt",
"status.quote_error.pending_approval": "Afventende indlæg",
"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.pending_approval_popout.title": "Afventende citat? Tag det roligt",
"status.quote_post_author": "Citerede et indlæg fra @{name}",
"status.read_more": "Læs mere",
"status.reblog": "Fremhæv",
"status.reblog_private": "Fremhæv med oprindelig synlighed",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "Beitrag übersetzen",
"keyboard_shortcuts.unfocus": "Eingabefeld/Suche nicht mehr fokussieren",
"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.next": "Vor",
"lightbox.previous": "Zurück",
@ -873,12 +875,11 @@
"status.open": "Beitrag öffnen",
"status.pin": "Im Profil anheften",
"status.quote_error.filtered": "Ausgeblendet wegen eines deiner Filter",
"status.quote_error.not_found": "Dieser Beitrag kann nicht angezeigt werden.",
"status.quote_error.pending_approval": "Dieser Beitrag muss noch durch das ursprüngliche Profil genehmigt werden.",
"status.quote_error.rejected": "Dieser Beitrag kann nicht angezeigt werden, weil das ursprüngliche Profil das Zitieren nicht erlaubt.",
"status.quote_error.removed": "Dieser Beitrag wurde durch das Profil entfernt.",
"status.quote_error.unauthorized": "Dieser Beitrag kann nicht angezeigt werden, weil du zum Ansehen nicht berechtigt bist.",
"status.quote_post_author": "Beitrag von {name}",
"status.quote_error.not_available": "Beitrag nicht verfügbar",
"status.quote_error.pending_approval": "Beitragsveröffentlichung ausstehend",
"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.pending_approval_popout.title": "Zitierter Beitrag noch nicht freigegeben? Immer mit der Ruhe",
"status.quote_post_author": "Zitierte einen Beitrag von @{name}",
"status.read_more": "Gesamten Beitrag anschauen",
"status.reblog": "Teilen",
"status.reblog_private": "Mit der ursprünglichen Zielgruppe teilen",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "για να μεταφραστεί μια ανάρτηση",
"keyboard_shortcuts.unfocus": "Αποεστίαση του πεδίου σύνθεσης/αναζήτησης",
"keyboard_shortcuts.up": "Μετακίνηση προς τα πάνω στη λίστα",
"learn_more_link.got_it": "Το κατάλαβα",
"learn_more_link.learn_more": "Μάθε περισσότερα",
"lightbox.close": "Κλείσιμο",
"lightbox.next": "Επόμενο",
"lightbox.previous": "Προηγούμενο",
@ -873,12 +875,11 @@
"status.open": "Επέκταση ανάρτησης",
"status.pin": "Καρφίτσωσε στο προφίλ",
"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.quote_error.not_available": "Ανάρτηση μη διαθέσιμη",
"status.quote_error.pending_approval": "Ανάρτηση σε αναμονή",
"status.quote_error.pending_approval_popout.body": "Οι παραθέσεις που μοιράζονται στο Fediverse μπορεί να χρειαστούν χρόνο για να εμφανιστούν, καθώς διαφορετικοί διακομιστές έχουν διαφορετικά πρωτόκολλα.",
"status.quote_error.pending_approval_popout.title": "Παράθεση σε εκκρεμότητα; Μείνετε ψύχραιμοι",
"status.quote_post_author": "Παρατίθεται μια ανάρτηση από @{name}",
"status.read_more": "Διάβασε περισότερα",
"status.reblog": "Ενίσχυση",
"status.reblog_private": "Ενίσχυση με αρχική ορατότητα",

View File

@ -871,12 +871,6 @@
"status.open": "Expand this post",
"status.pin": "Pin on profile",
"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.reblog": "Boost",
"status.reblog_private": "Boost with original visibility",

View File

@ -851,9 +851,6 @@
"status.mute_conversation": "Silentigi konversacion",
"status.open": "Pligrandigu ĉi tiun afiŝon",
"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.reblog": "Diskonigi",
"status.reblog_private": "Diskonigi kun la sama videbleco",

View File

@ -498,6 +498,8 @@
"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.up": "Subir en la lista",
"learn_more_link.got_it": "Entendido",
"learn_more_link.learn_more": "Aprendé más",
"lightbox.close": "Cerrar",
"lightbox.next": "Siguiente",
"lightbox.previous": "Anterior",
@ -873,12 +875,11 @@
"status.open": "Expandir este mensaje",
"status.pin": "Fijar en el perfil",
"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.pending_approval": "Este mensaje está pendiente de aprobación del autor original.",
"status.quote_error.rejected": "No se puede mostrar este mensaje, ya que el autor original no permite que se cite.",
"status.quote_error.removed": "Este mensaje fue eliminado por su autor.",
"status.quote_error.unauthorized": "No se puede mostrar este mensaje, ya que no tenés autorización para verlo.",
"status.quote_post_author": "Mensaje de @{name}",
"status.quote_error.not_available": "Mensaje no disponible",
"status.quote_error.pending_approval": "Mensaje pendiente",
"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.pending_approval_popout.title": "¿Cita pendiente? Esperá un momento",
"status.quote_post_author": "Se citó un mensaje de @{name}",
"status.read_more": "Leé más",
"status.reblog": "Adherir",
"status.reblog_private": "Adherir a la audiencia original",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "para traducir una publicación",
"keyboard_shortcuts.unfocus": "Desenfocar área de redacción/búsqueda",
"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.next": "Siguiente",
"lightbox.previous": "Anterior",
@ -873,12 +875,11 @@
"status.open": "Expandir estado",
"status.pin": "Fijar",
"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.pending_approval": "Esta publicación está pendiente de aprobación del autor original.",
"status.quote_error.rejected": "No se puede mostrar esta publicación, puesto que el autor original no permite que sea citado.",
"status.quote_error.removed": "Esta publicación fue eliminada por su autor.",
"status.quote_error.unauthorized": "No se puede mostrar esta publicación, puesto que no estás autorizado a verla.",
"status.quote_post_author": "Publicado por {name}",
"status.quote_error.not_available": "Publicación no disponible",
"status.quote_error.pending_approval": "Publicación pendiente",
"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.pending_approval_popout.title": "¿Cita pendiente? Mantén la calma",
"status.quote_post_author": "Ha citado una publicación de @{name}",
"status.read_more": "Leer más",
"status.reblog": "Impulsar",
"status.reblog_private": "Implusar a la audiencia original",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "para traducir una publicación",
"keyboard_shortcuts.unfocus": "Quitar el foco de la caja de redacción/búsqueda",
"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.next": "Siguiente",
"lightbox.previous": "Anterior",
@ -873,12 +875,11 @@
"status.open": "Expandir publicación",
"status.pin": "Fijar",
"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.pending_approval": "Esta publicación está pendiente de aprobación del autor original.",
"status.quote_error.rejected": "Esta publicación no puede mostrarse porque el autor original no permite que se cite.",
"status.quote_error.removed": "Esta publicación fue eliminada por su autor.",
"status.quote_error.unauthorized": "Esta publicación no puede mostrarse, ya que no estás autorizado a verla.",
"status.quote_post_author": "Publicación de {name}",
"status.quote_error.not_available": "Publicación no disponible",
"status.quote_error.pending_approval": "Publicación pendiente",
"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.pending_approval_popout.title": "¿Cita pendiente? Mantén la calma",
"status.quote_post_author": "Ha citado una publicación de @{name}",
"status.read_more": "Leer más",
"status.reblog": "Impulsar",
"status.reblog_private": "Impulsar a la audiencia original",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "postituse tõlkimiseks",
"keyboard_shortcuts.unfocus": "Fookus tekstialalt/otsingult ära",
"keyboard_shortcuts.up": "Liigu loetelus üles",
"learn_more_link.got_it": "Sain aru",
"learn_more_link.learn_more": "Lisateave",
"lightbox.close": "Sulge",
"lightbox.next": "Järgmine",
"lightbox.previous": "Eelmine",
@ -873,12 +875,11 @@
"status.open": "Laienda postitus",
"status.pin": "Kinnita profiilile",
"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.pending_approval": "See postitus on algse autori kinnituse ootel.",
"status.quote_error.rejected": "Seda postitust ei saa näidata, kuina algne autor ei luba teda tsiteerida.",
"status.quote_error.removed": "Autor kustutas selle postituse.",
"status.quote_error.unauthorized": "Kuna sul pole luba selle postituse nägemiseks, siis seda ei saa kuvada.",
"status.quote_post_author": "Postitajaks {name}",
"status.quote_error.not_available": "Postitus pole saadaval",
"status.quote_error.pending_approval": "Postitus on ootel",
"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.pending_approval_popout.title": "Tsiteerimine on ootel? Palun jää rahulikuks",
"status.quote_post_author": "Tsiteeris kasutaja @{name} postitust",
"status.read_more": "Loe veel",
"status.reblog": "Jaga",
"status.reblog_private": "Jaga algse nähtavusega",

View File

@ -844,8 +844,6 @@
"status.mute_conversation": "Mututu elkarrizketa",
"status.open": "Hedatu bidalketa hau",
"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.reblog": "Bultzada",
"status.reblog_private": "Bultzada jatorrizko hartzaileei",

View File

@ -873,12 +873,6 @@
"status.open": "گسترش این فرسته",
"status.pin": "سنجاق به نمایه",
"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.reblog": "تقویت",
"status.reblog_private": "تقویت برای مخاطبان نخستین",

View File

@ -311,7 +311,7 @@
"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_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.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ä.",
@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "Käännä julkaisu",
"keyboard_shortcuts.unfocus": "Poistu kirjoitus- tai hakukentästä",
"keyboard_shortcuts.up": "Siirry luettelossa taaksepäin",
"learn_more_link.got_it": "Selvä",
"learn_more_link.learn_more": "Lue lisää",
"lightbox.close": "Sulje",
"lightbox.next": "Seuraava",
"lightbox.previous": "Edellinen",
@ -754,7 +756,7 @@
"reply_indicator.cancel": "Peruuta",
"reply_indicator.poll": "Äänestys",
"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.other": "Muu",
"report.categories.spam": "Roskaposti",
@ -873,12 +875,11 @@
"status.open": "Laajenna julkaisu",
"status.pin": "Kiinnitä profiiliin",
"status.quote_error.filtered": "Piilotettu jonkin asettamasi suodattimen takia",
"status.quote_error.not_found": "Tätä julkaisua ei voi näyttää.",
"status.quote_error.pending_approval": "Tämä julkaisu odottaa alkuperäisen tekijänsä hyväksyntää.",
"status.quote_error.rejected": "Tätä julkaisua ei voi näyttää, sillä sen alkuperäinen tekijä ei salli lainattavan julkaisua.",
"status.quote_error.removed": "Tekijä on poistanut julkaisun.",
"status.quote_error.unauthorized": "Tätä julkaisua ei voi näyttää, koska sinulla ei ole oikeutta tarkastella sitä.",
"status.quote_post_author": "Julkaisu käyttäjältä {name}",
"status.quote_error.not_available": "Julkaisu ei saatavilla",
"status.quote_error.pending_approval": "Julkaisu odottaa",
"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.pending_approval_popout.title": "Odottava lainaus? Pysy rauhallisena",
"status.quote_post_author": "Lainaa käyttäjän @{name} julkaisua",
"status.read_more": "Näytä enemmän",
"status.reblog": "Tehosta",
"status.reblog_private": "Tehosta alkuperäiselle yleisölle",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "at umseta ein post",
"keyboard_shortcuts.unfocus": "Tak skrivi-/leiti-økið úr miðdeplinum",
"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.next": "Fram",
"lightbox.previous": "Aftur",
@ -873,12 +875,11 @@
"status.open": "Víðka henda postin",
"status.pin": "Ger fastan í vangan",
"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.pending_approval": "Hesin posturin bíðar eftir góðkenning frá upprunahøvundinum.",
"status.quote_error.rejected": "Hesin posturin kann ikki vísast, tí upprunahøvundurin loyvir ikki at posturin verður siteraður.",
"status.quote_error.removed": "Hesin posturin var strikaður av høvundinum.",
"status.quote_error.unauthorized": "Hesin posturin kann ikki vísast, tí tú hevur ikki rættindi at síggja hann.",
"status.quote_post_author": "Postur hjá @{name}",
"status.quote_error.not_available": "Postur ikki tøkur",
"status.quote_error.pending_approval": "Postur bíðar",
"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.pending_approval_popout.title": "Bíðar eftir sitati? Tak tað róligt",
"status.quote_post_author": "Siteraði ein post hjá @{name}",
"status.read_more": "Les meira",
"status.reblog": "Stimbra",
"status.reblog_private": "Stimbra við upprunasýni",

View File

@ -864,9 +864,6 @@
"status.mute_conversation": "Masquer la conversation",
"status.open": "Afficher la publication entière",
"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.reblog": "Booster",
"status.reblog_private": "Booster avec visibilité originale",

View File

@ -864,9 +864,6 @@
"status.mute_conversation": "Masquer la conversation",
"status.open": "Afficher le message entier",
"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.reblog": "Partager",
"status.reblog_private": "Partager à laudience originale",

View File

@ -873,12 +873,6 @@
"status.open": "Dit berjocht útklappe",
"status.pin": "Op profylside fêstsette",
"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.reblog": "Booste",
"status.reblog_private": "Boost nei oarspronklike ûntfangers",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "post a aistriú",
"keyboard_shortcuts.unfocus": "Unfocus cum textarea/search",
"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.next": "An céad eile",
"lightbox.previous": "Roimhe seo",
@ -873,12 +875,11 @@
"status.open": "Leathnaigh an post seo",
"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.not_found": "Ní féidir an post seo a thaispeáint.",
"status.quote_error.pending_approval": "Tá an post seo ag feitheamh ar cheadú ón údar bunaidh.",
"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.removed": "Baineadh an post seo ag a údar.",
"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": "Postáil le {name}",
"status.quote_error.not_available": "Níl an postáil ar fáil",
"status.quote_error.pending_approval": "Post ar feitheamh",
"status.quote_error.pending_approval_popout.body": "Dfhé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.pending_approval_popout.title": "Ag fanacht le luachan? Fan socair",
"status.quote_post_author": "Luaigh mé post le @{name}",
"status.read_more": "Léan a thuilleadh",
"status.reblog": "Treisiú",
"status.reblog_private": "Mol le léargas bunúsach",

View File

@ -871,12 +871,6 @@
"status.open": "Leudaich am post seo",
"status.pin": "Prìnich ris a phròifil",
"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.reblog": "Brosnaich",
"status.reblog_private": "Brosnaich leis an t-so-fhaicsinneachd tùsail",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "para traducir unha publicación",
"keyboard_shortcuts.unfocus": "Para deixar de destacar a área de escritura/procura",
"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.next": "Seguinte",
"lightbox.previous": "Anterior",
@ -873,12 +875,11 @@
"status.open": "Estender esta publicación",
"status.pin": "Fixar no perfil",
"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.pending_approval": "A publicación está pendente da aprobación pola autora orixinal.",
"status.quote_error.rejected": "Non se pode mostrar esta publicación xa que a autora orixinal non permite que se cite.",
"status.quote_error.removed": "Publicación eliminada pola autora.",
"status.quote_error.unauthorized": "Non se pode mostrar esta publicación porque non tes permiso para vela.",
"status.quote_post_author": "Publicación de {name}",
"status.quote_error.not_available": "Publicación non dispoñible",
"status.quote_error.pending_approval": "Publicación pendente",
"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.pending_approval_popout.title": "Cita pendente? Non te apures",
"status.quote_post_author": "Citou unha publicación de @{name}",
"status.read_more": "Ler máis",
"status.reblog": "Promover",
"status.reblog_private": "Compartir coa audiencia orixinal",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "לתרגם הודעה",
"keyboard_shortcuts.unfocus": "לצאת מתיבת חיבור/חיפוש",
"keyboard_shortcuts.up": "לנוע במעלה הרשימה",
"learn_more_link.got_it": "הבנתי",
"learn_more_link.learn_more": "למידע נוסף",
"lightbox.close": "סגירה",
"lightbox.next": "הבא",
"lightbox.previous": "הקודם",
@ -873,12 +875,11 @@
"status.open": "הרחבת הודעה זו",
"status.pin": "הצמדה לפרופיל שלי",
"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.quote_error.not_available": "ההודעה לא זמינה",
"status.quote_error.pending_approval": "ההודעה בהמתנה לאישור",
"status.quote_error.pending_approval_popout.body": "ציטוטים ששותפו בפדיוורס עשויים להתפרסם אחרי עיכוב קל, כיוון ששרתים שונים משתמשים בפרוטוקולים שונים.",
"status.quote_error.pending_approval_popout.title": "ההודעה בהמתנה? המתינו ברוגע",
"status.quote_post_author": "ההודעה צוטטה על ידי @{name}",
"status.read_more": "לקרוא עוד",
"status.reblog": "הדהוד",
"status.reblog_private": "להדהד ברמת הנראות המקורית",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "Bejegyzés lefordítása",
"keyboard_shortcuts.unfocus": "Szerkesztés/keresés fókuszból való kivétele",
"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.next": "Következő",
"lightbox.previous": "Előző",
@ -873,12 +875,11 @@
"status.open": "Bejegyzés kibontása",
"status.pin": "Kitűzés a profilodra",
"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.pending_approval": "Ez a bejegyzés az eredeti szerző jóváhagyására vár.",
"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.removed": "Ezt a bejegyzés eltávolította a szerzője.",
"status.quote_error.unauthorized": "Ez a bejegyzés nem jeleníthető meg, mert nem jogosult a megtekintésére.",
"status.quote_post_author": "Szerző: {name}",
"status.quote_error.not_available": "A bejegyzés nem érhető el",
"status.quote_error.pending_approval": "A bejegyzés függőben van",
"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.pending_approval_popout.title": "Függőben lévő idézet? Maradj nyugodt.",
"status.quote_post_author": "Idézte @{name} bejegyzését",
"status.read_more": "Bővebben",
"status.reblog": "Megtolás",
"status.reblog_private": "Megtolás az eredeti közönségnek",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "að þýða færslu",
"keyboard_shortcuts.unfocus": "Taka virkni úr textainnsetningarreit eða leit",
"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.next": "Næsta",
"lightbox.previous": "Fyrra",
@ -873,12 +875,11 @@
"status.open": "Opna þessa færslu",
"status.pin": "Festa á notandasnið",
"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.pending_approval": "Þessi færsla bíður eftir samþykki frá upprunalegum höfundi hennar.",
"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.removed": "Þessi færsla var fjarlægð af höfundi hennar.",
"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": "Færsla frá {name}",
"status.quote_error.not_available": "Færsla ekki tiltæk",
"status.quote_error.pending_approval": "Færsla í bið",
"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.pending_approval_popout.title": "Færsla í bið? Verum róleg",
"status.quote_post_author": "Vitnaði í færslu frá @{name}",
"status.read_more": "Lesa meira",
"status.reblog": "Endurbirting",
"status.reblog_private": "Endurbirta til upphaflegra lesenda",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "Traduce un post",
"keyboard_shortcuts.unfocus": "Rimuove il focus sull'area di composizione testuale/ricerca",
"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.next": "Successivo",
"lightbox.previous": "Precedente",
@ -873,12 +875,11 @@
"status.open": "Espandi questo post",
"status.pin": "Fissa in cima sul profilo",
"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.pending_approval": "Questo post è in attesa di approvazione dell'autore originale.",
"status.quote_error.rejected": "Questo post non può essere visualizzato perché l'autore originale non consente che venga citato.",
"status.quote_error.removed": "Questo post è stato rimosso dal suo autore.",
"status.quote_error.unauthorized": "Questo post non può essere visualizzato in quanto non sei autorizzato a visualizzarlo.",
"status.quote_post_author": "Post di @{name}",
"status.quote_error.not_available": "Post non disponibile",
"status.quote_error.pending_approval": "Post in attesa",
"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.pending_approval_popout.title": "Citazione in attesa? Resta calmo",
"status.quote_post_author": "Citato un post di @{name}",
"status.read_more": "Leggi di più",
"status.reblog": "Reblog",
"status.reblog_private": "Reblog con visibilità originale",

View File

@ -868,12 +868,6 @@
"status.open": "詳細を表示",
"status.pin": "プロフィールに固定表示",
"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.reblog": "ブースト",
"status.reblog_private": "ブースト",

View File

@ -623,7 +623,6 @@
"status.mute_conversation": "Sgugem adiwenni",
"status.open": "Semɣeṛ tasuffeɣt-ayi",
"status.pin": "Senteḍ-itt deg umaɣnu",
"status.quote_post_author": "Izen sɣur {name}",
"status.read_more": "Issin ugar",
"status.reblog": "Bḍu",
"status.reblogged_by": "Yebḍa-tt {name}",

View File

@ -871,12 +871,6 @@
"status.open": "상세 정보 표시",
"status.pin": "고정",
"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.reblog": "부스트",
"status.reblog_private": "원래의 수신자들에게 부스트",

View File

@ -738,12 +738,6 @@
"status.mute_conversation": "Apklusināt sarunu",
"status.open": "Izvērst šo ierakstu",
"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.reblog": "Pastiprināt",
"status.reblog_private": "Pastiprināt ar sākotnējo redzamību",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "kā PO文翻譯",
"keyboard_shortcuts.unfocus": "離開輸入框仔tshiau-tshuē格仔",
"keyboard_shortcuts.up": "佇列單內kā suá khah面頂",
"learn_more_link.got_it": "知矣",
"learn_more_link.learn_more": "看詳細",
"lightbox.close": "關",
"lightbox.next": "下tsi̍t ê",
"lightbox.previous": "頂tsi̍t ê",
@ -872,12 +874,8 @@
"status.mute_conversation": "Kā對話消音",
"status.open": "Kā PO文展開",
"status.quote_error.filtered": "Lí所設定ê過濾器kā tse khàm起來",
"status.quote_error.not_found": "Tsit篇PO文bē當顯示。",
"status.quote_error.pending_approval": "Tsit篇PO文teh等原作者審查。",
"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.quote_error.not_available": "鋪文bē當看",
"status.quote_error.pending_approval": "鋪文當咧送",
"status.read_more": "讀詳細",
"status.reblog": "轉送",
"status.reblog_private": "照原PO ê通看見ê範圍轉送",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "om een bericht te vertalen",
"keyboard_shortcuts.unfocus": "Tekst- en zoekveld ontfocussen",
"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.next": "Volgende",
"lightbox.previous": "Vorige",
@ -873,12 +875,11 @@
"status.open": "Volledig bericht tonen",
"status.pin": "Aan profielpagina vastmaken",
"status.quote_error.filtered": "Verborgen door een van je filters",
"status.quote_error.not_found": "Dit bericht kan niet worden weergegeven.",
"status.quote_error.pending_approval": "Dit bericht is in afwachting van goedkeuring door de oorspronkelijke auteur.",
"status.quote_error.rejected": "Dit bericht kan niet worden weergegeven omdat de oorspronkelijke auteur niet toestaat dat het wordt geciteerd.",
"status.quote_error.removed": "Dit bericht is verwijderd door de auteur.",
"status.quote_error.unauthorized": "Dit bericht kan niet worden weergegeven omdat je niet bevoegd bent om het te bekijken.",
"status.quote_post_author": "Bericht van {name}",
"status.quote_error.not_available": "Bericht niet beschikbaar",
"status.quote_error.pending_approval": "Bericht in afwachting",
"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.pending_approval_popout.title": "Even geduld wanneer het citaat nog moet worden goedgekeurd.",
"status.quote_post_author": "Citeerde een bericht van @{name}",
"status.read_more": "Meer lezen",
"status.reblog": "Boosten",
"status.reblog_private": "Boost naar oorspronkelijke ontvangers",

View File

@ -871,12 +871,6 @@
"status.open": "Utvid denne statusen",
"status.pin": "Fest på profil",
"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.reblog": "Framhev",
"status.reblog_private": "Framhev til dei originale mottakarane",

View File

@ -839,9 +839,6 @@
"status.open": "Utvid dette innlegget",
"status.pin": "Fest på profilen",
"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.reblog": "Fremhev",
"status.reblog_private": "Fremhev til det opprinnelige publikummet",

View File

@ -854,12 +854,6 @@
"status.open": "Abrir toot",
"status.pin": "Fixar",
"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.reblog": "Dar boost",
"status.reblog_private": "Dar boost para o mesmo público",

View File

@ -873,12 +873,6 @@
"status.open": "Expandir esta publicação",
"status.pin": "Afixar no perfil",
"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.reblog": "Impulsionar",
"status.reblog_private": "Impulsionar com a visibilidade original",

View File

@ -871,12 +871,6 @@
"status.open": "Открыть пост",
"status.pin": "Закрепить в профиле",
"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.reblog": "Продвинуть",
"status.reblog_private": "Продвинуть для своей аудитории",

View File

@ -864,12 +864,6 @@
"status.open": "Zgjeroje këtë mesazh",
"status.pin": "Fiksoje në profil",
"status.quote_error.filtered": "Fshehur për shkak të njërit nga filtrat tuaj",
"status.quote_error.not_found": "Ky postim smund të shfaqet.",
"status.quote_error.pending_approval": "Ky postim është në pritje të miratimit nga autori origjinal.",
"status.quote_error.rejected": "Ky postim smund 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 smund të shfaqet, ngaqë sjeni i autorizuar ta shihni.",
"status.quote_post_author": "Postim nga {name}",
"status.read_more": "Lexoni më tepër",
"status.reblog": "Përforcojeni",
"status.reblog_private": "Përforcim për publikun origjinal",

View File

@ -873,12 +873,6 @@
"status.open": "Utvidga detta inlägg",
"status.pin": "Fäst i profil",
"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.reblog": "Boosta",
"status.reblog_private": "Boosta med ursprunglig synlighet",

View File

@ -832,7 +832,6 @@
"status.mute_conversation": "ซ่อนการสนทนา",
"status.open": "ขยายโพสต์นี้",
"status.pin": "ปักหมุดในโปรไฟล์",
"status.quote_post_author": "โพสต์โดย {name}",
"status.read_more": "อ่านเพิ่มเติม",
"status.reblog": "ดัน",
"status.reblog_private": "ดันด้วยการมองเห็นดั้งเดิม",

View File

@ -873,12 +873,6 @@
"status.open": "Bu gönderiyi genişlet",
"status.pin": "Profile sabitle",
"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.reblog": "Yeniden paylaş",
"status.reblog_private": "Özgün görünürlük ile yeniden paylaş",

View File

@ -468,6 +468,8 @@
"keyboard_shortcuts.translate": "перекласти допис",
"keyboard_shortcuts.unfocus": "Розфокусуватися з нового допису чи пошуку",
"keyboard_shortcuts.up": "Рухатися вгору списком",
"learn_more_link.got_it": "Зрозуміло",
"learn_more_link.learn_more": "Докладніше",
"lightbox.close": "Закрити",
"lightbox.next": "Далі",
"lightbox.previous": "Назад",
@ -843,7 +845,8 @@
"status.open": "Розгорнути допис",
"status.pin": "Закріпити у профілі",
"status.quote_error.filtered": "Приховано через один з ваших фільтрів",
"status.quote_post_author": "@{name} опублікував допис",
"status.quote_error.not_available": "Пост недоступний",
"status.quote_post_author": "Цитований допис @{name}",
"status.read_more": "Дізнатися більше",
"status.reblog": "Поширити",
"status.reblog_private": "Поширити для початкової аудиторії",

View File

@ -498,6 +498,8 @@
"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.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.next": "Tiếp",
"lightbox.previous": "Trước",
@ -873,12 +875,11 @@
"status.open": "Mở tút",
"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.not_found": "Tút này không thể hiển thị.",
"status.quote_error.pending_approval": "Tút này cần chờ cho phép từ người đăng.",
"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.removed": "Tút này đã bị người đăng xóa.",
"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": "Tút của {name}",
"status.quote_error.not_available": "Tút không khả dụng",
"status.quote_error.pending_approval": "Tút đang chờ duyệt",
"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.pending_approval_popout.title": "Đang chờ trích dẫn? Hãy bình tĩnh",
"status.quote_post_author": "Trích dẫn từ tút của @{name}",
"status.read_more": "Đọc tiếp",
"status.reblog": "Đăng lại",
"status.reblog_private": "Đăng lại (Riêng tư)",

View File

@ -862,12 +862,6 @@
"status.open": "展开嘟文",
"status.pin": "在个人资料页面置顶",
"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.reblog": "转嘟",
"status.reblog_private": "以相同可见性转嘟",

View File

@ -498,6 +498,8 @@
"keyboard_shortcuts.translate": "翻譯嘟文",
"keyboard_shortcuts.unfocus": "跳離文字撰寫區塊或搜尋框",
"keyboard_shortcuts.up": "向上移動",
"learn_more_link.got_it": "了解",
"learn_more_link.learn_more": "了解更多",
"lightbox.close": "關閉",
"lightbox.next": "下一步",
"lightbox.previous": "上一步",
@ -873,12 +875,11 @@
"status.open": "展開此嘟文",
"status.pin": "釘選至個人檔案頁面",
"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.quote_error.not_available": "無法取得該嘟文",
"status.quote_error.pending_approval": "嘟文正在發送中",
"status.quote_error.pending_approval_popout.body": "因為伺服器間可能運行不同協定,顯示聯邦宇宙間之引用嘟文會有些許延遲。",
"status.quote_error.pending_approval_popout.title": "引用嘟文正在發送中?別著急,請稍候片刻",
"status.quote_post_author": "已引用 @{name} 之嘟文",
"status.read_more": "閱讀更多",
"status.reblog": "轉嘟",
"status.reblog_private": "依照原嘟可見性轉嘟",

View File

@ -2,10 +2,10 @@ import { createRoot } from 'react-dom/client';
import { Globals } from '@react-spring/web';
import * as perf from '@/mastodon/utils/performance';
import { setupBrowserNotifications } from 'mastodon/actions/notifications';
import Mastodon from 'mastodon/containers/mastodon';
import { me, reduceMotion } from 'mastodon/initial_state';
import * as perf from 'mastodon/performance';
import ready from 'mastodon/ready';
import { store } from 'mastodon/store';
@ -35,7 +35,7 @@ function main() {
if (isModernEmojiEnabled()) {
const { initializeEmoji } = await import('@/mastodon/features/emoji');
await initializeEmoji();
initializeEmoji();
}
const root = createRoot(mountNode);

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

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

View File

@ -19,5 +19,12 @@ export function isFeatureEnabled(feature: Features) {
}
export function isModernEmojiEnabled() {
return isFeatureEnabled('modern_emojis') && isDevelopment();
try {
return (
isFeatureEnabled('modern_emojis') &&
localStorage.getItem('experiments')?.split(',').includes('modern_emojis')
);
} catch {
return false;
}
}

View File

@ -4,15 +4,15 @@
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()) {
marky.mark(name);
}
}
export function stop(name) {
export function stop(name: string) {
if (isDevelopment()) {
marky.stop(name);
}

View File

@ -1,4 +1,8 @@
import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships';
import type {
CustomEmojiData,
UnicodeEmojiData,
} from '@/mastodon/features/emoji/types';
import { createAccountFromServerJSON } from '@/mastodon/models/account';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
@ -68,3 +72,26 @@ export const relationshipsFactory: FactoryFunction<ApiRelationshipJSON> = ({
showing_reblogs: true,
...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,
};
}

View File

@ -230,7 +230,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
return if @quote_uri.blank?
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?)
end

View File

@ -152,9 +152,6 @@ class ActivityPub::Parser::StatusParser
# Remove the special-meaning 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
flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:unknown] unless allowed_actors.empty?

View File

@ -51,6 +51,13 @@ class ActivityPub::TagManager
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)
[uri_for(target), '#main-key'].join
end

View File

@ -3,14 +3,18 @@
class DeliveryFailureTracker
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
@resolution = resolution
end
def track_failure!
redis.sadd(exhausted_deliveries_key, today)
redis.sadd(exhausted_deliveries_key, failure_time)
UnavailableDomain.create(domain: @host) if reached_failure_threshold?
end
@ -24,6 +28,12 @@ class DeliveryFailureTracker
end
def days
raise TypeError, 'resolution is not in days' unless @resolution == :days
failures
end
def failures
redis.scard(exhausted_deliveries_key) || 0
end
@ -32,7 +42,7 @@ class DeliveryFailureTracker
end
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
alias reset! track_success!
@ -89,11 +99,16 @@ class DeliveryFailureTracker
"exhausted_deliveries:#{@host}"
end
def today
Time.now.utc.strftime('%Y%m%d')
def failure_time
case @resolution
when :days
Time.now.utc.strftime('%Y%m%d')
when :minutes
Time.now.utc.strftime('%Y%m%d%H%M')
end
end
def reached_failure_threshold?
days >= FAILURE_DAYS_THRESHOLD
failures >= FAILURE_THRESHOLDS[@resolution]
end
end

View File

@ -33,16 +33,8 @@ module Status::InteractionPolicyConcern
automatic_policy = quote_approval_policy >> 16
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])
# 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])
following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil?
return :automatic if following_author

View File

@ -73,6 +73,9 @@ class Notification < ApplicationRecord
'admin.report': {
filterable: false,
}.freeze,
quote: {
filterable: true,
}.freeze,
}.freeze
TYPES = PROPERTIES.keys.freeze
@ -81,6 +84,7 @@ class Notification < ApplicationRecord
status: :status,
reblog: [status: :reblog],
mention: [mention: :status],
quote: [quote: :status],
favourite: [favourite: :status],
poll: [poll: :status],
update: :status,
@ -102,6 +106,7 @@ class Notification < ApplicationRecord
belongs_to :account_relationship_severance_event, inverse_of: false
belongs_to :account_warning, inverse_of: false
belongs_to :generated_annual_report, inverse_of: false
belongs_to :quote, inverse_of: :notification
end
validates :type, inclusion: { in: TYPES }
@ -122,6 +127,8 @@ class Notification < ApplicationRecord
favourite&.status
when :mention
mention&.status
when :quote
quote&.status
when :poll
poll&.status
end
@ -174,6 +181,8 @@ class Notification < ApplicationRecord
notification.mention.status = cached_status
when :poll
notification.poll.status = cached_status
when :quote
notification.quote.status = cached_status
end
end
@ -192,7 +201,7 @@ class Notification < ApplicationRecord
return unless new_record?
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
when 'Mention'
self.from_account_id = activity&.status&.account_id

View File

@ -49,6 +49,6 @@ class NotificationRequest < ApplicationRecord
private
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

View File

@ -17,6 +17,10 @@
# status_id :bigint(8) not null
#
class Quote < ApplicationRecord
include Paginable
has_one :notification, as: :activity, dependent: :destroy
BACKGROUND_REFRESH_INTERVAL = 1.week.freeze
REFRESH_DEADLINE = 6.hours
@ -33,6 +37,7 @@ class Quote < ApplicationRecord
before_validation :set_accounts
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 :approval_uri, absence: true, if: -> { quoted_account&.local? }
validate :validate_visibility
def accept!

View 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

View File

@ -21,7 +21,7 @@ class StatusPolicy < ApplicationPolicy
# This is about requesting a quote post, not validating it
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
def reblog?
@ -36,6 +36,10 @@ class StatusPolicy < ApplicationPolicy
owned?
end
def list_quotes?
owned?
end
alias unreblog? destroy?
def update?

View File

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

View File

@ -204,7 +204,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
def quote_authorization?
object.quote&.approval_uri.present?
object.quote.present? && ActivityPub::TagManager.instance.approval_uri_for(object.quote).present?
end
def quote
@ -213,8 +213,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
def quote_authorization
# TODO: approval of local quotes may work differently, perhaps?
object.quote.approval_uri
ActivityPub::TagManager.instance.approval_uri_for(object.quote)
end
class MediaAttachmentSerializer < ActivityPub::Serializer

View File

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

View File

@ -21,7 +21,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
end
def status_type?
[:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
[:favourite, :reblog, :status, :mention, :poll, :update, :quote].include?(object.type)
end
def report_type?

View File

@ -278,10 +278,10 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
return unless quote_uri.present? && @status.quote.present?
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 = 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
@ -293,11 +293,13 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
if quote_uri.present?
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 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
# 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
quote = Quote.create(status: @status, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?)
@quote_changed = true

View File

@ -13,6 +13,7 @@ class ActivityPub::VerifyQuoteService < BaseService
@fetching_error = nil
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?
@json = fetch_approval_object(quote.approval_uri, prefetched_body: prefetched_approval)
@ -34,6 +35,15 @@ class ActivityPub::VerifyQuoteService < BaseService
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
def fast_track_approval!
return false if @quote.quoted_status_id.blank?
@ -45,14 +55,7 @@ class ActivityPub::VerifyQuoteService < BaseService
true
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
end
false
end
def fetch_approval_object(uri, prefetched_body: nil)

View File

@ -40,6 +40,7 @@ class FanOutOnWriteService < BaseService
deliver_to_self!
unless @options[:skip_notifications]
notify_quoted_account!
notify_mentioned_accounts!
notify_about_update! if update?
end
@ -69,6 +70,12 @@ class FanOutOnWriteService < BaseService
FeedManager.instance.push_to_home(@account, @status, update: update?) if @account.local?
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!
@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|

View File

@ -247,7 +247,7 @@ class NotifyService < BaseService
end
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.last_status_id = @notification.target_status.id

View File

@ -47,6 +47,9 @@ class RemoveStatusService < BaseService
remove_media
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?
end
end

View 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

View File

@ -78,7 +78,7 @@
%h3= t('admin.instances.availability.title')
%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
%ul.availability-indicator__graphic

View File

@ -2047,8 +2047,6 @@ ar:
ownership: لا يمكن تثبيت منشور نشره شخص آخر
reblog: لا يمكن تثبيت إعادة نشر
quote_policies:
followers: المتابعين والمستخدمين المذكورين
nobody: المستخدمين المذكورين فقط
public: الجميع
title: '%{name}: "%{quote}"'
visibilities:

View File

@ -1842,8 +1842,6 @@ be:
ownership: Немагчыма замацаваць чужы допіс
reblog: Немагчыма замацаваць пашырэнне
quote_policies:
followers: Падпісчыкі і згаданыя карыстальнікі
nobody: Толькі згаданыя карыстальнікі
public: Усе
title: '%{name}: "%{quote}"'
visibilities:

View File

@ -1857,8 +1857,6 @@ bg:
ownership: Публикация на някого другиго не може да бъде закачена
reblog: Раздуване не може да бъде закачано
quote_policies:
followers: Последователи и споменати потребители
nobody: Само споменатите потребители
public: Всеки
title: "%{name}: „%{quote}“"
visibilities:

View File

@ -1878,8 +1878,8 @@ ca:
ownership: No es pot fixar el tut d'algú altre
reblog: No es pot fixar un impuls
quote_policies:
followers: Seguidors i usuaris mencionats
nobody: Només usuaris mencionats
followers: Només els vostres seguidors
nobody: Ningú
public: Tothom
title: '%{name}: "%{quote}"'
visibilities:

Some files were not shown because too many files have changed in this diff Show More