diff --git a/Gemfile b/Gemfile index ce775fc57bc..ee2369921de 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index b8813b7211c..19a97ef4962 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/controllers/activitypub/quote_authorizations_controller.rb b/app/controllers/activitypub/quote_authorizations_controller.rb new file mode 100644 index 00000000000..fa635d636a6 --- /dev/null +++ b/app/controllers/activitypub/quote_authorizations_controller.rb @@ -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 diff --git a/app/controllers/api/v1/statuses/quotes_controller.rb b/app/controllers/api/v1/statuses/quotes_controller.rb new file mode 100644 index 00000000000..7dd91e9a2ee --- /dev/null +++ b/app/controllers/api/v1/statuses/quotes_controller.rb @@ -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 diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 77ddee1122c..885f578fd0d 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -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 diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index 7b9d3f4fc1a..79e28c983af 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -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' diff --git a/app/javascript/mastodon/features/emoji/constants.ts b/app/javascript/mastodon/features/emoji/constants.ts index 09022371b22..a5ec9e6e2b4 100644 --- a/app/javascript/mastodon/features/emoji/constants.ts +++ b/app/javascript/mastodon/features/emoji/constants.ts @@ -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'; diff --git a/app/javascript/mastodon/features/emoji/database.test.ts b/app/javascript/mastodon/features/emoji/database.test.ts new file mode 100644 index 00000000000..0689fd7c542 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/database.test.ts @@ -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); + }); + }); +}); diff --git a/app/javascript/mastodon/features/emoji/database.ts b/app/javascript/mastodon/features/emoji/database.ts index 0b8ddd34fbe..0e8ada1d0e0 100644 --- a/app/javascript/mastodon/features/emoji/database.ts +++ b/app/javascript/mastodon/features/emoji/database.ts @@ -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; +type Database = IDBPDatabase; + const SCHEMA_VERSION = 1; -let db: IDBPDatabase | null = null; +const loadedLocales = new Set(); -async function loadDB() { - if (db) { - return db; - } - db = await openDB('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 | 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('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 => { + 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 { + 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(); +} diff --git a/app/javascript/mastodon/features/emoji/emoji_html.tsx b/app/javascript/mastodon/features/emoji/emoji_html.tsx index 85628e6723d..fdda62a3e61 100644 --- a/app/javascript/mastodon/features/emoji/emoji_html.tsx +++ b/app/javascript/mastodon/features/emoji/emoji_html.tsx @@ -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, +type EmojiHTMLProps = Omit< + ComponentPropsWithoutRef, 'dangerouslySetInnerHTML' > & { htmlString: string; - extraEmojis?: ExtraCustomEmojiMap | ImmutableList; + extraEmojis?: CustomEmojiMapArg; + as?: Element; }; -export const EmojiHTML: React.FC = ({ - htmlString, +export const EmojiHTML = ({ extraEmojis, + htmlString, + as: asElement, // Rename for syntax highlighting ...props -}) => { - if (isModernEmojiEnabled()) { - return ( - - ); - } - return
; -}; +}: EmojiHTMLProps) => { + const Wrapper = asElement ?? 'div'; + const emojifiedHtml = useEmojify(htmlString, extraEmojis); -const ModernEmojiHTML: React.FC = ({ - 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( - (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
; + return ( + + ); }; diff --git a/app/javascript/mastodon/features/emoji/emoji_text.tsx b/app/javascript/mastodon/features/emoji/emoji_text.tsx deleted file mode 100644 index 253371391a4..00000000000 --- a/app/javascript/mastodon/features/emoji/emoji_text.tsx +++ /dev/null @@ -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 = ({ 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 {fragment}; - } - return ( - {fragment.alt} - ); - })} - - ); -}; diff --git a/app/javascript/mastodon/features/emoji/hooks.ts b/app/javascript/mastodon/features/emoji/hooks.ts index fd38129a19b..47af37b3731 100644 --- a/app/javascript/mastodon/features/emoji/hooks.ts +++ b/app/javascript/mastodon/features/emoji/hooks.ts @@ -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(null); + + const appState = useEmojiAppState(); + const extra: ExtraCustomEmojiMap = useMemo(() => { + if (!extraEmojis) { + return {}; + } + if (isList(extraEmojis)) { + return ( + extraEmojis.toJS() as ApiCustomEmojiJSON[] + ).reduce( + (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'), + }; } diff --git a/app/javascript/mastodon/features/emoji/index.ts b/app/javascript/mastodon/features/emoji/index.ts index 541cea9aa99..99c16fe361c 100644 --- a/app/javascript/mastodon/features/emoji/index.ts +++ b/app/javascript/mastodon/features/emoji/index.ts @@ -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) => { 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'); } } diff --git a/app/javascript/mastodon/features/emoji/loader.ts b/app/javascript/mastodon/features/emoji/loader.ts index 454b8383f07..72f57b6f6c0 100644 --- a/app/javascript/mastodon/features/emoji/loader.ts +++ b/app/javascript/mastodon/features/emoji/loader.ts @@ -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( 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); diff --git a/app/javascript/mastodon/features/emoji/render.test.ts b/app/javascript/mastodon/features/emoji/render.test.ts index 23f85c36b3e..e9609e15dc5 100644 --- a/app/javascript/mastodon/features/emoji/render.test.ts +++ b/app/javascript/mastodon/features/emoji/render.test.ts @@ -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 = + '😊'; +const expectedFlagImage = + '🇪🇺'; +const expectedCustomEmojiImage = + ':custom:'; +const expectedRemoteCustomEmojiImage = + ':remote:'; + +const mockExtraCustom: ExtraCustomEmojiMap = { + remote: { + shortcode: 'remote', + static_url: 'remote.social/static', + url: 'remote.social/custom', + }, +}; + +function testAppState(state: Partial = {}) { + return { + locales: ['en'], + mode: EMOJI_MODE_TWEMOJI, + currentLocale: 'en', + darkTheme: false, + ...state, + } satisfies EmojiAppState; +} describe('emojifyElement', () => { - const testElement = document.createElement('div'); - testElement.innerHTML = '

Hello 😊🇪🇺!

:custom:

'; - - const expectedSmileImage = - '😊'; - const expectedFlagImage = - '🇪🇺'; - const expectedCustomEmojiImage = - ':custom:'; - - function cloneTestElement() { - return testElement.cloneNode(true) as HTMLElement; + function testElement(text = '

Hello 😊🇪🇺!

:custom:

') { + 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( `

Hello 😊🇪🇺!

${expectedCustomEmojiImage}

`, ); + 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( `

Hello 😊${expectedFlagImage}!

${expectedCustomEmojiImage}

`, ); + 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( `

Hello ${expectedSmileImage}${expectedFlagImage}!

${expectedCustomEmojiImage}

`, ); + expect(searchEmojisByHexcodes).toHaveBeenCalledOnce(); + expect(searchCustomEmojisByShortcodes).toHaveBeenCalledOnce(); + }); + + test('emojifies with provided custom emoji', async () => { + const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } = + mockDatabase(); + const actual = await emojifyElement( + testElement('

hi :remote:

'), + testAppState(), + mockExtraCustom, + ); + assert(actual); + expect(actual.innerHTML).toBe( + `

hi ${expectedRemoteCustomEmojiImage}

`, + ); + expect(searchEmojisByHexcodes).not.toHaveBeenCalled(); + expect(searchCustomEmojisByShortcodes).not.toHaveBeenCalled(); + }); + + test('returns null when no emoji are found', async () => { + mockDatabase(); + const actual = await emojifyElement( + testElement('

here is just text :)

'), + 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}`); }); }); diff --git a/app/javascript/mastodon/features/emoji/render.ts b/app/javascript/mastodon/features/emoji/render.ts index 6ef9492147c..6486e65a709 100644 --- a/app/javascript/mastodon/features/emoji/render.ts +++ b/app/javascript/mastodon/features/emoji/render.ts @@ -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([ - [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: Element, appState: EmojiAppState, extraEmojis: ExtraCustomEmojiMap = {}, -): Promise { +): Promise { + 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( 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( 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( } } } + updateCache(cacheKey, element.innerHTML); + perf.stop('emojifyElement()'); return element; } @@ -88,7 +103,54 @@ export async function emojifyText( text: string, appState: EmojiAppState, extraEmojis: ExtraCustomEmojiMap = {}, +): Promise { + 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({ 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 { // 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([ + [ + EMOJI_TYPE_CUSTOM, + createLimitedCache({ log: log.extend('custom') }), + ], +]); + function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap { - return localeCacheMap.get(locale) ?? (new Map() as EmojiStateMap); + return ( + localeCacheMap.get(locale) ?? + createLimitedCache({ 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(); const missingCustomEmoji = new Set(); @@ -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( + 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(); +}; diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts index f5932ed97fd..85bbe6d1a56 100644 --- a/app/javascript/mastodon/features/emoji/types.ts +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -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; +export type EmojiStateMap = LimitedCache; -export type ExtraCustomEmojiMap = Record; +export type CustomEmojiMapArg = + | ExtraCustomEmojiMap + | ImmutableList; +export type CustomEmojiRenderFields = Pick< + CustomEmojiData, + 'shortcode' | 'static_url' | 'url' +>; +export type ExtraCustomEmojiMap = Record; export interface TwemojiBorderInfo { hexCode: string; diff --git a/app/javascript/mastodon/features/emoji/utils.test.ts b/app/javascript/mastodon/features/emoji/utils.test.ts index 75cac8c5b4c..b9062294c47 100644 --- a/app/javascript/mastodon/features/emoji/utils.test.ts +++ b/app/javascript/mastodon/features/emoji/utils.test.ts @@ -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(); + }); +}); diff --git a/app/javascript/mastodon/features/emoji/utils.ts b/app/javascript/mastodon/features/emoji/utils.ts index d00accea8c5..89f8d926466 100644 --- a/app/javascript/mastodon/features/emoji/utils.ts +++ b/app/javascript/mastodon/features/emoji/utils.ts @@ -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); } diff --git a/app/javascript/mastodon/features/emoji/worker.ts b/app/javascript/mastodon/features/emoji/worker.ts index 1c48a077730..6fb7d36e936 100644 --- a/app/javascript/mastodon/features/emoji/worker.ts +++ b/app/javascript/mastodon/features/emoji/worker.ts @@ -5,9 +5,14 @@ self.postMessage('ready'); // After the worker is ready, notify the main thread function handleMessage(event: MessageEvent) { 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}`); } diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index 0443710638c..80056d1f6c9 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -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": "إعادة النشر إلى الجمهور الأصلي", diff --git a/app/javascript/mastodon/locales/be.json b/app/javascript/mastodon/locales/be.json index 0f49cc69799..706e746988f 100644 --- a/app/javascript/mastodon/locales/be.json +++ b/app/javascript/mastodon/locales/be.json @@ -821,7 +821,6 @@ "status.mute_conversation": "Ігнараваць размову", "status.open": "Разгарнуць гэты допіс", "status.pin": "Замацаваць у профілі", - "status.quote_post_author": "Допіс карыстальніка @{name}", "status.read_more": "Чытаць болей", "status.reblog": "Пашырыць", "status.reblog_private": "Пашырыць з першапачатковай бачнасцю", diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json index 392f9470c0e..6f0cf6f54fe 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -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": "Подсилване с оригиналната видимост", diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json index 8882ff916e3..cdca2446ca0 100644 --- a/app/javascript/mastodon/locales/br.json +++ b/app/javascript/mastodon/locales/br.json @@ -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ñ", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 645a3751243..5bee7f604c7 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -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", diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json index 24486800f30..17fb3eb2b84 100644 --- a/app/javascript/mastodon/locales/cs.json +++ b/app/javascript/mastodon/locales/cs.json @@ -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í", diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json index dfd900bece9..9dfc6b35a60 100644 --- a/app/javascript/mastodon/locales/cy.json +++ b/app/javascript/mastodon/locales/cy.json @@ -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", diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json index 15597e9a279..ff3ba99e8d6 100644 --- a/app/javascript/mastodon/locales/da.json +++ b/app/javascript/mastodon/locales/da.json @@ -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", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index fc34c0fe191..90251442849 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -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", diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index b18897576c5..79aa5874b5b 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -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": "Ενίσχυση με αρχική ορατότητα", diff --git a/app/javascript/mastodon/locales/en-GB.json b/app/javascript/mastodon/locales/en-GB.json index 441cfee6d10..1bb482d9888 100644 --- a/app/javascript/mastodon/locales/en-GB.json +++ b/app/javascript/mastodon/locales/en-GB.json @@ -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", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 92f529a8f41..5027ce48b39 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -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", diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json index 3eca9eab37c..84536740b2e 100644 --- a/app/javascript/mastodon/locales/es-AR.json +++ b/app/javascript/mastodon/locales/es-AR.json @@ -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", diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json index 67a91402f87..834c1f33bf2 100644 --- a/app/javascript/mastodon/locales/es-MX.json +++ b/app/javascript/mastodon/locales/es-MX.json @@ -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", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index cbd79ea10fd..475f4ca7068 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -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", diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json index c4b1c95fd3a..e5d717d2270 100644 --- a/app/javascript/mastodon/locales/et.json +++ b/app/javascript/mastodon/locales/et.json @@ -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", diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json index 65f3e87ac15..316dc43a4d2 100644 --- a/app/javascript/mastodon/locales/eu.json +++ b/app/javascript/mastodon/locales/eu.json @@ -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", diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index 0217d7bb2fb..9677454e0b8 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -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": "تقویت برای مخاطبان نخستین", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 36fcfd4e947..6efe46693d4 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -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", diff --git a/app/javascript/mastodon/locales/fo.json b/app/javascript/mastodon/locales/fo.json index 1bdccaeae86..808a1147be2 100644 --- a/app/javascript/mastodon/locales/fo.json +++ b/app/javascript/mastodon/locales/fo.json @@ -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", diff --git a/app/javascript/mastodon/locales/fr-CA.json b/app/javascript/mastodon/locales/fr-CA.json index 840b0e508d5..7803004869e 100644 --- a/app/javascript/mastodon/locales/fr-CA.json +++ b/app/javascript/mastodon/locales/fr-CA.json @@ -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", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index c0c9580a46d..65b97b498b3 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -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 à l’audience originale", diff --git a/app/javascript/mastodon/locales/fy.json b/app/javascript/mastodon/locales/fy.json index 1f7f5c0c8ad..f28fbb2ff86 100644 --- a/app/javascript/mastodon/locales/fy.json +++ b/app/javascript/mastodon/locales/fy.json @@ -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", diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json index 2c0b709bdc3..afdc15e99d1 100644 --- a/app/javascript/mastodon/locales/ga.json +++ b/app/javascript/mastodon/locales/ga.json @@ -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": "D’fhéadfadh sé go dtógfadh sé tamall le Sleachta a roinntear ar fud Fediverse a thaispeáint, toisc go mbíonn prótacail éagsúla ag freastalaithe éagsúla.", + "status.quote_error.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", diff --git a/app/javascript/mastodon/locales/gd.json b/app/javascript/mastodon/locales/gd.json index c783182cd5f..be02d0a67a7 100644 --- a/app/javascript/mastodon/locales/gd.json +++ b/app/javascript/mastodon/locales/gd.json @@ -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", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index 6a8570c1d05..4cd1f9718f6 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -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", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index d1918aa97dd..e9a755fab72 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -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": "להדהד ברמת הנראות המקורית", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 06a8bf88465..8f93fab6d4c 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -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", diff --git a/app/javascript/mastodon/locales/is.json b/app/javascript/mastodon/locales/is.json index 54aec7cbee8..54dcc70a093 100644 --- a/app/javascript/mastodon/locales/is.json +++ b/app/javascript/mastodon/locales/is.json @@ -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", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 5134b39e673..5168665f6a2 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -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", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 59ed074012a..b7af03c1099 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -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": "ブースト", diff --git a/app/javascript/mastodon/locales/kab.json b/app/javascript/mastodon/locales/kab.json index 0bc4665a253..52305fec2b6 100644 --- a/app/javascript/mastodon/locales/kab.json +++ b/app/javascript/mastodon/locales/kab.json @@ -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}", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index f0c127956a3..e6cdc4e7800 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -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": "원래의 수신자들에게 부스트", diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json index ee3c143412f..cb77337a658 100644 --- a/app/javascript/mastodon/locales/lv.json +++ b/app/javascript/mastodon/locales/lv.json @@ -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", diff --git a/app/javascript/mastodon/locales/nan.json b/app/javascript/mastodon/locales/nan.json index 8120312d80f..a5611c6c9de 100644 --- a/app/javascript/mastodon/locales/nan.json +++ b/app/javascript/mastodon/locales/nan.json @@ -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 ê通看見ê範圍轉送", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index 04ceb2591ba..54a1eac1ba8 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -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", diff --git a/app/javascript/mastodon/locales/nn.json b/app/javascript/mastodon/locales/nn.json index b5b97007c8c..4d7b6d401f0 100644 --- a/app/javascript/mastodon/locales/nn.json +++ b/app/javascript/mastodon/locales/nn.json @@ -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", diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index 8ef9b6a4818..f96377caa28 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -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", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index d00f44495d8..7844de56339 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -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", diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json index 6cc740f755f..8b0f2cbddf2 100644 --- a/app/javascript/mastodon/locales/pt-PT.json +++ b/app/javascript/mastodon/locales/pt-PT.json @@ -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", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index 40d8341713f..ada27cea5fd 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -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": "Продвинуть для своей аудитории", diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json index 75c00681603..d871d1bb278 100644 --- a/app/javascript/mastodon/locales/sq.json +++ b/app/javascript/mastodon/locales/sq.json @@ -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 s’mund të shfaqet.", - "status.quote_error.pending_approval": "Ky postim është në pritje të miratimit nga autori origjinal.", - "status.quote_error.rejected": "Ky postim s’mund të shfaqet, ngaqë autori origjinal nuk lejon citim të tij.", - "status.quote_error.removed": "Ky postim u hoq nga autori i tij.", - "status.quote_error.unauthorized": "Ky postim s’mund të shfaqet, ngaqë s’jeni i autorizuar ta shihni.", - "status.quote_post_author": "Postim nga {name}", "status.read_more": "Lexoni më tepër", "status.reblog": "Përforcojeni", "status.reblog_private": "Përforcim për publikun origjinal", diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json index 1697b0dcb07..45ca92bebeb 100644 --- a/app/javascript/mastodon/locales/sv.json +++ b/app/javascript/mastodon/locales/sv.json @@ -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", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index 03faba8a660..545371e56fb 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -832,7 +832,6 @@ "status.mute_conversation": "ซ่อนการสนทนา", "status.open": "ขยายโพสต์นี้", "status.pin": "ปักหมุดในโปรไฟล์", - "status.quote_post_author": "โพสต์โดย {name}", "status.read_more": "อ่านเพิ่มเติม", "status.reblog": "ดัน", "status.reblog_private": "ดันด้วยการมองเห็นดั้งเดิม", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index 9a8354d052e..fb2a9237bcb 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -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ş", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index 2e118f3589e..38401f18fa5 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -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": "Поширити для початкової аудиторії", diff --git a/app/javascript/mastodon/locales/vi.json b/app/javascript/mastodon/locales/vi.json index b5c8c3ec235..5062434ab99 100644 --- a/app/javascript/mastodon/locales/vi.json +++ b/app/javascript/mastodon/locales/vi.json @@ -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ư)", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 127ae87f637..43a7cf0ff84 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -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": "以相同可见性转嘟", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 40eed25a495..b086b55b833 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -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": "依照原嘟可見性轉嘟", diff --git a/app/javascript/mastodon/main.tsx b/app/javascript/mastodon/main.tsx index dcc71bdb843..456cc21c318 100644 --- a/app/javascript/mastodon/main.tsx +++ b/app/javascript/mastodon/main.tsx @@ -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); diff --git a/app/javascript/mastodon/utils/__tests__/cache.test.ts b/app/javascript/mastodon/utils/__tests__/cache.test.ts new file mode 100644 index 00000000000..340a51fdb4b --- /dev/null +++ b/app/javascript/mastodon/utils/__tests__/cache.test.ts @@ -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, + ); + }); +}); diff --git a/app/javascript/mastodon/utils/cache.ts b/app/javascript/mastodon/utils/cache.ts new file mode 100644 index 00000000000..2e3d21bfed4 --- /dev/null +++ b/app/javascript/mastodon/utils/cache.ts @@ -0,0 +1,60 @@ +export interface LimitedCache { + 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({ + maxSize = 100, + log = () => null, +}: LimitedCacheArguments = {}): LimitedCache { + const cacheMap = new Map(); + const cacheKeys = new Set(); + + 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(); + }, + }; +} diff --git a/app/javascript/mastodon/utils/environment.ts b/app/javascript/mastodon/utils/environment.ts index 457da5c7367..c5fe46bc931 100644 --- a/app/javascript/mastodon/utils/environment.ts +++ b/app/javascript/mastodon/utils/environment.ts @@ -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; + } } diff --git a/app/javascript/mastodon/performance.js b/app/javascript/mastodon/utils/performance.ts similarity index 70% rename from app/javascript/mastodon/performance.js rename to app/javascript/mastodon/utils/performance.ts index 1b2092cfc4c..e503e1ef587 100644 --- a/app/javascript/mastodon/performance.js +++ b/app/javascript/mastodon/utils/performance.ts @@ -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); } diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index 5b2fbfe594e..cd5f72a06f0 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -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 = ({ showing_reblogs: true, ...data, }); + +export function unicodeEmojiFactory( + data: Partial = {}, +): UnicodeEmojiData { + return { + hexcode: 'test', + label: 'Test', + unicode: '🧪', + ...data, + }; +} + +export function customEmojiFactory( + data: Partial = {}, +): CustomEmojiData { + return { + shortcode: 'custom', + static_url: 'emoji/custom/static', + url: 'emoji/custom', + visible_in_picker: true, + ...data, + }; +} diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 3fc9269dd3a..ab84a5dd472 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -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 diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index ad3ef72be8a..5a434ed915a 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -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? diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 4d83a9b8238..975763e82fe 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -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 diff --git a/app/lib/delivery_failure_tracker.rb b/app/lib/delivery_failure_tracker.rb index 96292923f42..619340a3d16 100644 --- a/app/lib/delivery_failure_tracker.rb +++ b/app/lib/delivery_failure_tracker.rb @@ -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 diff --git a/app/models/concerns/status/interaction_policy_concern.rb b/app/models/concerns/status/interaction_policy_concern.rb index 7e7642209db..6ad047fd8d5 100644 --- a/app/models/concerns/status/interaction_policy_concern.rb +++ b/app/models/concerns/status/interaction_policy_concern.rb @@ -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 diff --git a/app/models/notification.rb b/app/models/notification.rb index e7ada3399aa..f70991d801b 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -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 diff --git a/app/models/notification_request.rb b/app/models/notification_request.rb index eb9ff93ab72..d95fb58b476 100644 --- a/app/models/notification_request.rb +++ b/app/models/notification_request.rb @@ -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 diff --git a/app/models/quote.rb b/app/models/quote.rb index 89845ed9f49..a6c9dd0caca 100644 --- a/app/models/quote.rb +++ b/app/models/quote.rb @@ -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! diff --git a/app/policies/quote_policy.rb b/app/policies/quote_policy.rb new file mode 100644 index 00000000000..a8be64a7792 --- /dev/null +++ b/app/policies/quote_policy.rb @@ -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 diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index d9bb7201c00..b7463869959 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -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? diff --git a/app/serializers/activitypub/delete_quote_authorization_serializer.rb b/app/serializers/activitypub/delete_quote_authorization_serializer.rb new file mode 100644 index 00000000000..150f2a4554b --- /dev/null +++ b/app/serializers/activitypub/delete_quote_authorization_serializer.rb @@ -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 diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 95a869658c3..972f146bafc 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -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 diff --git a/app/serializers/activitypub/quote_authorization_serializer.rb b/app/serializers/activitypub/quote_authorization_serializer.rb new file mode 100644 index 00000000000..faef3dd6866 --- /dev/null +++ b/app/serializers/activitypub/quote_authorization_serializer.rb @@ -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 diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb index 320bc86961d..033bc1c0425 100644 --- a/app/serializers/rest/notification_serializer.rb +++ b/app/serializers/rest/notification_serializer.rb @@ -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? diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 9c96c51851f..0ada876d890 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -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 diff --git a/app/services/activitypub/verify_quote_service.rb b/app/services/activitypub/verify_quote_service.rb index a83a3c1155a..2b10de9d9b3 100644 --- a/app/services/activitypub/verify_quote_service.rb +++ b/app/services/activitypub/verify_quote_service.rb @@ -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) diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index f3aa479c153..41a4e210e15 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -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| diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index f820f969a6e..95563698f45 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -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 diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 2adb8c1edbe..f61cb632b23 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -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 diff --git a/app/services/revoke_quote_service.rb b/app/services/revoke_quote_service.rb new file mode 100644 index 00000000000..8f5dc8f9105 --- /dev/null +++ b/app/services/revoke_quote_service.rb @@ -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 diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml index c977011e1ff..d241844ea2f 100644 --- a/app/views/admin/instances/show.html.haml +++ b/app/views/admin/instances/show.html.haml @@ -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 diff --git a/config/locales/ar.yml b/config/locales/ar.yml index 2fe619a60c7..026972b2256 100644 --- a/config/locales/ar.yml +++ b/config/locales/ar.yml @@ -2047,8 +2047,6 @@ ar: ownership: لا يمكن تثبيت منشور نشره شخص آخر reblog: لا يمكن تثبيت إعادة نشر quote_policies: - followers: المتابعين والمستخدمين المذكورين - nobody: المستخدمين المذكورين فقط public: الجميع title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/be.yml b/config/locales/be.yml index 601a81b6487..2855230b156 100644 --- a/config/locales/be.yml +++ b/config/locales/be.yml @@ -1842,8 +1842,6 @@ be: ownership: Немагчыма замацаваць чужы допіс reblog: Немагчыма замацаваць пашырэнне quote_policies: - followers: Падпісчыкі і згаданыя карыстальнікі - nobody: Толькі згаданыя карыстальнікі public: Усе title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/bg.yml b/config/locales/bg.yml index cbb0b682fd3..077a729b919 100644 --- a/config/locales/bg.yml +++ b/config/locales/bg.yml @@ -1857,8 +1857,6 @@ bg: ownership: Публикация на някого другиго не може да бъде закачена reblog: Раздуване не може да бъде закачано quote_policies: - followers: Последователи и споменати потребители - nobody: Само споменатите потребители public: Всеки title: "%{name}: „%{quote}“" visibilities: diff --git a/config/locales/ca.yml b/config/locales/ca.yml index a3e62d7d721..2057e4cc3d5 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -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: diff --git a/config/locales/cs.yml b/config/locales/cs.yml index 7df98b90bbf..e4ebcd744c7 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -196,6 +196,7 @@ cs: create_relay: Vytvořit relay create_unavailable_domain: Vytvořit nedostupnou doménu create_user_role: Vytvořit roli + create_username_block: Vytvořit pravidlo pro uživatelská jména demote_user: Snížit roli uživatele destroy_announcement: Odstranit oznámení destroy_canonical_email_block: Odblokovat email @@ -209,6 +210,7 @@ cs: destroy_status: Odstranit Příspěvek destroy_unavailable_domain: Smazat nedostupnou doménu destroy_user_role: Zničit roli + destroy_username_block: Odstranit pravidlo pro uživatelská jména disable_2fa_user: Vypnout 2FA disable_custom_emoji: Zakázat vlastní emoji disable_relay: Deaktivovat relay @@ -243,6 +245,7 @@ cs: update_report: Upravit hlášení update_status: Aktualizovat Příspěvek update_user_role: Aktualizovat roli + update_username_block: Aktualizovat pravidlo pro uživatelská jména actions: approve_appeal_html: Uživatel %{name} schválil odvolání proti rozhodnutí moderátora %{target} approve_user_html: "%{name} schválil registraci od %{target}" @@ -261,6 +264,7 @@ cs: create_relay_html: "%{name} vytvořil*a relay %{target}" create_unavailable_domain_html: "%{name} zastavil doručování na doménu %{target}" create_user_role_html: "%{name} vytvořil %{target} roli" + create_username_block_html: "%{name} přidali pravidlo pro uživatelská jména obsahující %{target}" demote_user_html: Uživatel %{name} degradoval uživatele %{target} destroy_announcement_html: Uživatel %{name} odstranil oznámení %{target} destroy_canonical_email_block_html: "%{name} odblokoval*a e-mail s hashem %{target}" @@ -274,6 +278,7 @@ cs: destroy_status_html: Uživatel %{name} odstranil příspěvek uživatele %{target} destroy_unavailable_domain_html: "%{name} obnovil doručování na doménu %{target}" destroy_user_role_html: "%{name} odstranil %{target} roli" + destroy_username_block_html: "%{name} odstranili pravidlo pro uživatelská jména obsahující %{target}" disable_2fa_user_html: Uživatel %{name} vypnul dvoufázové ověřování pro uživatele %{target} disable_custom_emoji_html: Uživatel %{name} zakázal emoji %{target} disable_relay_html: "%{name} deaktivoval*a relay %{target}" @@ -308,6 +313,7 @@ cs: update_report_html: "%{name} aktualizoval hlášení %{target}" update_status_html: Uživatel %{name} aktualizoval příspěvek uživatele %{target} update_user_role_html: "%{name} změnil %{target} roli" + update_username_block_html: "%{name} aktualizovali pravidlo pro uživatelská jména obsahující %{target}" deleted_account: smazaný účet empty: Nebyly nalezeny žádné záznamy. filter_by_action: Filtrovat podle akce @@ -1121,6 +1127,25 @@ cs: other: Použit %{count} lidmi za poslední týden title: Doporučení & Trendy trending: Populární + username_blocks: + add_new: Přidat + block_registrations: Blokovat registrace + comparison: + contains: Obsahuje + equals: Rovná se + contains_html: Obsahuje %{string} + created_msg: Vytvořeno pravidlo pro uživatelská jména + delete: Smazat + edit: + title: Upravit pravidlo pro uživatelská jména + matches_exactly_html: Rovná se %{string} + new: + create: Vytvořit pravidlo + title: Vytvořit nové pravidlo pro uživatelská jména + no_username_block_selected: Nebyla změněna žádná pravidla pro uživatelská jména, protože žádná nebyla vybrána + not_permitted: Není povoleno + title: Pravidla uživatelských jmen + updated_msg: Pravidlo pro jména uživatelů bylo úspěšně aktualizováno warning_presets: add_new: Přidat nové delete: Smazat @@ -1966,8 +1991,8 @@ cs: ownership: Nelze připnout příspěvek někoho jiného reblog: Boosty nelze připnout quote_policies: - followers: Sledující a zmínění uživatelé - nobody: Pouze zmínění uživatelé + followers: Pouze vaši sledující + nobody: Nikdo public: Všichni title: "%{name}: „%{quote}“" visibilities: diff --git a/config/locales/cy.yml b/config/locales/cy.yml index ab45a600128..e8990d34820 100644 --- a/config/locales/cy.yml +++ b/config/locales/cy.yml @@ -2051,8 +2051,6 @@ cy: ownership: Nid oes modd pinio postiad rhywun arall reblog: Nid oes modd pinio hwb quote_policies: - followers: Dilynwyr a defnyddwyr wedi'u crybwyll - nobody: Dim ond defnyddwyr wedi'u crybwyll public: Pawb title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/da.yml b/config/locales/da.yml index cd8bd2d90ee..67e5a716227 100644 --- a/config/locales/da.yml +++ b/config/locales/da.yml @@ -1230,7 +1230,7 @@ da: prefix_invited_by_user: "@%{name} inviterer dig ind på denne Mastodon-server!" prefix_sign_up: Tilmeld dig Mastodon i dag! suffix: Du vil med en konto kunne følge personer, indsende opdateringer og udveksle beskeder med brugere fra enhver Mastodon-server, og meget mere! - didnt_get_confirmation: Intet bekræftelseslink modtaget? + didnt_get_confirmation: Ikke modtaget bekræftelseslink? dont_have_your_security_key: Har ikke din sikkerhedsnøgle? forgot_password: Glemt din adgangskode? invalid_reset_password_token: Adgangskodenulstillingstoken ugyldigt eller udløbet. Anmod om et nyt. @@ -1905,8 +1905,8 @@ da: ownership: Andres indlæg kan ikke fastgøres reblog: En fremhævelse kan ikke fastgøres quote_policies: - followers: Følgere og nævnte brugere - nobody: Kun nævnte brugere + followers: Kun dine følgere + nobody: Ingen public: Alle title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/de.yml b/config/locales/de.yml index 7b8594fcc5b..adef04dfa85 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1905,8 +1905,8 @@ de: ownership: Du kannst nur eigene Beiträge anheften reblog: Du kannst keine geteilten Beiträge anheften quote_policies: - followers: Follower und erwähnte Profile - nobody: Nur erwähnte Profile + followers: Nur meine Follower + nobody: Niemand public: Alle title: "%{name}: „%{quote}“" visibilities: diff --git a/config/locales/el.yml b/config/locales/el.yml index b1df6962ecf..73e46ee13c0 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -190,6 +190,7 @@ el: create_relay: Δημιουργία Relay create_unavailable_domain: Δημιουργία Μη Διαθέσιμου Τομέα create_user_role: Δημιουργία Ρόλου + create_username_block: Δημιουργία Κανόνα Ονόματος Χρήστη demote_user: Υποβιβασμός Χρήστη destroy_announcement: Διαγραφή Ανακοίνωσης destroy_canonical_email_block: Διαγραφή Αποκλεισμού Email @@ -203,6 +204,7 @@ el: destroy_status: Διαγραφή Ανάρτησης destroy_unavailable_domain: Διαγραφή Μη Διαθέσιμου Τομέα destroy_user_role: Καταστροφή Ρόλου + destroy_username_block: Διαγραφή Κανόνα Ονόματος Χρήστη disable_2fa_user: Απενεργοποίηση 2FA disable_custom_emoji: Απενεργοποίηση Προσαρμοσμένων Emoji disable_relay: Απενεργοποίηση Relay @@ -237,6 +239,7 @@ el: update_report: Ενημέρωση Αναφοράς update_status: Ενημέρωση Ανάρτησης update_user_role: Ενημέρωση ρόλου + update_username_block: Ενημέρωση Κανόνα Ονόματος Χρήστη actions: approve_appeal_html: Ο/Η %{name} ενέκρινε την ένσταση της απόφασης των συντονιστών από %{target} approve_user_html: ο/η %{name} ενέκρινε την εγγραφή του %{target} @@ -255,6 +258,7 @@ el: create_relay_html: Ο χρήστης %{name} δημιούργησε ένα relay %{target} create_unavailable_domain_html: Ο/Η %{name} σταμάτησε να τροφοδοτεί τον τομέα %{target} create_user_role_html: Ο/Η %{name} δημιούργησε ρόλο %{target} + create_username_block_html: "%{name} πρόσθεσε κανόνα για ονόματα χρηστών που περιέχουν %{target}" demote_user_html: Ο/Η %{name} υποβίβασε τον χρήστη %{target} destroy_announcement_html: Ο/Η %{name} διέγραψε την ανακοίνωση %{target} destroy_canonical_email_block_html: Ο χρήστης %{name} έκανε άρση αποκλεισμού email με το hash %{target} @@ -268,6 +272,7 @@ el: destroy_status_html: Ο/Η %{name} αφαίρεσε την ανάρτηση του/της %{target} destroy_unavailable_domain_html: Ο/Η %{name} ξανάρχισε να τροφοδοτεί το domain %{target} destroy_user_role_html: Ο/Η %{name} διέγραψε τον ρόλο του %{target} + destroy_username_block_html: "%{name} αφαίρεσε κανόνα για ονόματα χρηστών που περιέχουν %{target}" disable_2fa_user_html: Ο/Η %{name} απενεργοποίησε την απαίτηση για ταυτοποίηση δύο παραγόντων για τον χρήστη %{target} disable_custom_emoji_html: Ο/Η %{name} απενεργοποίησε το emoji %{target} disable_relay_html: Ο χρήστης %{name} απενεργοποίησε το relay %{target} @@ -302,6 +307,7 @@ el: update_report_html: Ο χρήστης %{name} ενημέρωσε την αναφορά %{target} update_status_html: Ο/Η %{name} ενημέρωσε την ανάρτηση του/της %{target} update_user_role_html: Ο/Η %{name} άλλαξε τον ρόλο %{target} + update_username_block_html: "%{name} ενημέρωσε κανόνα για ονόματα χρηστών που περιέχουν %{target}" deleted_account: διαγεγραμμένος λογαριασμός empty: Δεν βρέθηκαν αρχεία καταγραφής. filter_by_action: Φιλτράρισμα ανά ενέργεια @@ -1085,6 +1091,25 @@ el: other: Χρησιμοποιήθηκε από %{count} άτομα την τελευταία εβδομάδα title: Προτάσεις και τάσεις trending: Τάσεις + username_blocks: + add_new: Προσθήκη νέου + block_registrations: Φραγή εγγραφών + comparison: + contains: Περιέχει + equals: Ισούται + contains_html: Περιέχει %{string} + created_msg: Ο κανόνας ονόματος χρήστη δημιουργήθηκε με επιτυχία + delete: Διαγραφή + edit: + title: Επεξεργασία κανόνα ονόματος χρήστη + matches_exactly_html: Ισούται με %{string} + new: + create: Δημιουργία κανόνα + title: Δημιουργία νέου κανόνα ονόματος χρήστη + no_username_block_selected: Δεν άλλαξαν οι κανόνες ονόματος χρήστη καθώς κανένας δεν επιλέχθηκε + not_permitted: Δεν επιτρέπεται + title: Κανόνες ονόματος χρήστη + updated_msg: Ο κανόνας ονόματος χρήστη ενημερώθηκε με επιτυχία warning_presets: add_new: Πρόσθεση νέου delete: Διαγραφή @@ -1880,8 +1905,6 @@ el: ownership: Δεν μπορείς να καρφιτσώσεις ανάρτηση κάποιου άλλου reblog: Οι ενισχύσεις δεν καρφιτσώνονται quote_policies: - followers: Ακόλουθοι και αναφερόμενοι χρήστες - nobody: Μόνο αναφερόμενοι χρήστες public: Όλοι title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/en-GB.yml b/config/locales/en-GB.yml index a65ee021318..564900027c1 100644 --- a/config/locales/en-GB.yml +++ b/config/locales/en-GB.yml @@ -1879,8 +1879,6 @@ en-GB: ownership: Someone else's post cannot be pinned reblog: A boost cannot be pinned quote_policies: - followers: Followers and mentioned users - nobody: Only mentioned users public: Everyone title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/en.yml b/config/locales/en.yml index ef3ef6dbc93..3c6fb2cec92 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1906,8 +1906,8 @@ en: ownership: Someone else's post cannot be pinned reblog: A boost cannot be pinned quote_policies: - followers: Followers and mentioned users - nobody: Only mentioned users + followers: Only your followers + nobody: Nobody public: Everyone title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/eo.yml b/config/locales/eo.yml index 69c6c361bf2..f3e0b8186c6 100644 --- a/config/locales/eo.yml +++ b/config/locales/eo.yml @@ -1860,8 +1860,6 @@ eo: ownership: Mesaĝo de iu alia ne povas esti alpinglita reblog: Diskonigo ne povas esti alpinglita quote_policies: - followers: Sekvantoj kaj menciitaj uzantoj - nobody: Nur menciitaj uzantoj public: Ĉiuj title: "%{name}: “%{quote}”" visibilities: diff --git a/config/locales/es-AR.yml b/config/locales/es-AR.yml index c18172fccaa..f01db94590c 100644 --- a/config/locales/es-AR.yml +++ b/config/locales/es-AR.yml @@ -1905,8 +1905,8 @@ es-AR: ownership: No se puede fijar el mensaje de otra cuenta reblog: No se puede fijar una adhesión quote_policies: - followers: Seguidores y usuarios mencionados - nobody: Solo usuarios mencionados + followers: Solo tus seguidores + nobody: Nadie public: Todos title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/es-MX.yml b/config/locales/es-MX.yml index c5309f22660..e7a6e3df629 100644 --- a/config/locales/es-MX.yml +++ b/config/locales/es-MX.yml @@ -1905,8 +1905,8 @@ es-MX: ownership: La publicación de alguien más no puede fijarse reblog: No se puede fijar una publicación impulsada quote_policies: - followers: Seguidores y usuarios mencionados - nobody: Solo usuarios mencionados + followers: Solo tus seguidores + nobody: Nadie public: Cualquiera title: "%{name}: «%{quote}»" visibilities: diff --git a/config/locales/es.yml b/config/locales/es.yml index ee11d329076..2f7721a0ff7 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1905,8 +1905,8 @@ es: ownership: La publicación de otra persona no puede fijarse reblog: Una publicación impulsada no puede fijarse quote_policies: - followers: Seguidores y usuarios mencionados - nobody: Solo usuarios mencionados + followers: Solo tus seguidores + nobody: Nadie public: Cualquiera title: "%{name}: «%{quote}»" visibilities: diff --git a/config/locales/et.yml b/config/locales/et.yml index 2e9a7fbf74f..e9935cc72b5 100644 --- a/config/locales/et.yml +++ b/config/locales/et.yml @@ -1890,8 +1890,6 @@ et: ownership: Kellegi teise postitust ei saa kinnitada reblog: Jagamist ei saa kinnitada quote_policies: - followers: Jälgijad ja mainitud kasutajad - nobody: Vaid mainitud kasutajad public: Kõik title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/eu.yml b/config/locales/eu.yml index 95c163c3818..b777b05eb95 100644 --- a/config/locales/eu.yml +++ b/config/locales/eu.yml @@ -1742,8 +1742,6 @@ eu: ownership: Ezin duzu beste norbaiten bidalketa bat finkatu reblog: Bultzada bat ezin da finkatu quote_policies: - followers: Jarraitzaileak eta aipatutako erabiltzaileak - nobody: Aipatutako erabiltzaileak soilik public: Guztiak title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/fa.yml b/config/locales/fa.yml index b9d87d600f2..64a29da0953 100644 --- a/config/locales/fa.yml +++ b/config/locales/fa.yml @@ -1880,8 +1880,6 @@ fa: ownership: نوشته‌های دیگران را نمی‌توان ثابت کرد reblog: تقویت نمی‌تواند سنجاق شود quote_policies: - followers: پی‌گیران و کاربران اشاره شده - nobody: فقط کاربران اشاره شده public: هرکسی title: "%{name}: «%{quote}»" visibilities: diff --git a/config/locales/fi.yml b/config/locales/fi.yml index 54aadf60f32..6d7773a1f64 100644 --- a/config/locales/fi.yml +++ b/config/locales/fi.yml @@ -190,6 +190,7 @@ fi: create_relay: Luo välittäjä create_unavailable_domain: Luo ei-saatavilla oleva verkkotunnus create_user_role: Luo rooli + create_username_block: Luo käyttäjänimisääntö demote_user: Alenna käyttäjä destroy_announcement: Poista tiedote destroy_canonical_email_block: Poista sähköpostiosoitteen esto @@ -203,6 +204,7 @@ fi: destroy_status: Poista julkaisu destroy_unavailable_domain: Poista ei-saatavilla oleva verkkotunnus destroy_user_role: Hävitä rooli + destroy_username_block: Poista käyttäjänimisääntö disable_2fa_user: Poista kaksivaiheinen todennus käytöstä disable_custom_emoji: Poista mukautettu emoji käytöstä disable_relay: Poista välittäjä käytöstä @@ -237,6 +239,7 @@ fi: update_report: Päivitä raportti update_status: Päivitä julkaisu update_user_role: Päivitä rooli + update_username_block: Päivitä käyttäjänimisääntö actions: approve_appeal_html: "%{name} hyväksyi käyttäjän %{target} valituksen moderointipäätöksestä" approve_user_html: "%{name} hyväksyi käyttäjän %{target} rekisteröitymisen" @@ -255,6 +258,7 @@ fi: create_relay_html: "%{name} loi välittäjän %{target}" create_unavailable_domain_html: "%{name} pysäytti toimituksen verkkotunnukseen %{target}" create_user_role_html: "%{name} loi roolin %{target}" + create_username_block_html: "%{name} lisäsi säännön käyttäjänimille, joihin sisältyy %{target}" demote_user_html: "%{name} alensi käyttäjän %{target}" destroy_announcement_html: "%{name} poisti tiedotteen %{target}" destroy_canonical_email_block_html: "%{name} kumosi eston tiivistettä %{target} vastaavalta sähköpostiosoitteelta" @@ -268,6 +272,7 @@ fi: destroy_status_html: "%{name} poisti käyttäjän %{target} julkaisun" destroy_unavailable_domain_html: "%{name} jatkoi toimitusta verkkotunnukseen %{target}" destroy_user_role_html: "%{name} poisti roolin %{target}" + destroy_username_block_html: "%{name} poisti säännön käyttäjänimiltä, joihin sisältyy %{target}" disable_2fa_user_html: "%{name} poisti käyttäjältä %{target} vaatimuksen kaksivaiheiseen todentamiseen" disable_custom_emoji_html: "%{name} poisti emojin %{target} käytöstä" disable_relay_html: "%{name} poisti välittäjän %{target} käytöstä" @@ -302,6 +307,7 @@ fi: update_report_html: "%{name} päivitti raportin %{target}" update_status_html: "%{name} päivitti käyttäjän %{target} julkaisun" update_user_role_html: "%{name} muutti roolia %{target}" + update_username_block_html: "%{name} päivitti säännön käyttäjänimille, joihin sisältyy %{target}" deleted_account: poisti tilin empty: Lokeja ei löytynyt. filter_by_action: Suodata toimen mukaan @@ -1083,6 +1089,25 @@ fi: other: Käyttänyt %{count} käyttäjää viimeisen viikon aikana title: Suositukset ja trendit trending: Trendaus + username_blocks: + add_new: Lisää uusi + block_registrations: Estä rekisteröitymiset + comparison: + contains: Sisältää + equals: Vastaa + contains_html: Sisältää merkkijonon %{string} + created_msg: Käyttäjänimisääntö luotiin onnistuneesti + delete: Poista + edit: + title: Muokkaa käyttäjänimisääntöä + matches_exactly_html: Vastaa merkkijonoa %{string} + new: + create: Luo sääntö + title: Luo uusi käyttäjänimisääntö + no_username_block_selected: Käyttäjänimisääntöjä ei muutettu, koska yhtään ei ollut valittuna + not_permitted: Ei sallittu + title: Käyttäjänimisäännöt + updated_msg: Käyttäjänimisääntö päivitettiin onnistuneesti warning_presets: add_new: Lisää uusi delete: Poista @@ -1870,6 +1895,7 @@ fi: edited_at_html: Muokattu %{date} errors: in_reply_not_found: Julkaisua, johon yrität vastata, ei näytä olevan olemassa. + quoted_status_not_found: Julkaisua, jota yrität lainata, ei näytä olevan olemassa. over_character_limit: merkkimäärän rajoitus %{max} ylitetty pin_errors: direct: Vain mainituille käyttäjille näkyviä julkaisuja ei voi kiinnittää @@ -1877,8 +1903,8 @@ fi: ownership: Muiden julkaisuja ei voi kiinnittää reblog: Tehostusta ei voi kiinnittää quote_policies: - followers: Seuraajat ja mainitut käyttäjät - nobody: Vain mainitut käyttäjät + followers: Vain seuraajasi + nobody: Ei kukaan public: Kaikki title: "%{name}: ”%{quote}”" visibilities: diff --git a/config/locales/fo.yml b/config/locales/fo.yml index a63b39b5139..2dd500d694d 100644 --- a/config/locales/fo.yml +++ b/config/locales/fo.yml @@ -1905,8 +1905,6 @@ fo: ownership: Postar hjá øðrum kunnu ikki festast reblog: Ein stimbran kann ikki festast quote_policies: - followers: Fylgjarar og nevndir brúkarar - nobody: Bara nevndir brúkarar public: Øll title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/fr-CA.yml b/config/locales/fr-CA.yml index a866debe453..38de6ee8612 100644 --- a/config/locales/fr-CA.yml +++ b/config/locales/fr-CA.yml @@ -1852,8 +1852,6 @@ fr-CA: limit: Vous avez déjà épinglé le nombre maximum de messages ownership: Vous ne pouvez pas épingler un message ne vous appartenant pas reblog: Un partage ne peut pas être épinglé - quote_policies: - followers: Abonné·e·s et utilisateur·trice·s mentionné·e·s title: "%{name} : « %{quote} »" visibilities: direct: Direct diff --git a/config/locales/fr.yml b/config/locales/fr.yml index c171a9ed731..da5224fcf5e 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1852,8 +1852,6 @@ fr: limit: Vous avez déjà épinglé le nombre maximum de messages ownership: Vous ne pouvez pas épingler un message ne vous appartenant pas reblog: Un partage ne peut pas être épinglé - quote_policies: - followers: Abonné·e·s et utilisateur·trice·s mentionné·e·s title: "%{name} : « %{quote} »" visibilities: direct: Direct diff --git a/config/locales/fy.yml b/config/locales/fy.yml index 05b5b2fa39f..db7816996a0 100644 --- a/config/locales/fy.yml +++ b/config/locales/fy.yml @@ -1905,8 +1905,6 @@ fy: ownership: In berjocht fan in oar kin net fêstmakke wurde reblog: In boost kin net fêstset wurde quote_policies: - followers: Folgers en fermelde brûkers - nobody: Allinnich fermelde brûkers public: Elkenien title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/ga.yml b/config/locales/ga.yml index 8fff68b3d97..5e7da40a585 100644 --- a/config/locales/ga.yml +++ b/config/locales/ga.yml @@ -199,6 +199,7 @@ ga: create_relay: Cruthaigh Leaschraolacháin create_unavailable_domain: Cruthaigh Fearann ​​Gan Fáil create_user_role: Cruthaigh Ról + create_username_block: Cruthaigh Riail Ainm Úsáideora demote_user: Ísligh úsáideoir destroy_announcement: Scrios Fógra destroy_canonical_email_block: Scrios Bloc Ríomhphoist @@ -212,6 +213,7 @@ ga: destroy_status: Scrios Postáil destroy_unavailable_domain: Scrios Fearann ​​Gan Fáil destroy_user_role: Scrios ról + destroy_username_block: Scrios an Riail Ainm Úsáideora disable_2fa_user: Díchumasaigh 2FA disable_custom_emoji: Díchumasaigh Emoji Saincheaptha disable_relay: Díchumasaigh Leaschraolacháin @@ -246,6 +248,7 @@ ga: update_report: Tuairisc Nuashonraithe update_status: Nuashonraigh Postáil update_user_role: Nuashonraigh Ról + update_username_block: Nuashonraigh an Riail Ainm Úsáideora actions: approve_appeal_html: Cheadaigh %{name} achomharc ar chinneadh modhnóireachta ó %{target} approve_user_html: Cheadaigh %{name} clárú ó %{target} @@ -264,6 +267,7 @@ ga: create_relay_html: Chruthaigh %{name} athsheoladh %{target} create_unavailable_domain_html: Chuir %{name} deireadh leis an seachadadh chuig fearann ​​%{target} create_user_role_html: Chruthaigh %{name} %{target} ról + create_username_block_html: Chuir %{name} riail leis d'ainmneacha úsáideora ina bhfuil %{target} demote_user_html: "%{name} úsáideoir scriosta %{target}" destroy_announcement_html: "%{name} fógra scriosta %{target}" destroy_canonical_email_block_html: "%{name} ríomhphost díchoiscthe leis an hash %{target}" @@ -277,6 +281,7 @@ ga: destroy_status_html: Bhain %{name} postáil le %{target} destroy_unavailable_domain_html: D'athchrom %{name} ar an seachadadh chuig fearann ​​%{target} destroy_user_role_html: Scrios %{name} ról %{target} + destroy_username_block_html: Bhain %{name} riail as ainmneacha úsáideora ina bhfuil %{target} disable_2fa_user_html: Dhíchumasaigh %{name} riachtanas dhá fhachtóir don úsáideoir %{target} disable_custom_emoji_html: Dhíchumasaigh %{name} emoji %{target} disable_relay_html: Dhíchumasaigh %{name} an athsheoladh %{target} @@ -311,6 +316,7 @@ ga: update_report_html: "%{name} tuairisc nuashonraithe %{target}" update_status_html: "%{name} postáil nuashonraithe faoi %{target}" update_user_role_html: D'athraigh %{name} ról %{target} + update_username_block_html: Nuashonraíodh riail %{name} le haghaidh ainmneacha úsáideora ina bhfuil %{target} deleted_account: cuntas scriosta empty: Níor aimsíodh aon logaí. filter_by_action: Scag de réir gnímh @@ -1139,6 +1145,25 @@ ga: two: Úsáidte ag %{count} duine le seachtain anuas title: Moltaí & Treochtaí trending: Treocht + username_blocks: + add_new: Cuir nua leis + block_registrations: Clárúcháin blocála + comparison: + contains: Ina bhfuil + equals: Cothrom le + contains_html: Tá %{string} ann + created_msg: Cruthaíodh riail ainm úsáideora go rathúil + delete: Scrios + edit: + title: Cuir riail ainm úsáideora in eagar + matches_exactly_html: Cothrom le %{string} + new: + create: Cruthaigh riail + title: Cruthaigh riail ainm úsáideora nua + no_username_block_selected: Níor athraíodh aon rialacha ainm úsáideora mar nár roghnaíodh aon cheann acu + not_permitted: Ní cheadaítear + title: Rialacha ainm úsáideora + updated_msg: Nuashonraíodh an riail ainm úsáideora go rathúil warning_presets: add_new: Cuir nua leis delete: Scrios @@ -2009,8 +2034,8 @@ ga: ownership: Ní féidir postáil duine éigin eile a phionnáil reblog: Ní féidir treisiú a phinnáil quote_policies: - followers: Leantóirí agus úsáideoirí luaite - nobody: Úsáideoirí luaite amháin + followers: Do leanúna amháin + nobody: Aon duine public: Gach duine title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/gd.yml b/config/locales/gd.yml index f7791fe3544..fec5df06981 100644 --- a/config/locales/gd.yml +++ b/config/locales/gd.yml @@ -1961,8 +1961,6 @@ gd: ownership: Chan urrainn dhut post càich a phrìneachadh reblog: Chan urrainn dhut brosnachadh a phrìneachadh quote_policies: - followers: Luchd-leantainn ’s cleachdaichean le iomradh orra - nobody: Cleachdaichean le iomradh orra a-mhàin public: A h-uile duine title: "%{name}: “%{quote}”" visibilities: diff --git a/config/locales/gl.yml b/config/locales/gl.yml index ceace60dbc8..4a2f593d09e 100644 --- a/config/locales/gl.yml +++ b/config/locales/gl.yml @@ -1905,8 +1905,8 @@ gl: ownership: Non podes fixar a publicación doutra usuaria reblog: Non se poden fixar as mensaxes promovidas quote_policies: - followers: Seguidoras e usuarias mencionadas - nobody: Só usuarias mencionadas + followers: Só para seguidoras + nobody: Ninguén public: Calquera title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/he.yml b/config/locales/he.yml index 737d7464b26..53f42bfa3bc 100644 --- a/config/locales/he.yml +++ b/config/locales/he.yml @@ -1991,8 +1991,8 @@ he: ownership: הודעות של אחרים לא יכולות להיות מוצמדות reblog: אין אפשרות להצמיד הדהודים quote_policies: - followers: עוקבים ומאוזכרים - nobody: רק מאוזכרים ומאוזכרות + followers: לעוקביך בלבד + nobody: אף אחד public: כולם title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/hu.yml b/config/locales/hu.yml index ddb1841f1eb..4e8f9e80513 100644 --- a/config/locales/hu.yml +++ b/config/locales/hu.yml @@ -190,6 +190,7 @@ hu: create_relay: Továbbító létrehozása create_unavailable_domain: Elérhetetlen domain létrehozása create_user_role: Szerepkör létrehozása + create_username_block: Felhasználónév-szabály létrehozása demote_user: Felhasználó lefokozása destroy_announcement: Közlemény törlése destroy_canonical_email_block: E-mail-tiltás törlése @@ -203,6 +204,7 @@ hu: destroy_status: Bejegyzés törlése destroy_unavailable_domain: Elérhetetlen domain törlése destroy_user_role: Szerepkör eltávolítása + destroy_username_block: Felhasználónév-szabály törlése disable_2fa_user: Kétlépcsős hitelesítés letiltása disable_custom_emoji: Egyéni emodzsi letiltása disable_relay: Továbbító letiltása @@ -237,6 +239,7 @@ hu: update_report: Bejelentés frissítése update_status: Bejegyzés frissítése update_user_role: Szerepkör frissítése + update_username_block: Felhasználónév-szabály frissítése actions: approve_appeal_html: "%{name} jóváhagyott egy fellebbezést %{target} moderátori döntéséről" approve_user_html: "%{name} jóváhagyta %{target} regisztrációját" @@ -255,6 +258,7 @@ hu: create_relay_html: "%{name} létrehozta az átirányítót: %{target}" create_unavailable_domain_html: "%{name} leállította a kézbesítést a %{target} domainbe" create_user_role_html: "%{name} létrehozta a(z) %{target} szerepkört" + create_username_block_html: "%{name} az ezt tartalmazó felhasználónevekre vonatkozó szabályt hozott létre: %{target}" demote_user_html: "%{name} lefokozta %{target} felhasználót" destroy_announcement_html: "%{name} törölte a %{target} közleményt" destroy_canonical_email_block_html: "%{name} engedélyezte a(z) %{target} hashű e-mailt" @@ -268,6 +272,7 @@ hu: destroy_status_html: "%{name} eltávolította %{target} felhasználó bejegyzését" destroy_unavailable_domain_html: "%{name} újraindította a kézbesítést a %{target} domainbe" destroy_user_role_html: "%{name} törölte a(z) %{target} szerepkört" + destroy_username_block_html: "%{name} az ezt tartalmazó felhasználónevekre vonatkozó szabályt törölt: %{target}" disable_2fa_user_html: "%{name} kikapcsolta a kétlépcsős hitelesítést %{target} felhasználó fiókján" disable_custom_emoji_html: "%{name} letiltotta az emodzsit: %{target}" disable_relay_html: "%{name} letiltotta az átirányítót: %{target}" @@ -302,6 +307,7 @@ hu: update_report_html: "%{name} frissítette a %{target} bejelentést" update_status_html: "%{name} frissítette %{target} felhasználó bejegyzését" update_user_role_html: "%{name} módosította a(z) %{target} szerepkört" + update_username_block_html: "%{name} az ezt tartalmazó felhasználónevekre vonatkozó szabályt frissített: %{target}" deleted_account: törölt fiók empty: Nem található napló. filter_by_action: Szűrés művelet alapján @@ -1085,6 +1091,25 @@ hu: other: "%{count} ember használta a múlt héten" title: Ajánlások és trendek trending: Felkapott + username_blocks: + add_new: Új hozzáadása + block_registrations: Regisztrációk blokkolása + comparison: + contains: Tartalmazza + equals: Megegyezik vele + contains_html: 'Tartalmazza ezt: %{string}' + created_msg: Felhasználónév-szabály sikeresen létrehozva + delete: Törlés + edit: + title: Felhasználónév-szabály szerkesztése + matches_exactly_html: 'Megegyezik ezzel: %{string}' + new: + create: Szabály létrehozása + title: Új felhasználónév-szabály létrehozása + no_username_block_selected: Nem változott meg egy felhasználónév-szabály sem, mert semmi sem volt kiválasztva + not_permitted: Nem engedélyezett + title: Felhasználónév-szabályok + updated_msg: Felhasználónév-szabály sikeresen frissítve warning_presets: add_new: Új hozzáadása delete: Törlés @@ -1880,8 +1905,6 @@ hu: ownership: Nem tűzheted ki valaki más bejegyzését reblog: Megtolt bejegyzést nem tudsz kitűzni quote_policies: - followers: Követők és említett felhasználók - nobody: Csak említett felhasználók public: Mindenki title: "%{name}: „%{quote}”" visibilities: diff --git a/config/locales/ia.yml b/config/locales/ia.yml index 35dd56aad1d..f2b00a0e555 100644 --- a/config/locales/ia.yml +++ b/config/locales/ia.yml @@ -1832,8 +1832,6 @@ ia: ownership: Le message de alcuno altere non pote esser appunctate reblog: Un impulso non pote esser affixate quote_policies: - followers: Sequitores e usatores mentionate - nobody: Solmente usatores mentionate public: Omnes title: "%{name}: “%{quote}”" visibilities: diff --git a/config/locales/is.yml b/config/locales/is.yml index 11c8a7f568b..5feccdc9bd6 100644 --- a/config/locales/is.yml +++ b/config/locales/is.yml @@ -1909,8 +1909,8 @@ is: ownership: Færslur frá einhverjum öðrum er ekki hægt að festa reblog: Ekki er hægt að festa endurbirtingu quote_policies: - followers: Fylgjendur og notendur sem minnst er á - nobody: Einungis notendur sem minnst er á + followers: Einungis þeir sem fylgjast með þér + nobody: Enginn public: Allir title: "%{name}: „%{quote}‟" visibilities: diff --git a/config/locales/it.yml b/config/locales/it.yml index 2bf9f656ba0..cef3224f63a 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -190,6 +190,7 @@ it: create_relay: Crea Relay create_unavailable_domain: Crea Dominio Non Disponibile create_user_role: Crea Ruolo + create_username_block: Crea regola del nome utente demote_user: Retrocedi Utente destroy_announcement: Elimina Annuncio destroy_canonical_email_block: Elimina il blocco dell'e-mail @@ -203,6 +204,7 @@ it: destroy_status: Elimina Toot destroy_unavailable_domain: Elimina Dominio Non Disponibile destroy_user_role: Distruggi Ruolo + destroy_username_block: Cancella regola del nome utente disable_2fa_user: Disabilita A2F disable_custom_emoji: Disabilita Emoji Personalizzata disable_relay: Disabilita Relay @@ -237,6 +239,7 @@ it: update_report: Aggiorna segnalazione update_status: Aggiorna Toot update_user_role: Aggiorna Ruolo + update_username_block: Aggiorna regola del nome utente actions: approve_appeal_html: "%{name} ha approvato il ricorso sulla decisione di moderazione da %{target}" approve_user_html: "%{name} ha approvato l'iscrizione da %{target}" @@ -255,6 +258,7 @@ it: create_relay_html: "%{name} ha creato un relay %{target}" create_unavailable_domain_html: "%{name} ha interrotto la consegna al dominio %{target}" create_user_role_html: "%{name} ha creato il ruolo %{target}" + create_username_block_html: "%{name} ha aggiunto la regola per i nomi utente contenenti %{target}" demote_user_html: "%{name} ha retrocesso l'utente %{target}" destroy_announcement_html: "%{name} ha eliminato l'annuncio %{target}" destroy_canonical_email_block_html: "%{name} ha sbloccato l'e-mail con l'hash %{target}" @@ -268,6 +272,7 @@ it: destroy_status_html: "%{name} ha rimosso il toot di %{target}" destroy_unavailable_domain_html: "%{name} ha ripreso la consegna al dominio %{target}" destroy_user_role_html: "%{name} ha eliminato il ruolo %{target}" + destroy_username_block_html: "%{name} ha rimosso la regola per i nomi utente contenenti %{target}" disable_2fa_user_html: "%{name} ha disabilitato l'autenticazione a due fattori per l'utente %{target}" disable_custom_emoji_html: "%{name} ha disabilitato emoji %{target}" disable_relay_html: "%{name} ha disabilitato il relay %{target}" @@ -302,6 +307,7 @@ it: update_report_html: "%{name} ha aggiornato la segnalazione %{target}" update_status_html: "%{name} ha aggiornato lo status di %{target}" update_user_role_html: "%{name} ha modificato il ruolo %{target}" + update_username_block_html: "%{name} ha aggiornato la regola per i nomi utente contenenti %{target}" deleted_account: account eliminato empty: Nessun log trovato. filter_by_action: Filtra per azione @@ -1085,6 +1091,25 @@ it: other: Usato da %{count} persone nell'ultima settimana title: Raccomandazioni & Tendenze trending: Di tendenza + username_blocks: + add_new: Aggiungi una nuova + block_registrations: Blocco delle registrazioni + comparison: + contains: Contiene + equals: Uguale a + contains_html: Contiene %{string} + created_msg: Regola del nome utente creata con successo + delete: Elimina + edit: + title: Modifica regola del nome utente + matches_exactly_html: Uguale a %{string} + new: + create: Crea regola + title: Crea una nuova regola del nome utente + no_username_block_selected: Non sono state modificate le regole del nome utente in quanto non sono state selezionate + not_permitted: Non consentito + title: Regole del nome utente + updated_msg: Regola del nome utente aggiornata con successo warning_presets: add_new: Aggiungi nuovo delete: Cancella @@ -1874,6 +1899,7 @@ it: edited_at_html: Modificato il %{date} errors: in_reply_not_found: Il post a cui stai tentando di rispondere non sembra esistere. + quoted_status_not_found: Il post che stai cercando di citare non sembra esistere. over_character_limit: Limite caratteri superato di %{max} pin_errors: direct: I messaggi visibili solo agli utenti citati non possono essere fissati in cima @@ -1881,8 +1907,6 @@ it: ownership: Non puoi fissare in cima un post di qualcun altro reblog: Un toot condiviso non può essere fissato in cima quote_policies: - followers: Seguaci e utenti menzionati - nobody: Solo gli utenti menzionati public: Tutti title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/ja.yml b/config/locales/ja.yml index f3c4d3089ac..43433b7934c 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -1825,8 +1825,6 @@ ja: ownership: 他人の投稿を固定することはできません reblog: ブーストを固定することはできません quote_policies: - followers: フォロワーとメンションされたユーザー - nobody: メンションされたユーザーのみ public: 全員 title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/ko.yml b/config/locales/ko.yml index a3ae182a5c4..52825685013 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -1844,8 +1844,6 @@ ko: ownership: 다른 사람의 게시물은 고정될 수 없습니다 reblog: 부스트는 고정될 수 없습니다 quote_policies: - followers: 팔로워와 멘션된 사람들만 - nobody: 멘션된 사람들만 public: 모두 title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/lv.yml b/config/locales/lv.yml index f7db52a40f0..da04f36494c 100644 --- a/config/locales/lv.yml +++ b/config/locales/lv.yml @@ -1905,8 +1905,6 @@ lv: ownership: Kāda cita ierakstu nevar piespraust reblog: Pastiprinātu ierakstu nevar piespraust quote_policies: - followers: Sekotāji un pieminētie lietotāji - nobody: Tikai pieminētie lietotāji public: Visi title: "%{name}: “%{quote}”" visibilities: diff --git a/config/locales/nan.yml b/config/locales/nan.yml index 007ca847a81..f6c4f0dc383 100644 --- a/config/locales/nan.yml +++ b/config/locales/nan.yml @@ -187,6 +187,7 @@ nan: create_relay: 建立中繼 create_unavailable_domain: 建立bē當用ê域名 create_user_role: 建立角色 + create_username_block: 新造使用者號名規則 demote_user: Kā用者降級 destroy_announcement: Thâi掉公告 destroy_canonical_email_block: Thâi掉電子phue ê封鎖 @@ -200,6 +201,7 @@ nan: destroy_status: Thâi掉PO文 destroy_unavailable_domain: Thâi掉bē當用ê域名 destroy_user_role: Thâi掉角色 + destroy_username_block: 共使用者號名規則刣掉 disable_2fa_user: 停止用雙因素認證 disable_custom_emoji: 停止用自訂ê Emoji disable_relay: 停止用中繼 @@ -234,6 +236,7 @@ nan: update_report: 更新檢舉 update_status: 更新PO文 update_user_role: 更新角色 + update_username_block: 更新使用者號名規則 actions: approve_appeal_html: "%{name} 允准 %{target} 所寫ê tuì審核決定ê投訴" approve_user_html: "%{name} 允准 %{target} ê 註冊" diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 6d26903a20b..85fe706c44c 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -190,6 +190,7 @@ nl: create_relay: Relay aanmaken create_unavailable_domain: Niet beschikbaar domein aanmaken create_user_role: Rol aanmaken + create_username_block: Gebruikersnaam-regel aanmaken demote_user: Gebruiker degraderen destroy_announcement: Mededeling verwijderen destroy_canonical_email_block: E-mailblokkade verwijderen @@ -203,6 +204,7 @@ nl: destroy_status: Toot verwijderen destroy_unavailable_domain: Niet beschikbaar domein verwijderen destroy_user_role: Rol permanent verwijderen + destroy_username_block: Gebruikersnaam-regel verwijderen disable_2fa_user: Tweestapsverificatie uitschakelen disable_custom_emoji: Lokale emojij uitschakelen disable_relay: Relay uitschakelen @@ -237,6 +239,7 @@ nl: update_report: Rapportage bijwerken update_status: Bericht bijwerken update_user_role: Rol bijwerken + update_username_block: Gebruikersnaam-regel bijwerken actions: approve_appeal_html: "%{name} heeft het bezwaar tegen de moderatiemaatregel van %{target} goedgekeurd" approve_user_html: "%{name} heeft de registratie van %{target} goedgekeurd" @@ -255,6 +258,7 @@ nl: create_relay_html: "%{name} heeft een relay aangemaakt %{target}" create_unavailable_domain_html: "%{name} heeft de bezorging voor domein %{target} beëindigd" create_user_role_html: "%{name} maakte de rol %{target} aan" + create_username_block_html: "%{name} heeft regel toegevoegd voor gebruikersnamen die %{target} bevatten" demote_user_html: Gebruiker %{target} is door %{name} gedegradeerd destroy_announcement_html: "%{name} heeft de mededeling %{target} verwijderd" destroy_canonical_email_block_html: "%{name} deblokkeerde e-mail met de hash %{target}" @@ -268,6 +272,7 @@ nl: destroy_status_html: Bericht van %{target} is door %{name} verwijderd destroy_unavailable_domain_html: "%{name} heeft de bezorging voor domein %{target} hervat" destroy_user_role_html: "%{name} verwijderde de rol %{target}" + destroy_username_block_html: "%{name} heeft regel verwijderd voor gebruikersnamen die %{target} bevatten" disable_2fa_user_html: De vereiste tweestapsverificatie voor %{target} is door %{name} uitgeschakeld disable_custom_emoji_html: Emoji %{target} is door %{name} uitgeschakeld disable_relay_html: "%{name} heeft de relay %{target} uitgeschakeld" @@ -302,6 +307,7 @@ nl: update_report_html: Rapportage %{target} is door %{name} bijgewerkt update_status_html: "%{name} heeft de berichten van %{target} bijgewerkt" update_user_role_html: "%{name} wijzigde de rol %{target}" + update_username_block_html: "%{name} heeft regel bijgewerkt voor gebruikersnamen die %{target} bevatten" deleted_account: verwijderd account empty: Geen logs gevonden. filter_by_action: Op actie filteren @@ -1085,6 +1091,25 @@ nl: other: Door %{count} mensen tijdens de afgelopen week gebruikt title: Aanbevelingen & trends trending: Trending + username_blocks: + add_new: Nieuwe toevoegen + block_registrations: Registraties blokkeren + comparison: + contains: Bevat + equals: Is gelijk aan + contains_html: Bevat %{string} + created_msg: Gebruikersnaam-regel succesvol aangemaakt + delete: Verwijderen + edit: + title: Gebruikersnaam-regel bewerken + matches_exactly_html: Is gelijk aan %{string} + new: + create: Regel aanmaken + title: Nieuwe gebruikersnaam-regel aanmaken + no_username_block_selected: Er zijn geen gebruikersnaam-regels gewijzigd omdat er geen zijn geselecteerd + not_permitted: Niet toegestaan + title: Gebruikersnaam-regels + updated_msg: Gebruikersnaam-regel succesvol bijgewerkt warning_presets: add_new: Nieuwe toevoegen delete: Verwijderen @@ -1880,8 +1905,8 @@ nl: ownership: Een bericht van iemand anders kan niet worden vastgemaakt reblog: Een boost kan niet worden vastgezet quote_policies: - followers: Volgers en vermelde gebruikers - nobody: Alleen vermelde gebruikers + followers: Alleen jouw volgers + nobody: Niemand public: Iedereen title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/nn.yml b/config/locales/nn.yml index 7e3b453371f..14c26ea74a0 100644 --- a/config/locales/nn.yml +++ b/config/locales/nn.yml @@ -1875,8 +1875,6 @@ nn: ownership: Du kan ikkje festa andre sine tut reblog: Ei framheving kan ikkje festast quote_policies: - followers: Tilhengjarar og nemnde folk - nobody: Berre nemnde folk public: Alle title: "%{name}: «%{quote}»" visibilities: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 1e630275ce8..a4d5184f37e 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -1870,8 +1870,6 @@ pt-BR: ownership: As publicações dos outros não podem ser fixadas reblog: Um impulso não pode ser fixado quote_policies: - followers: Seguidores e usuários mencionados - nobody: Apenas usuários mencionados public: Todos title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/pt-PT.yml b/config/locales/pt-PT.yml index 1e988b5baed..e8c357ee0fa 100644 --- a/config/locales/pt-PT.yml +++ b/config/locales/pt-PT.yml @@ -1880,8 +1880,6 @@ pt-PT: ownership: Não podem ser fixadas publicações de outras pessoas reblog: Não é possível fixar um impulso quote_policies: - followers: Seguidores e utilizadores mencionados - nobody: Apenas utilizadores mencionados public: Todos title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/ru.yml b/config/locales/ru.yml index ad4b9d5122e..c825cac33dd 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -1653,9 +1653,9 @@ ru: on_cooldown: Вы пока не можете переезжать followers_count: Подписчиков на момент переезда incoming_migrations: Переезд со старой учётной записи - incoming_migrations_html: Переезжаете с другой учётной записи? Настройте псевдоним для переноса подписчиков. + incoming_migrations_html: Вы можете добавить связанную учётную запись, если собираетесь перенести оттуда подписчиков. moved_msg: Теперь ваша учётная запись перенаправляет к %{acct}, туда же перемещаются подписчики. - not_redirecting: Для вашей учётной записи пока не настроено перенаправление. + not_redirecting: Прямо сейчас ваша учётная запись никуда не перенаправлена. on_cooldown: Вы уже недавно переносили свою учётную запись. Эта возможность будет снова доступна через %{count} дн. past_migrations: История переездов proceed_with_move: Перенести подписчиков @@ -1666,7 +1666,7 @@ ru: backreference_required: Текущая учётная запись сначала должна быть добавлена как связанная в настройках новой учётной записи before: 'Внимательно ознакомьтесь со следующими замечаниями перед тем как продолжить:' cooldown: После переезда наступит период ожидания, в течение которого переезд будет невозможен - disabled_account: Вашу текущую учётная запись впоследствии нельзя будет больше использовать. При этом, у вас будет доступ к экспорту данных, а также к повторной активации учётной записи. + disabled_account: Переезд приведёт к тому, что вашу текущую учётную запись нельзя будет полноценно использовать. Тем не менее у вас останется доступ к экспорту данных и повторной активации учётной записи. followers: В результате переезда все ваши подписчики будут перенесены с текущей учётной записи на новую only_redirect_html: Также вы можете настроить перенаправление без переноса подписчиков. other_data: Никакие другие данные не будут автоматически перенесены @@ -1764,52 +1764,52 @@ ru: reach: Видимость reach_hint_html: Решите, нужна ли вам новая аудитория и новые подписчики. Настройте по своему желанию, показывать ли ваши посты в разделе «Актуальное», рекомендовать ли ваш профиль другим людям, принимать ли всех новых подписчиков автоматически или рассматривать каждый запрос на подписку в отдельности. search: Поиск - search_hint_html: Определите, как вас могут найти. Хотите ли вы, чтобы люди находили вас по тому, о чём вы публично писали? Хотите ли вы, чтобы люди за пределами Mastodon находили ваш профиль при поиске в Интернете? Следует помнить, что полное исключение из всех поисковых систем не может быть гарантировано для публичной информации. + search_hint_html: Решите, нужно ли вам скрыть себя из поиска. Настройте по своему желанию то, можно ли будет найти вас по публичным постам, а также то, можно ли будет кому угодно в интернете найти ваш профиль с помощью поисковых сайтов. Имейте в виду, что невозможно гарантировать полное исключение общедоступной информации из всех поисковых систем. title: Приватность и видимость privacy_policy: title: Политика конфиденциальности reactions: errors: - limit_reached: Достигнут лимит разных реакций - unrecognized_emoji: не является распознанным эмодзи + limit_reached: К этому объявлению уже добавлено максимальное количество уникальных реакций + unrecognized_emoji: не соответствует ни одному известному названию эмодзи redirects: - prompt: Если вы доверяете этой ссылке, нажмите на нее, чтобы продолжить. + prompt: Переходите по ссылке только в том случае, если доверяете сайту, на который она ведёт. title: Вы покидаете %{instance}. relationships: - activity: Активность учётной записи - confirm_follow_selected_followers: Вы уверены, что хотите подписаться на выбранных подписчиков? - confirm_remove_selected_followers: Вы уверены, что хотите удалить выбранных подписчиков? - confirm_remove_selected_follows: Вы уверены, что хотите удалить выбранные подписки? - dormant: Заброшенная + activity: Фильтр по активности + confirm_follow_selected_followers: Вы уверены, что хотите взаимно подписаться на выбранных пользователей? + confirm_remove_selected_followers: Вы уверены, что хотите убрать выбранных пользователей из подписчиков? + confirm_remove_selected_follows: Вы уверены, что хотите отписаться от выбранных пользователей? + dormant: Неактивные follow_failure: Не удалось подписаться за некоторыми из выбранных аккаунтов. - follow_selected_followers: Подписаться на выбранных подписчиков + follow_selected_followers: Подписаться в ответ followers: Подписчики following: Подписки invited: Приглашённые - last_active: По последней активности - most_recent: По недавности - moved: Мигрировавшая + last_active: Сначала активные + most_recent: Сначала новые + moved: Перенаправленные mutual: Взаимные подписки - primary: Основная - relationship: Связь - remove_selected_domains: Удалить всех подписчиков для выбранных доменов - remove_selected_followers: Удалить выбранных подписчиков + primary: Действующие + relationship: Вид отношений + remove_selected_domains: Убрать всех подписчиков с того же сервера + remove_selected_followers: Убрать из подписчиков remove_selected_follows: Отписаться от выбранных пользователей - status: Статус учётной записи + status: Фильтр по состоянию учётной записи remote_follow: missing_resource: Поиск требуемого перенаправления URL для Вашей учётной записи завершился неудачей reports: errors: - invalid_rules: не ссылается на действительные правила + invalid_rules: должны соответствовать правилам сервера rss: content_warning: 'Предупреждение о содержании:' descriptions: account: Публичные посты @%{acct} - tag: 'Публичные посты отмеченные хэштегом #%{hashtag}' + tag: 'Публичные посты с хештегом #%{hashtag}' scheduled_statuses: - over_daily_limit: Вы превысили лимит в %{limit} запланированных постов на указанный день - over_total_limit: Вы превысили лимит на %{limit} запланированных постов - too_soon: дата публикации должна быть в будущем + over_daily_limit: За сутки можно создать не более %{limit} отложенных постов + over_total_limit: Всего можно создать не более %{limit} отложенных постов + too_soon: задано слишком рано self_destruct: lead_html: К сожалению, %{domain} закрывается навсегда. Если вас учётная запись находиться здесь вы не сможете продолжить использовать его, но вы можете запросить резервную копию ваших данных. title: Этот сервер закрывается @@ -1818,13 +1818,13 @@ ru: browser: Браузер browsers: alipay: Alipay - blackberry: Blackberry + blackberry: BlackBerry chrome: Chrome edge: Microsoft Edge electron: Electron firefox: Firefox generic: Неизвестный браузер - huawei_browser: Huawei Browser + huawei_browser: Браузер Huawei ie: Internet Explorer micro_messenger: MicroMessenger nokia: Nokia S40 Ovi Browser @@ -1836,10 +1836,10 @@ ru: uc_browser: UC Browser unknown_browser: Неизвестный браузер weibo: Weibo - current_session: Текущая сессия + current_session: Текущий сеанс date: Дата - description: "%{browser} на %{platform}" - explanation: Здесь отображаются все браузеры, с которых выполнен вход в вашу учётную запись. Авторизованные приложения находятся в секции «Приложения». + description: "%{platform}, %{browser}" + explanation: Здесь перечислены все устройства, на которых вы используете свою учётную запись Mastodon. Также вы можете ip: IP platforms: adobe_air: Adobe Air @@ -1848,46 +1848,46 @@ ru: chrome_os: ChromeOS firefox_os: Firefox OS ios: iOS - kai_os: OS Кай + kai_os: KaiOS linux: Linux - mac: Mac + mac: macOS unknown_platform: Неизвестная платформа windows: Windows windows_mobile: Windows Mobile windows_phone: Windows Phone revoke: Завершить - revoke_success: Сессия завершена - title: Сессии - view_authentication_history: Посмотреть историю входов с учётной записью + revoke_success: Сеанс завершён + title: Сеансы + view_authentication_history: просмотреть историю входов в вашу учётную запись settings: account: Учётная запись - account_settings: Управление учётной записью - aliases: Псевдонимы учётной записи + account_settings: Настройки учётной записи + aliases: Связанные учётные записи appearance: Внешний вид authorized_apps: Приложения back: Назад в Mastodon delete: Удаление учётной записи development: Разработчикам - edit_profile: Изменить профиль + edit_profile: " Данные профиля" export: Экспорт - featured_tags: Избранные хэштеги + featured_tags: Рекомендации хештегов import: Импорт import_and_export: Импорт и экспорт - migrate: Миграция учётной записи - notifications: E-mail уведомление + migrate: Настройка перенаправления + notifications: Уведомления по эл. почте preferences: Настройки profile: Профиль relationships: Подписки и подписчики severed_relationships: Разорванные отношения - statuses_cleanup: Авто-удаление постов + statuses_cleanup: Автоудаление постов strikes: Замечания модерации two_factor_authentication: Подтверждение входа webauthn_authentication: Электронные ключи severed_relationships: download: Скачать (%{count}) event_type: - account_suspension: Приостановка аккаунта (%{target_name}) - domain_block: Приостановка сервера (%{target_name}) + account_suspension: Пользователь был заблокирован модераторами (%{target_name}) + domain_block: Сервер был заблокирован модераторами (%{target_name}) user_domain_block: Вы заблокировали %{target_name} lost_followers: Потерянные подписчики lost_follows: Потерянные подписки diff --git a/config/locales/simple_form.ar.yml b/config/locales/simple_form.ar.yml index 1bb2128f19b..ba1f3c12189 100644 --- a/config/locales/simple_form.ar.yml +++ b/config/locales/simple_form.ar.yml @@ -56,7 +56,6 @@ ar: scopes: ما هي المجالات المسموح بها في التطبيق ؟ إن قمت باختيار أعلى المجالات فيمكنك الاستغناء عن الخَيار اليدوي. setting_aggregate_reblogs: لا تقم بعرض المشارَكات الجديدة لمنشورات قد قُمتَ بمشاركتها سابقا (هذا الإجراء يعني المشاركات الجديدة فقط التي تلقيتَها) setting_always_send_emails: عادة لن تُرسَل إليك إشعارات البريد الإلكتروني عندما تكون نشطًا على ماستدون - setting_default_quote_policy: يسمح بالاقتباس دائما للمستخدمين المذكورين. هذا الإعداد سوف يكون نافذ المفعول فقط للمشاركات التي تم إنشاؤها مع إصدار ماستدون القادم، ولكن يمكنك تحديد تفضيلاتك للإعداد لذلك setting_default_sensitive: تُخفى الوسائط الحساسة تلقائيا ويمكن اظهارها عن طريق النقر عليها setting_display_media_default: إخفاء الوسائط المُعيَّنة كحساسة setting_display_media_hide_all: إخفاء كافة الوسائط دائمًا diff --git a/config/locales/simple_form.bg.yml b/config/locales/simple_form.bg.yml index e43970dba98..928b17ed1d1 100644 --- a/config/locales/simple_form.bg.yml +++ b/config/locales/simple_form.bg.yml @@ -56,7 +56,6 @@ bg: scopes: Указва до кои API има достъп приложението. Ако изберете диапазон от най-високо ниво, няма нужда да избирате индивидуални. setting_aggregate_reblogs: Без показване на нови подсилвания за публикации, които са неотдавна подсилени (засяга само новополучени подсилвания) setting_always_send_emails: Обикновено известията по имейл няма да са изпратени при дейна употреба на Mastodon - setting_default_quote_policy: Споменатите потребители винаги им е позволено да цитират. Тази настройка ще се отрази за публикациите, създадени със следващата версия на Mastodon, но може да изберете предпочитанията си в подготовката setting_default_sensitive: Деликатната мултимедия е скрита по подразбиране и може да се разкрие с едно щракване setting_display_media_default: Скриване на мултимедия отбелязана като деликатна setting_display_media_hide_all: Винаги скриване на мултимедията diff --git a/config/locales/simple_form.ca.yml b/config/locales/simple_form.ca.yml index 7ad6a71dd1e..64958711dab 100644 --- a/config/locales/simple_form.ca.yml +++ b/config/locales/simple_form.ca.yml @@ -56,7 +56,6 @@ ca: scopes: API permeses per a accedir a l'aplicació. Si selecciones un àmbit de nivell superior, no cal que en seleccionis un d'individual. setting_aggregate_reblogs: No mostra els nous impulsos dels tuts que ja s'han impulsat recentment (només afecta als impulsos nous rebuts) setting_always_send_emails: Normalment, no s'enviarà cap notificació per correu electrònic mentre facis servir Mastodon - setting_default_quote_policy: Els usuaris mencionats sempre poden citar. Aquesta configuració només tindrà efecte en les publicacions creades amb la següent versió de Mastodon, però podeu seleccionar-ho en preparació setting_default_sensitive: El contingut sensible està ocult per defecte i es pot mostrar fent-hi clic setting_display_media_default: Amaga el contingut gràfic marcat com a sensible setting_display_media_hide_all: Oculta sempre tot el contingut multimèdia diff --git a/config/locales/simple_form.cs.yml b/config/locales/simple_form.cs.yml index 9ee28a9b9f1..5ca0de9dc82 100644 --- a/config/locales/simple_form.cs.yml +++ b/config/locales/simple_form.cs.yml @@ -56,7 +56,7 @@ cs: scopes: Která API bude aplikace moct používat. Pokud vyberete rozsah nejvyššího stupně, nebudete je muset vybírat jednotlivě. setting_aggregate_reblogs: Nezobrazovat nové boosty pro příspěvky, které byly nedávno boostnuty (ovlivňuje pouze nově přijaté boosty) setting_always_send_emails: Jinak nebudou e-mailové notifikace posílány, když Mastodon aktivně používáte - setting_default_quote_policy: Zmínení uživatelé mají vždy povoleno citovat. Toto nastavení se projeví pouze u příspěvků vytvořených s další verzí Mastodonu, ale můžete si vybrat vaše předvolby v předstihu + setting_default_quote_policy: Toto nastavení se projeví pouze u příspěvků vytvořených s další verzí Mastodon, ale svou předvolbu si můžete vybrat předem. setting_default_sensitive: Citlivá média jsou ve výchozím stavu skryta a mohou být zobrazena kliknutím setting_display_media_default: Skrývat média označená jako citlivá setting_display_media_hide_all: Vždy skrývat média @@ -162,6 +162,10 @@ cs: name: Veřejný název role, pokud má být role zobrazena jako odznak permissions_as_keys: Uživatelé s touto rolí budou moci... position: Vyšší role rozhoduje o řešení konfliktů v určitých situacích. Některé akce lze provádět pouze na rolích s nižší prioritou + username_block: + allow_with_approval: Namísto toho, aby se zabránilo registraci, bude vyžadováno vaše schválení + comparison: Mějte prosím na paměti 'Scunthorpe problém' při blokování částečných shod + username: Bude součástí shod bez ohledu na kapitalizace a běžné homoglyfy jako "4" pro "a" nebo "3" pro "e" webhook: events: Zvolte odesílané události template: Sestavte si vlastní JSON payload pomocí interpolace proměnných. Pro výchozí JSON ponechte prázdné. @@ -373,6 +377,10 @@ cs: name: Název permissions_as_keys: Oprávnění position: Priorita + username_block: + allow_with_approval: Povolit registrace se schválením + comparison: Srovnávací metoda + username: Na základě slov webhook: events: Zapnuté události template: Šablona payloadu diff --git a/config/locales/simple_form.cy.yml b/config/locales/simple_form.cy.yml index a1390a3cc62..260d7530005 100644 --- a/config/locales/simple_form.cy.yml +++ b/config/locales/simple_form.cy.yml @@ -56,7 +56,6 @@ cy: scopes: Pa APIs y bydd y rhaglen yn cael mynediad iddynt. Os dewiswch gwmpas lefel uchaf, nid oes angen i chi ddewis rhai unigol. setting_aggregate_reblogs: Peidiwch â dangos hybiau newydd ar bostiadau sydd wedi cael eu hybu'n ddiweddar (dim ond yn effeithio ar hybiau newydd ei dderbyn) setting_always_send_emails: Fel arfer ni fydd hysbysiadau e-bost yn cael eu hanfon pan fyddwch chi wrthi'n defnyddio Mastodon - setting_default_quote_policy: Mae defnyddwyr sy'n cael eu crybwyll yn cael dyfynnu bob amser. Dim ond ar gyfer postiadau a grëwyd gyda'r fersiwn nesaf o Mastodon y bydd y gosodiad hwn yn dod i rym, ond gallwch ddewis eich dewis wrth baratoi setting_default_sensitive: Mae cyfryngau sensitif wedi'u cuddio yn rhagosodedig a gellir eu datgelu trwy glicio setting_display_media_default: Cuddio cyfryngau wedi eu marcio'n sensitif setting_display_media_hide_all: Cuddio cyfryngau bob tro diff --git a/config/locales/simple_form.da.yml b/config/locales/simple_form.da.yml index 72f98477b71..98412270e98 100644 --- a/config/locales/simple_form.da.yml +++ b/config/locales/simple_form.da.yml @@ -56,7 +56,7 @@ da: scopes: De API'er, som applikationen vil kunne tilgå. Vælges en topniveaudstrækning, vil detailvalg være unødvendige. setting_aggregate_reblogs: Vis ikke nye fremhævelser for nyligt fremhævede indlæg (påvirker kun nyligt modtagne fremhævelser) setting_always_send_emails: Normalt sendes ingen e-mailnotifikationer under aktivt brug af Mastodon - setting_default_quote_policy: Nævnte brugere har altid lov til at citere. Denne indstilling vil kun træde i kraft for indlæg oprettet med den næste Mastodon-version, men du kan som forberedelse vælge din præference + setting_default_quote_policy: Denne indstilling træder kun i kraft for indlæg, der oprettes med den næste Mastodon-version, men du kan vælge din præference som forberedelse. setting_default_sensitive: Sensitive medier er som standard skjult og kan vises med et klik setting_display_media_default: Skjul medier med sensitiv-markering setting_display_media_hide_all: Skjul altid medier diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index a6b1bc4f9a3..998befb1113 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -56,7 +56,7 @@ de: scopes: Welche Schnittstellen der Applikation erlaubt sind. Wenn du einen Top-Level-Scope auswählst, dann musst du nicht jeden einzelnen darunter auswählen. setting_aggregate_reblogs: Beiträge, die erst kürzlich geteilt wurden, werden nicht noch einmal angezeigt (betrifft nur zukünftig geteilte Beiträge) setting_always_send_emails: Normalerweise werden Benachrichtigungen nicht per E-Mail versendet, wenn du gerade auf Mastodon aktiv bist - setting_default_quote_policy: Erwähnte Profile dürfen immer zitieren. Diese Einstellung gilt nur für Beiträge, die mit der zukünftigen Mastodon-Version erstellt wurden. Als Vorbereitung darauf kannst du bereits jetzt die Einstellung vornehmen + setting_default_quote_policy: Diese Einstellung gilt nur für Beiträge, die mit der zukünftigen Mastodon-Version erstellt wurden. Als Vorbereitung darauf kannst du bereits jetzt die Einstellung vornehmen. setting_default_sensitive: Medien, die mit einer Inhaltswarnung versehen worden sind, werden – je nach Einstellung – erst nach einem zusätzlichen Klick angezeigt setting_display_media_default: Medien mit Inhaltswarnung ausblenden setting_display_media_hide_all: Medien immer ausblenden diff --git a/config/locales/simple_form.el.yml b/config/locales/simple_form.el.yml index fd32c087440..717ed59b1da 100644 --- a/config/locales/simple_form.el.yml +++ b/config/locales/simple_form.el.yml @@ -56,7 +56,6 @@ el: scopes: Ποια API θα επιτρέπεται στην εφαρμογή να χρησιμοποιήσεις. Αν επιλέξεις κάποιο υψηλό εύρος εφαρμογής, δε χρειάζεται να επιλέξεις και το καθένα ξεχωριστά. setting_aggregate_reblogs: Απόκρυψη των νέων αναρτήσεων για τις αναρτήσεις που έχουν ενισχυθεί πρόσφατα (επηρεάζει μόνο τις νέες ενισχύσεις) setting_always_send_emails: Κανονικά οι ειδοποιήσεις μέσω ηλεκτρονικού ταχυδρομείου δεν θα αποστέλλονται όταν χρησιμοποιείτε ενεργά το Mastodon - setting_default_quote_policy: Οι αναφερόμενοι χρήστες επιτρέπεται πάντα να παραθέτουν. Αυτή η ρύθμιση θα τεθεί σε ισχύ μόνο για αναρτήσεις που δημιουργήθηκαν με την επόμενη έκδοση Mastodon, αλλά μπορείς να επιλέξετε την προτίμησή σου κατά την προετοιμασία setting_default_sensitive: Τα ευαίσθητα πολυμέσα είναι κρυμμένα και εμφανίζονται με ένα κλικ setting_display_media_default: Απόκρυψη ευαίσθητων πολυμέσων setting_display_media_hide_all: Μόνιμη απόκρυψη όλων των πολυμέσων @@ -160,6 +159,10 @@ el: name: Δημόσιο όνομα του ρόλου, εάν ο ρόλος έχει οριστεί να εμφανίζεται ως σήμα permissions_as_keys: Οι χρήστες με αυτόν τον ρόλο θα έχουν πρόσβαση σε... position: Ανώτεροι ρόλοι αποφασίζει την επίλυση συγκρούσεων σε ορισμένες περιπτώσεις. Ορισμένες ενέργειες μπορούν να εκτελεστούν μόνο σε ρόλους με χαμηλότερη προτεραιότητα + username_block: + allow_with_approval: Αντί να αποτρέψετε την οριστική εγγραφή, η αντιστοίχιση εγγραφών θα απαιτήσει την έγκρισή σας + comparison: Παρακαλώ να λάβετε υπόψη το Πρόβλημα Scunthorpe κατά τη φραγή μερικών αντιστοιχίσεων + username: Θα αντιστοιχηθεί ανεξάρτητα από τα κεφαλαία/πεζά και τα κοινά ομόγλυφα όπως "4" για "α" ή "3" για "e" webhook: events: Επιλέξτε συμβάντα για αποστολή template: Σύνθεσε το δικό σου JSON payload χρησιμοποιώντας μεταβλητή παρεμβολή. Άφησε κενό για προεπιλογή JSON. @@ -371,6 +374,10 @@ el: name: Όνομα permissions_as_keys: Δικαιώματα position: Προτεραιότητα + username_block: + allow_with_approval: Να επιτρέπονται εγγραφές με έγκριση + comparison: Μέθοδος σύγκρισης + username: Λέξη για αντιστοίχιση webhook: events: Ενεργοποιημένα συμβάντα template: Πρότυπο payload diff --git a/config/locales/simple_form.en-GB.yml b/config/locales/simple_form.en-GB.yml index c61acd3248b..494f0c7b2a6 100644 --- a/config/locales/simple_form.en-GB.yml +++ b/config/locales/simple_form.en-GB.yml @@ -56,7 +56,6 @@ en-GB: scopes: Which APIs the application will be allowed to access. If you select a top-level scope, you don't need to select individual ones. setting_aggregate_reblogs: Do not show new boosts for posts that have been recently boosted (only affects newly-received boosts) setting_always_send_emails: Normally e-mail notifications won't be sent when you are actively using Mastodon - setting_default_quote_policy: Mentioned users are always allowed to quote. This setting will only take effect for posts created with the next Mastodon version, but you can select your preference in preparation setting_default_sensitive: Sensitive media is hidden by default and can be revealed with a click setting_display_media_default: Hide media marked as sensitive setting_display_media_hide_all: Always hide media diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 86fb4528de4..8da54f626f7 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -56,7 +56,7 @@ en: scopes: Which APIs the application will be allowed to access. If you select a top-level scope, you don't need to select individual ones. setting_aggregate_reblogs: Do not show new boosts for posts that have been recently boosted (only affects newly-received boosts) setting_always_send_emails: Normally e-mail notifications won't be sent when you are actively using Mastodon - setting_default_quote_policy: Mentioned users are always allowed to quote. This setting will only take effect for posts created with the next Mastodon version, but you can select your preference in preparation + setting_default_quote_policy: This setting will only take effect for posts created with the next Mastodon version, but you can select your preference in preparation. setting_default_sensitive: Sensitive media is hidden by default and can be revealed with a click setting_display_media_default: Hide media marked as sensitive setting_display_media_hide_all: Always hide media diff --git a/config/locales/simple_form.es-AR.yml b/config/locales/simple_form.es-AR.yml index b1b89124d80..0b7dde5cb8c 100644 --- a/config/locales/simple_form.es-AR.yml +++ b/config/locales/simple_form.es-AR.yml @@ -56,7 +56,7 @@ es-AR: scopes: Qué APIs de la aplicación tendrán acceso. Si seleccionás el alcance de nivel más alto, no necesitás seleccionar las individuales. setting_aggregate_reblogs: No mostrar nuevas adhesiones de los mensajes que fueron recientemente adheridos (sólo afecta a las adhesiones recibidas recientemente) setting_always_send_emails: Normalmente las notificaciones por correo electrónico no se enviarán cuando estés usando Mastodon activamente - setting_default_quote_policy: Los usuarios mencionados siempre pueden citar. Este ajuste solo afecta a las publicaciones creadas con la próxima versión de Mastodon, pero podés seleccionar tus preferencias con antelación. + setting_default_quote_policy: Este ajuste solo tendrá efecto en mensajes creados usando la próxima versión mayor de Mastodon, pero podés configurarlo con anticipación. setting_default_sensitive: El contenido de medios sensibles está oculto predeterminadamente y puede ser mostrado con un clic setting_display_media_default: Ocultar medios marcados como sensibles setting_display_media_hide_all: Siempre ocultar todos los medios diff --git a/config/locales/simple_form.es-MX.yml b/config/locales/simple_form.es-MX.yml index dd57c522a11..0aee8fab9d8 100644 --- a/config/locales/simple_form.es-MX.yml +++ b/config/locales/simple_form.es-MX.yml @@ -56,7 +56,7 @@ es-MX: scopes: Qué APIs de la aplicación tendrán acceso. Si seleccionas el alcance de nivel mas alto, no necesitas seleccionar las individuales. setting_aggregate_reblogs: No mostrar nuevos impulsos para las publicaciones que han sido recientemente impulsadas (sólo afecta a las publicaciones recibidas recientemente) setting_always_send_emails: Normalmente las notificaciones por correo electrónico no se enviarán cuando estés usando Mastodon activamente - setting_default_quote_policy: Los usuarios mencionados siempre pueden citar. Esta configuración solo se aplicará a las publicaciones creadas con la próxima versión de Mastodon, pero puedes seleccionar tus preferencias anticipadamente + setting_default_quote_policy: Este ajuste solo tendrá efecto en publicaciones creadas con la próxima versión de Mastodon, pero puedes configurarlo previamente. setting_default_sensitive: El contenido multimedia sensible está oculto por defecto y puede ser mostrado con un clic setting_display_media_default: Ocultar contenido multimedia marcado como sensible setting_display_media_hide_all: Siempre ocultar todo el contenido multimedia diff --git a/config/locales/simple_form.es.yml b/config/locales/simple_form.es.yml index 6bef4ba62d9..ce2a74c3e10 100644 --- a/config/locales/simple_form.es.yml +++ b/config/locales/simple_form.es.yml @@ -56,7 +56,7 @@ es: scopes: Qué APIs de la aplicación tendrán acceso. Si seleccionas el alcance de nivel mas alto, no necesitas seleccionar las individuales. setting_aggregate_reblogs: No mostrar nuevos impulsos para las publicaciones que han sido recientemente impulsadas (sólo afecta a los impulsos recibidos recientemente) setting_always_send_emails: Normalmente las notificaciones por correo electrónico no se enviarán cuando estés usando Mastodon activamente - setting_default_quote_policy: Los usuarios mencionados siempre pueden citar. Este ajuste solo afecta a las publicaciones creadas con la próxima versión de Mastodon, pero puedes seleccionar tus preferencias anticipadamente + setting_default_quote_policy: Este ajuste solo tendrá efecto en publicaciones creadas con la próxima versión de Mastodon, pero puedes configurarlo previamente. setting_default_sensitive: El contenido multimedia sensible está oculto por defecto y puede ser mostrado con un click setting_display_media_default: Ocultar contenido multimedia marcado como sensible setting_display_media_hide_all: Siempre ocultar todo el contenido multimedia diff --git a/config/locales/simple_form.eu.yml b/config/locales/simple_form.eu.yml index 239591e05f9..be04b7b0267 100644 --- a/config/locales/simple_form.eu.yml +++ b/config/locales/simple_form.eu.yml @@ -56,7 +56,6 @@ eu: scopes: Zeintzuk API atzitu ditzakeen aplikazioak. Goi mailako arloa aukeratzen baduzu, ez dituzu azpikoak aukeratu behar. setting_aggregate_reblogs: Ez erakutsi bultzada berriak berriki bultzada jaso duten tootentzat (berriki jasotako bultzadei eragiten die bakarrik) setting_always_send_emails: Normalean eposta jakinarazpenak ez dira bidaliko Mastodon aktiboki erabiltzen ari zaren bitartean - setting_default_quote_policy: Aipaturiko erabiltzaileek beti dute aipatzeko baimena. Ezarpen honek Mastodon-en hurrengo bertsioarekin sortutako argitalpenetan bakarrik izango du eragina, baina prestatzean lehentasuna hauta dezakezu setting_default_sensitive: Multimedia hunkigarria lehenetsita ezkutatzen da, eta sakatuz ikusi daiteke setting_display_media_default: Ezkutatu hunkigarri gisa markatutako multimedia setting_display_media_hide_all: Ezkutatu multimedia guztia beti diff --git a/config/locales/simple_form.fa.yml b/config/locales/simple_form.fa.yml index bc7c4703da0..e96f7d43db5 100644 --- a/config/locales/simple_form.fa.yml +++ b/config/locales/simple_form.fa.yml @@ -56,7 +56,6 @@ fa: scopes: واسط‌های برنامه‌نویسی که این برنامه به آن دسترسی دارد. اگر بالاترین سطح دسترسی را انتخاب کنید، دیگر نیازی به انتخاب سطح‌های پایینی ندارید. setting_aggregate_reblogs: برای تقویت‌هایی که به تازگی برایتان نمایش داده شده‌اند، تقویت‌های بیشتر را نمایش نده (فقط روی تقویت‌های اخیر تأثیر می‌گذارد) setting_always_send_emails: در حالت عادی آگاهی‌های رایانامه‌ای هنگامی که فعّالانه از ماستودون استفاده می‌کنید فرستاده نمی‌شوند - setting_default_quote_policy: کاربران اشاره شده همواره مجاز به نقلند. این تنظیمات تنها روی فرسته‌های ایجاد شده با نگارش بعدی ماستودون موثّر است، ولی می‌توانید ترجیحاتتان را پیشاپیش بگزینید setting_default_sensitive: تصاویر حساس به طور پیش‌فرض پنهان هستند و می‌توانند با یک کلیک آشکار شوند setting_display_media_default: تصویرهایی را که به عنوان حساس علامت زده شده‌اند پنهان کن setting_display_media_hide_all: همیشه همهٔ عکس‌ها و ویدیوها را پنهان کن diff --git a/config/locales/simple_form.fi.yml b/config/locales/simple_form.fi.yml index 5a806fb8e76..5edb75cdb90 100644 --- a/config/locales/simple_form.fi.yml +++ b/config/locales/simple_form.fi.yml @@ -56,11 +56,12 @@ fi: scopes: Mihin ohjelmointirajapintoihin sovelluksella on pääsy. Jos valitset ylätason käyttöoikeuden, sinun ei tarvitse valita yksittäisiä. setting_aggregate_reblogs: Älä näytä uusia tehostuksia julkaisuille, joita on äskettäin tehostettu (koskee vain juuri vastaanotettuja tehostuksia) setting_always_send_emails: Yleensä sähköposti-ilmoituksia ei lähetetä, kun käytät Mastodonia aktiivisesti - setting_default_quote_policy: Mainitut käyttäjät saavat aina lainata. Tämä asetus koskee vain julkaisuja, jotka on luotu seuraavalla Mastodon-versiolla, mutta voit valita asetuksesi valmistautuaksesi + setting_default_quote_policy: Tämä asetus tulee voimaan vain julkaisuissa, jotka on luotu seuraavalla Mastodon-versiolla, mutta voit valita asetuksesi jo etukäteen. setting_default_sensitive: Arkaluonteinen media piilotetaan oletusarvoisesti, ja se voidaan näyttää yhdellä napsautuksella setting_display_media_default: Piilota arkaluonteiseksi merkitty mediasisältö setting_display_media_hide_all: Piilota mediasisältö aina setting_display_media_show_all: Näytä mediasisältö aina + setting_emoji_style: Miten emojit näkyvät. ”Automaattinen” pyrkii käyttämään natiiveja emojeita, mutta Twemoji-emojeita käytetään varavaihtoehtoina vanhoissa selaimissa. setting_system_scrollbars_ui: Koskee vain Safari- ja Chrome-pohjaisia työpöytäselaimia setting_use_blurhash: Liukuvärit perustuvat piilotettujen kuvien väreihin mutta sumentavat yksityiskohdat setting_use_pending_items: Piilota aikajanan päivitykset napsautuksen taakse syötteen automaattisen vierityksen sijaan @@ -148,6 +149,9 @@ fi: min_age: Ei pidä alittaa lainkäyttöalueesi lakien vaatimaa vähimmäisikää. user: chosen_languages: Jos valitset kieliä oheisesta luettelosta, vain niidenkieliset julkaisut näkyvät sinulle julkisilla aikajanoilla + date_of_birth: + one: Meidän tulee varmistaa, että olet vähintään %{count}, jotta voit käyttää %{domain}. Emme tallenna tätä. + other: Meidän tulee varmistaa, että olet vähintään %{count}, jotta voit käyttää %{domain}. Emme tallenna tätä. role: Rooli määrää, millaiset käyttöoikeudet käyttäjällä on. user_role: color: Väri, jota käytetään roolille kaikkialla käyttöliittymässä, RGB-heksadesimaalimuodossa @@ -155,6 +159,10 @@ fi: name: Roolin julkinen nimi, jos rooli on asetettu näytettäväksi merkkinä permissions_as_keys: Käyttäjillä, joilla on tämä rooli, on käyttöoikeus… position: Korkeampi rooli ratkaisee konfliktit tietyissä tilanteissa. Tiettyjä toimia voidaan suorittaa vain rooleilla, joiden prioriteetti on pienempi + username_block: + allow_with_approval: Sen sijaan, että rekisteröityminen estetään kokonaan, sääntöä vastaavat rekisteröitymiset edellyttävät hyväksyntääsi + comparison: Ota Scunthorpe-ongelma huomioon, kun estät osittaisia osumia + username: Vastaa riippumatta aakkoskoosta ja yleisistä homoglyyfeistä kuten ”4” merkille ”a” ja ”3” merkille ”e” webhook: events: Valitse lähetettävät tapahtumat template: Luo oma JSON-hyötykuorma käyttäen muuttujien interpolointia. Jätä kenttä tyhjäksi käyttääksesi vakio-JSON-kuormaa. @@ -348,6 +356,7 @@ fi: admin_email: Sähköpostiosoite oikeudellisille ilmoituksille arbitration_address: Fyysinen osoite välimiesmenettelyn ilmoituksille arbitration_website: Sähköpostiosoite välimiesmenettelyn ilmoituksille + choice_of_law: Sovellettava lainsäädäntö dmca_address: Fyysinen osoite DMCA-/tekijänoikeusilmoituksille dmca_email: Sähköpostiosoite DMCA-/tekijänoikeusilmoituksille domain: Verkkotunnus @@ -365,6 +374,10 @@ fi: name: Nimi permissions_as_keys: Käyttöoikeudet position: Prioriteetti + username_block: + allow_with_approval: Salli rekisteröitymiset hyväksynnällä + comparison: Vertailumenetelmä + username: Vastattava sana webhook: events: Käytössä olevat tapahtumat template: Hyötykuormapohja diff --git a/config/locales/simple_form.fo.yml b/config/locales/simple_form.fo.yml index acbd35b9874..b9176cb6399 100644 --- a/config/locales/simple_form.fo.yml +++ b/config/locales/simple_form.fo.yml @@ -56,7 +56,6 @@ fo: scopes: Hvørji API nýtsluskipanin fær atgongd til. Velur tú eitt vav á hægsta stigi, so er ikki neyðugt at velja tey einstøku. setting_aggregate_reblogs: Vís ikki nýggjar stimbranir fyri postar, sum nýliga eru stimbraðir (ávirkar einans stimbranir, ið eru móttiknar fyri kortum) setting_always_send_emails: Vanliga vera teldupostfráboðanir ikki sendar, tá tú virkin brúkar Mastodon - setting_default_quote_policy: Nevndir brúkarar hava altíð loyvi at sitera. Hendan stillingin verður bara virkin fyri postar, sum verða stovnaðir í næstu Mastodon útgávuni, men sum fyrireiking til tað, kanst tú velja tína stilling longu nú setting_default_sensitive: Viðkvæmar miðlafílur eru fjaldar og kunnu avdúkast við einum klikki setting_display_media_default: Fjal miðlafílur, sum eru merktar sum viðkvæmar setting_display_media_hide_all: Fjal altíð miðlafílur diff --git a/config/locales/simple_form.fy.yml b/config/locales/simple_form.fy.yml index 584da81f3d9..690522b8cf6 100644 --- a/config/locales/simple_form.fy.yml +++ b/config/locales/simple_form.fy.yml @@ -56,7 +56,6 @@ fy: scopes: Ta hokker API’s hat de tapassing tagong. Wannear’t jo in tastimming fan it boppeste nivo kieze, hoege jo gjin yndividuele tastimmingen mear te kiezen. setting_aggregate_reblogs: Gjin nije boosts toane foar berjochten dy’t resintlik noch boost binne (hat allinnich effekt op nij ûntfongen boosts) setting_always_send_emails: Normaliter wurde der gjin e-mailmeldingen ferstjoerd wannear’t jo aktyf Mastodon brûke - setting_default_quote_policy: It is foar brûkers dy’t fermeld wurde altyd tastien om te sitearjen. Dizze ynstelling is allinnich fan tapassing foar berjochten dy’t makke binne mei de folgjende Mastodon-ferzje, mar jo kinne jo foarkar no al ynstelle setting_default_sensitive: Gefoelige media wurdt standert ferstoppe en kin mei ien klik toand wurde setting_display_media_default: As gefoelich markearre media ferstopje setting_display_media_hide_all: Media altyd ferstopje diff --git a/config/locales/simple_form.ga.yml b/config/locales/simple_form.ga.yml index 59cd532fd99..0f8c8c3a703 100644 --- a/config/locales/simple_form.ga.yml +++ b/config/locales/simple_form.ga.yml @@ -56,7 +56,7 @@ ga: scopes: Cé na APIanna a mbeidh cead ag an bhfeidhmchlár rochtain a fháil orthu. Má roghnaíonn tú raon feidhme barrleibhéil, ní gá duit cinn aonair a roghnú. setting_aggregate_reblogs: Ná taispeáin treisithe nua do phoist a treisíodh le déanaí (ní dhéanann difear ach do threisithe nuafhaighte) setting_always_send_emails: Go hiondúil ní sheolfar fógraí ríomhphoist agus tú ag úsáid Mastodon go gníomhach - setting_default_quote_policy: Ceadaítear d’úsáideoirí luaite lua a dhéanamh i gcónaí. Ní bheidh an socrú seo i bhfeidhm ach amháin maidir le poist a cruthaíodh leis an gcéad leagan eile de Mastodon, ach is féidir leat do rogha féin a roghnú agus tú ag ullmhú + setting_default_quote_policy: Ní bheidh an socrú seo i bhfeidhm ach amháin maidir le poist a cruthaíodh leis an gcéad leagan eile de Mastodon, ach is féidir leat do rogha féin a roghnú agus tú ag ullmhú. setting_default_sensitive: Tá meáin íogair i bhfolach de réir réamhshocraithe agus is féidir iad a nochtadh le cliceáil setting_display_media_default: Folaigh meáin atá marcáilte mar íogair setting_display_media_hide_all: Folaigh meáin i gcónaí @@ -163,6 +163,10 @@ ga: name: Ainm poiblí an róil, má tá an ról socraithe le taispeáint mar shuaitheantas permissions_as_keys: Beidh rochtain ag úsáideoirí a bhfuil an ról seo acu ar... position: Cinneann ról níos airde réiteach coinbhleachta i gcásanna áirithe. Ní féidir gníomhartha áirithe a dhéanamh ach amháin ar róil a bhfuil tosaíocht níos ísle acu + username_block: + allow_with_approval: In ionad cosc iomlán a chur ar chlárú, beidh ort do cheadú a fháil chun clárúcháin a mheaitseáil + comparison: Tabhair aird ar Fhadhb Scunthorpe agus tú ag blocáil cluichí páirteacha + username: Déanfar é a mheaitseáil beag beann ar an gcásáil agus homaiglifí coitianta cosúil le "4" in ionad "a" nó "3" in ionad "e" webhook: events: Roghnaigh imeachtaí le seoladh template: Cum do phálasta JSON féin ag baint úsáide as idirshuíomh athróg. Fág bán le haghaidh JSON réamhshocraithe. @@ -374,6 +378,10 @@ ga: name: Ainm permissions_as_keys: Ceadanna position: Tosaíocht + username_block: + allow_with_approval: Ceadaigh clárúcháin le ceadú + comparison: Modh comparáide + username: Focal le meaitseáil webhook: events: Imeachtaí cumasaithe template: Teimpléad pá-ualach diff --git a/config/locales/simple_form.gd.yml b/config/locales/simple_form.gd.yml index dfcd2590d94..7cdc27750b4 100644 --- a/config/locales/simple_form.gd.yml +++ b/config/locales/simple_form.gd.yml @@ -56,7 +56,6 @@ gd: scopes: Na APIan a dh’fhaodas an aplacaid inntrigeadh. Ma thaghas tu sgòp air ìre as àirde, cha leig thu leas sgòpaichean fa leth a thaghadh. setting_aggregate_reblogs: Na seall brosnachaidhean ùra do phostaichean a chaidh a bhrosnachadh o chionn goirid (cha doir seo buaidh ach air brosnachaidhean ùra o seo a-mach) setting_always_send_emails: Mar as àbhaist, cha dèid brathan puist-d a chur nuair a a bhios tu ri Mastodon gu cunbhalach - setting_default_quote_policy: Faodaidh luchd-cleachdaidh le iomradh orra luaidh an-còmhnaidh. Cha bhi an roghainn seo ann sàs ach air postaichean a thèid a chruthachadh leis an ath-thionndadh de Mhastodon ach ’s urrainn dhut do roghainn a thaghadh airson ullachadh dha. setting_default_sensitive: Thèid meadhanan frionasach fhalach a ghnàth is gabhaidh an nochdadh le briogadh orra setting_display_media_default: Falaich meadhanan ris a bheil comharra gu bheil iad frionasach setting_display_media_hide_all: Falaich na meadhanan an-còmhnaidh diff --git a/config/locales/simple_form.gl.yml b/config/locales/simple_form.gl.yml index 1e64cb94e64..18c259f22ce 100644 --- a/config/locales/simple_form.gl.yml +++ b/config/locales/simple_form.gl.yml @@ -56,7 +56,7 @@ gl: scopes: A que APIs terá acceso a aplicación. Se escolles un ámbito de alto nivel, non precisas seleccionar elementos individuais. setting_aggregate_reblogs: Non mostrar novas promocións de publicacións que foron promovidas recentemente (só afecta a promocións recén recibidas) setting_always_send_emails: Como norma xeral non che enviamos correos electrónicos se usas activamente Mastodon - setting_default_quote_policy: As usuarias mencionadas sempre teñen permiso para citar. Este axuste só ten efecto para publicacións creadas coa próxima versión de Mastodon, pero xa podes ir preparando o axuste. + setting_default_quote_policy: O axuste só afectará ás publicación creadas coa próxima versión de Mastodon, pero podes establecer o axuste con antelación. setting_default_sensitive: Medios sensibles marcados como ocultos por defecto e móstranse cun click setting_display_media_default: Ocultar medios marcados como sensibles setting_display_media_hide_all: Ocultar sempre os medios diff --git a/config/locales/simple_form.he.yml b/config/locales/simple_form.he.yml index d6ede9e3073..78c168f6fd5 100644 --- a/config/locales/simple_form.he.yml +++ b/config/locales/simple_form.he.yml @@ -56,7 +56,7 @@ he: scopes: לאיזה ממשק יורשה היישום לגשת. בבחירת תחום כללי, אין צורך לבחור ממשקים ספציפיים. setting_aggregate_reblogs: לא להראות הדהודים של הודעות שהודהדו לאחרונה (משפיע רק על הדהודים שהתקבלו לא מזמן) setting_always_send_emails: בדרך כלל התראות דוא"ל לא יישלחו בזמן שימוש פעיל במסטודון - setting_default_quote_policy: משתמשיםות מאוזכריםות תמיד חופשיים לצטט. הכיוונון הזה משפיע רק על פרסומים שישלחו בגרסאות מסטודון עתידיות, ניתן לבחור את העדפתך כהכנה לגרסא שתבוא + setting_default_quote_policy: הכיוונון הזה משפיע רק על פרסומים שישלחו בגרסאות מסטודון עתידיות, ניתן לבחור את העדפתך כהכנה לגרסא שתבוא. setting_default_sensitive: מדיה רגישה מוסתרת כברירת מחדל וניתן להציגה בקליק setting_display_media_default: הסתרת מדיה המסומנת כרגישה setting_display_media_hide_all: הסתר מדיה תמיד diff --git a/config/locales/simple_form.hu.yml b/config/locales/simple_form.hu.yml index 61ca09b951f..7e148d706c9 100644 --- a/config/locales/simple_form.hu.yml +++ b/config/locales/simple_form.hu.yml @@ -56,7 +56,6 @@ hu: scopes: Mely API-kat érheti el az alkalmazás. Ha felső szintű hatáskört választasz, nem kell egyesével kiválasztanod az alatta lévőeket. setting_aggregate_reblogs: Ne mutassunk megtolásokat olyan bejegyzésekhez, melyeket nemrég toltak meg (csak új megtolásokra lép életbe) setting_always_send_emails: Alapesetben nem küldünk e-mail-értesítéseket, ha aktívan használod a Mastodont - setting_default_quote_policy: A megemlített felhasználók mindig idézhetnek. A beállítás csak a Mastodon következő verziójával készült bejegyzésekre lesz hatással, de előre kiválaszthatod az előnyben részesített beállítást. setting_default_sensitive: A kényes médiatartalmat alapesetben elrejtjük, de egyetlen kattintással előhozható setting_display_media_default: Kényes tartalomnak jelölt média elrejtése setting_display_media_hide_all: Média elrejtése mindig @@ -160,6 +159,10 @@ hu: name: A szerep nyilvános neve, ha a szerepet úgy állították be, hogy jelvényként látható legyen permissions_as_keys: A felhasználók ezzel a szereppel elérhetik a... position: A magasabb szerepkör oldja fel az ütközéseket bizonyos helyzetekben. Bizonyos műveleteket csak alacsonyabb prioritású szerepkörrel lehet elvégezni. + username_block: + allow_with_approval: A regisztráció azonnali megakadályozása helyett az illeszkedő regisztrációkhoz jóváhagyás szükséges + comparison: Vegye figyelembe a Scunthorpe-problémát, amikor részleges egyezéseket blokkol + username: Az illeszkedés egyezőnek tekinti a kis- és nagybetűket, valamint a gyakori homoglifákat, mint a „4” és az „a” vagy a „3” és az „e” webhook: events: Válaszd ki a küldendő eseményeket template: Saját JSON adatcsomagot állíthatsz össze változó-behelyettesítés használatával. Hagyd üresen az alapértelmezett JSON adatcsomaghoz. @@ -371,6 +374,10 @@ hu: name: Név permissions_as_keys: Engedélyek position: Prioritás + username_block: + allow_with_approval: Regisztráció engedélyezése jóváhagyással + comparison: Összehasonlítás módja + username: Ellenőrzendő szó webhook: events: Engedélyezett események template: Adatcsomag sablon diff --git a/config/locales/simple_form.ia.yml b/config/locales/simple_form.ia.yml index edcb43634a5..78af1c11922 100644 --- a/config/locales/simple_form.ia.yml +++ b/config/locales/simple_form.ia.yml @@ -56,7 +56,6 @@ ia: scopes: Le APIs al quales le application habera accesso. Si tu selige un ambito de nivello superior, non es necessari seliger ambitos individual. setting_aggregate_reblogs: Non monstrar nove impulsos pro messages que ha essite recentemente impulsate (affecta solmente le impulsos novemente recipite) setting_always_send_emails: Normalmente, le notificationes de e-mail non es inviate quando tu activemente usa Mastodon - setting_default_quote_policy: Le usatores mentionate sempre ha permission pro citar. Iste parametro solo habera effecto pro messages create con le proxime version de Mastodon, ma tu pote seliger tu preferentia anticipatemente setting_default_sensitive: Le medios sensibile es celate de ordinario e pote esser revelate con un clic setting_display_media_default: Celar le medios marcate como sensibile setting_display_media_hide_all: Sempre celar contento multimedial diff --git a/config/locales/simple_form.is.yml b/config/locales/simple_form.is.yml index 2a1895bfd76..49b3d6da35d 100644 --- a/config/locales/simple_form.is.yml +++ b/config/locales/simple_form.is.yml @@ -56,7 +56,7 @@ is: scopes: Að hvaða API-kerfisviðmótum forritið fær aðgang. Ef þú velur efsta-stigs svið, þarftu ekki að gefa einstakar heimildir. setting_aggregate_reblogs: Ekki sýna nýjar endurbirtingar á færslum sem hafa nýlega verið endurbirtar (hefur bara áhrif á ný-mótteknar endurbirtingar) setting_always_send_emails: Venjulega eru tilkynningar í tölvupósti ekki sendar þegar þú ert virk/ur í að nota Mastodon - setting_default_quote_policy: Notendur sem minnst er á geta alltaf gert tilvitnanir. Þessi stilling virkar einungis á færslur sem gerðar hafa verið í næstu útgáfu Mastodon, en þú getur samt valið þetta til að undirbúa þig + setting_default_quote_policy: Þessi stilling gildir einungis fyrir færslur sem útbúnar eru með næstu útgáfu Mastodon, en þú getur valið þetta fyrirfram til undirbúnings. setting_default_sensitive: Viðkvæmt myndefni er sjálfgefið falið og er hægt að birta með smelli setting_display_media_default: Fela myndefni sem merkt er viðkvæmt setting_display_media_hide_all: Alltaf fela allt myndefni diff --git a/config/locales/simple_form.it.yml b/config/locales/simple_form.it.yml index 7410cd4ae45..0dc781c74e7 100644 --- a/config/locales/simple_form.it.yml +++ b/config/locales/simple_form.it.yml @@ -56,7 +56,6 @@ it: scopes: A quali API l'applicazione potrà avere accesso. Se selezionate un ambito di alto livello, non c'è bisogno di selezionare quelle singole. setting_aggregate_reblogs: Non mostrare nuove condivisioni per toot che sono stati condivisi di recente (ha effetto solo sulle nuove condivisioni) setting_always_send_emails: Normalmente le notifiche e-mail non vengono inviate quando si utilizza attivamente Mastodon - setting_default_quote_policy: Gli utenti menzionati sono sempre in grado di citare. Questa impostazione avrà effetto solo per i post che verranno creati con la prossima versione di Mastodon, ma puoi selezionare le tue preferenze in preparazione del rilascio della prossima versione setting_default_sensitive: Media con contenuti sensibili sono nascosti in modo predefinito e possono essere rivelati con un click setting_display_media_default: Nascondi media segnati come sensibili setting_display_media_hide_all: Nascondi sempre tutti i media @@ -160,6 +159,10 @@ it: name: Nome pubblico del ruolo, se il ruolo è impostato per essere visualizzato come distintivo permissions_as_keys: Gli utenti con questo ruolo avranno accesso a... position: Un ruolo più alto decide la risoluzione dei conflitti in determinate situazioni. Alcune azioni possono essere eseguite solo su ruoli con priorità più bassa + username_block: + allow_with_approval: Invece di impedire del tutto l'iscrizione, le iscrizioni corrispondenti richiederanno la tua approvazione + comparison: Si prega di tenere presente il problema di Scunthorpe quando si bloccano corrispondenze parziali + username: Coinciderà indipendentemente da lettere e omoglifi comuni come "4" per "a" o "3" per "e" webhook: events: Seleziona eventi da inviare template: Componi il tuo carico utile JSON utilizzando l'interpolazione variabile. Lascia vuoto per il JSON predefinito. @@ -371,6 +374,10 @@ it: name: Nome permissions_as_keys: Permessi position: Priorità + username_block: + allow_with_approval: Consenti le registrazioni con approvazione + comparison: Metodo di confronto + username: Parola da abbinare webhook: events: Eventi abilitati template: Modello di carico utile diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index 98cebfa6c97..a2fe712ed28 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -56,7 +56,6 @@ ja: scopes: アプリの API に許可するアクセス権を選択してください。最上位のスコープを選択する場合、個々のスコープを選択する必要はありません。 setting_aggregate_reblogs: 最近ブーストされた投稿が新たにブーストされても表示しません (設定後受信したものにのみ影響) setting_always_send_emails: 通常、Mastodon からメール通知は行われません。 - setting_default_quote_policy: メンションされたユーザーが常にその投稿を引用できるようになる。 この設定はMastodonの次のバージョンからしか効力を発揮しませんが、現時点で設定を選択しておくことができます setting_default_sensitive: 閲覧注意状態のメディアはデフォルトでは内容が伏せられ、クリックして初めて閲覧できるようになります setting_display_media_default: 閲覧注意としてマークされたメディアは隠す setting_display_media_hide_all: メディアを常に隠す diff --git a/config/locales/simple_form.ko.yml b/config/locales/simple_form.ko.yml index 5700f43d3d6..da8f90d189e 100644 --- a/config/locales/simple_form.ko.yml +++ b/config/locales/simple_form.ko.yml @@ -56,7 +56,6 @@ ko: scopes: 애플리케이션에 허용할 API들입니다. 최상위 스코프를 선택하면 개별적인 것은 선택하지 않아도 됩니다. setting_aggregate_reblogs: 최근에 부스트 됐던 게시물은 새로 부스트 되어도 보여주지 않기 (새로 받은 부스트에만 적용됩니다) setting_always_send_emails: 기본적으로 마스토돈을 활동적으로 사용하고 있을 때에는 이메일 알림이 보내지지 않습니다 - setting_default_quote_policy: 멘션된 사용자는 항상 인용할 수 있도록 허용됩니다. 이 설정은 다음 마스토돈 버전부터 효과가 적용되지만 미리 준비할 수 있도록 설정을 제공합니다 setting_default_sensitive: 민감한 미디어는 기본적으로 가려져 있으며 클릭해서 볼 수 있습니다 setting_display_media_default: 민감함으로 표시된 미디어 가리기 setting_display_media_hide_all: 모든 미디어를 가리기 diff --git a/config/locales/simple_form.lv.yml b/config/locales/simple_form.lv.yml index adc546b9adb..0349aa1809c 100644 --- a/config/locales/simple_form.lv.yml +++ b/config/locales/simple_form.lv.yml @@ -56,7 +56,6 @@ lv: scopes: Kuriem API lietotnei būs ļauts piekļūt. Ja atlasa augstākā līmeņa tvērumu, nav nepieciešamas atlasīt atsevišķus. setting_aggregate_reblogs: Nerādīt jaunus izcēlumus ziņām, kas nesen tika palielinātas (ietekmē tikai nesen saņemtos palielinājumus) setting_always_send_emails: Parasti e-pasta paziņojumi netiek sūtīti, kad aktīvi izmantojat Mastodon - setting_default_quote_policy: Pieminētajiem lietotājiem vienmēr ir atļauts citēt. Šis iestatījums stāsies spēkā tikai nākamo Mastodon versiju ierakstiem. Bet jūs tik un tā variet iestatīt savu izvēli, kamēr notiek ieviešana setting_default_sensitive: Pēc noklusējuma jūtīgi informācijas nesēji ir paslēpti, un tos var atklāt ar klikšķi setting_display_media_default: Paslēpt informācijas nesējus, kas atzīmēti kā jūtīgi setting_display_media_hide_all: Vienmēr slēpt multividi diff --git a/config/locales/simple_form.nl.yml b/config/locales/simple_form.nl.yml index 70e302da031..1f0a4de9120 100644 --- a/config/locales/simple_form.nl.yml +++ b/config/locales/simple_form.nl.yml @@ -56,7 +56,7 @@ nl: scopes: Tot welke API's heeft de toepassing toegang. Wanneer je een toestemming van het bovenste niveau kiest, hoef je geen individuele toestemmingen meer te kiezen. setting_aggregate_reblogs: Geen nieuwe boosts tonen voor berichten die recentelijk nog zijn geboost (heeft alleen effect op nieuw ontvangen boosts) setting_always_send_emails: Normaliter worden er geen e-mailmeldingen verstuurd wanneer je actief Mastodon gebruikt - setting_default_quote_policy: Het is voor gebruikers die vermeld worden altijd toegestaan om te citeren. Deze instelling is alleen van kracht voor berichten die gemaakt zijn met de volgende Mastodon-versie, maar je kunt je voorkeur nu alvast instellen + setting_default_quote_policy: Deze instelling is alleen van kracht voor berichten die gemaakt zijn met de volgende Mastodon-versie, maar je kunt je voorkeur nu alvast instellen. setting_default_sensitive: Gevoelige media wordt standaard verborgen en kan met één klik worden getoond setting_display_media_default: Als gevoelig gemarkeerde media verbergen setting_display_media_hide_all: Media altijd verbergen @@ -160,6 +160,10 @@ nl: name: Openbare naam van de rol, wanneer de rol als badge op profielpagina's wordt getoond permissions_as_keys: Gebruikers met deze rol hebben toegang tot... position: Een hogere rol beslist in bepaalde situaties over het oplossen van conflicten. Bepaalde acties kunnen alleen worden uitgevoerd op rollen met een lagere prioriteit + username_block: + allow_with_approval: In plaats van dat het registreren helemaal wordt voorkomen, zullen overeenkomstige registraties jouw goedkeuring vereisen + comparison: Houd rekening met het Scunthorpe-probleem wanneer je gedeeltelijke overeenkomsten blokkeert + username: Wordt gezien als overeenkomst ongeacht de lettergrootte en gangbare lettervervangingen zoals "4" voor "a" of "3" voor "e" webhook: events: Selecteer de te verzenden gebeurtenissen template: Maak een eigen JSON payload aan met variabele interpolatie. Laat leeg voor standaard JSON. @@ -371,6 +375,10 @@ nl: name: Naam permissions_as_keys: Rechten position: Prioriteit + username_block: + allow_with_approval: Registraties met goedkeuring toestaan + comparison: Methode van vergelijking + username: Overeen te komen woord webhook: events: Ingeschakelde gebeurtenissen template: Sjabloon Payload diff --git a/config/locales/simple_form.nn.yml b/config/locales/simple_form.nn.yml index d23ef70830c..12ae70ffa40 100644 --- a/config/locales/simple_form.nn.yml +++ b/config/locales/simple_form.nn.yml @@ -56,7 +56,6 @@ nn: scopes: API-ane som programmet vil få tilgjenge til. Ettersom du vel eit toppnivåomfang tarv du ikkje velja einskilde API-ar. setting_aggregate_reblogs: Ikkje vis nye framhevingar for tut som nyleg har vorte heva fram (Påverkar berre nylege framhevingar) setting_always_send_emails: Vanlegvis vil ikkje e-postvarsel bli sendt når du brukar Mastodon aktivt - setting_default_quote_policy: Dei nemnde folka får alltid lov å sitera. Denne innstillinga har berre verknad for innlegg som er laga med den neste utgåva av Mastodon, men du kan velja kva du vil ha i førebuingane setting_default_sensitive: Sensitive media vert gøymde som standard, og du syner dei ved å klikka på dei setting_display_media_default: Gøym media som er merka som sensitive setting_display_media_hide_all: Alltid skjul alt media diff --git a/config/locales/simple_form.pt-BR.yml b/config/locales/simple_form.pt-BR.yml index 39bce7c5943..9bcb70fc383 100644 --- a/config/locales/simple_form.pt-BR.yml +++ b/config/locales/simple_form.pt-BR.yml @@ -56,7 +56,6 @@ pt-BR: scopes: Quais APIs o aplicativo vai ter permissão de acessar. Se você selecionar uma autorização de alto nível, você não precisa selecionar individualmente os outros. setting_aggregate_reblogs: Não mostrar novos impulsos para publicações que já foram impulsionadas recentemente (afeta somente os impulsos mais recentes) setting_always_send_emails: Normalmente, as notificações por e-mail não serão enviadas enquanto você estiver usando ativamente o Mastodon - setting_default_quote_policy: Usuários mencionados sempre têm permissão para citar. Esta configuração só terá efeito para postagens criadas com a próxima versão do Mastodon, mas você pode selecionar sua preferência em preparação setting_default_sensitive: Mídia sensível está oculta por padrão e pode ser revelada com um clique setting_display_media_default: Sempre ocultar mídia sensível setting_display_media_hide_all: Sempre ocultar todas as mídias diff --git a/config/locales/simple_form.pt-PT.yml b/config/locales/simple_form.pt-PT.yml index 811b2ecd501..0e65d82fd83 100644 --- a/config/locales/simple_form.pt-PT.yml +++ b/config/locales/simple_form.pt-PT.yml @@ -56,7 +56,6 @@ pt-PT: scopes: Quais as API a que a aplicação terá permissão para aceder. Se selecionar um âmbito de nível superior, não precisa de selecionar âmbitos individuais. setting_aggregate_reblogs: Não mostrar os novos impulsos para publicações que tenham sido recentemente impulsionadas (apenas afeta os impulsos recentemente recebidos) setting_always_send_emails: Normalmente as notificações por e-mail não serão enviadas quando estiver a utilizar ativamente o Mastodon - setting_default_quote_policy: Os utilizadores mencionados têm sempre permissão para citar. Esta definição só terá efeito para publicações criadas com a próxima versão do Mastodon, mas pode selecionar a sua preferência em antecipação setting_default_sensitive: Os multimédia sensíveis são ocultados por predefinição e podem ser revelados com um clique/toque setting_display_media_default: Esconder multimédia marcada como sensível setting_display_media_hide_all: Esconder sempre toda a multimédia diff --git a/config/locales/simple_form.ru.yml b/config/locales/simple_form.ru.yml index cebd5d1281e..5c48d751b55 100644 --- a/config/locales/simple_form.ru.yml +++ b/config/locales/simple_form.ru.yml @@ -56,7 +56,6 @@ ru: scopes: Выберите, какие API приложение сможет использовать. Разрешения верхнего уровня имплицитно включают в себя все разрешения более низких уровней. setting_aggregate_reblogs: Не показывать новые продвижения постов, которые уже были недавно продвинуты (применяется только к будущим продвижениям) setting_always_send_emails: По умолчанию уведомления не доставляются по электронной почте, пока вы активно используете Mastodon - setting_default_quote_policy: Упомянутые пользователи всегда смогут вас цитировать. Эта настройка будет применена только к постам, созданным в следующей версии Mastodon, но вы можете заранее определить свои предпочтения setting_default_sensitive: Медиа деликатного характера скрыты по умолчанию и могут быть показаны по нажатию на них setting_display_media_default: Скрывать медиа деликатного характера setting_display_media_hide_all: Скрывать все медиа diff --git a/config/locales/simple_form.si.yml b/config/locales/simple_form.si.yml index f0a0dd02865..119e3d253db 100644 --- a/config/locales/simple_form.si.yml +++ b/config/locales/simple_form.si.yml @@ -56,7 +56,6 @@ si: scopes: යෙදුමට ප්‍රවේශ වීමට ඉඩ දෙන්නේ කුමන API වලටද. ඔබ ඉහළ මට්ටමේ විෂය පථයක් තෝරා ගන්නේ නම්, ඔබට තනි ඒවා තෝරා ගැනීමට අවශ්‍ය නොවේ. setting_aggregate_reblogs: මෑතකදී වැඩි කරන ලද පළ කිරීම් සඳහා නව වැඩි කිරීම් නොපෙන්වන්න (අලුතින් ලැබුණු වැඩි කිරීම් වලට පමණක් බලපායි) setting_always_send_emails: ඔබ නිතර මාස්ටඩන් භාවිතා කරන විට වි-තැපැල් දැනුම්දීම් නොලැබෙයි - setting_default_quote_policy: සඳහන් කළ පරිශීලකයින්ට සැමවිටම උපුටා දැක්වීමට අවසර ඇත. මෙම සැකසුම ඊළඟ Mastodon අනුවාදය සමඟ නිර්මාණය කරන ලද පළ කිරීම් සඳහා පමණක් ක්‍රියාත්මක වනු ඇත, නමුත් ඔබට සූදානම් වීමේදී ඔබේ මනාපය තෝරා ගත හැකිය. setting_default_sensitive: සංවේදී මාධ්‍ය පෙරනිමියෙන් සඟවා ඇති අතර ක්ලික් කිරීමකින් හෙළිදරව් කළ හැක setting_display_media_default: සංවේදී බව සලකුණු කළ මාධ්‍ය සඟවන්න setting_display_media_hide_all: සැමවිට මාධ්‍ය සඟවන්න diff --git a/config/locales/simple_form.sq.yml b/config/locales/simple_form.sq.yml index 5059fbd1e97..0672fe05a44 100644 --- a/config/locales/simple_form.sq.yml +++ b/config/locales/simple_form.sq.yml @@ -56,7 +56,6 @@ sq: scopes: Cilat API do të lejohen të përdorin aplikacioni. Nëse përzgjidhni një shkallë të epërme, nuk ju duhet të përzgjidhni individualet një nga një. setting_aggregate_reblogs: Mos shfaq përforcime të reja për mesazhe që janë përforcuar tani së fundi (prek vetëm përforcime të marra rishtas) setting_always_send_emails: Normalisht s’do të dërgohen njoftime, kur përdorni aktivisht Mastodon-in - setting_default_quote_policy: Përdoruesit e përmendur lejohen përherë të citojnë. Ky rregullim do të ketë efekt vetëm për postime të krijuar me versionin pasues të Mastodon-it, por mund të përzgjidhni paraprakisht parapëlqimin tuaj setting_default_sensitive: Media rezervat fshihet, si parazgjedhje, dhe mund të shfaqet me një klikim setting_display_media_default: Fshih media me shenjën rezervat setting_display_media_hide_all: Fshih përherë mediat diff --git a/config/locales/simple_form.sv.yml b/config/locales/simple_form.sv.yml index a5571e3d4db..96f00e48c25 100644 --- a/config/locales/simple_form.sv.yml +++ b/config/locales/simple_form.sv.yml @@ -56,7 +56,6 @@ sv: scopes: 'Vilka API: er applikationen kommer tillåtas åtkomst till. Om du väljer en omfattning på högstanivån behöver du inte välja individuella sådana.' setting_aggregate_reblogs: Visa inte nya boostar för inlägg som nyligen blivit boostade (påverkar endast nymottagna boostar) setting_always_send_emails: E-postnotiser kommer vanligtvis inte skickas när du aktivt använder Mastodon - setting_default_quote_policy: Nämnda användare får alltid citeras. Denna inställning kommer att träda i kraft för inlägg som skapats med nästa Mastodon-version, men förbereda dina inställningar för det redan nu setting_default_sensitive: Känslig media döljs som standard och kan visas med ett klick setting_display_media_default: Dölj media markerad som känslig setting_display_media_hide_all: Dölj alltid all media diff --git a/config/locales/simple_form.tr.yml b/config/locales/simple_form.tr.yml index 0d9122efae4..e359b657d67 100644 --- a/config/locales/simple_form.tr.yml +++ b/config/locales/simple_form.tr.yml @@ -56,7 +56,6 @@ tr: scopes: Uygulamanın erişmesine izin verilen API'ler. Üst seviye bir kapsam seçtiyseniz, bireysel kapsam seçmenize gerek yoktur. setting_aggregate_reblogs: Yakın zamanda teşvik edilmiş gönderiler için yeni teşvikleri göstermeyin (yalnızca yeni alınan teşvikleri etkiler) setting_always_send_emails: Normalde, Mastodon'u aktif olarak kullanırken e-posta bildirimleri gönderilmeyecektir - setting_default_quote_policy: Bahsedilen kullanıcıların her zaman alıntı yapmasına izin verilir. Bu ayar yalnızca bir sonraki Mastodon sürümü ile oluşturulan gönderiler için geçerli olacaktır, ancak tercihinizi hazırlık aşamasında seçebilirsiniz setting_default_sensitive: Hassas medya varsayılan olarak gizlidir ve bir tıklama ile gösterilebilir setting_display_media_default: Hassas olarak işaretlenmiş medyayı gizle setting_display_media_hide_all: Medyayı her zaman gizle diff --git a/config/locales/simple_form.vi.yml b/config/locales/simple_form.vi.yml index 70230198967..b33c5f4ccca 100644 --- a/config/locales/simple_form.vi.yml +++ b/config/locales/simple_form.vi.yml @@ -56,7 +56,7 @@ vi: scopes: Ứng dụng sẽ được phép truy cập những API nào. Nếu bạn chọn quyền cấp cao nhất, không cần chọn quyền nhỏ. setting_aggregate_reblogs: Nếu một tút đã được đăng lại thì sẽ không hiện những lượt đăng lại khác trên bảng tin setting_always_send_emails: Bình thường thì sẽ không gửi khi bạn đang dùng Mastodon - setting_default_quote_policy: Thiết lập này chỉ hiệu lực đối với các tút được tạo bằng phiên bản Mastodon tiếp theo, nhưng bạn có thể chọn trước sẵn + setting_default_quote_policy: Thiết lập này chỉ hiệu lực đối với các tút được tạo bằng phiên bản Mastodon tiếp theo, nhưng bạn có thể chọn trước sẵn. setting_default_sensitive: Bắt buộc nhấn vào mới có thể xem setting_display_media_default: Click để xem setting_display_media_hide_all: Luôn ẩn diff --git a/config/locales/simple_form.zh-CN.yml b/config/locales/simple_form.zh-CN.yml index c6b6f0904b9..f754cb07e3b 100644 --- a/config/locales/simple_form.zh-CN.yml +++ b/config/locales/simple_form.zh-CN.yml @@ -56,7 +56,6 @@ zh-CN: scopes: 哪些 API 被允许使用。如果你勾选了更高一级的范围,就不用单独选中子项目了。 setting_aggregate_reblogs: 不显示最近已经被转嘟过的嘟文(只会影响新收到的转嘟) setting_always_send_emails: 一般情况下,如果你活跃使用 Mastodon,我们不会向你发送电子邮件通知 - setting_default_quote_policy: 总是允许引用被提及的用户。此设置将仅对下个Mastodon版本创建的帖子生效,但您可以在准备中选择您的偏好 setting_default_sensitive: 敏感内容默认隐藏,并在点击后显示 setting_display_media_default: 隐藏被标记为敏感内容的媒体 setting_display_media_hide_all: 始终隐藏媒体 diff --git a/config/locales/simple_form.zh-TW.yml b/config/locales/simple_form.zh-TW.yml index e02e264ce90..2b57647f9eb 100644 --- a/config/locales/simple_form.zh-TW.yml +++ b/config/locales/simple_form.zh-TW.yml @@ -56,7 +56,7 @@ zh-TW: scopes: 允許使應用程式存取的 API。 若您選擇最高階範圍,則無須選擇個別項目。 setting_aggregate_reblogs: 不顯示最近已被轉嘟之嘟文的最新轉嘟(只影響最新收到的嘟文) setting_always_send_emails: 一般情況下若您活躍使用 Mastodon ,我們不會寄送電子郵件通知 - setting_default_quote_policy: 已提及使用者總是能引用嘟文。此設定將僅生效於下一版本 Mastodon 建立之嘟文,但您可以預先選好您的偏好設定 + setting_default_quote_policy: 此設定將僅生效於下一版本 Mastodon 建立之嘟文,但您可以預先選好您的偏好設定。 setting_default_sensitive: 敏感內容媒體預設隱藏,且按一下即可重新顯示 setting_display_media_default: 隱藏標為敏感內容的媒體 setting_display_media_hide_all: 總是隱藏所有媒體 diff --git a/config/locales/sq.yml b/config/locales/sq.yml index a29344ac80f..871c1123a73 100644 --- a/config/locales/sq.yml +++ b/config/locales/sq.yml @@ -1865,8 +1865,6 @@ sq: ownership: S’mund të fiksohen mesazhet e të tjerëve reblog: S’mund të fiksohet një përforcim quote_policies: - followers: Ndjekës dhe përdorues të përmendur - nobody: Vetëm përdorues të përmendur public: Këdo title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/sv.yml b/config/locales/sv.yml index d406eabba32..25bc593917a 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -1880,8 +1880,6 @@ sv: ownership: Någon annans inlägg kan inte fästas reblog: En boost kan inte fästas quote_policies: - followers: Följare och omnämnda användare - nobody: Endast nämnda användare public: Alla title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/tr.yml b/config/locales/tr.yml index 2b0164e9bb1..f697e3b199e 100644 --- a/config/locales/tr.yml +++ b/config/locales/tr.yml @@ -1880,8 +1880,6 @@ tr: ownership: Başkasının gönderisi sabitlenemez reblog: Bir gönderi sabitlenemez quote_policies: - followers: Takipçiler ve bahsedilen kullanıcılar - nobody: Sadece bahsedilen kullanıcılar public: Herkes title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/vi.yml b/config/locales/vi.yml index 33a779f417d..32625d46576 100644 --- a/config/locales/vi.yml +++ b/config/locales/vi.yml @@ -1862,8 +1862,8 @@ vi: ownership: Không thể ghim tút của người khác reblog: Không thể ghim tút đăng lại quote_policies: - followers: Người được nhắc đến và người theo dõi - nobody: Người được nhắc đến + followers: Chỉ người theo dõi + nobody: Không ai public: Mọi người title: '%{name}: "%{quote}"' visibilities: diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index c62dc8e3191..82d0a169cc9 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -1827,8 +1827,6 @@ zh-CN: ownership: 不能置顶别人的嘟文 reblog: 不能置顶转嘟 quote_policies: - followers: 关注者和提及的用户 - nobody: 仅提及的用户 public: 所有人 title: "%{name}:“%{quote}”" visibilities: diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml index 1dadf43932c..fc29a3b5310 100644 --- a/config/locales/zh-TW.yml +++ b/config/locales/zh-TW.yml @@ -1864,8 +1864,8 @@ zh-TW: ownership: 不能釘選他人的嘟文 reblog: 不能釘選轉嘟 quote_policies: - followers: 跟隨者與已提及使用者 - nobody: 僅限已提及使用者 + followers: 僅限您之跟隨者 + nobody: 禁止任何人 public: 任何人 title: "%{name}:「%{quote}」" visibilities: diff --git a/config/routes.rb b/config/routes.rb index 2fff44851e0..49fcf3de792 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -115,6 +115,7 @@ Rails.application.routes.draw do resource :inbox, only: [:create] resources :collections, only: [:show] resource :followers_synchronization, only: [:show] + resources :quote_authorizations, only: [:show] end end diff --git a/config/routes/api.rb b/config/routes/api.rb index 4040a4350fa..83190610d0b 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -18,6 +18,12 @@ namespace :api, format: false do resource :reblog, only: :create post :unreblog, to: 'reblogs#destroy' + resources :quotes, only: :index do + member do + post :revoke + end + end + resource :favourite, only: :create post :unfavourite, to: 'favourites#destroy' diff --git a/eslint.config.mjs b/eslint.config.mjs index 3d00a4adce9..43aabc51100 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -406,6 +406,12 @@ export default tseslint.config([ globals: globals.vitest, }, }, + { + files: ['**/*.test.*'], + rules: { + 'no-global-assign': 'off', + }, + }, { files: ['**/*.stories.ts', '**/*.stories.tsx', '.storybook/*'], rules: { diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index f76d1891611..2aad68d53d8 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -3,420 +3,439 @@ namespace :dev do desc 'Populate database with test data. Can be run multiple times. Should not be run in production environments' task populate_sample_data: :environment do - # Create a valid account to showcase multiple post types - showcase_account = Account.create_with(username: 'showcase_account').find_or_create_by!(id: 10_000_000) - showcase_user = User.create_with( - account_id: showcase_account.id, - agreement: true, - password: SecureRandom.hex, - email: ENV.fetch('TEST_DATA_SHOWCASE_EMAIL', 'showcase_account@joinmastodon.org'), - confirmed_at: Time.now.utc, - approved: true, - bypass_registration_checks: true - ).find_or_create_by!(id: 10_000_000) - showcase_user.mark_email_as_confirmed! - showcase_user.approve! + Chewy.strategy(:mastodon) do + # Create a valid account to showcase multiple post types + showcase_account = Account.create_with(username: 'showcase_account').find_or_create_by!(id: 10_000_000) + showcase_user = User.create_with( + account_id: showcase_account.id, + agreement: true, + password: SecureRandom.hex, + email: ENV.fetch('TEST_DATA_SHOWCASE_EMAIL', 'showcase_account@joinmastodon.org'), + confirmed_at: Time.now.utc, + approved: true, + bypass_registration_checks: true + ).find_or_create_by!(id: 10_000_000) + showcase_user.mark_email_as_confirmed! + showcase_user.approve! - french_post = Status.create_with( - text: 'Ceci est un sondage public écrit en Français', - language: 'fr', - account: showcase_account, - visibility: :public, - poll_attributes: { - voters_count: 0, + french_post = Status.create_with( + text: 'Ceci est un sondage public écrit en Français', + language: 'fr', account: showcase_account, - expires_at: 1.day.from_now, - options: ['ceci est un choix', 'ceci est un autre choix'], - multiple: false, - } - ).find_or_create_by!(id: 10_000_000) + visibility: :public, + poll_attributes: { + voters_count: 0, + account: showcase_account, + expires_at: 1.day.from_now, + options: ['ceci est un choix', 'ceci est un autre choix'], + multiple: false, + } + ).find_or_create_by!(id: 10_000_000) - private_mentionless = Status.create_with( - text: 'This is a private message written in English', - language: 'en', - account: showcase_account, - visibility: :private - ).find_or_create_by!(id: 10_000_001) - - public_self_reply_with_cw = Status.create_with( - text: 'This is a public self-reply written in English; it has a CW and a multi-choice poll', - spoiler_text: 'poll (CW example)', - language: 'en', - account: showcase_account, - visibility: :public, - thread: french_post, - poll_attributes: { - voters_count: 0, + private_mentionless = Status.create_with( + text: 'This is a private message written in English', + language: 'en', account: showcase_account, - expires_at: 1.day.from_now, - options: ['this is a choice', 'this is another choice', 'you can chose any number of them'], - multiple: true, - } - ).find_or_create_by!(id: 10_000_002) - ProcessHashtagsService.new.call(public_self_reply_with_cw) + visibility: :private + ).find_or_create_by!(id: 10_000_001) - unlisted_self_reply_with_cw_tag_mention = Status.create_with( - text: 'This is an unlisted (Quiet Public) self-reply written in #English; it has a CW, mentions @showcase_account, and uses an emoji 🦣', - spoiler_text: 'CW example', - language: 'en', - account: showcase_account, - visibility: :unlisted, - thread: public_self_reply_with_cw - ).find_or_create_by!(id: 10_000_003) - Mention.find_or_create_by!(status: unlisted_self_reply_with_cw_tag_mention, account: showcase_account) - ProcessHashtagsService.new.call(unlisted_self_reply_with_cw_tag_mention) + public_self_reply_with_cw = Status.create_with( + text: 'This is a public self-reply written in English; it has a CW and a multi-choice poll', + spoiler_text: 'poll (CW example)', + language: 'en', + account: showcase_account, + visibility: :public, + thread: french_post, + poll_attributes: { + voters_count: 0, + account: showcase_account, + expires_at: 1.day.from_now, + options: ['this is a choice', 'this is another choice', 'you can chose any number of them'], + multiple: true, + } + ).find_or_create_by!(id: 10_000_002) + ProcessHashtagsService.new.call(public_self_reply_with_cw) - media_attachment = MediaAttachment.create_with( - account: showcase_account, - file: File.open('spec/fixtures/files/600x400.png'), - description: 'Mastodon logo' - ).find_or_create_by!(id: 10_000_000) - status_with_media = Status.create_with( - text: "This is a public status with a picture and tags. The attached picture has an alt text\n\n#Mastodon #Logo #English #Test", - ordered_media_attachment_ids: [media_attachment.id], - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_004) - media_attachment.update(status_id: status_with_media.id) - ProcessHashtagsService.new.call(status_with_media) + unlisted_self_reply_with_cw_tag_mention = Status.create_with( + text: 'This is an unlisted (Quiet Public) self-reply written in #English; it has a CW, mentions @showcase_account, and uses an emoji 🦣', + spoiler_text: 'CW example', + language: 'en', + account: showcase_account, + visibility: :unlisted, + thread: public_self_reply_with_cw + ).find_or_create_by!(id: 10_000_003) + Mention.find_or_create_by!(status: unlisted_self_reply_with_cw_tag_mention, account: showcase_account) + ProcessHashtagsService.new.call(unlisted_self_reply_with_cw_tag_mention) - media_attachment = MediaAttachment.create_with( - account: showcase_account, - file: File.open('spec/fixtures/files/600x400.png'), - description: 'Mastodon logo' - ).find_or_create_by!(id: 10_000_001) - status_with_sensitive_media = Status.create_with( - text: "This is the same public status with a picture and tags, but it is marked as sensitive. The attached picture has an alt text\n\n#Mastodon #Logo #English #Test", - ordered_media_attachment_ids: [media_attachment.id], - account: showcase_account, - visibility: :public, - sensitive: true, - thread: status_with_media - ).find_or_create_by!(id: 10_000_005) - media_attachment.update(status_id: status_with_sensitive_media.id) - ProcessHashtagsService.new.call(status_with_sensitive_media) - - media_attachment = MediaAttachment.create_with( - account: showcase_account, - file: File.open('spec/fixtures/files/600x400.png'), - description: 'Mastodon logo' - ).find_or_create_by!(id: 10_000_002) - status_with_cw_media = Status.create_with( - text: "This is the same public status with a picture and tags, but it is behind a CW. The attached picture has an alt text\n\n#Mastodon #Logo #English #Test", - spoiler_text: 'Mastodon logo', - ordered_media_attachment_ids: [media_attachment.id], - account: showcase_account, - visibility: :public, - sensitive: true, - thread: status_with_sensitive_media - ).find_or_create_by!(id: 10_000_006) - media_attachment.update(status_id: status_with_cw_media.id) - ProcessHashtagsService.new.call(status_with_cw_media) - - media_attachment = MediaAttachment.create_with( - account: showcase_account, - file: File.open('spec/fixtures/files/boop.ogg'), - description: 'Mastodon boop' - ).find_or_create_by!(id: 10_000_003) - status_with_audio = Status.create_with( - text: "This is the same public status with an audio file and tags. The attached picture has an alt text\n\n#Mastodon #English #Test", - ordered_media_attachment_ids: [media_attachment.id], - account: showcase_account, - visibility: :public, - thread: status_with_cw_media - ).find_or_create_by!(id: 10_000_007) - media_attachment.update(status_id: status_with_audio.id) - ProcessHashtagsService.new.call(status_with_audio) - - media_attachment = MediaAttachment.create_with( - account: showcase_account, - file: File.open('spec/fixtures/files/boop.ogg'), - description: 'Mastodon boop' - ).find_or_create_by!(id: 10_000_004) - status_with_sensitive_audio = Status.create_with( - text: "This is the same public status with an audio file and tags, but it is marked as sensitive. The attached picture has an alt text\n\n#Mastodon #English #Test", - ordered_media_attachment_ids: [media_attachment.id], - account: showcase_account, - visibility: :public, - sensitive: true, - thread: status_with_audio - ).find_or_create_by!(id: 10_000_008) - media_attachment.update(status_id: status_with_sensitive_audio.id) - ProcessHashtagsService.new.call(status_with_sensitive_audio) - - media_attachment = MediaAttachment.create_with( - account: showcase_account, - file: File.open('spec/fixtures/files/boop.ogg'), - description: 'Mastodon boop' - ).find_or_create_by!(id: 10_000_005) - status_with_cw_audio = Status.create_with( - text: "This is the same public status with an audio file and tags, but it is behind a CW. The attached picture has an alt text\n\n#Mastodon #English #Test", - spoiler_text: 'Mastodon boop', - ordered_media_attachment_ids: [media_attachment.id], - account: showcase_account, - visibility: :public, - sensitive: true, - thread: status_with_sensitive_audio - ).find_or_create_by!(id: 10_000_009) - media_attachment.update(status_id: status_with_cw_audio.id) - ProcessHashtagsService.new.call(status_with_cw_audio) - - media_attachments = [ - MediaAttachment.create_with( + media_attachment = MediaAttachment.create_with( account: showcase_account, file: File.open('spec/fixtures/files/600x400.png'), description: 'Mastodon logo' - ).find_or_create_by!(id: 10_000_006), - MediaAttachment.create_with( + ).find_or_create_by!(id: 10_000_000) + status_with_media = Status.create_with( + text: "This is a public status with a picture and tags. The attached picture has an alt text\n\n#Mastodon #Logo #English #Test", + ordered_media_attachment_ids: [media_attachment.id], + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_004) + media_attachment.update(status_id: status_with_media.id) + ProcessHashtagsService.new.call(status_with_media) + + media_attachment = MediaAttachment.create_with( + account: showcase_account, + file: File.open('spec/fixtures/files/600x400.png'), + description: 'Mastodon logo' + ).find_or_create_by!(id: 10_000_001) + status_with_sensitive_media = Status.create_with( + text: "This is the same public status with a picture and tags, but it is marked as sensitive. The attached picture has an alt text\n\n#Mastodon #Logo #English #Test", + ordered_media_attachment_ids: [media_attachment.id], + account: showcase_account, + visibility: :public, + sensitive: true, + thread: status_with_media + ).find_or_create_by!(id: 10_000_005) + media_attachment.update(status_id: status_with_sensitive_media.id) + ProcessHashtagsService.new.call(status_with_sensitive_media) + + media_attachment = MediaAttachment.create_with( + account: showcase_account, + file: File.open('spec/fixtures/files/600x400.png'), + description: 'Mastodon logo' + ).find_or_create_by!(id: 10_000_002) + status_with_cw_media = Status.create_with( + text: "This is the same public status with a picture and tags, but it is behind a CW. The attached picture has an alt text\n\n#Mastodon #Logo #English #Test", + spoiler_text: 'Mastodon logo', + ordered_media_attachment_ids: [media_attachment.id], + account: showcase_account, + visibility: :public, + sensitive: true, + thread: status_with_sensitive_media + ).find_or_create_by!(id: 10_000_006) + media_attachment.update(status_id: status_with_cw_media.id) + ProcessHashtagsService.new.call(status_with_cw_media) + + media_attachment = MediaAttachment.create_with( + account: showcase_account, + file: File.open('spec/fixtures/files/boop.ogg'), + description: 'Mastodon boop' + ).find_or_create_by!(id: 10_000_003) + status_with_audio = Status.create_with( + text: "This is the same public status with an audio file and tags. The attached picture has an alt text\n\n#Mastodon #English #Test", + ordered_media_attachment_ids: [media_attachment.id], + account: showcase_account, + visibility: :public, + thread: status_with_cw_media + ).find_or_create_by!(id: 10_000_007) + media_attachment.update(status_id: status_with_audio.id) + ProcessHashtagsService.new.call(status_with_audio) + + media_attachment = MediaAttachment.create_with( + account: showcase_account, + file: File.open('spec/fixtures/files/boop.ogg'), + description: 'Mastodon boop' + ).find_or_create_by!(id: 10_000_004) + status_with_sensitive_audio = Status.create_with( + text: "This is the same public status with an audio file and tags, but it is marked as sensitive. The attached picture has an alt text\n\n#Mastodon #English #Test", + ordered_media_attachment_ids: [media_attachment.id], + account: showcase_account, + visibility: :public, + sensitive: true, + thread: status_with_audio + ).find_or_create_by!(id: 10_000_008) + media_attachment.update(status_id: status_with_sensitive_audio.id) + ProcessHashtagsService.new.call(status_with_sensitive_audio) + + media_attachment = MediaAttachment.create_with( + account: showcase_account, + file: File.open('spec/fixtures/files/boop.ogg'), + description: 'Mastodon boop' + ).find_or_create_by!(id: 10_000_005) + status_with_cw_audio = Status.create_with( + text: "This is the same public status with an audio file and tags, but it is behind a CW. The attached picture has an alt text\n\n#Mastodon #English #Test", + spoiler_text: 'Mastodon boop', + ordered_media_attachment_ids: [media_attachment.id], + account: showcase_account, + visibility: :public, + sensitive: true, + thread: status_with_sensitive_audio + ).find_or_create_by!(id: 10_000_009) + media_attachment.update(status_id: status_with_cw_audio.id) + ProcessHashtagsService.new.call(status_with_cw_audio) + + media_attachments = [ + MediaAttachment.create_with( + account: showcase_account, + file: File.open('spec/fixtures/files/600x400.png'), + description: 'Mastodon logo' + ).find_or_create_by!(id: 10_000_006), + MediaAttachment.create_with( + account: showcase_account, + file: File.open('spec/fixtures/files/attachment.jpg') + ).find_or_create_by!(id: 10_000_007), + MediaAttachment.create_with( + account: showcase_account, + file: File.open('spec/fixtures/files/avatar-high.gif'), + description: 'Walking cartoon cat' + ).find_or_create_by!(id: 10_000_008), + MediaAttachment.create_with( + account: showcase_account, + file: File.open('spec/fixtures/files/text.png'), + description: 'Text saying “Hello Mastodon”' + ).find_or_create_by!(id: 10_000_009), + ] + status_with_multiple_attachments = Status.create_with( + text: "This is a post with multiple attachments, not all of which have a description\n\n#Mastodon #English #Test", + spoiler_text: 'multiple attachments', + ordered_media_attachment_ids: media_attachments.pluck(:id), + account: showcase_account, + visibility: :public, + sensitive: true, + thread: status_with_cw_audio + ).find_or_create_by!(id: 10_000_010) + media_attachments.each { |attachment| attachment.update!(status_id: status_with_multiple_attachments.id) } + ProcessHashtagsService.new.call(status_with_multiple_attachments) + + remote_account = Account.create_with( + username: 'fake.example', + domain: 'example.org', + uri: 'https://example.org/foo/bar', + url: 'https://example.org/foo/bar', + locked: true + ).find_or_create_by!(id: 10_000_001) + + remote_formatted_post = Status.create_with( + text: <<~HTML, +

This is a post with a variety of HTML in it

+

For instance, this text is bold and this one as well, while this text is stricken through and this one as well.

+
+

This thing, here, is a block quote
with some bold as well

+
    +
  • a list item
  • +
  • + and another with +
      +
    • nested
    • +
    • items!
    • +
    +
  • +
+
+
// And this is some code
+          // with two lines of comments
+          
+

And this is inline code

+

Finally, please observe this Ruby element: 明日 (Ashita)

+ HTML + account: remote_account, + uri: 'https://example.org/foo/bar/baz', + url: 'https://example.org/foo/bar/baz' + ).find_or_create_by!(id: 10_000_011) + Status.create_with(account: showcase_account, reblog: remote_formatted_post).find_or_create_by!(id: 10_000_012) + + unattached_quote_post = Status.create_with( + text: 'This is a quote of a post that does not exist', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_013) + Quote.create_with( + status: unattached_quote_post, + quoted_status: nil + ).find_or_create_by!(id: 10_000_000) + + self_quote = Status.create_with( + text: 'This is a quote of a public self-post', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_014) + Quote.create_with( + status: self_quote, + quoted_status: status_with_media, + state: :accepted + ).find_or_create_by!(id: 10_000_001) + + nested_self_quote = Status.create_with( + text: 'This is a quote of a public self-post which itself is a self-quote', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_015) + Quote.create_with( + status: nested_self_quote, + quoted_status: self_quote, + state: :accepted + ).find_or_create_by!(id: 10_000_002) + + recursive_self_quote = Status.create_with( + text: 'This is a recursive self-quote; no real reason for it to exist, but just to make sure we handle them gracefuly', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_016) + Quote.create_with( + status: recursive_self_quote, + quoted_status: recursive_self_quote, + state: :accepted + ).find_or_create_by!(id: 10_000_003) + + self_private_quote = Status.create_with( + text: 'This is a public post of a private self-post: the quoted post should not be visible to non-followers', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_017) + Quote.create_with( + status: self_private_quote, + quoted_status: private_mentionless, + state: :accepted + ).find_or_create_by!(id: 10_000_004) + + uncwed_quote_cwed = Status.create_with( + text: 'This is a quote without CW of a quoted post that has a CW', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_018) + Quote.create_with( + status: uncwed_quote_cwed, + quoted_status: public_self_reply_with_cw, + state: :accepted + ).find_or_create_by!(id: 10_000_005) + + cwed_quote_cwed = Status.create_with( + text: 'This is a quote with a CW of a quoted post that itself has a CW', + spoiler_text: 'Quote post with a CW', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_019) + Quote.create_with( + status: cwed_quote_cwed, + quoted_status: public_self_reply_with_cw, + state: :accepted + ).find_or_create_by!(id: 10_000_006) + + pending_quote_post = Status.create_with( + text: 'This quote post is pending', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_020) + Quote.create_with( + status: pending_quote_post, + quoted_status: remote_formatted_post, + activity_uri: 'https://foo/bar', + state: :pending + ).find_or_create_by!(id: 10_000_007) + + rejected_quote_post = Status.create_with( + text: 'This quote post is rejected', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_021) + Quote.create_with( + status: rejected_quote_post, + quoted_status: remote_formatted_post, + activity_uri: 'https://foo/foo', + state: :rejected + ).find_or_create_by!(id: 10_000_008) + + revoked_quote_post = Status.create_with( + text: 'This quote post is revoked', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_022) + Quote.create_with( + status: revoked_quote_post, + quoted_status: remote_formatted_post, + activity_uri: 'https://foo/baz', + state: :revoked + ).find_or_create_by!(id: 10_000_009) + + StatusPin.create_with(account: showcase_account, status: public_self_reply_with_cw).find_or_create_by!(id: 10_000_000) + StatusPin.create_with(account: showcase_account, status: private_mentionless).find_or_create_by!(id: 10_000_001) + + showcase_account.update!( + display_name: 'Mastodon test/showcase account', + note: 'Test account to showcase many Mastodon features. Most of its posts are public, but some are private!' + ) + + remote_quote = Status.create_with( + text: <<~HTML, +

This is a self-quote of a remote formatted post

+

RE: https://example.org/foo/bar/baz

+ HTML + account: remote_account, + uri: 'https://example.org/foo/bar/quote', + url: 'https://example.org/foo/bar/quote' + ).find_or_create_by!(id: 10_000_023) + Quote.create_with( + status: remote_quote, + quoted_status: remote_formatted_post, + state: :accepted + ).find_or_create_by!(id: 10_000_010) + Status.create_with( + account: showcase_account, + reblog: remote_quote + ).find_or_create_by!(id: 10_000_024) + + media_attachment = MediaAttachment.create_with( account: showcase_account, file: File.open('spec/fixtures/files/attachment.jpg') - ).find_or_create_by!(id: 10_000_007), - MediaAttachment.create_with( + ).find_or_create_by!(id: 10_000_010) + quote_post_with_media = Status.create_with( + text: "This is a status with a picture and tags which also quotes a status with a picture.\n\n#Mastodon #Test", + ordered_media_attachment_ids: [media_attachment.id], account: showcase_account, - file: File.open('spec/fixtures/files/avatar-high.gif'), - description: 'Walking cartoon cat' - ).find_or_create_by!(id: 10_000_008), - MediaAttachment.create_with( + visibility: :public + ).find_or_create_by!(id: 10_000_025) + media_attachment.update(status_id: quote_post_with_media.id) + ProcessHashtagsService.new.call(quote_post_with_media) + Quote.create_with( + status: quote_post_with_media, + quoted_status: status_with_media, + state: :accepted + ).find_or_create_by!(id: 10_000_011) + + showcase_sidekick_account = Account.create_with(username: 'showcase_sidekick').find_or_create_by!(id: 10_000_002) + sidekick_user = User.create_with( + account_id: showcase_sidekick_account.id, + agreement: true, + password: SecureRandom.hex, + email: ENV.fetch('TEST_DATA_SHOWCASE_SIDEKICK_EMAIL', 'showcase_sidekick@joinmastodon.org'), + confirmed_at: Time.now.utc, + approved: true, + bypass_registration_checks: true + ).find_or_create_by!(id: 10_000_001) + sidekick_user.mark_email_as_confirmed! + sidekick_user.approve! + + sidekick_post = Status.create_with( + text: 'This post only exists to be quoted.', + account: showcase_sidekick_account, + visibility: :public + ).find_or_create_by!(id: 10_000_026) + sidekick_quote_post = Status.create_with( + text: 'This is a quote of a different user.', account: showcase_account, - file: File.open('spec/fixtures/files/text.png'), - description: 'Text saying “Hello Mastodon”' - ).find_or_create_by!(id: 10_000_009), - ] - status_with_multiple_attachments = Status.create_with( - text: "This is a post with multiple attachments, not all of which have a description\n\n#Mastodon #English #Test", - spoiler_text: 'multiple attachments', - ordered_media_attachment_ids: media_attachments.pluck(:id), - account: showcase_account, - visibility: :public, - sensitive: true, - thread: status_with_cw_audio - ).find_or_create_by!(id: 10_000_010) - media_attachments.each { |attachment| attachment.update!(status_id: status_with_multiple_attachments.id) } - ProcessHashtagsService.new.call(status_with_multiple_attachments) + visibility: :public + ).find_or_create_by!(id: 10_000_027) + Quote.create_with( + status: sidekick_quote_post, + quoted_status: sidekick_post, + activity_uri: 'https://foo/cross-account-quote', + state: :accepted + ).find_or_create_by!(id: 10_000_012) - remote_account = Account.create_with( - username: 'fake.example', - domain: 'example.org', - uri: 'https://example.org/foo/bar', - url: 'https://example.org/foo/bar', - locked: true - ).find_or_create_by!(id: 10_000_001) - - remote_formatted_post = Status.create_with( - text: <<~HTML, -

This is a post with a variety of HTML in it

-

For instance, this text is bold and this one as well, while this text is stricken through and this one as well.

-
-

This thing, here, is a block quote
with some bold as well

-
    -
  • a list item
  • -
  • - and another with -
      -
    • nested
    • -
    • items!
    • -
    -
  • -
-
-
// And this is some code
-        // with two lines of comments
-        
-

And this is inline code

-

Finally, please observe this Ruby element: 明日 (Ashita)

- HTML - account: remote_account, - uri: 'https://example.org/foo/bar/baz', - url: 'https://example.org/foo/bar/baz' - ).find_or_create_by!(id: 10_000_011) - Status.create_with(account: showcase_account, reblog: remote_formatted_post).find_or_create_by!(id: 10_000_012) - - unattached_quote_post = Status.create_with( - text: 'This is a quote of a post that does not exist', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_013) - Quote.create_with( - status: unattached_quote_post, - quoted_status: nil - ).find_or_create_by!(id: 10_000_000) - - self_quote = Status.create_with( - text: 'This is a quote of a public self-post', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_014) - Quote.create_with( - status: self_quote, - quoted_status: status_with_media, - state: :accepted - ).find_or_create_by!(id: 10_000_001) - - nested_self_quote = Status.create_with( - text: 'This is a quote of a public self-post which itself is a self-quote', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_015) - Quote.create_with( - status: nested_self_quote, - quoted_status: self_quote, - state: :accepted - ).find_or_create_by!(id: 10_000_002) - - recursive_self_quote = Status.create_with( - text: 'This is a recursive self-quote; no real reason for it to exist, but just to make sure we handle them gracefuly', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_016) - Quote.create_with( - status: recursive_self_quote, - quoted_status: recursive_self_quote, - state: :accepted - ).find_or_create_by!(id: 10_000_003) - - self_private_quote = Status.create_with( - text: 'This is a public post of a private self-post: the quoted post should not be visible to non-followers', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_017) - Quote.create_with( - status: self_private_quote, - quoted_status: private_mentionless, - state: :accepted - ).find_or_create_by!(id: 10_000_004) - - uncwed_quote_cwed = Status.create_with( - text: 'This is a quote without CW of a quoted post that has a CW', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_018) - Quote.create_with( - status: uncwed_quote_cwed, - quoted_status: public_self_reply_with_cw, - state: :accepted - ).find_or_create_by!(id: 10_000_005) - - cwed_quote_cwed = Status.create_with( - text: 'This is a quote with a CW of a quoted post that itself has a CW', - spoiler_text: 'Quote post with a CW', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_019) - Quote.create_with( - status: cwed_quote_cwed, - quoted_status: public_self_reply_with_cw, - state: :accepted - ).find_or_create_by!(id: 10_000_006) - - pending_quote_post = Status.create_with( - text: 'This quote post is pending', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_020) - Quote.create_with( - status: pending_quote_post, - quoted_status: remote_formatted_post, - activity_uri: 'https://foo/bar', - state: :pending - ).find_or_create_by!(id: 10_000_007) - - rejected_quote_post = Status.create_with( - text: 'This quote post is rejected', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_021) - Quote.create_with( - status: rejected_quote_post, - quoted_status: remote_formatted_post, - activity_uri: 'https://foo/foo', - state: :rejected - ).find_or_create_by!(id: 10_000_008) - - revoked_quote_post = Status.create_with( - text: 'This quote post is revoked', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_022) - Quote.create_with( - status: revoked_quote_post, - quoted_status: remote_formatted_post, - activity_uri: 'https://foo/baz', - state: :revoked - ).find_or_create_by!(id: 10_000_009) - - StatusPin.create_with(account: showcase_account, status: public_self_reply_with_cw).find_or_create_by!(id: 10_000_000) - StatusPin.create_with(account: showcase_account, status: private_mentionless).find_or_create_by!(id: 10_000_001) - - showcase_account.update!( - display_name: 'Mastodon test/showcase account', - note: 'Test account to showcase many Mastodon features. Most of its posts are public, but some are private!' - ) - - remote_quote = Status.create_with( - text: <<~HTML, -

This is a self-quote of a remote formatted post

-

RE: https://example.org/foo/bar/baz

- HTML - account: remote_account, - uri: 'https://example.org/foo/bar/quote', - url: 'https://example.org/foo/bar/quote' - ).find_or_create_by!(id: 10_000_023) - Quote.create_with( - status: remote_quote, - quoted_status: remote_formatted_post, - state: :accepted - ).find_or_create_by!(id: 10_000_010) - Status.create_with( - account: showcase_account, - reblog: remote_quote - ).find_or_create_by!(id: 10_000_024) - - media_attachment = MediaAttachment.create_with( - account: showcase_account, - file: File.open('spec/fixtures/files/attachment.jpg') - ).find_or_create_by!(id: 10_000_010) - quote_post_with_media = Status.create_with( - text: "This is a status with a picture and tags which also quotes a status with a picture.\n\n#Mastodon #Test", - ordered_media_attachment_ids: [media_attachment.id], - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_025) - media_attachment.update(status_id: quote_post_with_media.id) - ProcessHashtagsService.new.call(quote_post_with_media) - Quote.create_with( - status: quote_post_with_media, - quoted_status: status_with_media, - state: :accepted - ).find_or_create_by!(id: 10_000_011) - - showcase_sidekick_account = Account.create_with(username: 'showcase_sidekick').find_or_create_by!(id: 10_000_002) - sidekick_user = User.create_with( - account_id: showcase_sidekick_account.id, - agreement: true, - password: SecureRandom.hex, - email: ENV.fetch('TEST_DATA_SHOWCASE_SIDEKICK_EMAIL', 'showcase_sidekick@joinmastodon.org'), - confirmed_at: Time.now.utc, - approved: true, - bypass_registration_checks: true - ).find_or_create_by!(id: 10_000_001) - sidekick_user.mark_email_as_confirmed! - sidekick_user.approve! - - sidekick_post = Status.create_with( - text: 'This post only exists to be quoted.', - account: showcase_sidekick_account, - visibility: :public - ).find_or_create_by!(id: 10_000_026) - sidekick_quote_post = Status.create_with( - text: 'This is a quote of a different user.', - account: showcase_account, - visibility: :public - ).find_or_create_by!(id: 10_000_027) - Quote.create_with( - status: sidekick_quote_post, - quoted_status: sidekick_post, - activity_uri: 'https://foo/cross-account-quote', - state: :accepted - ).find_or_create_by!(id: 10_000_012) + quoted = Status.create_with( + text: 'This should have a preview card: https://joinmastodon.org', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_028) + LinkCrawlWorker.perform_async(10_000_028) + quoting = Status.create_with( + text: 'This should quote a post with a preview card', + account: showcase_account, + visibility: :public + ).find_or_create_by!(id: 10_000_029) + Quote.create_with( + status: quoting, + quoted_status: quoted, + state: :accepted + ).find_or_create_by!(id: 10_000_013) + end end end diff --git a/package.json b/package.json index efe5b45fefb..736f29fb814 100644 --- a/package.json +++ b/package.json @@ -64,11 +64,11 @@ "color-blend": "^4.0.0", "core-js": "^3.30.2", "cross-env": "^10.0.0", + "debug": "^4.4.1", "detect-passive-events": "^2.0.3", "emoji-mart": "npm:emoji-mart-lazyload@latest", "emojibase": "^16.0.0", "emojibase-data": "^16.0.3", - "emojibase-regex": "^16.0.0", "escape-html": "^1.0.3", "fast-glob": "^3.3.3", "fuzzysort": "^3.0.0", @@ -137,6 +137,7 @@ "@storybook/react-vite": "^9.0.4", "@testing-library/dom": "^10.2.0", "@testing-library/react": "^16.0.0", + "@types/debug": "^4", "@types/emoji-mart": "3.0.14", "@types/escape-html": "^1.0.2", "@types/hoist-non-react-statics": "^3.3.1", @@ -174,6 +175,7 @@ "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-storybook": "^9.0.4", + "fake-indexeddb": "^6.0.1", "globals": "^16.0.0", "husky": "^9.0.11", "lint-staged": "^16.0.0", diff --git a/spec/helpers/home_helper_spec.rb b/spec/helpers/home_helper_spec.rb index a8f6d99f032..a056eae364d 100644 --- a/spec/helpers/home_helper_spec.rb +++ b/spec/helpers/home_helper_spec.rb @@ -41,40 +41,6 @@ RSpec.describe HomeHelper do end end - describe 'obscured_counter' do - context 'with a value of less than zero' do - let(:count) { -10 } - - it 'returns the correct string' do - expect(helper.obscured_counter(count)).to eq '0' - end - end - - context 'with a value of zero' do - let(:count) { 0 } - - it 'returns the correct string' do - expect(helper.obscured_counter(count)).to eq '0' - end - end - - context 'with a value of one' do - let(:count) { 1 } - - it 'returns the correct string' do - expect(helper.obscured_counter(count)).to eq '1' - end - end - - context 'with a value of more than one' do - let(:count) { 10 } - - it 'returns the correct string' do - expect(helper.obscured_counter(count)).to eq '1+' - end - end - end - describe 'field_verified_class' do subject { helper.field_verified_class(verified) } diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 74c9f107187..cdd5cb3194d 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -888,7 +888,7 @@ RSpec.describe ActivityPub::Activity::Create do end context 'with an unverifiable quote of a known post' do - let(:quoted_status) { Fabricate(:status) } + let(:quoted_status) { Fabricate(:status, account: Fabricate(:account, domain: 'example.com')) } let(:object_json) do build_object( diff --git a/spec/lib/delivery_failure_tracker_spec.rb b/spec/lib/delivery_failure_tracker_spec.rb index 34912c8133e..6935dbccb43 100644 --- a/spec/lib/delivery_failure_tracker_spec.rb +++ b/spec/lib/delivery_failure_tracker_spec.rb @@ -3,37 +3,101 @@ require 'rails_helper' RSpec.describe DeliveryFailureTracker do - subject { described_class.new('http://example.com/inbox') } + context 'with the default resolution of :days' do + subject { described_class.new('http://example.com/inbox') } - describe '#track_success!' do - before do - subject.track_failure! - subject.track_success! + describe '#track_success!' do + before do + track_failure(7, :days) + subject.track_success! + end + + it 'marks URL as available again' do + expect(described_class.available?('http://example.com/inbox')).to be true + end + + it 'resets days to 0' do + expect(subject.days).to be_zero + end end - it 'marks URL as available again' do - expect(described_class.available?('http://example.com/inbox')).to be true + describe '#track_failure!' do + it 'marks URL as unavailable after 7 days of being called' do + track_failure(7, :days) + + expect(subject.days).to eq 7 + expect(described_class.available?('http://example.com/inbox')).to be false + end + + it 'repeated calls on the same day do not count' do + subject.track_failure! + subject.track_failure! + + expect(subject.days).to eq 1 + end end - it 'resets days to 0' do - expect(subject.days).to be_zero + describe '#exhausted_deliveries_days' do + it 'returns the days on which failures were recorded' do + track_failure(3, :days) + + expect(subject.exhausted_deliveries_days).to contain_exactly(3.days.ago.to_date, 2.days.ago.to_date, Date.yesterday) + end end end - describe '#track_failure!' do - it 'marks URL as unavailable after 7 days of being called' do - 6.times { |i| redis.sadd('exhausted_deliveries:example.com', i) } - subject.track_failure! + context 'with a resolution of :minutes' do + subject { described_class.new('http://example.com/inbox', resolution: :minutes) } - expect(subject.days).to eq 7 - expect(described_class.available?('http://example.com/inbox')).to be false + describe '#track_success!' do + before do + track_failure(5, :minutes) + subject.track_success! + end + + it 'marks URL as available again' do + expect(described_class.available?('http://example.com/inbox')).to be true + end + + it 'resets failures to 0' do + expect(subject.failures).to be_zero + end end - it 'repeated calls on the same day do not count' do - subject.track_failure! - subject.track_failure! + describe '#track_failure!' do + it 'marks URL as unavailable after 5 minutes of being called' do + track_failure(5, :minutes) - expect(subject.days).to eq 1 + expect(subject.failures).to eq 5 + expect(described_class.available?('http://example.com/inbox')).to be false + end + + it 'repeated calls within the same minute do not count' do + freeze_time + subject.track_failure! + subject.track_failure! + + expect(subject.failures).to eq 1 + end + end + + describe '#exhausted_deliveries_days' do + it 'returns the days on which failures were recorded' do + # Make sure this does not accidentally span two days when run + # around midnight + travel_to Time.zone.now.change(hour: 10) + track_failure(3, :minutes) + + expect(subject.exhausted_deliveries_days).to contain_exactly(Time.zone.today) + end + end + + describe '#days' do + it 'raises due to wrong resolution' do + assert_raises TypeError do + subject.days + end + end end end @@ -60,4 +124,12 @@ RSpec.describe DeliveryFailureTracker do expect(described_class.available?('http://foo.bar/inbox')).to be true end end + + def track_failure(times, unit) + times.times do + travel_to 1.send(unit).ago + subject.track_failure! + end + travel_back + end end diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index 63622970455..af6958b68dc 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -94,19 +94,19 @@ RSpec.describe StatusPolicy, type: :model do expect(subject).to permit(status.account, status) end - it 'grants access when direct and viewer is mentioned' do + it 'does not grant access access when direct and viewer is mentioned but not explicitly allowed' do status.visibility = :direct - status.mentions = [Fabricate(:mention, account: alice)] + status.mentions = [Fabricate(:mention, account: bob)] - expect(subject).to permit(alice, status) + expect(subject).to_not permit(bob, status) end - it 'grants access when direct and non-owner viewer is mentioned and mentions are loaded' do + it 'does not grant access access when direct and viewer is mentioned but not explicitly allowed and mentions are loaded' do status.visibility = :direct status.mentions = [Fabricate(:mention, account: bob)] status.active_mentions.load - expect(subject).to permit(bob, status) + expect(subject).to_not permit(bob, status) end it 'denies access when direct and viewer is not mentioned' do @@ -123,11 +123,11 @@ RSpec.describe StatusPolicy, type: :model do expect(subject).to_not permit(viewer, status) end - it 'grants access when private and viewer is mentioned' do + it 'grants access when private and viewer is mentioned but not otherwise allowed' do status.visibility = :private status.mentions = [Fabricate(:mention, account: bob)] - expect(subject).to permit(bob, status) + expect(subject).to_not permit(bob, status) end it 'denies access when private and non-viewer is mentioned' do diff --git a/spec/requests/activitypub/quote_authorizations_spec.rb b/spec/requests/activitypub/quote_authorizations_spec.rb new file mode 100644 index 00000000000..98daa3a79b7 --- /dev/null +++ b/spec/requests/activitypub/quote_authorizations_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'ActivityPub QuoteAuthorization endpoint' do + let(:account) { Fabricate(:account, domain: nil) } + let(:status) { Fabricate :status, account: account } + let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + + before { Fabricate :favourite, status: status } + + describe 'GET /accounts/:account_username/quote_authorizations/:quote_id' do + context 'with an accepted quote' do + it 'returns http success and activity json' do + get account_quote_authorization_url(quote.quoted_account, quote) + + expect(response) + .to have_http_status(200) + expect(response.media_type) + .to eq 'application/activity+json' + + expect(response.parsed_body) + .to include(type: 'QuoteAuthorization') + end + end + + context 'with an incorrect quote authorization URL' do + it 'returns http not found' do + get account_quote_authorization_url(quote.account, quote) + + expect(response) + .to have_http_status(404) + end + end + + context 'with a rejected quote' do + before do + quote.reject! + end + + it 'returns http not found' do + get account_quote_authorization_url(quote.quoted_account, quote) + + expect(response) + .to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/statuses/quotes_spec.rb b/spec/requests/api/v1/statuses/quotes_spec.rb new file mode 100644 index 00000000000..bbf697ce323 --- /dev/null +++ b/spec/requests/api/v1/statuses/quotes_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'API V1 Statuses Quotes' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + describe 'GET /api/v1/statuses/:status_id/quotes' do + subject do + get "/api/v1/statuses/#{status.id}/quotes", headers: headers, params: { limit: 2 } + end + + let(:scopes) { 'read:statuses' } + + let(:status) { Fabricate(:status, account: user.account) } + let!(:accepted_quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + let!(:rejected_quote) { Fabricate(:quote, quoted_status: status, state: :rejected) } + let!(:pending_quote) { Fabricate(:quote, quoted_status: status, state: :pending) } + let!(:another_accepted_quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + + context 'with an OAuth token' do + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + it_behaves_like 'forbidden for wrong scope', 'write write:statuses' + + it 'returns http success and statuses quoting this post' do + subject + + expect(response) + .to have_http_status(200) + .and include_pagination_headers( + prev: api_v1_status_quotes_url(limit: 2, since_id: another_accepted_quote.id), + next: api_v1_status_quotes_url(limit: 2, max_id: accepted_quote.id) + ) + expect(response.content_type) + .to start_with('application/json') + + expect(response.parsed_body) + .to contain_exactly( + include(id: accepted_quote.status.id.to_s), + include(id: another_accepted_quote.status.id.to_s) + ) + + expect(response.parsed_body) + .to_not include( + include(id: rejected_quote.status.id.to_s), + include(id: pending_quote.status.id.to_s) + ) + end + + context 'with a different user than the post owner' do + let(:status) { Fabricate(:status) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + expect(response.content_type) + .to start_with('application/json') + end + end + end + + context 'without an OAuth token' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + expect(response.content_type) + .to start_with('application/json') + end + end + end + + describe 'POST /api/v1/statuses/:status_id/quotes/:id/revoke' do + subject do + post "/api/v1/statuses/#{status.id}/quotes/#{quote.status.id}/revoke", headers: headers + end + + let(:scopes) { 'write:statuses' } + + let(:status) { Fabricate(:status, account: user.account) } + let!(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + + context 'with an OAuth token' do + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + it_behaves_like 'forbidden for wrong scope', 'read read:statuses' + + context 'with a different user than the post owner' do + let(:status) { Fabricate(:status) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + expect(response.content_type) + .to start_with('application/json') + end + end + + it 'revokes the quote and returns HTTP success' do + expect { subject } + .to change { quote.reload.state }.from('accepted').to('revoked') + + expect(response) + .to have_http_status(200) + end + end + + context 'without an OAuth token' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + expect(response.content_type) + .to start_with('application/json') + end + end + end +end diff --git a/spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb b/spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb new file mode 100644 index 00000000000..48e3a4ddf73 --- /dev/null +++ b/spec/serializers/activitypub/delete_quote_authorization_serializer_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::DeleteQuoteAuthorizationSerializer do + subject { serialized_record_json(quote, described_class, adapter: ActivityPub::Adapter) } + + describe 'serializing an object' do + let(:status) { Fabricate(:status) } + let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + + it 'returns expected attributes' do + expect(subject.deep_symbolize_keys) + .to include( + actor: eq(ActivityPub::TagManager.instance.uri_for(status.account)), + object: ActivityPub::TagManager.instance.approval_uri_for(quote, check_approval: false), + type: 'Delete' + ) + end + end +end diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index d1af3f068f5..9c898e52121 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -44,8 +44,7 @@ RSpec.describe ActivityPub::NoteSerializer do context 'with a quote' do let(:quoted_status) { Fabricate(:status) } - let(:approval_uri) { 'https://example.com/foo/bar' } - let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, approval_uri: approval_uri) } + let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, state: :accepted) } it 'has the expected shape' do expect(subject).to include({ @@ -53,7 +52,7 @@ RSpec.describe ActivityPub::NoteSerializer do 'quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status), 'quoteUri' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status), '_misskey_quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status), - 'quoteAuthorization' => approval_uri, + 'quoteAuthorization' => ActivityPub::TagManager.instance.approval_uri_for(quote), }) end end diff --git a/spec/serializers/activitypub/quote_authorization_serializer_spec.rb b/spec/serializers/activitypub/quote_authorization_serializer_spec.rb new file mode 100644 index 00000000000..6a157756934 --- /dev/null +++ b/spec/serializers/activitypub/quote_authorization_serializer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::QuoteAuthorizationSerializer do + subject { serialized_record_json(quote, described_class, adapter: ActivityPub::Adapter) } + + describe 'serializing an object' do + let(:status) { Fabricate(:status) } + let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + + it 'returns expected attributes' do + expect(subject.deep_symbolize_keys) + .to include( + attributedTo: eq(ActivityPub::TagManager.instance.uri_for(status.account)), + interactionTarget: ActivityPub::TagManager.instance.uri_for(status), + interactingObject: ActivityPub::TagManager.instance.uri_for(quote.status), + type: 'QuoteAuthorization' + ) + end + end +end diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index a7e1b923832..74b8cef413a 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -564,6 +564,80 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do end end + context 'when an approved quote of a local post gets updated through an explicit update' do + let(:quoted_account) { Fabricate(:account) } + let(:quoted_status) { Fabricate(:status, account: quoted_account, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) } + let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted) } + let(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) } + + let(:payload) do + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + '@id': 'https://w3id.org/fep/044f#quote', + '@type': '@id', + }, + { + '@id': 'https://w3id.org/fep/044f#quoteAuthorization', + '@type': '@id', + }, + ], + id: 'foo', + type: 'Note', + summary: 'Show more', + content: 'Hello universe', + updated: '2021-09-08T22:39:25Z', + quote: ActivityPub::TagManager.instance.uri_for(quoted_status), + quoteAuthorization: approval_uri, + } + end + + it 'updates the quote post without changing the quote status' do + expect { subject.call(status, json, json) } + .to not_change(quote, :approval_uri) + .and not_change(quote, :state).from('accepted') + .and change(status, :text).from('Hello world').to('Hello universe') + end + end + + context 'when an unapproved quote of a local post gets updated through an explicit update and claims approval' do + let(:quoted_account) { Fabricate(:account) } + let(:quoted_status) { Fabricate(:status, account: quoted_account, quote_approval_policy: 0) } + let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, state: :rejected) } + let(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) } + + let(:payload) do + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + '@id': 'https://w3id.org/fep/044f#quote', + '@type': '@id', + }, + { + '@id': 'https://w3id.org/fep/044f#quoteAuthorization', + '@type': '@id', + }, + ], + id: 'foo', + type: 'Note', + summary: 'Show more', + content: 'Hello universe', + updated: '2021-09-08T22:39:25Z', + quote: ActivityPub::TagManager.instance.uri_for(quoted_status), + quoteAuthorization: approval_uri, + } + end + + it 'updates the quote post without changing the quote status' do + expect { subject.call(status, json, json) } + .to not_change(quote, :approval_uri) + .and not_change(quote, :state).from('rejected') + .and change(status, :text).from('Hello world').to('Hello universe') + end + end + context 'when the status has an existing verified quote and removes an approval link through an explicit update' do let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') } let(:quoted_status) { Fabricate(:status, account: quoted_account) } diff --git a/spec/services/activitypub/verify_quote_service_spec.rb b/spec/services/activitypub/verify_quote_service_spec.rb index ae4ffae9bb2..94b9e33ed3b 100644 --- a/spec/services/activitypub/verify_quote_service_spec.rb +++ b/spec/services/activitypub/verify_quote_service_spec.rb @@ -267,9 +267,9 @@ RSpec.describe ActivityPub::VerifyQuoteService do quoted_status.mentions << Mention.new(account: account) end - it 'updates the status' do + it 'does not the status' do expect { subject.call(quote) } - .to change(quote, :state).to('accepted') + .to_not change(quote, :state).from('pending') end end end diff --git a/spec/services/revoke_quote_service_spec.rb b/spec/services/revoke_quote_service_spec.rb new file mode 100644 index 00000000000..c1dbcfda54e --- /dev/null +++ b/spec/services/revoke_quote_service_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe RevokeQuoteService do + subject { described_class.new } + + let!(:alice) { Fabricate(:account) } + let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + + let(:status) { Fabricate(:status, account: alice) } + + let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) } + + before do + hank.follow!(alice) + end + + context 'with an accepted quote' do + it 'revokes the quote and sends a Delete activity' do + expect { described_class.new.call(quote) } + .to change { quote.reload.state }.from('accepted').to('revoked') + .and enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(/Delete/, alice.id, hank.inbox_url) + end + end +end diff --git a/vitest.config.mts b/vitest.config.mts index 7df462ed6db..b129c293f4c 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -49,6 +49,7 @@ const legacyTests: TestProjectInlineConfiguration = { 'tmp/**', ], globals: true, + setupFiles: ['fake-indexeddb/auto'], }, }; diff --git a/yarn.lock b/yarn.lock index 9cb29176d8b..377f2999c85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2632,6 +2632,7 @@ __metadata: "@storybook/react-vite": "npm:^9.0.4" "@testing-library/dom": "npm:^10.2.0" "@testing-library/react": "npm:^16.0.0" + "@types/debug": "npm:^4" "@types/emoji-mart": "npm:3.0.14" "@types/escape-html": "npm:^1.0.2" "@types/hoist-non-react-statics": "npm:^3.3.1" @@ -2673,11 +2674,11 @@ __metadata: color-blend: "npm:^4.0.0" core-js: "npm:^3.30.2" cross-env: "npm:^10.0.0" + debug: "npm:^4.4.1" detect-passive-events: "npm:^2.0.3" emoji-mart: "npm:emoji-mart-lazyload@latest" emojibase: "npm:^16.0.0" emojibase-data: "npm:^16.0.3" - emojibase-regex: "npm:^16.0.0" escape-html: "npm:^1.0.3" eslint: "npm:^9.23.0" eslint-import-resolver-typescript: "npm:^4.2.5" @@ -2689,6 +2690,7 @@ __metadata: eslint-plugin-react: "npm:^7.37.4" eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-storybook: "npm:^9.0.4" + fake-indexeddb: "npm:^6.0.1" fast-glob: "npm:^3.3.3" fuzzysort: "npm:^3.0.0" globals: "npm:^16.0.0" @@ -3931,6 +3933,15 @@ __metadata: languageName: node linkType: hard +"@types/debug@npm:^4": + version: 4.1.12 + resolution: "@types/debug@npm:4.1.12" + dependencies: + "@types/ms": "npm:*" + checksum: 10c0/5dcd465edbb5a7f226e9a5efd1f399c6172407ef5840686b73e3608ce135eeca54ae8037dcd9f16bdb2768ac74925b820a8b9ecc588a58ca09eca6acabe33e2f + languageName: node + linkType: hard + "@types/deep-eql@npm:*": version: 4.0.2 resolution: "@types/deep-eql@npm:4.0.2" @@ -4112,6 +4123,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 2.1.0 + resolution: "@types/ms@npm:2.1.0" + checksum: 10c0/5ce692ffe1549e1b827d99ef8ff71187457e0eb44adbae38fdf7b9a74bae8d20642ee963c14516db1d35fa2652e65f47680fdf679dcbde52bbfadd021f497225 + languageName: node + linkType: hard + "@types/node@npm:*, @types/node@npm:^22.0.0": version: 22.13.14 resolution: "@types/node@npm:22.13.14" @@ -6599,13 +6617,6 @@ __metadata: languageName: node linkType: hard -"emojibase-regex@npm:^16.0.0": - version: 16.0.0 - resolution: "emojibase-regex@npm:16.0.0" - checksum: 10c0/8ee5ff798e51caa581434b1cb2f9737e50195093c4efa1739df21a50a5496f80517924787d865e8cf7d6a0b4c90dbedc04bdc506dcbcc582e14cdf0bb47af0f0 - languageName: node - linkType: hard - "emojibase@npm:^16.0.0": version: 16.0.0 resolution: "emojibase@npm:16.0.0" @@ -7370,6 +7381,13 @@ __metadata: languageName: node linkType: hard +"fake-indexeddb@npm:^6.0.1": + version: 6.0.1 + resolution: "fake-indexeddb@npm:6.0.1" + checksum: 10c0/60f4ccdfd5ecb37bb98019056c688366847840cce7146e0005c5ca54823238455403b0a8803b898a11cf80f6147b1bb553457c6af427a644a6e64566cdbe42ec + languageName: node + linkType: hard + "fast-copy@npm:^3.0.2": version: 3.0.2 resolution: "fast-copy@npm:3.0.2"