diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1850a45bbcd..07400a07a49 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -23,7 +23,6 @@ matchManagers: ['npm'], matchPackageNames: [ 'tesseract.js', // Requires code changes - 'react-hotkeys', // Requires code changes // react-router: Requires manual upgrade 'history', diff --git a/Gemfile.lock b/Gemfile.lock index a5855883e39..094c9b1d6ee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -95,8 +95,8 @@ GEM activesupport (>= 6.0.0) ast (2.4.3) attr_required (1.0.2) - aws-eventstream (1.3.2) - aws-partitions (1.1103.0) + aws-eventstream (1.4.0) + aws-partitions (1.1131.0) aws-sdk-core (3.215.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -109,9 +109,9 @@ GEM aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) - azure-blob (0.5.8) + azure-blob (0.5.9.1) rexml base64 (0.3.0) bcp47_spec (0.2.1) @@ -228,12 +228,12 @@ GEM erubi (1.13.1) et-orbi (1.2.11) tzinfo - excon (1.2.5) + excon (1.2.8) logger fabrication (3.0.0) faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.13.1) + faraday (2.13.2) faraday-net_http (>= 2.0, < 3.5) json logger @@ -241,7 +241,7 @@ GEM faraday (>= 1, < 3) faraday-httpclient (2.0.2) httpclient (>= 2.2) - faraday-net_http (3.4.0) + faraday-net_http (3.4.1) net-http (>= 0.5.0) fast_blank (1.0.1) fastimage (2.4.0) @@ -266,14 +266,14 @@ GEM fog-openstack (1.1.5) fog-core (~> 2.1) fog-json (>= 1.0) - formatador (1.1.0) + formatador (1.1.1) forwardable (1.3.3) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - google-protobuf (4.31.0) + google-protobuf (4.31.1) bigdecimal rake (>= 13) googleapis-common-protos-types (1.20.0) @@ -287,21 +287,21 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.64.0) + haml_lint (0.65.0) haml (>= 5.0) parallel (~> 1.10) rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hashdiff (1.1.2) + hashdiff (1.2.0) hashie (5.0.0) hcaptcha (7.1.0) json highline (3.1.2) reline hiredis (0.6.3) - hiredis-client (0.24.0) - redis-client (= 0.24.0) + hiredis-client (0.25.1) + redis-client (= 0.25.1) hkdf (0.3.0) htmlentities (4.3.4) http (5.3.1) @@ -345,7 +345,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.12.2) + json (2.13.0) json-canonicalization (1.0.0) json-jwt (1.16.7) activesupport (>= 4.2) @@ -369,7 +369,7 @@ GEM addressable (~> 2.8) bigdecimal (~> 3.1) jsonapi-renderer (0.2.2) - jwt (2.10.1) + jwt (2.10.2) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -433,21 +433,21 @@ GEM marcel (1.0.4) mario-redis-lock (1.2.1) redis (>= 3.0.5) - matrix (0.4.2) + matrix (0.4.3) memory_profiler (1.1.0) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0514) + mime-types-data (3.2025.0715) mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (5.25.5) msgpack (1.8.0) - multi_json (1.15.0) + multi_json (1.17.0) mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.8) + net-imap (0.5.9) date net-protocol net-ldap (0.19.0) @@ -458,7 +458,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.8) + nokogiri (1.18.9) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.11) @@ -515,7 +515,7 @@ GEM opentelemetry-api (~> 1.0) opentelemetry-instrumentation-active_support (~> 0.7) opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-action_pack (0.12.1) + opentelemetry-instrumentation-action_pack (0.12.3) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-rack (~> 0.21) @@ -597,7 +597,7 @@ GEM opentelemetry-semantic_conventions (1.11.0) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) - ostruct (0.6.1) + ostruct (0.6.3) ox (2.14.23) bigdecimal (>= 3.0) parallel (1.27.0) @@ -610,7 +610,7 @@ GEM pg (1.5.9) pghero (3.7.0) activerecord (>= 7.1) - playwright-ruby-client (1.52.0) + playwright-ruby-client (1.54.0) concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) pp (0.6.2) @@ -701,23 +701,28 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) - rdf (3.3.2) + rdf (3.3.4) bcp47_spec (~> 0.2) bigdecimal (~> 3.1, >= 3.1.5) link_header (~> 0.0, >= 0.0.8) + logger (~> 1.5) + ostruct (~> 0.6) + readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) rdoc (6.14.2) erb psych (>= 4.0.0) + readline (0.0.4) + reline redcarpet (3.6.1) redis (4.8.1) - redis-client (0.24.0) + redis-client (0.25.1) connection_pool redlock (1.3.2) redis (>= 3.0.0, < 6.0) regexp_parser (2.10.0) - reline (0.6.1) + reline (0.6.2) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) @@ -732,11 +737,11 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.0) - rspec (3.13.0) + rspec (3.13.1) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.4) + rspec-core (3.13.5) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) @@ -771,7 +776,7 @@ GEM rubocop-ast (>= 1.45.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.45.1) + rubocop-ast (1.46.0) parser (>= 3.3.7.2) prism (~> 1.4) rubocop-capybara (2.22.1) @@ -845,7 +850,7 @@ GEM docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.13.1) + simplecov-html (0.13.2) simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) stackprof (0.2.27) @@ -865,11 +870,11 @@ GEM temple (0.10.3) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) - terrapin (1.1.0) + terrapin (1.1.1) climate_control test-prof (1.4.4) - thor (1.3.2) - tilt (2.6.0) + thor (1.4.0) + tilt (2.6.1) timeout (0.4.3) tpm-key_attestation (0.14.1) bindata (~> 2.4) @@ -931,7 +936,7 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) webrick (1.9.1) - websocket-driver (0.7.7) + websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -1101,4 +1106,4 @@ RUBY VERSION ruby 3.4.1p0 BUNDLED WITH - 2.6.9 + 2.7.0 diff --git a/app/controllers/api/v1/invites_controller.rb b/app/controllers/api/v1/invites_controller.rb index ea17ba74038..7b24cec7918 100644 --- a/app/controllers/api/v1/invites_controller.rb +++ b/app/controllers/api/v1/invites_controller.rb @@ -7,6 +7,7 @@ class Api::V1::InvitesController < Api::BaseController skip_around_action :set_locale before_action :set_invite + before_action :check_valid_usage! before_action :check_enabled_registrations! # Override `current_user` to avoid reading session cookies @@ -22,9 +23,11 @@ class Api::V1::InvitesController < Api::BaseController @invite = Invite.find_by!(code: params[:invite_code]) end - def check_enabled_registrations! - return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use? + def check_valid_usage! + render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use? + end + def check_enabled_registrations! raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite) end end diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index e0127f20923..cdac41b8a7d 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -2,27 +2,44 @@ import { useCallback } from 'react'; import { useLinks } from 'mastodon/hooks/useLinks'; +import { EmojiHTML } from '../features/emoji/emoji_html'; +import { isFeatureEnabled } from '../initial_state'; +import { useAppSelector } from '../store'; + interface AccountBioProps { - note: string; className: string; - dropdownAccountId?: string; + accountId: string; + showDropdown?: boolean; } export const AccountBio: React.FC = ({ - note, className, - dropdownAccountId, + accountId, + showDropdown = false, }) => { - const handleClick = useLinks(!!dropdownAccountId); + const handleClick = useLinks(showDropdown); const handleNodeChange = useCallback( (node: HTMLDivElement | null) => { - if (!dropdownAccountId || !node || node.childNodes.length === 0) { + if (!showDropdown || !node || node.childNodes.length === 0) { return; } - addDropdownToHashtags(node, dropdownAccountId); + addDropdownToHashtags(node, accountId); }, - [dropdownAccountId], + [showDropdown, accountId], ); + const note = useAppSelector((state) => { + const account = state.accounts.get(accountId); + if (!account) { + return ''; + } + return isFeatureEnabled('modern_emojis') + ? account.note + : account.note_emojified; + }); + const extraEmojis = useAppSelector((state) => { + const account = state.accounts.get(accountId); + return account?.emojis; + }); if (note.length === 0) { return null; @@ -31,10 +48,11 @@ export const AccountBio: React.FC = ({ return (
+ > + +
); }; diff --git a/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx b/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx new file mode 100644 index 00000000000..b95c9410e18 --- /dev/null +++ b/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; + +import type { HandlerMap } from '.'; +import { Hotkeys } from '.'; + +const meta = { + title: 'Components/Hotkeys', + component: Hotkeys, + args: { + global: undefined, + focusable: undefined, + handlers: {}, + }, + tags: ['test'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => { + async function confirmHotkey(name: string, shouldFind = true) { + // 'status' is the role of the 'output' element + const output = await canvas.findByRole('status'); + if (shouldFind) { + await expect(output).toHaveTextContent(name); + } else { + await expect(output).not.toHaveTextContent(name); + } + } + + const button = await canvas.findByRole('button'); + await userEvent.click(button); + + await userEvent.keyboard('n'); + await confirmHotkey('new'); + + await userEvent.keyboard('/'); + await confirmHotkey('search'); + + await userEvent.keyboard('o'); + await confirmHotkey('open'); + + await userEvent.keyboard('{Alt>}N{/Alt}'); + await confirmHotkey('forceNew'); + + await userEvent.keyboard('gh'); + await confirmHotkey('goToHome'); + + await userEvent.keyboard('gn'); + await confirmHotkey('goToNotifications'); + + await userEvent.keyboard('gf'); + await confirmHotkey('goToFavourites'); + + /** + * Ensure that hotkeys are not triggered when certain + * interactive elements are focused: + */ + + await userEvent.keyboard('{enter}'); + await confirmHotkey('open', false); + + const input = await canvas.findByRole('textbox'); + await userEvent.click(input); + + await userEvent.keyboard('n'); + await confirmHotkey('new', false); + + await userEvent.keyboard('{backspace}'); + await confirmHotkey('None', false); + + /** + * Reset playground: + */ + + await userEvent.click(button); + await userEvent.keyboard('{backspace}'); +}; + +export const Default = { + render: function Render() { + const [matchedHotkey, setMatchedHotkey] = useState( + null, + ); + + const handlers = { + back: () => { + setMatchedHotkey(null); + }, + new: () => { + setMatchedHotkey('new'); + }, + forceNew: () => { + setMatchedHotkey('forceNew'); + }, + search: () => { + setMatchedHotkey('search'); + }, + open: () => { + setMatchedHotkey('open'); + }, + goToHome: () => { + setMatchedHotkey('goToHome'); + }, + goToNotifications: () => { + setMatchedHotkey('goToNotifications'); + }, + goToFavourites: () => { + setMatchedHotkey('goToFavourites'); + }, + }; + + return ( + +
+

+ Hotkey playground +

+

+ Last pressed hotkey: {matchedHotkey ?? 'None'} +

+

+ Click within the dashed border and press the "n + " or "/" key. Press " + Backspace" to clear the displayed hotkey. +

+

+ Try typing a sequence, like "g" shortly + followed by "h", "n", or + "f" +

+

+ Note that this playground doesn't support all hotkeys we use in + the app. +

+

+ When a is focused, " + Enter + " should not trigger "open", but "o + " should. +

+

+ When an input element is focused, hotkeys should not interfere with + regular typing: +

+ +
+
+ ); + }, + play: hotkeyTest, +}; diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx new file mode 100644 index 00000000000..b5e0de4c594 --- /dev/null +++ b/app/javascript/mastodon/components/hotkeys/index.tsx @@ -0,0 +1,282 @@ +import { useEffect, useRef } from 'react'; + +import { normalizeKey, isKeyboardEvent } from './utils'; + +/** + * In case of multiple hotkeys matching the pressed key(s), + * the hotkey with a higher priority is selected. All others + * are ignored. + */ +const hotkeyPriority = { + singleKey: 0, + combo: 1, + sequence: 2, +} as const; + +/** + * This type of function receives a keyboard event and an array of + * previously pressed keys (within the last second), and returns + * `isMatch` (whether the pressed keys match a hotkey) and `priority` + * (a weighting used to resolve conflicts when two hotkeys match the + * pressed keys) + */ +type KeyMatcher = ( + event: KeyboardEvent, + bufferedKeys?: string[], +) => { + /** + * Whether the event.key matches the hotkey + */ + isMatch: boolean; + /** + * If there are multiple matching hotkeys, the + * first one with the highest priority will be handled + */ + priority: (typeof hotkeyPriority)[keyof typeof hotkeyPriority]; +}; + +/** + * Matches a single key + */ +function just(keyName: string): KeyMatcher { + return (event) => ({ + isMatch: normalizeKey(event.key) === keyName, + priority: hotkeyPriority.singleKey, + }); +} + +/** + * Matches any single key out of those provided + */ +function any(...keys: string[]): KeyMatcher { + return (event) => ({ + isMatch: keys.some((keyName) => just(keyName)(event).isMatch), + priority: hotkeyPriority.singleKey, + }); +} + +/** + * Matches a single key combined with the option/alt modifier + */ +function optionPlus(key: string): KeyMatcher { + return (event) => ({ + // Matching against event.code here as alt combos are often + // mapped to other characters + isMatch: event.altKey && event.code === `Key${key.toUpperCase()}`, + priority: hotkeyPriority.combo, + }); +} + +/** + * Matches when all provided keys are pressed in sequence. + */ +function sequence(...sequence: string[]): KeyMatcher { + return (event, bufferedKeys) => { + const lastKeyInSequence = sequence.at(-1); + const startOfSequence = sequence.slice(0, -1); + const relevantBufferedKeys = bufferedKeys?.slice(-startOfSequence.length); + + const bufferMatchesStartOfSequence = + !!relevantBufferedKeys && + startOfSequence.join('') === relevantBufferedKeys.join(''); + + return { + isMatch: + bufferMatchesStartOfSequence && + normalizeKey(event.key) === lastKeyInSequence, + priority: hotkeyPriority.sequence, + }; + }; +} + +/** + * This is a map of all global hotkeys we support. + * To trigger a hotkey, a handler with a matching name must be + * provided to the `useHotkeys` hook or `Hotkeys` component. + */ +const hotkeyMatcherMap = { + help: just('?'), + search: any('s', '/'), + back: just('backspace'), + new: just('n'), + forceNew: optionPlus('n'), + focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'), + reply: just('r'), + favourite: just('f'), + boost: just('b'), + mention: just('m'), + open: any('enter', 'o'), + openProfile: just('p'), + moveDown: any('down', 'j'), + moveUp: any('up', 'k'), + toggleHidden: just('x'), + toggleSensitive: just('h'), + toggleComposeSpoilers: optionPlus('x'), + openMedia: just('e'), + onTranslate: just('t'), + goToHome: sequence('g', 'h'), + goToNotifications: sequence('g', 'n'), + goToLocal: sequence('g', 'l'), + goToFederated: sequence('g', 't'), + goToDirect: sequence('g', 'd'), + goToStart: sequence('g', 's'), + goToFavourites: sequence('g', 'f'), + goToPinned: sequence('g', 'p'), + goToProfile: sequence('g', 'u'), + goToBlocked: sequence('g', 'b'), + goToMuted: sequence('g', 'm'), + goToRequests: sequence('g', 'r'), + cheat: sequence( + 'up', + 'up', + 'down', + 'down', + 'left', + 'right', + 'left', + 'right', + 'b', + 'a', + 'enter', + ), +} as const; + +type HotkeyName = keyof typeof hotkeyMatcherMap; + +export type HandlerMap = Partial< + Record void> +>; + +export function useHotkeys(handlers: HandlerMap) { + const ref = useRef(null); + const bufferedKeys = useRef([]); + const sequenceTimer = useRef | null>(null); + + /** + * Store the latest handlers object in a ref so we don't need to + * add it as a dependency to the main event listener effect + */ + const handlersRef = useRef(handlers); + useEffect(() => { + handlersRef.current = handlers; + }, [handlers]); + + useEffect(() => { + const element = ref.current ?? document; + + function listener(event: Event) { + // Ignore key presses from input, textarea, or select elements + const tagName = (event.target as HTMLElement).tagName.toLowerCase(); + const shouldHandleEvent = + isKeyboardEvent(event) && + !event.defaultPrevented && + !['input', 'textarea', 'select'].includes(tagName) && + !( + ['a', 'button'].includes(tagName) && + normalizeKey(event.key) === 'enter' + ); + + if (shouldHandleEvent) { + const matchCandidates: { + handler: (event: KeyboardEvent) => void; + priority: number; + }[] = []; + + (Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach( + (handlerName) => { + const handler = handlersRef.current[handlerName]; + + if (handler) { + const hotkeyMatcher = hotkeyMatcherMap[handlerName]; + + const { isMatch, priority } = hotkeyMatcher( + event, + bufferedKeys.current, + ); + + if (isMatch) { + matchCandidates.push({ handler, priority }); + } + } + }, + ); + + // Sort all matches by priority + matchCandidates.sort((a, b) => b.priority - a.priority); + + const bestMatchingHandler = matchCandidates.at(0)?.handler; + if (bestMatchingHandler) { + bestMatchingHandler(event); + event.stopPropagation(); + event.preventDefault(); + } + + // Add last keypress to buffer + bufferedKeys.current.push(normalizeKey(event.key)); + + // Reset the timeout + if (sequenceTimer.current) { + clearTimeout(sequenceTimer.current); + } + sequenceTimer.current = setTimeout(() => { + bufferedKeys.current = []; + }, 1000); + } + } + element.addEventListener('keydown', listener); + + return () => { + element.removeEventListener('keydown', listener); + if (sequenceTimer.current) { + clearTimeout(sequenceTimer.current); + } + }; + }, []); + + return ref; +} + +/** + * The Hotkeys component allows us to globally register keyboard combinations + * under a name and assign actions to them, either globally or scoped to a portion + * of the app. + * + * ### How to use + * + * To add a new hotkey, add its key combination to the `hotkeyMatcherMap` object + * and give it a name. + * + * Use the `` component or the `useHotkeys` hook in the part of of the app + * where you want to handle the action, and pass in a handlers object. + * + * ```tsx + * + * ``` + * + * Now this function will be called when the 'open' hotkey is pressed by the user. + */ +export const Hotkeys: React.FC<{ + /** + * An object containing functions to be run when a hotkey is pressed. + * The key must be the name of a registered hotkey, e.g. "help" or "search" + */ + handlers: HandlerMap; + /** + * When enabled, hotkeys will be matched against the document root + * rather than only inside of this component's DOM node. + */ + global?: boolean; + /** + * Allow the rendered `div` to be focused + */ + focusable?: boolean; + children: React.ReactNode; +}> = ({ handlers, global, focusable = true, children }) => { + const ref = useHotkeys(handlers); + + return ( +
+ {children} +
+ ); +}; diff --git a/app/javascript/mastodon/components/hotkeys/utils.ts b/app/javascript/mastodon/components/hotkeys/utils.ts new file mode 100644 index 00000000000..1430e1685b3 --- /dev/null +++ b/app/javascript/mastodon/components/hotkeys/utils.ts @@ -0,0 +1,29 @@ +export function isKeyboardEvent(event: Event): event is KeyboardEvent { + return 'key' in event; +} + +export function normalizeKey(key: string): string { + const lowerKey = key.toLowerCase(); + + switch (lowerKey) { + case ' ': + case 'spacebar': // for older browsers + return 'space'; + + case 'arrowup': + return 'up'; + case 'arrowdown': + return 'down'; + case 'arrowleft': + return 'left'; + case 'arrowright': + return 'right'; + + case 'esc': + case 'escape': + return 'escape'; + + default: + return lowerKey; + } +} diff --git a/app/javascript/mastodon/components/hover_card_account.tsx b/app/javascript/mastodon/components/hover_card_account.tsx index a6bdda21686..a5a5e4c9575 100644 --- a/app/javascript/mastodon/components/hover_card_account.tsx +++ b/app/javascript/mastodon/components/hover_card_account.tsx @@ -102,7 +102,7 @@ export const HoverCardAccount = forwardRef< <>
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 29fd4234dd5..171ae780ff9 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -8,10 +8,9 @@ import { Link } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { HotKeys } from 'react-hotkeys'; - import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { ContentWarning } from 'mastodon/components/content_warning'; import { FilterWarning } from 'mastodon/components/filter_warning'; import { Icon } from 'mastodon/components/icon'; @@ -35,7 +34,6 @@ import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; import { StatusThreadLabel } from './status_thread_label'; import { VisibilityIcon } from './visibility_icon'; - const domParser = new DOMParser(); export const textForScreenReader = (intl, status, rebloggedByText = false) => { @@ -325,11 +323,11 @@ class Status extends ImmutablePureComponent { }; handleHotkeyMoveUp = e => { - this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured')); + this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured')); }; handleHotkeyMoveDown = e => { - this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured')); + this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured')); }; handleHotkeyToggleHidden = () => { @@ -437,13 +435,13 @@ class Status extends ImmutablePureComponent { if (hidden) { return ( - +
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('spoiler_text').length > 0 && ({status.get('spoiler_text')})} {expanded && {status.get('content')}}
-
+
); } @@ -543,7 +541,7 @@ class Status extends ImmutablePureComponent { const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); return ( - +
{!skipPrepend && prepend} @@ -604,7 +602,7 @@ class Status extends ImmutablePureComponent { }
-
+
); } diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 0628e0791b5..02f06ec96ac 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -13,7 +13,8 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react' import { Icon } from 'mastodon/components/icon'; import { Poll } from 'mastodon/components/poll'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; +import { autoPlayGif, isFeatureEnabled, languages as preloadedLanguages } from 'mastodon/initial_state'; +import { EmojiHTML } from '../features/emoji/emoji_html'; const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) @@ -23,6 +24,9 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) * @returns {string} */ export function getStatusContent(status) { + if (isFeatureEnabled('modern_emojis')) { + return status.getIn(['translation', 'content']) || status.get('content'); + } return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); } @@ -228,7 +232,7 @@ class StatusContent extends PureComponent { const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); - const content = { __html: statusContent ?? getStatusContent(status) }; + const content = statusContent ?? getStatusContent(status); const language = status.getIn(['translation', 'language']) || status.get('language'); const classNames = classnames('status__content', { 'status__content--with-action': this.props.onClick && this.props.history, @@ -253,7 +257,12 @@ class StatusContent extends PureComponent { return ( <>
-
+ {poll} {translateButton} @@ -265,7 +274,12 @@ class StatusContent extends PureComponent { } else { return (
-
+ {poll} {translateButton} diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index 390659e9b6a..cca449b0ca8 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -56,7 +56,7 @@ export default class StatusList extends ImmutablePureComponent { const elementIndex = this.getCurrentStatusIndex(id, featured) - 1; this._selectChild(elementIndex, true); }; - + handleMoveDown = (id, featured) => { const elementIndex = this.getCurrentStatusIndex(id, featured) + 1; this._selectChild(elementIndex, false); @@ -69,6 +69,7 @@ export default class StatusList extends ImmutablePureComponent { _selectChild (index, align_top) { const container = this.node.node; + // TODO: This breaks at the inline-follow-suggestions container const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); if (element) { diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index b9f83bebaaa..0bae0395031 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -898,8 +898,7 @@ export const AccountHeader: React.FC<{ )} diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 6dd3dbd0545..5bc77c4bcd8 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -92,10 +92,29 @@ class ComposeForm extends ImmutablePureComponent { this.props.onChange(e.target.value); }; - handleKeyDown = (e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.handleSubmit(); + blurOnEscape = (e) => { + if (['esc', 'escape'].includes(e.key.toLowerCase())) { + e.target.blur(); } + } + + handleKeyDownPost = (e) => { + if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + this.blurOnEscape(e); + }; + + handleKeyDownSpoiler = (e) => { + if (e.key.toLowerCase() === 'enter') { + if (e.ctrlKey || e.metaKey) { + this.handleSubmit(); + } else { + e.preventDefault(); + this.textareaRef.current?.focus(); + } + } + this.blurOnEscape(e); }; getFulltextForCharacterCounting = () => { @@ -248,7 +267,7 @@ class ComposeForm extends ImmutablePureComponent { value={this.props.spoilerText} disabled={isSubmitting} onChange={this.handleChangeSpoilerText} - onKeyDown={this.handleKeyDown} + onKeyDown={this.handleKeyDownSpoiler} ref={this.setSpoilerText} suggestions={this.props.suggestions} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} @@ -273,7 +292,7 @@ class ComposeForm extends ImmutablePureComponent { onChange={this.handleChange} suggestions={this.props.suggestions} onFocus={this.handleFocus} - onKeyDown={this.handleKeyDown} + onKeyDown={this.handleKeyDownPost} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionSelected={this.onSuggestionSelected} diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx index c27cd3727f1..ec3621f0c06 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx @@ -10,15 +10,13 @@ import { createSelector } from '@reduxjs/toolkit'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { useDispatch, useSelector } from 'react-redux'; - -import { HotKeys } from 'react-hotkeys'; - import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import { replyCompose } from 'mastodon/actions/compose'; import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations'; import { openModal } from 'mastodon/actions/modal'; import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import AttachmentList from 'mastodon/components/attachment_list'; import AvatarComposite from 'mastodon/components/avatar_composite'; import { IconButton } from 'mastodon/components/icon_button'; @@ -169,7 +167,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) }; return ( - +
@@ -219,7 +217,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
- + ); }; diff --git a/app/javascript/mastodon/features/emoji/constants.ts b/app/javascript/mastodon/features/emoji/constants.ts index d38f17f2160..09022371b22 100644 --- a/app/javascript/mastodon/features/emoji/constants.ts +++ b/app/javascript/mastodon/features/emoji/constants.ts @@ -15,6 +15,16 @@ export const SKIN_TONE_CODES = [ 0x1f3ff, // Dark skin tone ] as const; +// 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'; +export const EMOJI_MODE_TWEMOJI = 'twemoji'; + +export const EMOJI_TYPE_UNICODE = 'unicode'; +export const EMOJI_TYPE_CUSTOM = 'custom'; + +export const EMOJI_STATE_MISSING = 'missing'; + export const EMOJIS_WITH_DARK_BORDER = [ '🎱', // 1F3B1 '🐜', // 1F41C diff --git a/app/javascript/mastodon/features/emoji/database.ts b/app/javascript/mastodon/features/emoji/database.ts index 618f0108509..0b8ddd34fbe 100644 --- a/app/javascript/mastodon/features/emoji/database.ts +++ b/app/javascript/mastodon/features/emoji/database.ts @@ -1,17 +1,19 @@ import { SUPPORTED_LOCALES } from 'emojibase'; -import type { FlatCompactEmoji, Locale } from 'emojibase'; -import type { DBSchema } from 'idb'; +import type { Locale } from 'emojibase'; +import type { DBSchema, IDBPDatabase } from 'idb'; import { openDB } from 'idb'; -import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; - -import type { LocaleOrCustom } from './locale'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; +import type { + CustomEmojiData, + UnicodeEmojiData, + LocaleOrCustom, +} from './types'; interface EmojiDB extends LocaleTables, DBSchema { custom: { key: string; - value: ApiCustomEmojiJSON; + value: CustomEmojiData; indexes: { category: string; }; @@ -24,7 +26,7 @@ interface EmojiDB extends LocaleTables, DBSchema { interface LocaleTable { key: string; - value: FlatCompactEmoji; + value: UnicodeEmojiData; indexes: { group: number; label: string; @@ -36,63 +38,114 @@ type LocaleTables = Record; const SCHEMA_VERSION = 1; -const db = await openDB('mastodon-emoji', SCHEMA_VERSION, { - upgrade(database) { - const customTable = database.createObjectStore('custom', { - keyPath: 'shortcode', - autoIncrement: false, - }); - customTable.createIndex('category', 'category'); +let db: IDBPDatabase | null = null; - database.createObjectStore('etags'); - - for (const locale of SUPPORTED_LOCALES) { - const localeTable = database.createObjectStore(locale, { - keyPath: 'hexcode', +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, }); - localeTable.createIndex('group', 'group'); - localeTable.createIndex('label', 'label'); - localeTable.createIndex('order', 'order'); - localeTable.createIndex('tags', 'tags', { multiEntry: true }); - } - }, -}); + customTable.createIndex('category', 'category'); -export async function putEmojiData(emojis: FlatCompactEmoji[], locale: Locale) { + 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 }); + } + }, + }); + return db; +} + +export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) { + const db = await loadDB(); const trx = db.transaction(locale, 'readwrite'); await Promise.all(emojis.map((emoji) => trx.store.put(emoji))); await trx.done; } -export async function putCustomEmojiData(emojis: ApiCustomEmojiJSON[]) { +export async function putCustomEmojiData(emojis: CustomEmojiData[]) { + const db = await loadDB(); const trx = db.transaction('custom', 'readwrite'); await Promise.all(emojis.map((emoji) => trx.store.put(emoji))); await trx.done; } -export function putLatestEtag(etag: string, localeString: string) { +export async function putLatestEtag(etag: string, localeString: string) { const locale = toSupportedLocaleOrCustom(localeString); + const db = await loadDB(); return db.put('etags', etag, locale); } -export function searchEmojiByHexcode(hexcode: string, localeString: string) { +export async function searchEmojiByHexcode( + hexcode: string, + localeString: string, +) { const locale = toSupportedLocale(localeString); + const db = await loadDB(); return db.get(locale, hexcode); } -export function searchEmojiByTag(tag: string, localeString: string) { +export async function searchEmojisByHexcodes( + hexcodes: string[], + localeString: string, +) { + const locale = toSupportedLocale(localeString); + const db = await loadDB(); + return db.getAll( + locale, + IDBKeyRange.bound(hexcodes[0], hexcodes[hexcodes.length - 1]), + ); +} + +export async function searchEmojiByTag(tag: string, localeString: string) { const locale = toSupportedLocale(localeString); const range = IDBKeyRange.only(tag.toLowerCase()); + const db = await loadDB(); return db.getAllFromIndex(locale, 'tags', range); } -export function searchCustomEmojiByShortcode(shortcode: string) { +export async function searchCustomEmojiByShortcode(shortcode: string) { + const db = await loadDB(); return db.get('custom', shortcode); } +export async function searchCustomEmojisByShortcodes(shortcodes: string[]) { + const db = await loadDB(); + return db.getAll( + 'custom', + IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 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; +} + export async function loadLatestEtag(localeString: string) { const locale = toSupportedLocaleOrCustom(localeString); + const db = await loadDB(); const rowCount = await db.count(locale); if (!rowCount) { return null; // No data for this locale, return null even if there is an etag. diff --git a/app/javascript/mastodon/features/emoji/emoji_html.tsx b/app/javascript/mastodon/features/emoji/emoji_html.tsx new file mode 100644 index 00000000000..27af2dda279 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_html.tsx @@ -0,0 +1,81 @@ +import type { HTMLAttributes } from 'react'; +import { useEffect, useMemo, useState } from 'react'; + +import type { List as ImmutableList } from 'immutable'; +import { isList } from 'immutable'; + +import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; +import { isFeatureEnabled } from '@/mastodon/initial_state'; +import type { CustomEmoji } from '@/mastodon/models/custom_emoji'; + +import { useEmojiAppState } from './hooks'; +import { emojifyElement } from './render'; +import type { ExtraCustomEmojiMap } from './types'; + +type EmojiHTMLProps = Omit< + HTMLAttributes, + 'dangerouslySetInnerHTML' +> & { + htmlString: string; + extraEmojis?: ExtraCustomEmojiMap | ImmutableList; +}; + +export const EmojiHTML: React.FC = ({ + htmlString, + extraEmojis, + ...props +}) => { + if (isFeatureEnabled('modern_emojis')) { + return ( + + ); + } + return
; +}; + +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) { + return null; + } + + return
; +}; diff --git a/app/javascript/mastodon/features/emoji/emoji_text.tsx b/app/javascript/mastodon/features/emoji/emoji_text.tsx new file mode 100644 index 00000000000..253371391a4 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/emoji_text.tsx @@ -0,0 +1,45 @@ +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 new file mode 100644 index 00000000000..fd38129a19b --- /dev/null +++ b/app/javascript/mastodon/features/emoji/hooks.ts @@ -0,0 +1,16 @@ +import { useAppSelector } from '@/mastodon/store'; + +import { toSupportedLocale } from './locale'; +import { determineEmojiMode } from './mode'; +import type { EmojiAppState } from './types'; + +export function useEmojiAppState(): EmojiAppState { + const locale = useAppSelector((state) => + toSupportedLocale(state.meta.get('locale') as string), + ); + const mode = useAppSelector((state) => + determineEmojiMode(state.meta.get('emoji_style') as string), + ); + + return { currentLocale: locale, locales: [locale], mode }; +} diff --git a/app/javascript/mastodon/features/emoji/index.ts b/app/javascript/mastodon/features/emoji/index.ts index 6975465b55f..ef6cd67aeb5 100644 --- a/app/javascript/mastodon/features/emoji/index.ts +++ b/app/javascript/mastodon/features/emoji/index.ts @@ -2,7 +2,7 @@ import initialState from '@/mastodon/initial_state'; import { toSupportedLocale } from './locale'; -const serverLocale = toSupportedLocale(initialState?.meta.locale ?? 'en'); +const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en'); const worker = 'Worker' in window @@ -16,13 +16,22 @@ export async function initializeEmoji() { worker.addEventListener('message', (event: MessageEvent) => { const { data: message } = event; if (message === 'ready') { - worker.postMessage(serverLocale); worker.postMessage('custom'); + void loadEmojiLocale(userLocale); + // Load English locale as well, because people are still used to + // using it from before we supported other locales. + if (userLocale !== 'en') { + void loadEmojiLocale('en'); + } } }); } else { - const { importCustomEmojiData, importEmojiData } = await import('./loader'); - await Promise.all([importCustomEmojiData(), importEmojiData(serverLocale)]); + 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 f9c69713515..482d9e5c359 100644 --- a/app/javascript/mastodon/features/emoji/loader.ts +++ b/app/javascript/mastodon/features/emoji/loader.ts @@ -11,7 +11,7 @@ import { putLatestEtag, } from './database'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; -import type { LocaleOrCustom } from './locale'; +import type { LocaleOrCustom } from './types'; export async function importEmojiData(localeString: string) { const locale = toSupportedLocale(localeString); diff --git a/app/javascript/mastodon/features/emoji/locale.ts b/app/javascript/mastodon/features/emoji/locale.ts index 561c94afb0a..8ff23f5161a 100644 --- a/app/javascript/mastodon/features/emoji/locale.ts +++ b/app/javascript/mastodon/features/emoji/locale.ts @@ -1,7 +1,7 @@ import type { Locale } from 'emojibase'; import { SUPPORTED_LOCALES } from 'emojibase'; -export type LocaleOrCustom = Locale | 'custom'; +import type { LocaleOrCustom } from './types'; export function toSupportedLocale(localeBase: string): Locale { const locale = localeBase.toLowerCase(); diff --git a/app/javascript/mastodon/features/emoji/mode.ts b/app/javascript/mastodon/features/emoji/mode.ts new file mode 100644 index 00000000000..0f581d8b504 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/mode.ts @@ -0,0 +1,119 @@ +// Credit to Nolan Lawson for the original implementation. +// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js + +import { isDevelopment } from '@/mastodon/utils/environment'; + +import { + EMOJI_MODE_NATIVE, + EMOJI_MODE_NATIVE_WITH_FLAGS, + EMOJI_MODE_TWEMOJI, +} from './constants'; +import type { EmojiMode } from './types'; + +type Feature = Uint8ClampedArray; + +// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js +const FONT_FAMILY = + '"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' + + '"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif'; + +function getTextFeature(text: string, color: string) { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + + const ctx = canvas.getContext('2d', { + // Improves the performance of `getImageData()` + // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently + willReadFrequently: true, + }); + if (!ctx) { + throw new Error('Canvas context not available'); + } + ctx.textBaseline = 'top'; + ctx.font = `100px ${FONT_FAMILY}`; + ctx.fillStyle = color; + ctx.scale(0.01, 0.01); + ctx.fillText(text, 0, 0); + + return ctx.getImageData(0, 0, 1, 1).data satisfies Feature; +} + +function compareFeatures(feature1: Feature, feature2: Feature) { + const feature1Str = [...feature1].join(','); + const feature2Str = [...feature2].join(','); + // This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes. + // Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is + // 0,0,0,61 - there is a transparency here. + return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,'); +} + +function testEmojiSupport(text: string) { + // Render white and black and then compare them to each other and ensure they're the same + // color, and neither one is black. This shows that the emoji was rendered in color. + const feature1 = getTextFeature(text, '#000'); + const feature2 = getTextFeature(text, '#fff'); + return compareFeatures(feature1, feature2); +} + +const EMOJI_VERSION_TEST_EMOJI = '🫨'; // shaking head, from v15 +const EMOJI_FLAG_TEST_EMOJI = '🇨🇭'; + +export function determineEmojiMode(style: string): EmojiMode { + if (style === EMOJI_MODE_NATIVE) { + // If flags are not supported, we replace them with Twemoji. + if (shouldReplaceFlags()) { + return EMOJI_MODE_NATIVE_WITH_FLAGS; + } + return EMOJI_MODE_NATIVE; + } + if (style === EMOJI_MODE_TWEMOJI) { + return EMOJI_MODE_TWEMOJI; + } + + // Auto style so determine based on browser capabilities. + if (shouldUseTwemoji()) { + return EMOJI_MODE_TWEMOJI; + } else if (shouldReplaceFlags()) { + return EMOJI_MODE_NATIVE_WITH_FLAGS; + } + return EMOJI_MODE_NATIVE; +} + +export function shouldUseTwemoji(): boolean { + if (typeof window === 'undefined') { + return false; + } + try { + // Test a known color emoji to see if 15.1 is supported. + return !testEmojiSupport(EMOJI_VERSION_TEST_EMOJI); + } catch (err: unknown) { + // If an error occurs, fall back to Twemoji to be safe. + if (isDevelopment()) { + console.warn( + 'Emoji rendering test failed, defaulting to Twemoji. Error:', + err, + ); + } + return true; + } +} + +// Based on https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L19 +export function shouldReplaceFlags(): boolean { + if (typeof window === 'undefined') { + return false; + } + try { + // Test a known flag emoji to see if it is rendered in color. + return !testEmojiSupport(EMOJI_FLAG_TEST_EMOJI); + } catch (err: unknown) { + // If an error occurs, assume flags should be replaced. + if (isDevelopment()) { + console.warn( + 'Flag emoji rendering test failed, defaulting to replacement. Error:', + err, + ); + } + return true; + } +} diff --git a/app/javascript/mastodon/features/emoji/normalize.test.ts b/app/javascript/mastodon/features/emoji/normalize.test.ts index ee9cd89487f..f0ea140590b 100644 --- a/app/javascript/mastodon/features/emoji/normalize.test.ts +++ b/app/javascript/mastodon/features/emoji/normalize.test.ts @@ -22,9 +22,9 @@ const emojiSVGFiles = await readdir( ); const svgFileNames = emojiSVGFiles .filter((file) => file.isFile() && file.name.endsWith('.svg')) - .map((file) => basename(file.name, '.svg').toUpperCase()); + .map((file) => basename(file.name, '.svg')); const svgFileNamesWithoutBorder = svgFileNames.filter( - (fileName) => !fileName.endsWith('_BORDER'), + (fileName) => !fileName.endsWith('_border'), ); const unicodeEmojis = flattenEmojiData(unicodeRawEmojis); @@ -60,13 +60,13 @@ describe('unicodeToTwemojiHex', () => { describe('twemojiHasBorder', () => { test.concurrent.for( svgFileNames - .filter((file) => file.endsWith('_BORDER')) + .filter((file) => file.endsWith('_border')) .map((file) => { - const hexCode = file.replace('_BORDER', ''); + const hexCode = file.replace('_border', ''); return [ hexCode, - CODES_WITH_LIGHT_BORDER.includes(hexCode), - CODES_WITH_DARK_BORDER.includes(hexCode), + CODES_WITH_LIGHT_BORDER.includes(hexCode.toUpperCase()), + CODES_WITH_DARK_BORDER.includes(hexCode.toUpperCase()), ] as const; }), )('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => { diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index 94dc33a6ea2..6a64c3b8bfa 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -7,6 +7,7 @@ import { EMOJIS_WITH_DARK_BORDER, EMOJIS_WITH_LIGHT_BORDER, } from './constants'; +import type { TwemojiBorderInfo } from './types'; // Misc codes that have special handling const SKIER_CODE = 0x26f7; @@ -51,13 +52,7 @@ export function unicodeToTwemojiHex(unicodeHex: string): string { normalizedCodes.push(code); } - return hexNumbersToString(normalizedCodes, 0); -} - -interface TwemojiBorderInfo { - hexCode: string; - hasLightBorder: boolean; - hasDarkBorder: boolean; + return hexNumbersToString(normalizedCodes, 0).toLowerCase(); } export const CODES_WITH_DARK_BORDER = @@ -77,7 +72,7 @@ export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo { hasDarkBorder = true; } return { - hexCode: normalizedHex, + hexCode: twemojiHex, hasLightBorder, hasDarkBorder, }; diff --git a/app/javascript/mastodon/features/emoji/render.test.ts b/app/javascript/mastodon/features/emoji/render.test.ts new file mode 100644 index 00000000000..23f85c36b3e --- /dev/null +++ b/app/javascript/mastodon/features/emoji/render.test.ts @@ -0,0 +1,163 @@ +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'; + +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( + () => + [ + { + hexcode: '1F60A', + group: 0, + label: 'smiling face with smiling eyes', + order: 0, + tags: ['smile', 'happy'], + unicode: '😊', + }, + { + hexcode: '1F1EA-1F1FA', + group: 0, + label: 'flag-eu', + order: 0, + tags: ['flag', 'european union'], + unicode: '🇪🇺', + }, + ] satisfies UnicodeEmojiData[], + ), + findMissingLocales: vitest.fn(() => []), +})); + +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; + } + + 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( + `

Hello 😊🇪🇺!

${expectedCustomEmojiImage}

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

Hello 😊${expectedFlagImage}!

${expectedCustomEmojiImage}

`, + ); + }); + + test('emojifies everything in twemoji mode', async () => { + const emojifiedElement = await emojifyElement(cloneTestElement(), { + locales: ['en'], + mode: EMOJI_MODE_TWEMOJI, + currentLocale: 'en', + }); + expect(emojifiedElement.innerHTML).toBe( + `

Hello ${expectedSmileImage}${expectedFlagImage}!

${expectedCustomEmojiImage}

`, + ); + }); +}); + +describe('tokenizeText', () => { + test('returns empty array for string with only whitespace', () => { + expect(tokenizeText(' \n')).toEqual([]); + }); + + test('returns an array of text to be a single token', () => { + expect(tokenizeText('Hello')).toEqual(['Hello']); + }); + + test('returns tokens for text with emoji', () => { + expect(tokenizeText('Hello 😊 🇿🇼!!')).toEqual([ + 'Hello ', + { + type: 'unicode', + code: '😊', + }, + ' ', + { + type: 'unicode', + code: '🇿🇼', + }, + '!!', + ]); + }); + + test('returns tokens for text with custom emoji', () => { + expect(tokenizeText('Hello :smile:!!')).toEqual([ + 'Hello ', + { + type: 'custom', + code: 'smile', + }, + '!!', + ]); + }); + + test('handles custom emoji with underscores and numbers', () => { + expect(tokenizeText('Hello :smile_123:!!')).toEqual([ + 'Hello ', + { + type: 'custom', + code: 'smile_123', + }, + '!!', + ]); + }); + + test('returns tokens for text with mixed emoji', () => { + expect(tokenizeText('Hello 😊 :smile:!!')).toEqual([ + 'Hello ', + { + type: 'unicode', + code: '😊', + }, + ' ', + { + type: 'custom', + code: 'smile', + }, + '!!', + ]); + }); + + test('does not capture custom emoji with invalid characters', () => { + expect(tokenizeText('Hello :smile-123:!!')).toEqual([ + 'Hello :smile-123:!!', + ]); + }); +}); diff --git a/app/javascript/mastodon/features/emoji/render.ts b/app/javascript/mastodon/features/emoji/render.ts new file mode 100644 index 00000000000..8f0c1ee15fe --- /dev/null +++ b/app/javascript/mastodon/features/emoji/render.ts @@ -0,0 +1,331 @@ +import type { Locale } from 'emojibase'; +import EMOJI_REGEX from 'emojibase-regex/emoji-loose'; + +import { autoPlayGif } from '@/mastodon/initial_state'; +import { assetHost } from '@/mastodon/utils/config'; + +import { loadEmojiLocale } from '.'; +import { + EMOJI_MODE_NATIVE, + EMOJI_MODE_NATIVE_WITH_FLAGS, + EMOJI_TYPE_UNICODE, + EMOJI_TYPE_CUSTOM, + EMOJI_STATE_MISSING, +} from './constants'; +import { + findMissingLocales, + searchCustomEmojisByShortcodes, + searchEmojisByHexcodes, +} from './database'; +import { + emojiToUnicodeHex, + twemojiHasBorder, + unicodeToTwemojiHex, +} from './normalize'; +import type { + CustomEmojiToken, + EmojiAppState, + EmojiLoadedState, + EmojiMode, + EmojiState, + EmojiStateMap, + EmojiToken, + ExtraCustomEmojiMap, + LocaleOrCustom, + UnicodeEmojiToken, +} from './types'; +import { stringHasUnicodeFlags } from './utils'; + +const localeCacheMap = new Map([ + [EMOJI_TYPE_CUSTOM, new Map()], +]); + +// 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 { + const queue: (HTMLElement | Text)[] = [element]; + while (queue.length > 0) { + const current = queue.shift(); + if ( + !current || + current instanceof HTMLScriptElement || + current instanceof HTMLStyleElement + ) { + continue; + } + + if ( + current.textContent && + (current instanceof Text || !current.hasChildNodes()) + ) { + const renderedContent = await emojifyText( + current.textContent, + appState, + extraEmojis, + ); + if (renderedContent) { + if (!(current instanceof Text)) { + current.textContent = null; // Clear the text content if it's not a Text node. + } + current.replaceWith(renderedToHTMLFragment(renderedContent)); + } + continue; + } + + for (const child of current.childNodes) { + if (child instanceof HTMLElement || child instanceof Text) { + queue.push(child); + } + } + } + return element; +} + +export async function emojifyText( + text: string, + appState: EmojiAppState, + extraEmojis: ExtraCustomEmojiMap = {}, +) { + // Exit if no text to convert. + if (!text.trim()) { + return null; + } + + const tokens = tokenizeText(text); + + // If only one token and it's a string, exit early. + if (tokens.length === 1 && typeof tokens[0] === 'string') { + return null; + } + + // Get all emoji from the state map, loading any missing ones. + await ensureLocalesAreLoaded(appState.locales); + await loadMissingEmojiIntoCache(tokens, appState.locales); + + const renderedFragments: (string | HTMLImageElement)[] = []; + for (const token of tokens) { + if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) { + let state: EmojiState | undefined; + if (token.type === EMOJI_TYPE_CUSTOM) { + const extraEmojiData = extraEmojis[token.code]; + if (extraEmojiData) { + state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData }; + } else { + state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM); + } + } else { + state = emojiForLocale( + emojiToUnicodeHex(token.code), + appState.currentLocale, + ); + } + + // If the state is valid, create an image element. Otherwise, just append as text. + if (state && typeof state !== 'string') { + const image = stateToImage(state); + renderedFragments.push(image); + continue; + } + } + const text = typeof token === 'string' ? token : token.code; + renderedFragments.push(text); + } + + 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 { + if (!text.trim()) { + return []; + } + + const tokens = []; + let lastIndex = 0; + for (const match of text.matchAll(TOKENIZE_REGEX)) { + if (match.index > lastIndex) { + tokens.push(text.slice(lastIndex, match.index)); + } + + const code = match[0]; + + if (code.startsWith(':') && code.endsWith(':')) { + // Custom emoji + tokens.push({ + type: EMOJI_TYPE_CUSTOM, + code: code.slice(1, -1), // Remove the colons + } satisfies CustomEmojiToken); + } else { + // Unicode emoji + tokens.push({ + type: EMOJI_TYPE_UNICODE, + code: code, + } satisfies UnicodeEmojiToken); + } + lastIndex = match.index + code.length; + } + if (lastIndex < text.length) { + tokens.push(text.slice(lastIndex)); + } + return tokens; +} + +function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap { + return localeCacheMap.get(locale) ?? (new Map() as EmojiStateMap); +} + +function emojiForLocale( + code: string, + locale: LocaleOrCustom, +): EmojiState | undefined { + const cache = cacheForLocale(locale); + return cache.get(code); +} + +async function loadMissingEmojiIntoCache( + tokens: TokenizedText, + locales: Locale[], +) { + const missingUnicodeEmoji = new Set(); + const missingCustomEmoji = new Set(); + + // Iterate over tokens and check if they are in the cache already. + for (const token of tokens) { + if (typeof token === 'string') { + continue; // Skip plain strings. + } + + // If this is a custom emoji, check it separately. + if (token.type === EMOJI_TYPE_CUSTOM) { + const code = token.code; + 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 { + 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); + } + } + } + } + + 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); + } + } + + if (missingCustomEmoji.size > 0) { + const missingEmojis = Array.from(missingCustomEmoji).toSorted(); + const emojis = await searchCustomEmojisByShortcodes(missingEmojis); + const cache = cacheForLocale(EMOJI_TYPE_CUSTOM); + for (const emoji of emojis) { + cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji }); + } + const notFoundEmojis = missingEmojis.filter((code) => + emojis.every((emoji) => emoji.shortcode !== 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(EMOJI_TYPE_CUSTOM, cache); + } +} + +function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean { + if (token.type === EMOJI_TYPE_UNICODE) { + // If the mode is native or native with flags for non-flag emoji + // we can just append the text node directly. + if ( + mode === EMOJI_MODE_NATIVE || + (mode === EMOJI_MODE_NATIVE_WITH_FLAGS && + !stringHasUnicodeFlags(token.code)) + ) { + return false; + } + } + + return true; +} + +function stateToImage(state: EmojiLoadedState) { + 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`; + } + + image.alt = state.data.unicode; + image.title = state.data.label; + image.src = `${assetHost}/emoji/${emojiInfo.hexCode}.svg`; + } else { + // Custom emoji + const shortCode = `:${state.data.shortcode}:`; + image.classList.add('custom-emoji'); + image.alt = shortCode; + image.title = shortCode; + image.src = autoPlayGif ? state.data.url : state.data.static_url; + image.dataset.original = state.data.url; + image.dataset.static = state.data.static_url; + } + + return image; +} + +function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) { + const fragment = document.createDocumentFragment(); + for (const fragmentItem of renderedArray) { + if (typeof fragmentItem === 'string') { + fragment.appendChild(document.createTextNode(fragmentItem)); + } else if (fragmentItem instanceof HTMLImageElement) { + fragment.appendChild(fragmentItem); + } + } + return fragment; +} diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts new file mode 100644 index 00000000000..f5932ed97fd --- /dev/null +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -0,0 +1,64 @@ +import type { FlatCompactEmoji, Locale } from 'emojibase'; + +import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; + +import type { + EMOJI_MODE_NATIVE, + EMOJI_MODE_NATIVE_WITH_FLAGS, + EMOJI_MODE_TWEMOJI, + EMOJI_STATE_MISSING, + EMOJI_TYPE_CUSTOM, + EMOJI_TYPE_UNICODE, +} from './constants'; + +export type EmojiMode = + | typeof EMOJI_MODE_NATIVE + | typeof EMOJI_MODE_NATIVE_WITH_FLAGS + | typeof EMOJI_MODE_TWEMOJI; + +export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM; + +export interface EmojiAppState { + locales: Locale[]; + currentLocale: Locale; + mode: EmojiMode; +} + +export interface UnicodeEmojiToken { + type: typeof EMOJI_TYPE_UNICODE; + code: string; +} +export interface CustomEmojiToken { + type: typeof EMOJI_TYPE_CUSTOM; + code: string; +} +export type EmojiToken = UnicodeEmojiToken | CustomEmojiToken; + +export type CustomEmojiData = ApiCustomEmojiJSON; +export type UnicodeEmojiData = FlatCompactEmoji; +export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData; + +export type EmojiStateMissing = typeof EMOJI_STATE_MISSING; +export interface EmojiStateUnicode { + type: typeof EMOJI_TYPE_UNICODE; + data: UnicodeEmojiData; +} +export interface EmojiStateCustom { + type: typeof EMOJI_TYPE_CUSTOM; + data: CustomEmojiData; +} +export type EmojiState = + | EmojiStateMissing + | EmojiStateUnicode + | EmojiStateCustom; +export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom; + +export type EmojiStateMap = Map; + +export type ExtraCustomEmojiMap = Record; + +export interface TwemojiBorderInfo { + hexCode: string; + hasLightBorder: boolean; + hasDarkBorder: boolean; +} diff --git a/app/javascript/mastodon/features/emoji/utils.test.ts b/app/javascript/mastodon/features/emoji/utils.test.ts new file mode 100644 index 00000000000..75cac8c5b4c --- /dev/null +++ b/app/javascript/mastodon/features/emoji/utils.test.ts @@ -0,0 +1,47 @@ +import { stringHasUnicodeEmoji, stringHasUnicodeFlags } from './utils'; + +describe('stringHasEmoji', () => { + test.concurrent.for([ + ['only text', false], + ['text with emoji 😀', true], + ['multiple emojis 😀😃😄', true], + ['emoji with skin tone 👍🏽', true], + ['emoji with ZWJ 👩‍❤️‍👨', true], + ['emoji with variation selector ✊️', true], + ['emoji with keycap 1️⃣', true], + ['emoji with flags 🇺🇸', true], + ['emoji with regional indicators 🇦🇺', true], + ['emoji with gender 👩‍⚕️', true], + ['emoji with family 👨‍👩‍👧‍👦', true], + ['emoji with zero width joiner 👩‍🔬', true], + ['emoji with non-BMP codepoint 🧑‍🚀', true], + ['emoji with combining marks 👨‍👩‍👧‍👦', true], + ['emoji with enclosing keycap #️⃣', true], + ['emoji with no visible glyph \u200D', false], + ] as const)( + 'stringHasEmoji has emojis in "%s": %o', + ([text, expected], { expect }) => { + expect(stringHasUnicodeEmoji(text)).toBe(expected); + }, + ); +}); + +describe('stringHasFlags', () => { + test.concurrent.for([ + ['EU 🇪🇺', true], + ['Germany 🇩🇪', true], + ['Canada 🇨🇦', true], + ['São Tomé & Príncipe 🇸🇹', true], + ['Scotland 🏴󠁧󠁢󠁳󠁣󠁴󠁿', true], + ['black flag 🏴', false], + ['arrr 🏴‍☠️', false], + ['rainbow flag 🏳️‍🌈', false], + ['non-flag 🔥', false], + ['only text', false], + ] as const)( + 'stringHasFlags has flag in "%s": %o', + ([text, expected], { expect }) => { + expect(stringHasUnicodeFlags(text)).toBe(expected); + }, + ); +}); diff --git a/app/javascript/mastodon/features/emoji/utils.ts b/app/javascript/mastodon/features/emoji/utils.ts new file mode 100644 index 00000000000..d00accea8c5 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/utils.ts @@ -0,0 +1,13 @@ +import EMOJI_REGEX from 'emojibase-regex/emoji-loose'; + +export function stringHasUnicodeEmoji(text: string): boolean { + return EMOJI_REGEX.test(text); +} + +// 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); +} diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx index 86431f62fd5..b38e5da1594 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.jsx +++ b/app/javascript/mastodon/features/notifications/components/notification.jsx @@ -8,7 +8,6 @@ import { Link, withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { HotKeys } from 'react-hotkeys'; import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react'; @@ -20,6 +19,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import { Account } from 'mastodon/components/account'; import { Icon } from 'mastodon/components/icon'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import { me } from 'mastodon/initial_state'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; @@ -137,7 +137,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -149,7 +149,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -157,7 +157,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -169,7 +169,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -195,7 +195,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -217,7 +217,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -225,7 +225,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -247,7 +247,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -259,7 +259,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
@@ -282,7 +282,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -294,7 +294,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
@@ -317,7 +317,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -331,7 +331,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
@@ -358,7 +358,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -371,7 +371,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
-
+
); } @@ -394,7 +394,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
-
+
); } @@ -410,7 +410,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -422,7 +422,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -438,7 +438,7 @@ class Notification extends ImmutablePureComponent { const targetLink = ; return ( - +
@@ -450,7 +450,7 @@ class Notification extends ImmutablePureComponent {
- + ); } diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx index d5eb851985c..f0f2139ad21 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx @@ -1,9 +1,8 @@ import { useMemo } from 'react'; -import { HotKeys } from 'react-hotkeys'; - import { navigateToProfile } from 'mastodon/actions/accounts'; import { mentionComposeById } from 'mastodon/actions/compose'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; @@ -156,5 +155,5 @@ export const NotificationGroup: React.FC<{ return null; } - return {content}; + return {content}; }; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx index e7ed8792f67..4be1eefcddc 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx @@ -3,12 +3,11 @@ import type { JSX } from 'react'; import classNames from 'classnames'; -import { HotKeys } from 'react-hotkeys'; - import { replyComposeById } from 'mastodon/actions/compose'; import { navigateToStatus } from 'mastodon/actions/statuses'; import { Avatar } from 'mastodon/components/avatar'; import { AvatarGroup } from 'mastodon/components/avatar_group'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import type { IconProp } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; @@ -91,7 +90,7 @@ export const NotificationGroupWithStatus: React.FC<{ ); return ( - +
-
+
); }; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx index de484322fb0..96a4a4d65df 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx @@ -2,14 +2,13 @@ import { useMemo } from 'react'; import classNames from 'classnames'; -import { HotKeys } from 'react-hotkeys'; - import { replyComposeById } from 'mastodon/actions/compose'; import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions'; import { navigateToStatus, toggleStatusSpoilers, } from 'mastodon/actions/statuses'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import type { IconProp } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon'; import { StatusQuoteManager } from 'mastodon/components/status_quoted'; @@ -83,7 +82,7 @@ export const NotificationWithStatus: React.FC<{ if (!statusId || isFiltered) return null; return ( - +
-
+
); }; diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 0f02e7b50ff..64cd0c4f825 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -10,10 +10,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; -import { HotKeys } from 'react-hotkeys'; - import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { Icon } from 'mastodon/components/icon'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { TimelineHint } from 'mastodon/components/timeline_hint'; @@ -616,7 +615,7 @@ class Status extends ImmutablePureComponent {
{ancestors} - +
-
+
{descendants} {remoteHint} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index c9834eb0a48..e8eef704efa 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -9,13 +9,13 @@ import { Redirect, Route, withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; -import { HotKeys } from 'react-hotkeys'; import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; import { fetchNotifications } from 'mastodon/actions/notification_groups'; import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; import { AlertsController } from 'mastodon/components/alerts_controller'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { HoverCardController } from 'mastodon/components/hover_card_controller'; import { PictureInPicture } from 'mastodon/features/picture_in_picture'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; @@ -98,40 +98,6 @@ const mapStateToProps = state => ({ username: state.getIn(['accounts', me, 'username']), }); -const keyMap = { - help: '?', - new: 'n', - search: ['s', '/'], - forceNew: 'option+n', - toggleComposeSpoilers: 'option+x', - focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], - reply: 'r', - favourite: 'f', - boost: 'b', - mention: 'm', - open: ['enter', 'o'], - openProfile: 'p', - moveDown: ['down', 'j'], - moveUp: ['up', 'k'], - back: 'backspace', - goToHome: 'g h', - goToNotifications: 'g n', - goToLocal: 'g l', - goToFederated: 'g t', - goToDirect: 'g d', - goToStart: 'g s', - goToFavourites: 'g f', - goToPinned: 'g p', - goToProfile: 'g u', - goToBlocked: 'g b', - goToMuted: 'g m', - goToRequests: 'g r', - toggleHidden: 'x', - toggleSensitive: 'h', - openMedia: 'e', - onTranslate: 't', -}; - class SwitchingColumnsArea extends PureComponent { static propTypes = { identity: identityContextPropShape, @@ -400,6 +366,10 @@ class UI extends PureComponent { } }; + handleDonate = () => { + location.href = 'https://joinmastodon.org/sponsors#donate' + } + componentDidMount () { const { signedIn } = this.props.identity; @@ -426,10 +396,6 @@ class UI extends PureComponent { setTimeout(() => this.props.dispatch(fetchServer()), 3000); } - - this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); - }; } componentWillUnmount () { @@ -509,10 +475,6 @@ class UI extends PureComponent { } }; - setHotkeysRef = c => { - this.hotkeys = c; - }; - handleHotkeyToggleHelp = () => { if (this.props.location.pathname === '/keyboard-shortcuts') { this.props.history.goBack(); @@ -593,10 +555,11 @@ class UI extends PureComponent { goToBlocked: this.handleHotkeyGoToBlocked, goToMuted: this.handleHotkeyGoToMuted, goToRequests: this.handleHotkeyGoToRequests, + cheat: this.handleDonate, }; return ( - +
{children} @@ -611,7 +574,7 @@ class UI extends PureComponent {
-
+
); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 590c4c8d2b4..7763d9cb798 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -45,6 +45,7 @@ * @property {string} sso_redirect * @property {string} status_page_url * @property {boolean} terms_of_service_enabled + * @property {string?} emoji_style */ /** @@ -95,6 +96,7 @@ export const disableHoverCards = getMeta('disable_hover_cards'); export const disabledAccountId = getMeta('disabled_account_id'); export const displayMedia = getMeta('display_media'); export const domain = getMeta('domain'); +export const emojiStyle = getMeta('emoji_style') || 'auto'; export const expandSpoilers = getMeta('expand_spoilers'); export const forceSingleColumn = !getMeta('advanced_layout'); export const limitedFederationMode = getMeta('limited_federation_mode'); diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index a6c556ac0aa..fadbb247007 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -62,7 +62,7 @@ "account.mute_notifications_short": "خموشی آگاهی‌ها", "account.mute_short": "خموشی", "account.muted": "خموش", - "account.muting": "خموش کرده", + "account.muting": "خموشش کرده‌اید", "account.mutual": "یکدیگر را پی می‌گیرید", "account.no_bio": "شرحی فراهم نشده.", "account.open_original_page": "گشودن صفحهٔ اصلی", @@ -129,14 +129,14 @@ "annual_report.summary.thanks": "سپاس که بخشی از ماستودون هستید!", "attachments_list.unprocessed": "(پردازش نشده)", "audio.hide": "نهفتن صدا", - "block_modal.remote_users_caveat": "ما از کارساز {domain} خواهیم خواست که به تصمیم شما احترام بگذارد. با این حال، تضمینی برای رعایت آن وجود ندارد زیرا برخی کارسازها ممکن است بلوک‌ها را به‌طور متفاوتی مدیریت کنند. فرسته‌های عمومی ممکن است همچنان برای کاربران که وارد نشده قابل مشاهده باشند.", + "block_modal.remote_users_caveat": "از کارساز {domain} خواهیم خواست که به تصمیمتان احترام بگذارد. با این حال تضمینی برای رعایتش وجود ندارد؛ زیرا برخی کارسازها ممکن است مسدودی را متفاوت مدیریت کنند. ممکن است فرسته‌های عمومی همچنان برای کاربران وارد نشده نمایان باشند.", "block_modal.show_less": "نمایش کم‌تر", "block_modal.show_more": "نمایش بیش‌تر", - "block_modal.they_cant_mention": "نمی‌توانند نامتان را برده یا پی‌تان بگیرند.", - "block_modal.they_cant_see_posts": "نمی‌توانند فرسته‌هایتان را دیده و فرسته‌هایشان را نمی‌بینید.", - "block_modal.they_will_know": "می‌توانند ببینند که مسدود شده‌اند.", + "block_modal.they_cant_mention": "نمی‌تواند نامتان را برده یا پیتان بگیرد.", + "block_modal.they_cant_see_posts": "نمی‌تواند فرسته‌هایتان را ببیند و فرسته‌هایش را نمی‌بینید.", + "block_modal.they_will_know": "می‌تواند ببینند که مسدود شده.", "block_modal.title": "انسداد کاربر؟", - "block_modal.you_wont_see_mentions": "فرسته‌هایی که از اون نام برده را نخواهید دید.", + "block_modal.you_wont_see_mentions": "فرسته‌هایی که به او اشاره کرده‌اند را نخواهید دید.", "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید", "boost_modal.reblog": "تقویت فرسته؟", "boost_modal.undo_reblog": "ناتقویت فرسته؟", @@ -269,9 +269,9 @@ "dismissable_banner.public_timeline": "این‌ها جدیدترین فرسته‌های عمومی از افرادی روی وب اجتماعیند که اعضای {domain} پی می‌گیرندشان.", "domain_block_modal.block": "انسداد کارساز", "domain_block_modal.block_account_instead": "انسداد @{name} به جایش", - "domain_block_modal.they_can_interact_with_old_posts": "افزارد روی این کراساز می‌توانند با فرسته‌های قدیمیتان تعامل داشته باشند.", + "domain_block_modal.they_can_interact_with_old_posts": "افزارد روی این کارساز می‌توانند با فرسته‌های قدیمیتان تعامل داشته باشند.", "domain_block_modal.they_cant_follow": "هیچ‌کسی از این کارساز نمی‌تواند پیتان بگیرد.", - "domain_block_modal.they_wont_know": "نخواهند دانست که مسدود شده‌اند.", + "domain_block_modal.they_wont_know": "نخواهد دانست که مسدود شده.", "domain_block_modal.title": "انسداد دامنه؟", "domain_block_modal.you_will_lose_num_followers": "شما {followersCount, plural, one {{followersCountDisplay} پی‌گیرنده} other {{followersCountDisplay} پی‌گیرنده}} و {followingCount, plural, one {{followingCountDisplay} فرد پی‌گرفته‌شده} other {{followingCountDisplay} فرد پی‌گرفته‌شده}} را از دست خواهید داد.", "domain_block_modal.you_will_lose_relationships": "شما تمام پیگیرکنندگان و افرادی که از این کارساز پیگیری می‌کنید را از دست خواهید داد.", @@ -543,11 +543,11 @@ "mute_modal.hide_options": "گزینه‌های نهفتن", "mute_modal.indefinite": "تا وقتی ناخموشش کنم", "mute_modal.show_options": "نمایش گزینه‌ها", - "mute_modal.they_can_mention_and_follow": "می‌توانند به شما اشاره کرده و پیتان بگیرند، ولی نخواهید دیدشان.", - "mute_modal.they_wont_know": "نخواهند دانست که خموش شده‌اند.", + "mute_modal.they_can_mention_and_follow": "می‌تواند به شما اشاره کرده و پیتان بگیرد؛ ولی نخواهید دیدش.", + "mute_modal.they_wont_know": "نخواهد دانست که خموش شده.", "mute_modal.title": "خموشی کاربر؟", "mute_modal.you_wont_see_mentions": "فرسته‌هایی که به او اشاره کرده‌اند را نخواهید دید.", - "mute_modal.you_wont_see_posts": "هنوز می‌توانند فرسته‌هایتان را ببینند، ولی فرسته‌هایشان را نمی‌بینید.", + "mute_modal.you_wont_see_posts": "همچنان می‌تواند فرسته‌هایتان را ببینند؛ ولی فرسته‌هایش را نمی‌بینید.", "navigation_bar.about": "درباره", "navigation_bar.account_settings": "گذرواژه و امنیت", "navigation_bar.administration": "مدیریت", @@ -687,7 +687,7 @@ "notifications.policy.filter_limited_accounts_title": "حساب‌های مدیریت شده", "notifications.policy.filter_new_accounts.hint": "ساخته شده در {days, plural, one {یک} other {#}} روز اخیر", "notifications.policy.filter_new_accounts_title": "حساب‌های جدید", - "notifications.policy.filter_not_followers_hint": "از جمله کسانی که کم‌تر از {days, plural, one {یک} other {#}} روز است پی‌تان می‌گیرند", + "notifications.policy.filter_not_followers_hint": "از جمله کسانی که کم‌تر از {days, plural, one {یک} other {#}} روز است پیتان می‌گیرند", "notifications.policy.filter_not_followers_title": "کسانی که شما را دنبال میکنند", "notifications.policy.filter_not_following_hint": "تا به صورت دستی تأییدشان کنید", "notifications.policy.filter_not_following_title": "کسانی که پی نمی‌گیرید", @@ -756,7 +756,7 @@ "reply_indicator.cancel": "لغو", "reply_indicator.poll": "نظرسنجی", "report.block": "انسداد", - "report.block_explanation": "شما فرسته‌هایشان را نخواهید دید. آن‌ها نمی‌توانند فرسته‌هایتان را ببینند یا شما را پی‌بگیرند. آنها می‌توانند بگویند که مسدود شده‌اند.", + "report.block_explanation": "فرسته‌هایش را نخواهید دید. نخواهد توانست فرسته‌هایتان را دیده یا پیتان بگیرد. قادر است تشخیص دهد مسدود شده.", "report.categories.legal": "حقوقی", "report.categories.other": "غیره", "report.categories.spam": "هرزنامه", @@ -770,7 +770,7 @@ "report.forward": "فرستادن به {target}", "report.forward_hint": "این حساب در کارساز دیگری ثبت شده. آیا می‌خواهید رونوشتی ناشناس از این گزارش به آن‌جا هم فرستاده شود؟", "report.mute": "خموش", - "report.mute_explanation": "شما فرسته‌های آن‌ها را نخواهید دید. آن‌ها همچنان می‌توانند شما را پی‌بگیرند و فرسته‌هایتان را ببینند و نمی‌دانند که خموش شده‌اند.", + "report.mute_explanation": "فرسته‌هایش را نخواهید دید. همچنان خواهد توانست پیتان گرفته و فرسته‌هایتان را ببیند. نخواهد دانست که خموش شده.", "report.next": "بعدی", "report.placeholder": "توضیحات اضافه", "report.reasons.dislike": "من آن را دوست ندارم", diff --git a/app/javascript/mastodon/locales/kab.json b/app/javascript/mastodon/locales/kab.json index dce8fc460a1..0c2faa120e1 100644 --- a/app/javascript/mastodon/locales/kab.json +++ b/app/javascript/mastodon/locales/kab.json @@ -116,6 +116,7 @@ "column.domain_blocks": "Taɣulin yeffren", "column.edit_list": "Ẓreg tabdart", "column.favourites": "Imenyafen", + "column.firehose": "Isuddam usriden", "column.follow_requests": "Isuturen n teḍfeṛt", "column.home": "Agejdan", "column.lists": "Tibdarin", @@ -224,6 +225,7 @@ "empty_column.bookmarked_statuses": "Ulac kra n tsuffeɣt i terniḍ ɣer yismenyifen-ik·im ar tura. Ticki terniḍ yiwet, ad d-tettwasken da.", "empty_column.community": "Tasuddemt tazayezt tadigant n yisallen d tilemt. Aru ihi kra akken ad tt-teččareḍ!", "empty_column.domain_blocks": "Ulac kra n taɣult yettwaffren ar tura.", + "empty_column.explore_statuses": "Ulac ayen yellan d anezzuɣ akka tura. Uɣal-d ticki!", "empty_column.follow_requests": "Ulac ɣur-k·m ula yiwen n usuter n teḍfeṛt. Ticki teṭṭfeḍ-d yiwen ad d-yettwasken da.", "empty_column.hashtag": "Ar tura ulac kra n ugbur yesɛan assaɣ ɣer uhacṭag-agi.", "empty_column.home": "Tasuddemt tagejdant n yisallen d tilemt! Ẓer {public} neɣ nadi ad tafeḍ imseqdacen-nniḍen ad ten-ḍefṛeḍ.", @@ -235,6 +237,7 @@ "errors.unexpected_crash.copy_stacktrace": "Nɣel stacktrace ɣef wafus", "errors.unexpected_crash.report_issue": "Mmel ugur", "explore.suggested_follows": "Imdanen", + "explore.title": "Inezzaɣ", "explore.trending_links": "Isallen", "explore.trending_statuses": "Tisuffaɣ", "explore.trending_tags": "Ihacṭagen", @@ -401,6 +404,8 @@ "navigation_bar.followed_tags": "Ihacṭagen yettwaḍfaren", "navigation_bar.follows_and_followers": "Imeḍfaṛen akked wid i teṭṭafaṛeḍ", "navigation_bar.lists": "Tibdarin", + "navigation_bar.live_feed_local": "Asuddem usrid (adigan)", + "navigation_bar.live_feed_public": "Asuddem usrid (azayaz)", "navigation_bar.logout": "Ffeɣ", "navigation_bar.moderation": "Aseɣyed", "navigation_bar.more": "Ugar", @@ -408,6 +413,7 @@ "navigation_bar.opened_in_classic_interface": "Tisuffaɣ, imiḍanen akked isebtar-nniḍen igejdanen ldin-d s wudem amezwer deg ugrudem web aklasiki.", "navigation_bar.preferences": "Imenyafen", "navigation_bar.search": "Nadi", + "navigation_bar.search_trends": "Anadi / Anezzuɣ", "not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.", "notification.admin.report": "Yemla-t-id {name} {target}", "notification.admin.sign_up": "Ijerred {name}", @@ -512,6 +518,7 @@ "recommended": "Yettuwelleh", "refresh": "Smiren", "regeneration_indicator.please_stand_by": "Ttxil rǧu.", + "regeneration_indicator.preparing_your_home_feed": "Ha-tt-an tsuddemt-ik·im tagejdant tettwaheggay…", "relative_time.days": "{number}u", "relative_time.full.just_now": "tura kan", "relative_time.hours": "{number}isr", @@ -552,6 +559,7 @@ "report.thanks.title": "Ur tebɣiḍ ara ad twaliḍ aya?", "report.thanks.title_actionable": "Tanemmirt ɣef uneqqis, ad nwali deg waya.", "report.unfollow": "Seḥbes aḍfar n @{name}", + "report.unfollow_explanation": "Aql-ik·ikem teṭṭafareḍ amiḍan-a. I wakken ur tettwaliḍ ara akk, akka d asawen, tisuffaɣ-is deg tsuddemt-ik·im tagejdant, ur teṭṭafar ara.", "report_notification.attached_statuses": "{count, plural, one {{count} n tsuffeɣt} other {{count} n tsuffiɣin}} ttwaqnent", "report_notification.categories.legal": "Azerfan", "report_notification.categories.other": "Ayen nniḍen", @@ -654,7 +662,7 @@ "time_remaining.moments": "Akuden i d-yeqqimen", "time_remaining.seconds": "Mazal {number, plural, one {# n tasint} other {# n tsinin}} id yugran", "trends.counter_by_accounts": "{count, plural, one {{counter} wemdan} other {{counter} medden}} deg {days, plural, one {ass} other {{days} wussan}} iɛeddan", - "trends.trending_now": "Ayen mucaɛen tura", + "trends.trending_now": "Anezzuɣ tura", "ui.beforeunload": "Arewway-ik·im ad iruḥ ma yella tefeɣ-d deg Maṣṭudun.", "units.short.billion": "{count}B", "units.short.million": "{count}M", diff --git a/app/javascript/mastodon/locales/pa.json b/app/javascript/mastodon/locales/pa.json index 8ab4eb22527..5236d246c0f 100644 --- a/app/javascript/mastodon/locales/pa.json +++ b/app/javascript/mastodon/locales/pa.json @@ -1,8 +1,12 @@ { "about.contact": "ਸੰਪਰਕ:", + "about.default_locale": "ਮੂਲ", + "about.disclaimer": "ਮਸਟੋਡੋਨ ਇੱਕ ਆਜ਼ਾਦ, ਖੁੱਲ੍ਹੇ ਸਰੋਤ ਵਾਲਾ ਸਾਫਟਵੇਅਰ ਹੈ ਅਤੇ Mastodon gGmbH ਦਾ ਮਾਰਕਾ ਹੈ।", "about.domain_blocks.no_reason_available": "ਕਾਰਨ ਮੌਜੂਦ ਨਹੀਂ ਹੈ", "about.domain_blocks.silenced.title": "ਸੀਮਿਤ", - "about.domain_blocks.suspended.title": "ਮੁਅੱਤਲ ਕੀਤੀ", + "about.domain_blocks.suspended.title": "ਸਸਪੈਂਡ ਕੀਤਾ", + "about.language_label": "ਭਾਸ਼ਾ", + "about.not_available": "ਇਹ ਜਾਣਕਾਰੀ ਨੂੰ ਇਸ ਸਰਵਰ ਉੱਤੇ ਉਪਲੱਬਧ ਨਹੀਂ ਕੀਤਾ ਗਿਆ ਹੈ।", "about.rules": "ਸਰਵਰ ਨਿਯਮ", "account.account_note_header": "ਨਿੱਜੀ ਨੋਟ", "account.add_or_remove_from_list": "ਸੂਚੀ ਵਿੱਚ ਜੋੜੋ ਜਾਂ ਹਟਾਓ", @@ -12,21 +16,33 @@ "account.block_domain": "{domain} ਡੋਮੇਨ ਉੱਤੇ ਪਾਬੰਦੀ ਲਾਓ", "account.block_short": "ਪਾਬੰਦੀ", "account.blocked": "ਪਾਬੰਦੀਸ਼ੁਦਾ", + "account.blocking": "ਪਾਬੰਦੀ ਲਾਉਣੀ", "account.cancel_follow_request": "ਫ਼ਾਲੋ ਕਰਨ ਨੂੰ ਰੱਦ ਕਰੋ", "account.copy": "ਪਰੋਫਾਇਲ ਲਈ ਲਿੰਕ ਕਾਪੀ ਕਰੋ", "account.direct": "ਨਿੱਜੀ ਜ਼ਿਕਰ @{name}", + "account.disable_notifications": "ਜਦੋਂ {name} ਕੋਈ ਪੋਸਟ ਕਰੇ ਤਾਂ ਮੈਨੂੰ ਸੂਚਨਾ ਨਾ ਦਿਓ", + "account.domain_blocking": "ਡੋਮੇਨ ਉੱਤੇ ਪਾਬੰਦੀ", "account.edit_profile": "ਪਰੋਫਾਈਲ ਨੂੰ ਸੋਧੋ", "account.enable_notifications": "ਜਦੋਂ {name} ਪੋਸਟ ਕਰੇ ਤਾਂ ਮੈਨੂੰ ਸੂਚਨਾ ਦਿਓ", "account.endorse": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਫ਼ੀਚਰ", + "account.familiar_followers_one": "{name1} ਵਲੋਂ ਫ਼ਾਲੋ ਕੀਤਾ", + "account.familiar_followers_two": "{name1} ਅਤੇ {name2} ਵਲੋਂ ਫ਼ਾਲੋ ਕੀਤਾ", + "account.featured": "ਫ਼ੀਚਰ", + "account.featured.accounts": "ਪਰੋਫਾਈਲ", + "account.featured.hashtags": "ਹੈਸ਼ਟੈਗ", "account.featured_tags.last_status_at": "{date} ਨੂੰ ਆਖਰੀ ਪੋਸਟ", "account.featured_tags.last_status_never": "ਕੋਈ ਪੋਸਟ ਨਹੀਂ", "account.follow": "ਫ਼ਾਲੋ", "account.follow_back": "ਵਾਪਸ ਫਾਲ਼ੋ ਕਰੋ", "account.followers": "ਫ਼ਾਲੋਅਰ", "account.followers.empty": "ਇਸ ਵਰਤੋਂਕਾਰ ਨੂੰ ਹਾਲੇ ਕੋਈ ਫ਼ਾਲੋ ਨਹੀਂ ਕਰਦਾ ਹੈ।", + "account.followers_counter": "{count, plural, one {{counter} ਫ਼ਾਲੋਅਰ} other {{counter} ਫ਼ਾਲੋਅਰ}}", + "account.followers_you_know_counter": "{counter} ਤੁਸੀਂ ਜਾਣਦੇ ਹੋ", "account.following": "ਫ਼ਾਲੋ ਕੀਤਾ", "account.follows.empty": "ਇਹ ਵਰਤੋਂਕਾਰ ਹਾਲੇ ਕਿਸੇ ਨੂੰ ਫ਼ਾਲੋ ਨਹੀਂ ਕਰਦਾ ਹੈ।", + "account.follows_you": "ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਦੇ ਹਨ", "account.go_to_profile": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਜਾਓ", + "account.hide_reblogs": "{name} ਵਲੋਂ ਬੂਸਟ ਨੂੰ ਲੁਕਾਓ", "account.joined_short": "ਜੁਆਇਨ ਕੀਤਾ", "account.media": "ਮੀਡੀਆ", "account.mention": "@{name} ਦਾ ਜ਼ਿਕਰ", @@ -34,6 +50,7 @@ "account.mute_notifications_short": "ਨੋਟਫਿਕੇਸ਼ਨਾਂ ਨੂੰ ਮੌਨ ਕਰੋ", "account.mute_short": "ਮੌਨ ਕਰੋ", "account.muted": "ਮੌਨ ਕੀਤੀਆਂ", + "account.mutual": "ਤੁਸੀਂ ਇੱਕ ਦੂਜੇ ਨੂੰ ਫ਼ਾਲੋ ਕਰਦੇ ਹੋ", "account.no_bio": "ਕੋਈ ਵਰਣਨ ਨਹੀਂ ਦਿੱਤਾ।", "account.open_original_page": "ਅਸਲ ਸਫ਼ੇ ਨੂੰ ਖੋਲ੍ਹੋ", "account.posts": "ਪੋਸਟਾਂ", @@ -42,8 +59,10 @@ "account.requested": "ਮਨਜ਼ੂਰੀ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ। ਫ਼ਾਲੋ ਬੇਨਤੀਆਂ ਨੂੰ ਰੱਦ ਕਰਨ ਲਈ ਕਲਿੱਕ ਕਰੋ", "account.requested_follow": "{name} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਨ ਦੀ ਬੇਨਤੀ ਕੀਤੀ ਹੈ", "account.share": "{name} ਦਾ ਪਰੋਫ਼ਾਇਲ ਸਾਂਝਾ ਕਰੋ", + "account.statuses_counter": "{count, plural, one {{counter} ਪੋਸਟ} other {{counter} ਪੋਸਟਾਂ}}", "account.unblock": "@{name} ਤੋਂ ਪਾਬੰਦੀ ਹਟਾਓ", "account.unblock_domain": "{domain} ਡੋਮੇਨ ਤੋਂ ਪਾਬੰਦੀ ਹਟਾਓ", + "account.unblock_domain_short": "ਪਾਬੰਦੀ ਹਟਾਓ", "account.unblock_short": "ਪਾਬੰਦੀ ਹਟਾਓ", "account.unendorse": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਫ਼ੀਚਰ ਨਾ ਕਰੋ", "account.unfollow": "ਅਣ-ਫ਼ਾਲੋ", @@ -148,6 +167,8 @@ "confirmations.missing_alt_text.secondary": "ਕਿਵੇਂ ਵੀ ਪੋਸਟ ਕਰੋ", "confirmations.mute.confirm": "ਮੌਨ ਕਰੋ", "confirmations.redraft.confirm": "ਹਟਾਓ ਤੇ ਮੁੜ-ਡਰਾਫਟ", + "confirmations.remove_from_followers.confirm": "ਫ਼ਾਲੋਅਰ ਨੂੰ ਹਟਾਓ", + "confirmations.remove_from_followers.title": "ਫ਼ਾਲੋਅਰ ਨੂੰ ਹਟਾਉਣਾ ਹੈ?", "confirmations.unfollow.confirm": "ਅਣ-ਫ਼ਾਲੋ", "confirmations.unfollow.message": "ਕੀ ਤੁਸੀਂ {name} ਨੂੰ ਅਣ-ਫ਼ਾਲੋ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?", "confirmations.unfollow.title": "ਵਰਤੋਂਕਾਰ ਨੂੰ ਅਣ-ਫ਼ਾਲੋ ਕਰਨਾ ਹੈ?", @@ -182,7 +203,9 @@ "emoji_button.custom": "ਕਸਟਮ", "emoji_button.flags": "ਝੰਡੀਆਂ", "emoji_button.food": "ਖਾਣਾ-ਪੀਣਾ", + "emoji_button.label": "ਇਮੋਜੀ ਪਾਓ", "emoji_button.nature": "ਕੁਦਰਤ", + "emoji_button.not_found": "ਕੋਈ ਮਿਲਦਾ ਇਮੋਜ਼ੀ ਨਹੀਂ ਲੱਭਿਆ", "emoji_button.objects": "ਇਕਾਈ", "emoji_button.people": "ਲੋਕ", "emoji_button.recent": "ਅਕਸਰ ਵਰਤੇ", @@ -199,9 +222,15 @@ "empty_column.list": "ਇਸ ਸੂਚੀ ਵਿੱਚ ਹਾਲੇ ਕੁਝ ਵੀ ਨਹੀਂ ਹੈ। ਜਦੋਂ ਇਸ ਸੂਚੀ ਦੇ ਮੈਂਬਰ ਨਵੀਆਂ ਪੋਸਟਾਂ ਪਾਉਂਦੇ ਹਨ ਤਾਂ ਉਹ ਇੱਥੇ ਦਿਖਾਈ ਦੇਣਗੀਆਂ।", "errors.unexpected_crash.report_issue": "ਮੁੱਦੇ ਦੀ ਰਿਪੋਰਟ ਕਰੋ", "explore.suggested_follows": "ਲੋਕ", + "explore.title": "ਰੁਝਾਨ", "explore.trending_links": "ਖ਼ਬਰਾਂ", "explore.trending_statuses": "ਪੋਸਟਾਂ", "explore.trending_tags": "ਹੈਸ਼ਟੈਗ", + "featured_carousel.header": "{count, plural, one {ਟੰਗੀ ਹੋਈ ਪੋਸਟ} other {ਟੰਗੀਆਂ ਹੋਈਆਂ ਪੋਸਟਾਂ}}", + "featured_carousel.next": "ਅੱਗੇ", + "featured_carousel.post": "ਪੋਸਟ", + "featured_carousel.previous": "ਪਿੱਛੇ", + "featured_carousel.slide": "{total} ਵਿੱਚੋਂ {index}", "filter_modal.added.expired_title": "ਫਿਲਟਰ ਦੀ ਮਿਆਦ ਪੁੱਗੀ!", "filter_modal.added.review_and_configure_title": "ਫਿਲਟਰ ਸੈਟਿੰਗਾਂ", "filter_modal.added.settings_link": "ਸੈਟਿੰਗਾਂ ਸਫ਼ਾ", @@ -252,6 +281,8 @@ "home.column_settings.show_replies": "ਜਵਾਬਾਂ ਨੂੰ ਵੇਖੋ", "home.hide_announcements": "ਐਲਾਨਾਂ ਨੂੰ ਓਹਲੇ ਕਰੋ", "home.pending_critical_update.link": "ਅੱਪਡੇਟ ਵੇਖੋ", + "home.pending_critical_update.title": "ਗੰਭੀਰ ਸੁਰੱਖਿਆ ਅੱਪਡੇਟ ਮੌਜੂਦ ਹੈ!", + "home.show_announcements": "ਐਲਾਨਾਂ ਨੂੰ ਵੇਖਾਓ", "ignore_notifications_modal.ignore": "ਨੋਟਫਿਕੇਸ਼ਨਾਂ ਨੂੰ ਅਣਡਿੱਠਾ ਕਰੋ", "info_button.label": "ਮਦਦ", "interaction_modal.go": "ਜਾਓ", @@ -332,9 +363,12 @@ "media_gallery.hide": "ਲੁਕਾਓ", "mute_modal.hide_from_notifications": "ਨੋਟੀਫਿਕੇਸ਼ਨਾਂ ਵਿੱਚੋਂ ਲੁਕਾਓ", "mute_modal.show_options": "ਚੋਣਾਂ ਨੂੰ ਵੇਖਾਓ", + "mute_modal.title": "ਵਰਤੋਂਕਾਰ ਨੂੰ ਮੌਨ ਕਰਨਾ ਹੈ?", "navigation_bar.about": "ਇਸ ਬਾਰੇ", + "navigation_bar.account_settings": "ਪਾਸਵਰਡ ਅਤੇ ਸੁਰੱਖਿਆ", "navigation_bar.administration": "ਪਰਸ਼ਾਸ਼ਨ", "navigation_bar.advanced_interface": "ਤਕਨੀਕੀ ਵੈੱਬ ਇੰਟਰਫੇਸ ਵਿੱਚ ਖੋਲ੍ਹੋ", + "navigation_bar.automated_deletion": "ਆਪਣੇ-ਆਪ ਹਟਾਈ ਪੋਸਟ", "navigation_bar.blocks": "ਪਾਬੰਦੀ ਲਾਏ ਵਰਤੋਂਕਾਰ", "navigation_bar.bookmarks": "ਬੁੱਕਮਾਰਕ", "navigation_bar.direct": "ਨਿੱਜੀ ਜ਼ਿਕਰ", @@ -346,11 +380,16 @@ "navigation_bar.follows_and_followers": "ਫ਼ਾਲੋ ਅਤੇ ਫ਼ਾਲੋ ਕਰਨ ਵਾਲੇ", "navigation_bar.lists": "ਸੂਚੀਆਂ", "navigation_bar.logout": "ਲਾਗ ਆਉਟ", + "navigation_bar.more": "ਹੋਰ", "navigation_bar.mutes": "ਮੌਨ ਕੀਤੇ ਵਰਤੋਂਕਾਰ", "navigation_bar.preferences": "ਪਸੰਦਾਂ", + "navigation_bar.privacy_and_reach": "ਪਰਦੇਦਾਰੀ ਅਤੇ ਪਹੁੰਚ", "navigation_bar.search": "ਖੋਜੋ", + "navigation_bar.search_trends": "ਖੋਜ / ਰੁਝਾਨ", "not_signed_in_indicator.not_signed_in": "ਇਹ ਸਰੋਤ ਵਰਤਣ ਲਈ ਤੁਹਾਨੂੰ ਲਾਗਇਨ ਕਰਨ ਦੀ ਲੋੜ ਹੈ।", "notification.admin.sign_up": "{name} ਨੇ ਸਾਈਨ ਅੱਪ ਕੀਤਾ", + "notification.favourite": "{name} ਨੇ ਤੁਹਾਡੀ ਪੋਸਟ ਨੂੰ ਪਸੰਦ ਕੀਤਾ", + "notification.favourite_pm": "{name} ਨੇ ਤੁਹਾਡੇ ਨਿੱਜੀ ਜ਼ਿਕਰ ਨੂੰ ਪਸੰਦ ਕੀਤਾ", "notification.follow": "{name} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕੀਤਾ", "notification.follow.name_and_others": "{name} ਅਤੇ {count, plural, one {# ਹੋਰ} other {# ਹੋਰਾਂ}} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕੀਤਾ", "notification.follow_request": "{name} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਨ ਦੀ ਬੇਨਤੀ ਕੀਤੀ ਹੈ", @@ -365,6 +404,7 @@ "notification.moderation_warning.action_silence": "ਤੁਹਾਡੇ ਖਾਤੇ ਨੂੰ ਸੀਮਿਤ ਕੀਤਾ ਗਿਆ ਹੈ।", "notification.moderation_warning.action_suspend": "ਤੁਹਾਡੇ ਖਾਤੇ ਨੂੰ ਮੁਅੱਤਲ ਕੀਤਾ ਗਿਆ ਹੈ।", "notification.reblog": "{name} boosted your status", + "notification.relationships_severance_event": "{name} ਨਾਲ ਕਨੈਕਸ਼ਨ ਗੁਆਚੇ", "notification.relationships_severance_event.learn_more": "ਹੋਰ ਜਾਣੋ", "notification.status": "{name} ਨੇ ਹੁਣੇ ਪੋਸਟ ਕੀਤਾ", "notification.update": "{name} ਨੋ ਪੋਸਟ ਨੂੰ ਸੋਧਿਆ", @@ -540,7 +580,10 @@ "status.unpin": "ਪਰੋਫਾਈਲ ਤੋਂ ਲਾਹੋ", "subscribed_languages.save": "ਤਬਦੀਲੀਆਂ ਸੰਭਾਲੋ", "tabs_bar.home": "ਘਰ", + "tabs_bar.menu": "ਮੇਨੂ", "tabs_bar.notifications": "ਸੂਚਨਾਵਾਂ", + "tabs_bar.publish": "ਨਵੀਂ ਪੋਸਟ", + "tabs_bar.search": "ਖੋਜੋ", "terms_of_service.title": "ਸੇਵਾ ਦੀਆਂ ਸ਼ਰਤਾਂ", "time_remaining.days": "{number, plural, one {# ਦਿਨ} other {# ਦਿਨ}} ਬਾਕੀ", "time_remaining.hours": "{number, plural, one {# ਘੰਟਾ} other {# ਘੰਟੇ}} ਬਾਕੀ", @@ -561,6 +604,9 @@ "video.expand": "ਵੀਡੀਓ ਨੂੰ ਫੈਲਾਓ", "video.fullscreen": "ਪੂਰੀ ਸਕਰੀਨ", "video.hide": "ਵੀਡੀਓ ਨੂੰ ਲੁਕਾਓ", + "video.mute": "ਮੌਨ", "video.pause": "ਠਹਿਰੋ", - "video.play": "ਚਲਾਓ" + "video.play": "ਚਲਾਓ", + "video.volume_down": "ਅਵਾਜ਼ ਘਟਾਓ", + "video.volume_up": "ਅਵਾਜ਼ ਵਧਾਓ" } diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 57958076a7b..8928f253b16 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -537,8 +537,10 @@ "mute_modal.you_wont_see_mentions": "你看不到提及对方的嘟文。", "mute_modal.you_wont_see_posts": "对方可以看到你的嘟文,但是你看不到对方的。", "navigation_bar.about": "关于", + "navigation_bar.account_settings": "密码与安全", "navigation_bar.administration": "管理", "navigation_bar.advanced_interface": "在高级网页界面中打开", + "navigation_bar.automated_deletion": "自动删除嘟文", "navigation_bar.blocks": "已屏蔽的用户", "navigation_bar.bookmarks": "书签", "navigation_bar.direct": "私下提及", diff --git a/app/javascript/mastodon/main.tsx b/app/javascript/mastodon/main.tsx index 70e6391beee..e840429c41e 100644 --- a/app/javascript/mastodon/main.tsx +++ b/app/javascript/mastodon/main.tsx @@ -4,7 +4,7 @@ import { Globals } from '@react-spring/web'; import { setupBrowserNotifications } from 'mastodon/actions/notifications'; import Mastodon from 'mastodon/containers/mastodon'; -import { me, reduceMotion } from 'mastodon/initial_state'; +import { isFeatureEnabled, me, reduceMotion } from 'mastodon/initial_state'; import * as perf from 'mastodon/performance'; import ready from 'mastodon/ready'; import { store } from 'mastodon/store'; @@ -29,6 +29,11 @@ function main() { }); } + if (isFeatureEnabled('modern_emojis')) { + const { initializeEmoji } = await import('@/mastodon/features/emoji'); + await initializeEmoji(); + } + const root = createRoot(mountNode); root.render(); store.dispatch(setupBrowserNotifications()); diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index ddcb214a472..cc95d8e7543 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -30,6 +30,7 @@ class InitialStateSerializer < ActiveModel::Serializer store[:use_blurhash] = object_account_user.setting_use_blurhash store[:use_pending_items] = object_account_user.setting_use_pending_items store[:show_trends] = Setting.trends && object_account_user.setting_trends + store[:emoji_style] = object_account_user.settings['web.emoji_style'] if Mastodon::Feature.modern_emojis_enabled? else store[:auto_play_gif] = Setting.auto_play_gif store[:display_media] = Setting.display_media diff --git a/config/locales/cy.yml b/config/locales/cy.yml index 6e1826510c2..d7242fbf2e1 100644 --- a/config/locales/cy.yml +++ b/config/locales/cy.yml @@ -1425,6 +1425,9 @@ cy: basic_information: Gwybodaeth Sylfaenol hint_html: "Addaswch yr hyn y mae pobl yn ei weld ar eich proffil cyhoeddus ac wrth ymyl eich postiadau. Mae pobl eraill yn fwy tebygol o'ch dilyn yn ôl a rhyngweithio â chi pan fydd gennych broffil wedi'i lenwi a llun proffil." other: Arall + emoji_styles: + auto: Awto + native: Cynhenid errors: '400': Roedd y cais a gyflwynwyd gennych yn annilys neu wedi'i gamffurfio. '403': Nid oes gennych ganiatâd i weld y dudalen hon. @@ -1590,7 +1593,7 @@ cy: domain_blocking_html: few: Rydych ar fin amnewid eich rhestr rhwystro parthau gyda hyd at %{count} parth o %{filename}. many: Rydych ar fin amnewid eich rhestr rhwystro parthau gyda hyd at %{count} parth o %{filename}. - one: Rydych ar fin disodli eich rhestr blociau parth gyda hyd at %{count} parth o %{filename}. + one: Rydych ar fin amnewid eich rhestr blociau parth gyda hyd at %{count} parth o %{filename}. other: Rydych ar fin amnewid eich rhestr rhwystro parthau gyda hyd at %{count} parth o %{filename}. two: Rydych ar fin amnewid eich rhestr rhwystro parthau gyda hyd at %{count} parth o %{filename}amnewid eich rhestr rhwystro parthau gyda hyd at %{count} parth o %{filename}. diff --git a/config/locales/et.yml b/config/locales/et.yml index dba31f4ffd7..962f6be9db8 100644 --- a/config/locales/et.yml +++ b/config/locales/et.yml @@ -318,6 +318,8 @@ et: new: create: Loo teadaanne title: Uus teadaanne + preview: + title: Info teavituse üle vaatamine publish: Postita published_msg: Teadaande avaldamine õnnestus! scheduled_for: Kavandatud ajaks %{time} @@ -485,6 +487,7 @@ et: request_body: Päringu sisu providers: active: Aktiivne + base_url: Baas-URL callback: Pöördliiklus delete: Kustuta edit: Muuda teenusepakkujat @@ -499,6 +502,7 @@ et: reject: Keeldu title: Kinnita FASP-i registreerimine save: Salvesta + select_capabilities: Vali oskused sign_in: Logi sisse status: Olek title: Täiendavad teenusepakkujad Födiversumis (FASP - Fediverse Auxiliary Service Providers) diff --git a/config/locales/fa.yml b/config/locales/fa.yml index dac21886085..ec0ed1ca997 100644 --- a/config/locales/fa.yml +++ b/config/locales/fa.yml @@ -182,7 +182,7 @@ fa: create_account_warning: ایجاد هشدار create_announcement: ایجاد اعلامیه create_canonical_email_block: ایجاد انسداد رایانامه - create_custom_emoji: ایجاد اموجی سفارشی + create_custom_emoji: ایجاد شکلک سفارشی create_domain_allow: ایجاد اجازهٔ دامنه create_domain_block: ایجاد انسداد دامنه create_email_domain_block: ایجاد انسداد دامنهٔ رایانامه @@ -193,7 +193,7 @@ fa: demote_user: تنزل کاربر destroy_announcement: حذف اعلامیه destroy_canonical_email_block: حذف انسداد رایانامه - destroy_custom_emoji: حذف اموجی سفارشی + destroy_custom_emoji: حذف شکلک سفارشی destroy_domain_allow: حذف اجازهٔ دامنه destroy_domain_block: حذف انسداد دامنه destroy_email_domain_block: حذف انسداد دامنهٔ رایانامه @@ -204,11 +204,11 @@ fa: destroy_unavailable_domain: حذف دامنهٔ ناموجود destroy_user_role: نابودی نقش disable_2fa_user: از کار انداختن ورود دومرحله‌ای - disable_custom_emoji: از کار انداختن اموجی سفارشی + disable_custom_emoji: از کار انداختن شکلک سفارشی disable_relay: غیرفعال‌سازی رله disable_sign_in_token_auth_user: از کار انداختن تأیید هویت ژتون رایانامه‌ای برای کاربر disable_user: از کار انداختن کاربر - enable_custom_emoji: به کار انداختن اموجی سفارشی + enable_custom_emoji: به کار انداختن شکلک سفارشی enable_relay: فعال‌سازی رله enable_sign_in_token_auth_user: به کار انداختن تأیید هویت ژتون رایانامه‌ای برای کاربر enable_user: به کار انداختن کاربر @@ -231,7 +231,7 @@ fa: unsilence_account: رفع خموشی حساب unsuspend_account: رفع تعلیق حساب update_announcement: به‌روز رسانی اعلامیه - update_custom_emoji: به‌روز رسانی اموجی سفارشی + update_custom_emoji: به‌روز رسانی شکلک سفارشی update_domain_block: به‌روزرسانی مسدودسازی دامنه update_ip_block: بروزرسانی قاعدهٔ آی‌پی update_report: به‌روز رسانی گزارش @@ -247,7 +247,7 @@ fa: create_account_warning_html: "%{name} هشداری برای %{target} فرستاد" create_announcement_html: "%{name} اعلامیه‌ای جدید ایجاد کرد %{target}" create_canonical_email_block_html: "%{name} رایانامه با درهم‌ریزی %{target} را مسدود کرد" - create_custom_emoji_html: "%{name} اموجی تازهٔ %{target} را بارگذاشت" + create_custom_emoji_html: "%{name} شکلک تازهٔ %{target} را بارگذاشت" create_domain_allow_html: "%{name} دامنهٔ %{target} را مجاز کرد" create_domain_block_html: "%{name} دامنهٔ %{target} را مسدود کرد" create_email_domain_block_html: "%{name} دامنهٔ رایانامهٔ %{target} را مسدود کرد" @@ -1351,6 +1351,8 @@ fa: other: سایر emoji_styles: auto: خودکار + native: بومی + twemoji: توییموجی errors: '400': درخواستی که فرستادید نامعتبر یا اشتباه بود. '403': شما اجازهٔ دیدن این صفحه را ندارید. diff --git a/config/locales/ga.yml b/config/locales/ga.yml index c9e943c91c1..111ae9c56f0 100644 --- a/config/locales/ga.yml +++ b/config/locales/ga.yml @@ -1220,9 +1220,9 @@ ga: confirmation_dialogs: Dialóga deimhnithe discovery: Fionnachtain localization: - body: Aistríonn oibrithe deonacha Mastodon. + body: Oibrithe deonacha a dhéanann aistriúchán Mastodon. guide_link: https://crowdin.com/project/mastodon - guide_link_text: Is féidir le gach duine rannchuidiú. + guide_link_text: Is féidir le gach duine cur leis. sensitive_content: Ábhar íogair application_mailer: notification_preferences: Athraigh roghanna ríomhphoist diff --git a/config/locales/kab.yml b/config/locales/kab.yml index e68c99e2f81..96f69dcbdc4 100644 --- a/config/locales/kab.yml +++ b/config/locales/kab.yml @@ -444,7 +444,7 @@ kab: media: title: Amidya open: Ldi tasuffeɣt - trending: Ayen mucaɛen + trending: Inezzaɣ visibility: Abani with_media: S umidya system_checks: @@ -475,12 +475,16 @@ kab: title: Tadbelt trends: allow: Sireg + links: + title: Iseɣwan inezzaɣ statuses: - title: Tisuffaɣ mucaɛen + title: Tisuffaɣ tinezzaɣ tags: dashboard: tag_languages_dimension: Tutlayin ifazen - trending: Ayen mucaɛen + title: Ihacṭagen inezzaɣ + trending_rank: 'Anezzuɣ #%{rank}' + trending: Inezzaɣ warning_presets: add_new: Rnu amaynut delete: Kkes @@ -492,8 +496,12 @@ kab: body: "%{reporter} yettwazen ɣef %{target}" subject: Aneqqis amaynut i %{instance} (#%{id}) new_trends: + new_trending_links: + title: Iseɣwan inezzaɣ new_trending_statuses: - title: Tisuffaɣ mucaɛen + title: Tisuffaɣ tinezzaɣ + new_trending_tags: + title: Ihacṭagen inezzaɣ appearance: advanced_web_interface: Agrudem n web leqqayen discovery: Asnirem @@ -916,12 +924,13 @@ kab: edit_profile_title: Sagen amaɣnu-inek·inem feature_action: Issin ugar follow_action: Ḍfeṛ + follow_title: Sagen isuddam n yisallen n wejgu-k·m agejdan follows_subtitle: Ḍfer imiḍanen yettwassnen mliḥ follows_title: Anwa ara ḍefṛeḍ follows_view_more: Ssken-d ugar n medden ay tzemred ad tḍefred - hashtags_subtitle: Wali ayen ileḥḥun seg sin wussan-a iεeddan - hashtags_title: Ihacṭagen mucaɛen - hashtags_view_more: Sken-d ugar n yihacṭagen mucaɛen + hashtags_subtitle: Snirem ayen yellan d anezzuɣ deg 2 n wussan-a iεeddan + hashtags_title: Ihacṭagen inezzaɣ + hashtags_view_more: Sken-d ugar n yihacṭagen inezzaɣ post_step: Ini-as azul i umaḍal s uḍris, s tiwlafin, s tividyutin neɣ s tefranin. post_title: Aru tasuffeɣt-inek·inem tamezwarut share_step: Init-asen i yimeddukal-nwen amek ara ken-id-afen deg Mastodon. diff --git a/config/locales/nan.yml b/config/locales/nan.yml index 28c98a58d67..354ef8b0f85 100644 --- a/config/locales/nan.yml +++ b/config/locales/nan.yml @@ -454,6 +454,7 @@ nan: title: 封鎖新ê電子phue網域 no_email_domain_block_selected: 因為無揀任何電子phue域名封鎖,所以lóng無改變 not_permitted: 無允准 + resolved_dns_records_hint_html: 域名解析做下kha ê MX域名,tsiah ê域名上後負責收電子phue。封鎖MX域名ē封任何有siâng款MX域名ê電子郵件ê註冊,就算通看見ê域名無kâng,mā án-ne。Tio̍h細膩,m̄通封鎖主要ê電子phue提供者。 resolved_through_html: 通過 %{domain} 解析 title: 封鎖ê電子phue網域 export_domain_allows: @@ -468,9 +469,78 @@ nan: private_comment_template: 佇 %{date} tuì %{source} 輸入 title: 輸入域名封鎖 invalid_domain_block: 因為下kha ê錯誤,làng過tsi̍t ê以上ê域名封鎖:%{error} + new: + title: 輸入域名封鎖 + no_file: Iáu bē揀檔案 + fasp: + debug: + callbacks: + created_at: 建立佇 + delete: Thâi掉 + ip: IP地址 + request_body: 請求主文 + title: 除蟲callback + providers: + active: 有效 + base_url: 基本URL + callback: Callback + delete: Thâi掉 + edit: 編輯提供者 + finish_registration: 完成註冊 + name: 名 + providers: 提供者 + public_key_fingerprint: 公開鎖匙ê指頭仔螺(public key fingerprint) + registration_requested: 註冊請求ah + registrations: + confirm: 確認 + description: Lí收著FASP ê註冊ah。nā準lí bô啟動,請拒絕。若有啟動,請佇確認註冊以前,細膩比較名kap鎖匙ê指頭仔螺。 + reject: 拒絕 + title: 確認FASP註冊 + save: 儲存 + select_capabilities: 揀功能 + sign_in: 登入 + status: 狀態 + title: 聯邦宇宙輔助服務提供者 (FASP) + title: FASP + follow_recommendations: + description_html: "跟tuè建議幫tsān新用者緊tshuē著心適ê內容。Nā使用者無hām別lâng有夠額ê互動,來形成個人化ê跟tuè建議,就ē推薦tsiah ê口座。In是佇指定語言內底,由最近上tsia̍p參與ê,kap上tsē lâng跟tuè ê口座,用ta̍k kang做基礎,相濫koh計算出來ê。" + language: 揀語言 + status: 狀態 + suppress: Khàm掉跟tuè建議 + suppressed: Khàm掉ê + title: 跟tuè建議 + unsuppress: 恢復跟tuè建議 instances: + audit_log: + title: 最近ê審核日誌 + view_all: 看完整ê審核日誌 + availability: + description_html: + other: Nā佇 %{count} kang內,寄送kàu hit ê域名lóng失敗,除非收著hit ê域名來ê寄送,a̍h無buē koh試寄送。 + failure_threshold_reached: 佇 %{date} kàu失敗ê底限。 + failures_recorded: + other: 連suà %{count} kang lóng寄失敗。 + no_failures_recorded: 報告內底無失敗。 + title: 可用性 + warning: 頂kái試連接tsit臺服侍器是無成功 + back_to_all: 全部 + back_to_limited: 受限制 + back_to_warning: 警告 + by_domain: 域名 + confirm_purge: Lí kám確定beh永永thâi掉tsit ê域名來ê資料? + content_policies: + comment: 內部ê筆記 + description_html: Lí ē當定義用tī所有tuì tsit ê域名kap伊ê子域名來ê口座ê內容政策。 dashboard: instance_languages_dimension: Tsia̍p用ê語言 + invites: + filter: + available: 通用ê + expired: 過期ê + title: 過濾器 + title: 邀請 + ip_blocks: + add_new: 建立規則 statuses: language: 語言 trends: diff --git a/config/locales/pa.yml b/config/locales/pa.yml index 1899d71008a..f9508f9b9a4 100644 --- a/config/locales/pa.yml +++ b/config/locales/pa.yml @@ -7,7 +7,13 @@ pa: hosted_on: "%{domain} ਉੱਤੇ ਹੋਸਟ ਕੀਤਾ ਮਸਟਾਡੋਨ" title: ਇਸ ਬਾਰੇ accounts: + followers: + one: ਫ਼ਾਲੋਅਰ + other: ਫ਼ਾਲੋਅਰ following: ਫ਼ਾਲੋ ਕੀਤੇ ਜਾ ਰਹੇ + posts: + one: ਪੋਸਟ + other: ਪੋਸਟਾਂ posts_tab_heading: ਪੋਸਟਾਂ admin: account_moderation_notes: @@ -126,6 +132,9 @@ pa: thread: ਗੱਲਾਂਬਾਤਾਂ index: delete: ਹਟਾਓ + statuses: + one: "%{count} ਪੋਸਟ" + other: "%{count} ਪੋਸਟ" generic: all: ਸਭ copy: ਕਾਪੀ ਕਰੋ diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 6f9fe4da8ae..971846789b6 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -1471,10 +1471,10 @@ ru: other: Выберите все %{count} предмет(-ов), совпадающий(-их) вашему поисковому запросу. today: сегодня validation_errors: - few: Что-то здесь не так! Пожалуйста, прочитайте о %{count} ошибках ниже - many: Что-то здесь не так! Пожалуйста, прочитайте о %{count} ошибках ниже - one: Что-то здесь не так! Пожалуйста, прочитайте об ошибке ниже - other: Что-то здесь не так! Пожалуйста, прочитайте о %{count} ошибках ниже + few: Проверьте введённые вами данные! Далее по странице вы можете увидеть %{count} сообщения об ошибке + many: Проверьте введённые вами данные! Далее по странице вы можете увидеть %{count} сообщений об ошибке + one: Проверьте введённые вами данные! Далее по странице вы можете увидеть %{count} сообщение об ошибке + other: Проверьте введённые вами данные! Далее по странице вы можете увидеть %{count} сообщений об ошибке imports: errors: empty: Пустой CSV-файл diff --git a/config/locales/simple_form.cy.yml b/config/locales/simple_form.cy.yml index 8e806016e5e..a1390a3cc62 100644 --- a/config/locales/simple_form.cy.yml +++ b/config/locales/simple_form.cy.yml @@ -61,6 +61,7 @@ cy: setting_display_media_default: Cuddio cyfryngau wedi eu marcio'n sensitif setting_display_media_hide_all: Cuddio cyfryngau bob tro setting_display_media_show_all: Dangos cyfryngau bob tro + setting_emoji_style: Sut i arddangos emojis. Bydd "Awto" yn ceisio defnyddio emoji cynhenid, ond mae'n disgyn yn ôl i Twemoji ar gyfer porwyr traddodiadol. setting_system_scrollbars_ui: Yn berthnasol i borwyr bwrdd gwaith yn seiliedig ar Safari a Chrome yn unig setting_use_blurhash: Mae graddiannau wedi'u seilio ar liwiau'r delweddau cudd ond maen nhw'n cuddio unrhyw fanylion setting_use_pending_items: Cuddio diweddariadau llinell amser y tu ôl i glic yn lle sgrolio'n awtomatig @@ -149,6 +150,13 @@ cy: min_age: Ni ddylai fod yn is na'r isafswm oedran sy'n ofynnol gan gyfreithiau eich awdurdodaeth. user: chosen_languages: Wedi eu dewis, dim ond tŵtiau yn yr ieithoedd hyn bydd yn cael eu harddangos mewn ffrydiau cyhoeddus + date_of_birth: + few: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn. + many: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn. + one: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn. + other: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn. + two: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn. + zero: Mae'n rhaid i ni sicrhau eich bod chi yn o leiaf %{count} oed i ddefnyddio %{domain}. Fyddwn ni ddim yn cadw hyn. role: Mae'r rôl yn rheoli pa ganiatâd sydd gan y defnyddiwr. user_role: color: Lliw i'w ddefnyddio ar gyfer y rôl drwy'r UI, fel RGB mewn fformat hecs @@ -238,6 +246,7 @@ cy: setting_display_media_default: Rhagosodiad setting_display_media_hide_all: Cuddio popeth setting_display_media_show_all: Dangos popeth + setting_emoji_style: Arddull Emojis setting_expand_spoilers: Dangos postiadau wedi'u marcio â rhybudd cynnwys bob tro setting_hide_network: Cuddio eich graff cymdeithasol setting_missing_alt_text_modal: Dangos deialog cadarnhau cyn postio cyfrwng heb destun amgen diff --git a/config/locales/simple_form.es-MX.yml b/config/locales/simple_form.es-MX.yml index 02ecd4ebe8d..e8080b2a768 100644 --- a/config/locales/simple_form.es-MX.yml +++ b/config/locales/simple_form.es-MX.yml @@ -61,7 +61,7 @@ es-MX: setting_display_media_default: Ocultar contenido multimedia marcado como sensible setting_display_media_hide_all: Siempre ocultar todo el contenido multimedia setting_display_media_show_all: Mostrar siempre contenido multimedia marcado como sensible - setting_emoji_style: Cómo se mostrarán los emojis. "Auto" intentará usar emojis nativos, cambiando a Twemoji en navegadores antiguos. + setting_emoji_style: Cómo se muestran los emojis. «Automático» intentará usar emojis nativos, pero vuelve a Twemoji para los navegadores antiguos. setting_system_scrollbars_ui: Solo se aplica a los navegadores de escritorio basados en Safari y Chrome setting_use_blurhash: Los degradados se basan en los colores de los elementos visuales ocultos, pero ocultan cualquier detalle setting_use_pending_items: Ocultar las publicaciones de la línea de tiempo tras un clic en lugar de desplazar automáticamente el feed @@ -151,8 +151,8 @@ es-MX: user: chosen_languages: Cuando se marca, solo se mostrarán las publicaciones en los idiomas seleccionados en las líneas de tiempo públicas date_of_birth: - one: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No guardaremos esta información. - other: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No guardaremos esta información. + one: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No almacenaremos esta información. + other: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No almacenaremos esta información. role: El rol controla qué permisos tiene el usuario. user_role: color: Color que se usará para el rol en toda la interfaz de usuario, como RGB en formato hexadecimal diff --git a/config/locales/simple_form.fa.yml b/config/locales/simple_form.fa.yml index 671f275fa50..f80097832ca 100644 --- a/config/locales/simple_form.fa.yml +++ b/config/locales/simple_form.fa.yml @@ -61,6 +61,7 @@ fa: setting_display_media_default: تصویرهایی را که به عنوان حساس علامت زده شده‌اند پنهان کن setting_display_media_hide_all: همیشه همهٔ عکس‌ها و ویدیوها را پنهان کن setting_display_media_show_all: همیشه تصویرهایی را که به عنوان حساس علامت زده شده‌اند را نشان بده + setting_emoji_style: چگونگی نمایش شکلک‌ها. «خودکار» تلاش خواهد کرد از شکلک‌های بومی استفاده کند؛ ولی برای مرورگرهای قدیمی به توییموجی برخواهد گشت. setting_system_scrollbars_ui: فقط برای مرورگرهای دسکتاپ مبتنی بر سافاری و کروم اعمال می شود setting_use_blurhash: سایه‌ها بر اساس رنگ‌های به‌کاررفته در تصویر پنهان‌شده ساخته می‌شوند ولی جزئیات تصویر در آن‌ها آشکار نیست setting_use_pending_items: به جای پیش‌رفتن خودکار در فهرست، به‌روزرسانی فهرست نوشته‌ها را پشت یک کلیک پنهان کن @@ -238,6 +239,7 @@ fa: setting_display_media_default: پیش‌فرض setting_display_media_hide_all: نهفتن همه setting_display_media_show_all: نمایش همه + setting_emoji_style: سبک شکلک setting_expand_spoilers: همیشه فرسته‌هایی را که هشدار محتوا دارند کامل نشان بده setting_hide_network: نهفتن شبکهٔ ارتباطی setting_missing_alt_text_modal: نمایش گفتگوی تایید قبل از ارسال رسانه بدون متن جایگزین diff --git a/config/locales/simple_form.pt-PT.yml b/config/locales/simple_form.pt-PT.yml index ebee60a9532..811b2ecd501 100644 --- a/config/locales/simple_form.pt-PT.yml +++ b/config/locales/simple_form.pt-PT.yml @@ -150,6 +150,9 @@ pt-PT: min_age: Não deve ter menos do que a idade mínima exigida pela legislação da sua jurisdição. user: chosen_languages: Quando selecionado, só serão mostradas nas cronologias públicas as publicações nos idiomas escolhidos + date_of_birth: + one: Temos de nos certificar que tem pelo menos %{count} para utilizar %{domain}. Não vamos guardar esta informação. + other: Temos de nos certificar que tem pelo menos %{count} para utilizar %{domain}. Não vamos guardar esta informação. role: A função controla as permissões que o utilizador tem. user_role: color: Cor a ser utilizada para a função em toda a interface de utilizador, como RGB no formato hexadecimal diff --git a/config/vite/plugin-assets-manifest.ts b/config/vite/plugin-assets-manifest.ts new file mode 100644 index 00000000000..3d465549cea --- /dev/null +++ b/config/vite/plugin-assets-manifest.ts @@ -0,0 +1,84 @@ +// Heavily inspired by https://github.com/ElMassimo/vite_ruby + +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import glob from 'fast-glob'; +import type { Plugin } from 'vite'; + +interface AssetManifestChunk { + file: string; + integrity: string; +} + +const ALGORITHM = 'sha384'; + +export function MastodonAssetsManifest(): Plugin { + let manifest: string | boolean = true; + let jsRoot = ''; + + return { + name: 'mastodon-assets-manifest', + applyToEnvironment(environment) { + return !!environment.config.build.manifest; + }, + configResolved(resolvedConfig) { + manifest = resolvedConfig.build.manifest; + jsRoot = resolvedConfig.root; + }, + async generateBundle() { + // Glob all assets and return an array of absolute paths. + const assetPaths = await glob('{fonts,icons,images}/**/*', { + cwd: jsRoot, + absolute: true, + }); + + const assetManifest: Record = {}; + const excludeExts = ['', '.md']; + for (const file of assetPaths) { + // Exclude files like markdown or README files with no extension. + const ext = path.extname(file); + if (excludeExts.includes(ext)) { + continue; + } + + // Read the file and emit it as an asset. + const contents = await fs.readFile(file); + const ref = this.emitFile({ + name: path.basename(file), + type: 'asset', + source: contents, + }); + const hashedFilename = this.getFileName(ref); + + // With the emitted file information, hash the contents and store in manifest. + const name = path.relative(jsRoot, file); + const hash = createHash(ALGORITHM) + .update(contents) + .digest() + .toString('base64'); + assetManifest[name] = { + file: hashedFilename, + integrity: `${ALGORITHM}-${hash}`, + }; + } + + if (Object.keys(assetManifest).length === 0) { + console.warn('Asset manifest is empty'); + return; + } + + // Get manifest location and emit the manifest. + const manifestDir = + typeof manifest === 'string' ? path.dirname(manifest) : '.vite'; + const fileName = `${manifestDir}/manifest-assets.json`; + + this.emitFile({ + fileName, + type: 'asset', + source: JSON.stringify(assetManifest, null, 2), + }); + }, + }; +} diff --git a/package.json b/package.json index 2ddb85b7631..1d1952e262f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "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", @@ -88,7 +89,6 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", - "react-hotkeys": "^1.1.4", "react-immutable-proptypes": "^2.2.0", "react-immutable-pure-component": "^2.2.2", "react-intl": "^7.1.10", @@ -163,12 +163,12 @@ "@vitest/browser": "^3.2.1", "@vitest/coverage-v8": "^3.2.0", "@vitest/ui": "^3.2.1", - "chromatic": "^12.1.0", + "chromatic": "^13.0.0", "eslint": "^9.23.0", "eslint-import-resolver-typescript": "^4.2.5", "eslint-plugin-formatjs": "^5.3.1", "eslint-plugin-import": "~2.31.0", - "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-jsdoc": "^51.0.0", "eslint-plugin-jsx-a11y": "~6.10.2", "eslint-plugin-promise": "~7.2.1", "eslint-plugin-react": "^7.37.4", @@ -179,7 +179,7 @@ "lint-staged": "^16.0.0", "msw": "^2.10.2", "msw-storybook-addon": "^2.0.5", - "playwright": "^1.52.0", + "playwright": "^1.54.1", "prettier": "^3.3.3", "react-test-renderer": "^18.2.0", "storybook": "^9.0.4", diff --git a/spec/requests/invite_spec.rb b/spec/requests/invite_spec.rb index ba046453890..027b0fc738d 100644 --- a/spec/requests/invite_spec.rb +++ b/spec/requests/invite_spec.rb @@ -6,13 +6,49 @@ RSpec.describe 'invites' do let(:invite) { Fabricate(:invite) } context 'when requesting a JSON document' do - it 'returns a JSON document with expected attributes' do - get "/invite/#{invite.code}", headers: { 'Accept' => 'application/activity+json' } + subject { get "/invite/#{invite.code}", headers: { 'Accept' => 'application/activity+json' } } - expect(response).to have_http_status(200) - expect(response.media_type).to eq 'application/json' + context 'when invite is valid' do + it 'returns a JSON document with expected attributes' do + subject - expect(response.parsed_body[:invite_code]).to eq invite.code + expect(response) + .to have_http_status(200) + expect(response.media_type) + .to eq 'application/json' + expect(response.parsed_body) + .to include(invite_code: invite.code) + end + end + + context 'when invite is expired' do + before { invite.update(expires_at: 3.days.ago) } + + it 'returns a JSON document with error details' do + subject + + expect(response) + .to have_http_status(401) + expect(response.media_type) + .to eq 'application/json' + expect(response.parsed_body) + .to include(error: I18n.t('invites.invalid')) + end + end + + context 'when user IP is blocked' do + before { Fabricate :ip_block, severity: :sign_up_block, ip: '127.0.0.1' } + + it 'returns a JSON document with error details' do + subject + + expect(response) + .to have_http_status(403) + expect(response.media_type) + .to eq 'application/json' + expect(response.parsed_body) + .to include(error: /This action is not allowed/) + end end end diff --git a/vite.config.mts b/vite.config.mts index b47bea382cf..7f93157b7e1 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -4,7 +4,6 @@ import { readdir } from 'node:fs/promises'; import { optimizeLodashImports } from '@optimize-lodash/rollup-plugin'; import legacy from '@vitejs/plugin-legacy'; import react from '@vitejs/plugin-react'; -import glob from 'fast-glob'; import postcssPresetEnv from 'postcss-preset-env'; import Compress from 'rollup-plugin-gzip'; import { visualizer } from 'rollup-plugin-visualizer'; @@ -24,6 +23,7 @@ import { MastodonServiceWorkerLocales } from './config/vite/plugin-sw-locales'; import { MastodonEmojiCompressed } from './config/vite/plugin-emoji-compressed'; import { MastodonThemes } from './config/vite/plugin-mastodon-themes'; import { MastodonNameLookup } from './config/vite/plugin-name-lookup'; +import { MastodonAssetsManifest } from './config/vite/plugin-assets-manifest'; const jsRoot = path.resolve(__dirname, 'app/javascript'); @@ -120,6 +120,7 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => { }, }), MastodonThemes(), + MastodonAssetsManifest(), viteStaticCopy({ targets: [ { @@ -144,7 +145,7 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => { isProdBuild && (Compress() as PluginOption), command === 'build' && manifestSRI({ - manifestPaths: ['.vite/manifest.json', '.vite/manifest-assets.json'], + manifestPaths: ['.vite/manifest.json'], }), VitePWA({ srcDir: path.resolve(jsRoot, 'mastodon/service_worker'), @@ -211,21 +212,6 @@ async function findEntrypoints() { } } - // Lastly other assets - const assetEntrypoints = await glob('{fonts,icons,images}/**/*', { - cwd: jsRoot, - absolute: true, - }); - const excludeExts = ['', '.md']; - for (const file of assetEntrypoints) { - const ext = path.extname(file); - if (excludeExts.includes(ext)) { - continue; - } - const name = path.basename(file); - entrypoints[name] = path.resolve(jsRoot, file); - } - return entrypoints; } diff --git a/yarn.lock b/yarn.lock index 3575fbedde0..147da72ad12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1932,14 +1932,16 @@ __metadata: languageName: node linkType: hard -"@es-joy/jsdoccomment@npm:~0.49.0": - version: 0.49.0 - resolution: "@es-joy/jsdoccomment@npm:0.49.0" +"@es-joy/jsdoccomment@npm:~0.52.0": + version: 0.52.0 + resolution: "@es-joy/jsdoccomment@npm:0.52.0" dependencies: + "@types/estree": "npm:^1.0.8" + "@typescript-eslint/types": "npm:^8.34.1" comment-parser: "npm:1.4.1" esquery: "npm:^1.6.0" jsdoc-type-pratt-parser: "npm:~4.1.0" - checksum: 10c0/16717507d557d37e7b59456fedeefbe0a3bc93aa2d9c043d5db91e24e076509b6fcb10ee6fd1dafcb0c5bbe50ae329b45de5b83541cb5994a98c9e862a45641e + checksum: 10c0/4def78060ef58859f31757b9d30c4939fc33e7d9ee85637a7f568c1d209c33aa0abd2cf5a3a4f3662ec5b12b85ecff2f2035d809dc93b9382a31a6dfb200d83c languageName: node linkType: hard @@ -2659,7 +2661,7 @@ __metadata: babel-plugin-formatjs: "npm:^10.5.37" babel-plugin-transform-react-remove-prop-types: "npm:^0.4.24" blurhash: "npm:^2.0.5" - chromatic: "npm:^12.1.0" + chromatic: "npm:^13.0.0" classnames: "npm:^2.3.2" cocoon-js-vanilla: "npm:^1.5.1" color-blend: "npm:^4.0.0" @@ -2669,12 +2671,13 @@ __metadata: 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" eslint-plugin-formatjs: "npm:^5.3.1" eslint-plugin-import: "npm:~2.31.0" - eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-jsdoc: "npm:^51.0.0" eslint-plugin-jsx-a11y: "npm:~6.10.2" eslint-plugin-promise: "npm:~7.2.1" eslint-plugin-react: "npm:^7.37.4" @@ -2698,7 +2701,7 @@ __metadata: msw: "npm:^2.10.2" msw-storybook-addon: "npm:^2.0.5" path-complete-extname: "npm:^1.0.0" - playwright: "npm:^1.52.0" + playwright: "npm:^1.54.1" postcss-preset-env: "npm:^10.1.5" prettier: "npm:^3.3.3" prop-types: "npm:^15.8.1" @@ -2706,7 +2709,6 @@ __metadata: react: "npm:^18.2.0" react-dom: "npm:^18.2.0" react-helmet: "npm:^6.1.0" - react-hotkeys: "npm:^1.1.4" react-immutable-proptypes: "npm:^2.2.0" react-immutable-pure-component: "npm:^2.2.2" react-intl: "npm:^7.1.10" @@ -3089,13 +3091,6 @@ __metadata: languageName: node linkType: hard -"@pkgr/core@npm:^0.1.0": - version: 0.1.1 - resolution: "@pkgr/core@npm:0.1.1" - checksum: 10c0/3f7536bc7f57320ab2cf96f8973664bef624710c403357429fbf680a5c3b4843c1dbd389bb43daa6b1f6f1f007bb082f5abcb76bb2b5dc9f421647743b71d3d8 - languageName: node - linkType: hard - "@polka/url@npm:^1.0.0-next.24": version: 1.0.0-next.29 resolution: "@polka/url@npm:1.0.0-next.29" @@ -3986,10 +3981,10 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:*, @types/estree@npm:1.0.7, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6": - version: 1.0.7 - resolution: "@types/estree@npm:1.0.7" - checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c +"@types/estree@npm:*, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 languageName: node linkType: hard @@ -4000,6 +3995,13 @@ __metadata: languageName: node linkType: hard +"@types/estree@npm:1.0.7": + version: 1.0.7 + resolution: "@types/estree@npm:1.0.7" + checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c + languageName: node + linkType: hard + "@types/express-serve-static-core@npm:^4.17.33": version: 4.17.41 resolution: "@types/express-serve-static-core@npm:4.17.41" @@ -4502,13 +4504,20 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.33.0, @typescript-eslint/types@npm:^8.33.0": +"@typescript-eslint/types@npm:8.33.0": version: 8.33.0 resolution: "@typescript-eslint/types@npm:8.33.0" checksum: 10c0/348b64eb408719d7711a433fc9716e0c2aab8b3f3676f5a1cc2e00269044132282cf655deb6d0dd9817544116909513de3b709005352d186949d1014fad1a3cb languageName: node linkType: hard +"@typescript-eslint/types@npm:^8.33.0, @typescript-eslint/types@npm:^8.34.1": + version: 8.36.0 + resolution: "@typescript-eslint/types@npm:8.36.0" + checksum: 10c0/cacb941a0caad6ab556c416051b97ec33b364b7c8e0703e2729ae43f12daf02b42eef12011705329107752e3f1685ca82cfffe181d637f85907293cb634bee31 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:8.29.1": version: 8.29.1 resolution: "@typescript-eslint/typescript-estree@npm:8.29.1" @@ -4997,12 +5006,12 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.14.0, acorn@npm:^8.8.2": - version: 8.14.1 - resolution: "acorn@npm:8.14.1" +"acorn@npm:^8.14.0, acorn@npm:^8.15.0, acorn@npm:^8.8.2": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" bin: acorn: bin/acorn - checksum: 10c0/dbd36c1ed1d2fa3550140000371fcf721578095b18777b85a79df231ca093b08edc6858d75d6e48c73e431c174dcf9214edbd7e6fa5911b93bd8abfa54e47123 + checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec languageName: node linkType: hard @@ -5380,13 +5389,13 @@ __metadata: linkType: hard "axios@npm:^1.4.0": - version: 1.9.0 - resolution: "axios@npm:1.9.0" + version: 1.10.0 + resolution: "axios@npm:1.10.0" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10c0/9371a56886c2e43e4ff5647b5c2c3c046ed0a3d13482ef1d0135b994a628c41fbad459796f101c655e62f0c161d03883454474d2e435b2e021b1924d9f24994c + checksum: 10c0/2239cb269cc789eac22f5d1aabd58e1a83f8f364c92c2caa97b6f5cbb4ab2903d2e557d9dc670b5813e9bcdebfb149e783fb8ab3e45098635cd2f559b06bd5d8 languageName: node linkType: hard @@ -5816,9 +5825,9 @@ __metadata: languageName: node linkType: hard -"chromatic@npm:^12.1.0": - version: 12.1.0 - resolution: "chromatic@npm:12.1.0" +"chromatic@npm:^13.0.0": + version: 13.0.0 + resolution: "chromatic@npm:13.0.0" peerDependencies: "@chromatic-com/cypress": ^0.*.* || ^1.0.0 "@chromatic-com/playwright": ^0.*.* || ^1.0.0 @@ -5831,7 +5840,7 @@ __metadata: chroma: dist/bin.js chromatic: dist/bin.js chromatic-cli: dist/bin.js - checksum: 10c0/4acb70a4a84605f1963a823beed4f3062ec91e373104500f4295af2298b8d0b49f864d06ca81bc9389e44cae3a284332aac07c6cbfc123aa6457f3b52a4c4b78 + checksum: 10c0/30c697eb84d5b3b8cdab989df0e4fed0bf51f4bfefb616873f68fc00337978b9b38b84e52af22861769176181bd98525d467baeb22daa712a0f7a58bd61bf336 languageName: node linkType: hard @@ -6599,6 +6608,13 @@ __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" @@ -6781,7 +6797,7 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.5.3, es-module-lexer@npm:^1.7.0": +"es-module-lexer@npm:^1.7.0": version: 1.7.0 resolution: "es-module-lexer@npm:1.7.0" checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b @@ -7041,24 +7057,23 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jsdoc@npm:^50.6.9": - version: 50.6.9 - resolution: "eslint-plugin-jsdoc@npm:50.6.9" +"eslint-plugin-jsdoc@npm:^51.0.0": + version: 51.3.4 + resolution: "eslint-plugin-jsdoc@npm:51.3.4" dependencies: - "@es-joy/jsdoccomment": "npm:~0.49.0" + "@es-joy/jsdoccomment": "npm:~0.52.0" are-docs-informative: "npm:^0.0.2" comment-parser: "npm:1.4.1" - debug: "npm:^4.3.6" + debug: "npm:^4.4.1" escape-string-regexp: "npm:^4.0.0" - espree: "npm:^10.1.0" + espree: "npm:^10.4.0" esquery: "npm:^1.6.0" - parse-imports: "npm:^2.1.1" - semver: "npm:^7.6.3" + parse-imports-exports: "npm:^0.2.4" + semver: "npm:^7.7.2" spdx-expression-parse: "npm:^4.0.0" - synckit: "npm:^0.9.1" peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - checksum: 10c0/cad199d262c2e889a3af4e402f6adc624e4273b3d5ca1940e7227b37d87af8090ca3444f7fff57f58dab9a827faed8722fc2f5d4daf31ec085eb00e9f5a338a7 + checksum: 10c0/59e5aa972bdd1bd4e2ca2796ed4455dff1069044abc028621e107aa4b0cbb62ce09554c8e7c2ff3a44a1cbd551e54b6970adc420ba3a89adc6236b94310a81ff languageName: node linkType: hard @@ -7164,10 +7179,10 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^4.2.0": - version: 4.2.0 - resolution: "eslint-visitor-keys@npm:4.2.0" - checksum: 10c0/2ed81c663b147ca6f578312919483eb040295bbab759e5a371953456c636c5b49a559883e2677112453728d66293c0a4c90ab11cab3428cf02a0236d2e738269 +"eslint-visitor-keys@npm:^4.2.0, eslint-visitor-keys@npm:^4.2.1": + version: 4.2.1 + resolution: "eslint-visitor-keys@npm:4.2.1" + checksum: 10c0/fcd43999199d6740db26c58dbe0c2594623e31ca307e616ac05153c9272f12f1364f5a0b1917a8e962268fdecc6f3622c1c2908b4fcc2e047a106fe6de69dc43 languageName: node linkType: hard @@ -7221,14 +7236,14 @@ __metadata: languageName: node linkType: hard -"espree@npm:^10.0.1, espree@npm:^10.1.0, espree@npm:^10.3.0": - version: 10.3.0 - resolution: "espree@npm:10.3.0" +"espree@npm:^10.0.1, espree@npm:^10.3.0, espree@npm:^10.4.0": + version: 10.4.0 + resolution: "espree@npm:10.4.0" dependencies: - acorn: "npm:^8.14.0" + acorn: "npm:^8.15.0" acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^4.2.0" - checksum: 10c0/272beeaca70d0a1a047d61baff64db04664a33d7cfb5d144f84bc8a5c6194c6c8ebe9cc594093ca53add88baa23e59b01e69e8a0160ab32eac570482e165c462 + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10c0/c63fe06131c26c8157b4083313cb02a9a54720a08e21543300e55288c40e06c3fc284bdecf108d3a1372c5934a0a88644c98714f38b6ae8ed272b40d9ea08d6b languageName: node linkType: hard @@ -9172,27 +9187,6 @@ __metadata: languageName: node linkType: hard -"lodash.isboolean@npm:^3.0.3": - version: 3.0.3 - resolution: "lodash.isboolean@npm:3.0.3" - checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7 - languageName: node - linkType: hard - -"lodash.isequal@npm:^4.5.0": - version: 4.5.0 - resolution: "lodash.isequal@npm:4.5.0" - checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f - languageName: node - linkType: hard - -"lodash.isobject@npm:^3.0.2": - version: 3.0.2 - resolution: "lodash.isobject@npm:3.0.2" - checksum: 10c0/da4c8480d98b16835b59380b2fbd43c54081acd9466febb788ba77c434384349e0bec162d1c4e89f613f21687b2b6d8384d8a112b80da00c78d28d9915a5cdde - languageName: node - linkType: hard - "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -9603,13 +9597,6 @@ __metadata: languageName: node linkType: hard -"mousetrap@npm:^1.5.2": - version: 1.6.5 - resolution: "mousetrap@npm:1.6.5" - checksum: 10c0/5c361bdbbff3966fd58d70f39b9fe1f8e32c78f3ce65989d83af7aad32a3a95313ce835a8dd8a55cb5de9eeb7c1f0c2b9048631a3073b5606241589e8fc0ba53 - languageName: node - linkType: hard - "mrmime@npm:^2.0.0": version: 2.0.1 resolution: "mrmime@npm:2.0.1" @@ -10032,13 +10019,12 @@ __metadata: languageName: node linkType: hard -"parse-imports@npm:^2.1.1": - version: 2.1.1 - resolution: "parse-imports@npm:2.1.1" +"parse-imports-exports@npm:^0.2.4": + version: 0.2.4 + resolution: "parse-imports-exports@npm:0.2.4" dependencies: - es-module-lexer: "npm:^1.5.3" - slashes: "npm:^3.0.12" - checksum: 10c0/c9bb0b4e1823f84f034d2d7bd2b37415b1715a5c963fda14968c706186b48b02c10e97d04bce042b9dcd679b42f29c391ea120799ddf581c7f54786edd99e3a9 + parse-statements: "npm:1.0.11" + checksum: 10c0/51b729037208abdf65c4a1f8e9ed06f4e7ccd907c17c668a64db54b37d95bb9e92081f8b16e4133e14102af3cb4e89870975b6ad661b4d654e9ec8f4fb5c77d6 languageName: node linkType: hard @@ -10054,6 +10040,13 @@ __metadata: languageName: node linkType: hard +"parse-statements@npm:1.0.11": + version: 1.0.11 + resolution: "parse-statements@npm:1.0.11" + checksum: 10c0/48960e085019068a5f5242e875fd9d21ec87df2e291acf5ad4e4887b40eab6929a8c8d59542acb85a6497e870c5c6a24f5ab7f980ef5f907c14cc5f7984a93f3 + languageName: node + linkType: hard + "parse5@npm:^7.2.1": version: 7.2.1 resolution: "parse5@npm:7.2.1" @@ -10342,27 +10335,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.52.0": - version: 1.52.0 - resolution: "playwright-core@npm:1.52.0" +"playwright-core@npm:1.54.1": + version: 1.54.1 + resolution: "playwright-core@npm:1.54.1" bin: playwright-core: cli.js - checksum: 10c0/640945507e6ca2144e9f596b2a6ecac042c2fd3683ff99e6271e9a7b38f3602d415f282609d569456f66680aab8b3c5bb1b257d8fb63a7fc0ed648261110421f + checksum: 10c0/b821262b024d7753b1bfa71eb2bc99f2dda12a869d175b2e1bc6ac2764bd661baf36d9d42f45caf622854ad7e4a6077b9b57014c74bb5a78fe339c9edf1c9019 languageName: node linkType: hard -"playwright@npm:^1.52.0": - version: 1.52.0 - resolution: "playwright@npm:1.52.0" +"playwright@npm:^1.54.1": + version: 1.54.1 + resolution: "playwright@npm:1.54.1" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.52.0" + playwright-core: "npm:1.54.1" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10c0/2c6edf1e15e59bbaf77f3fa0fe0ac975793c17cff835d9c8b8bc6395a3b6f1c01898b3058ab37891b2e4d424bcc8f1b4844fe70d943e0143d239d7451408c579 + checksum: 10c0/c5fedae31a03a1f4c4846569aef3ffb98da23000a4d255abfc8c2ede15b43cc7cd87b80f6fa078666c030373de8103787cf77ef7653ae9458aabbbd4320c2599 languageName: node linkType: hard @@ -11115,22 +11108,6 @@ __metadata: languageName: node linkType: hard -"react-hotkeys@npm:^1.1.4": - version: 1.1.4 - resolution: "react-hotkeys@npm:1.1.4" - dependencies: - lodash.isboolean: "npm:^3.0.3" - lodash.isequal: "npm:^4.5.0" - lodash.isobject: "npm:^3.0.2" - mousetrap: "npm:^1.5.2" - prop-types: "npm:^15.6.0" - peerDependencies: - react: ">= 0.14.0" - react-dom: ">= 0.14.0" - checksum: 10c0/6bd566ea97e00058749d43d768ee843e5132f988571536e090b564d5dbaa71093695255514fc5b9fcf9fbd03fcb0603f6e135dcab6dcaaffe43dedbfe742a163 - languageName: node - linkType: hard - "react-immutable-proptypes@npm:^2.2.0": version: 2.2.0 resolution: "react-immutable-proptypes@npm:2.2.0" @@ -12076,7 +12053,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1": +"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.7.1, semver@npm:^7.7.2": version: 7.7.2 resolution: "semver@npm:7.7.2" bin: @@ -12281,13 +12258,6 @@ __metadata: languageName: node linkType: hard -"slashes@npm:^3.0.12": - version: 3.0.12 - resolution: "slashes@npm:3.0.12" - checksum: 10c0/71ca2a1fcd1ab6814b0fdb8cf9c33a3d54321deec2aa8d173510f0086880201446021a9b9e6a18561f7c472b69a2145977c6a8fb9c53a8ff7be31778f203d175 - languageName: node - linkType: hard - "slice-ansi@npm:^4.0.0": version: 4.0.0 resolution: "slice-ansi@npm:4.0.0" @@ -12975,16 +12945,6 @@ __metadata: languageName: node linkType: hard -"synckit@npm:^0.9.1": - version: 0.9.1 - resolution: "synckit@npm:0.9.1" - dependencies: - "@pkgr/core": "npm:^0.1.0" - tslib: "npm:^2.6.2" - checksum: 10c0/d8b89e1bf30ba3ffb469d8418c836ad9c0c062bf47028406b4d06548bc66af97155ea2303b96c93bf5c7c0f0d66153a6fbd6924c76521b434e6a9898982abc2e - languageName: node - linkType: hard - "systemjs@npm:^6.15.1": version: 6.15.1 resolution: "systemjs@npm:6.15.1" @@ -13325,7 +13285,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.4.0, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62