Merge remote-tracking branch 'parent/main' into upstream-20250403
This commit is contained in:
commit
32f5604499
265 changed files with 6227 additions and 3383 deletions
|
@ -1,13 +0,0 @@
|
||||||
/build/**
|
|
||||||
/coverage/**
|
|
||||||
/db/**
|
|
||||||
/lib/**
|
|
||||||
/log/**
|
|
||||||
/node_modules/**
|
|
||||||
/nonobox/**
|
|
||||||
/public/**
|
|
||||||
!/public/embed.js
|
|
||||||
/spec/**
|
|
||||||
/tmp/**
|
|
||||||
/vendor/**
|
|
||||||
!.eslintrc.js
|
|
367
.eslintrc.js
367
.eslintrc.js
|
@ -1,367 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
const { defineConfig } = require('eslint-define-config');
|
|
||||||
|
|
||||||
module.exports = defineConfig({
|
|
||||||
root: true,
|
|
||||||
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:react/recommended',
|
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
'plugin:jsx-a11y/recommended',
|
|
||||||
'plugin:import/recommended',
|
|
||||||
'plugin:promise/recommended',
|
|
||||||
'plugin:jsdoc/recommended',
|
|
||||||
],
|
|
||||||
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
node: true,
|
|
||||||
es6: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
|
|
||||||
plugins: [
|
|
||||||
'react',
|
|
||||||
'jsx-a11y',
|
|
||||||
'import',
|
|
||||||
'promise',
|
|
||||||
'@typescript-eslint',
|
|
||||||
'formatjs',
|
|
||||||
],
|
|
||||||
|
|
||||||
parserOptions: {
|
|
||||||
sourceType: 'module',
|
|
||||||
ecmaFeatures: {
|
|
||||||
jsx: true,
|
|
||||||
},
|
|
||||||
ecmaVersion: 2021,
|
|
||||||
requireConfigFile: false,
|
|
||||||
babelOptions: {
|
|
||||||
configFile: false,
|
|
||||||
presets: ['@babel/react', '@babel/env'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: 'detect',
|
|
||||||
},
|
|
||||||
'import/ignore': [
|
|
||||||
'node_modules',
|
|
||||||
'\\.(css|scss|json)$',
|
|
||||||
],
|
|
||||||
'import/resolver': {
|
|
||||||
typescript: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
rules: {
|
|
||||||
'consistent-return': 'error',
|
|
||||||
'dot-notation': 'error',
|
|
||||||
eqeqeq: ['error', 'always', { 'null': 'ignore' }],
|
|
||||||
'indent': ['error', 2],
|
|
||||||
'jsx-quotes': ['error', 'prefer-single'],
|
|
||||||
'semi': ['error', 'always'],
|
|
||||||
'no-catch-shadow': 'error',
|
|
||||||
'no-console': [
|
|
||||||
'warn',
|
|
||||||
{
|
|
||||||
allow: [
|
|
||||||
'error',
|
|
||||||
'warn',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'no-empty': ['error', { "allowEmptyCatch": true }],
|
|
||||||
'no-restricted-properties': [
|
|
||||||
'error',
|
|
||||||
{ property: 'substring', message: 'Use .slice instead of .substring.' },
|
|
||||||
{ property: 'substr', message: 'Use .slice instead of .substr.' },
|
|
||||||
],
|
|
||||||
'no-restricted-syntax': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
selector: 'Literal[value=/•/], JSXText[value=/•/]',
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
message: "Use '·' (middle dot) instead of '•' (bullet)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'no-unused-expressions': 'error',
|
|
||||||
'no-unused-vars': 'off',
|
|
||||||
'@typescript-eslint/no-unused-vars': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
vars: 'all',
|
|
||||||
args: 'after-used',
|
|
||||||
destructuredArrayIgnorePattern: '^_',
|
|
||||||
ignoreRestSiblings: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'valid-typeof': 'error',
|
|
||||||
|
|
||||||
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', 'tsx'] }],
|
|
||||||
'react/jsx-boolean-value': 'error',
|
|
||||||
'react/display-name': 'off',
|
|
||||||
'react/jsx-fragments': ['error', 'syntax'],
|
|
||||||
'react/jsx-equals-spacing': 'error',
|
|
||||||
'react/jsx-no-bind': 'error',
|
|
||||||
'react/jsx-no-useless-fragment': 'error',
|
|
||||||
'react/jsx-no-target-blank': ['error', { allowReferrer: true }],
|
|
||||||
'react/jsx-tag-spacing': 'error',
|
|
||||||
'react/jsx-uses-react': 'off', // not needed with new JSX transform
|
|
||||||
'react/jsx-wrap-multilines': 'error',
|
|
||||||
'react/react-in-jsx-scope': 'off', // not needed with new JSX transform
|
|
||||||
'react/self-closing-comp': 'error',
|
|
||||||
|
|
||||||
// recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46
|
|
||||||
'jsx-a11y/click-events-have-key-events': 'off',
|
|
||||||
'jsx-a11y/label-has-associated-control': 'off',
|
|
||||||
'jsx-a11y/media-has-caption': 'off',
|
|
||||||
'jsx-a11y/no-autofocus': 'off',
|
|
||||||
// recommended rule is:
|
|
||||||
// 'jsx-a11y/no-interactive-element-to-noninteractive-role': [
|
|
||||||
// 'error',
|
|
||||||
// {
|
|
||||||
// tr: ['none', 'presentation'],
|
|
||||||
// canvas: ['img'],
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
'jsx-a11y/no-interactive-element-to-noninteractive-role': 'off',
|
|
||||||
// recommended rule is:
|
|
||||||
// 'jsx-a11y/no-noninteractive-tabindex': [
|
|
||||||
// 'error',
|
|
||||||
// {
|
|
||||||
// tags: [],
|
|
||||||
// roles: ['tabpanel'],
|
|
||||||
// allowExpressionValues: true,
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
'jsx-a11y/no-noninteractive-tabindex': 'off',
|
|
||||||
// recommended is full 'error'
|
|
||||||
'jsx-a11y/no-static-element-interactions': [
|
|
||||||
'warn',
|
|
||||||
{
|
|
||||||
handlers: [
|
|
||||||
'onClick',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// See https://github.com/import-js/eslint-plugin-import/blob/v2.29.1/config/recommended.js
|
|
||||||
'import/extensions': [
|
|
||||||
'error',
|
|
||||||
'always',
|
|
||||||
{
|
|
||||||
js: 'never',
|
|
||||||
jsx: 'never',
|
|
||||||
mjs: 'never',
|
|
||||||
ts: 'never',
|
|
||||||
tsx: 'never',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'import/first': 'error',
|
|
||||||
'import/newline-after-import': 'error',
|
|
||||||
'import/no-anonymous-default-export': 'error',
|
|
||||||
'import/no-extraneous-dependencies': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
devDependencies: [
|
|
||||||
'.eslintrc.js',
|
|
||||||
'config/webpack/**',
|
|
||||||
'app/javascript/mastodon/performance.js',
|
|
||||||
'app/javascript/mastodon/test_setup.js',
|
|
||||||
'app/javascript/**/__tests__/**',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'import/no-amd': 'error',
|
|
||||||
'import/no-commonjs': 'error',
|
|
||||||
'import/no-import-module-exports': 'error',
|
|
||||||
'import/no-relative-packages': 'error',
|
|
||||||
'import/no-self-import': 'error',
|
|
||||||
'import/no-useless-path-segments': 'error',
|
|
||||||
'import/no-webpack-loader-syntax': 'error',
|
|
||||||
|
|
||||||
'import/order': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
alphabetize: { order: 'asc' },
|
|
||||||
'newlines-between': 'always',
|
|
||||||
groups: [
|
|
||||||
'builtin',
|
|
||||||
'external',
|
|
||||||
'internal',
|
|
||||||
'parent',
|
|
||||||
['index', 'sibling'],
|
|
||||||
'object',
|
|
||||||
],
|
|
||||||
pathGroups: [
|
|
||||||
// React core packages
|
|
||||||
{
|
|
||||||
pattern: '{react,react-dom,react-dom/client,prop-types}',
|
|
||||||
group: 'builtin',
|
|
||||||
position: 'after',
|
|
||||||
},
|
|
||||||
// I18n
|
|
||||||
{
|
|
||||||
pattern: '{react-intl,intl-messageformat}',
|
|
||||||
group: 'builtin',
|
|
||||||
position: 'after',
|
|
||||||
},
|
|
||||||
// Common React utilities
|
|
||||||
{
|
|
||||||
pattern: '{classnames,react-helmet,react-router,react-router-dom}',
|
|
||||||
group: 'external',
|
|
||||||
position: 'before',
|
|
||||||
},
|
|
||||||
// Immutable / Redux / data store
|
|
||||||
{
|
|
||||||
pattern: '{immutable,@reduxjs/toolkit,react-redux,react-immutable-proptypes,react-immutable-pure-component}',
|
|
||||||
group: 'external',
|
|
||||||
position: 'before',
|
|
||||||
},
|
|
||||||
// Internal packages
|
|
||||||
{
|
|
||||||
pattern: '{mastodon/**}',
|
|
||||||
group: 'internal',
|
|
||||||
position: 'after',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pathGroupsExcludedImportTypes: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
'promise/always-return': 'off',
|
|
||||||
'promise/catch-or-return': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
allowFinally: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'promise/no-callback-in-promise': 'off',
|
|
||||||
'promise/no-nesting': 'off',
|
|
||||||
'promise/no-promise-in-callback': 'off',
|
|
||||||
|
|
||||||
'formatjs/blocklist-elements': 'error',
|
|
||||||
'formatjs/enforce-default-message': ['error', 'literal'],
|
|
||||||
'formatjs/enforce-description': 'off', // description values not currently used
|
|
||||||
'formatjs/enforce-id': 'off', // Explicit IDs are used in the project
|
|
||||||
'formatjs/enforce-placeholders': 'off', // Issues in short_number.jsx
|
|
||||||
'formatjs/enforce-plural-rules': 'error',
|
|
||||||
'formatjs/no-camel-case': 'off', // disabledAccount is only non-conforming
|
|
||||||
'formatjs/no-complex-selectors': 'error',
|
|
||||||
'formatjs/no-emoji': 'error',
|
|
||||||
'formatjs/no-id': 'off', // IDs are used for translation keys
|
|
||||||
'formatjs/no-invalid-icu': 'error',
|
|
||||||
'formatjs/no-literal-string-in-jsx': 'off', // Should be looked at, but mainly flagging punctuation outside of strings
|
|
||||||
'formatjs/no-multiple-whitespaces': 'error',
|
|
||||||
'formatjs/no-offset': 'error',
|
|
||||||
'formatjs/no-useless-message': 'error',
|
|
||||||
'formatjs/prefer-formatted-message': 'error',
|
|
||||||
'formatjs/prefer-pound-in-plural': 'error',
|
|
||||||
|
|
||||||
'jsdoc/check-types': 'off',
|
|
||||||
'jsdoc/no-undefined-types': 'off',
|
|
||||||
'jsdoc/require-jsdoc': 'off',
|
|
||||||
'jsdoc/require-param-description': 'off',
|
|
||||||
'jsdoc/require-property-description': 'off',
|
|
||||||
'jsdoc/require-returns-description': 'off',
|
|
||||||
'jsdoc/require-returns': 'off',
|
|
||||||
},
|
|
||||||
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: [
|
|
||||||
'.eslintrc.js',
|
|
||||||
'*.config.js',
|
|
||||||
'.*rc.js',
|
|
||||||
'ide-helper.js',
|
|
||||||
'config/webpack/**/*',
|
|
||||||
'config/formatjs-formatter.js',
|
|
||||||
],
|
|
||||||
|
|
||||||
env: {
|
|
||||||
commonjs: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
parserOptions: {
|
|
||||||
sourceType: 'script',
|
|
||||||
},
|
|
||||||
|
|
||||||
rules: {
|
|
||||||
'import/no-commonjs': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: [
|
|
||||||
'**/*.ts',
|
|
||||||
'**/*.tsx',
|
|
||||||
],
|
|
||||||
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/strict-type-checked',
|
|
||||||
'plugin:@typescript-eslint/stylistic-type-checked',
|
|
||||||
'plugin:react/recommended',
|
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
'plugin:jsx-a11y/recommended',
|
|
||||||
'plugin:import/recommended',
|
|
||||||
'plugin:import/typescript',
|
|
||||||
'plugin:promise/recommended',
|
|
||||||
'plugin:jsdoc/recommended-typescript',
|
|
||||||
],
|
|
||||||
|
|
||||||
parserOptions: {
|
|
||||||
projectService: true,
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
},
|
|
||||||
|
|
||||||
rules: {
|
|
||||||
// Disable formatting rules that have been enabled in the base config
|
|
||||||
'indent': 'off',
|
|
||||||
|
|
||||||
// This is not needed as we use noImplicitReturns, which handles this in addition to understanding types
|
|
||||||
'consistent-return': 'off',
|
|
||||||
|
|
||||||
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
|
|
||||||
|
|
||||||
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
|
|
||||||
'@typescript-eslint/consistent-type-exports': 'error',
|
|
||||||
'@typescript-eslint/consistent-type-imports': 'error',
|
|
||||||
"@typescript-eslint/prefer-nullish-coalescing": ['error', { ignorePrimitives: { boolean: true } }],
|
|
||||||
"@typescript-eslint/no-restricted-imports": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
"name": "react-redux",
|
|
||||||
"importNames": ["useSelector", "useDispatch"],
|
|
||||||
"message": "Use typed hooks `useAppDispatch` and `useAppSelector` instead."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@typescript-eslint/restrict-template-expressions": ['warn', { allowNumber: true }],
|
|
||||||
'jsdoc/require-jsdoc': 'off',
|
|
||||||
|
|
||||||
// Those rules set stricter rules for TS files
|
|
||||||
// to enforce better practices when converting from JS
|
|
||||||
'import/no-default-export': 'warn',
|
|
||||||
'react/prefer-stateless-function': 'warn',
|
|
||||||
'react/function-component-definition': ['error', { namedComponents: 'arrow-function' }],
|
|
||||||
'react/jsx-uses-react': 'off', // not needed with new JSX transform
|
|
||||||
'react/react-in-jsx-scope': 'off', // not needed with new JSX transform
|
|
||||||
'react/prop-types': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: [
|
|
||||||
'**/__tests__/*.js',
|
|
||||||
'**/__tests__/*.jsx',
|
|
||||||
],
|
|
||||||
|
|
||||||
env: {
|
|
||||||
jest: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
});
|
|
10
.github/renovate.json5
vendored
10
.github/renovate.json5
vendored
|
@ -15,6 +15,8 @@
|
||||||
// to `null` after any other rule set it to something.
|
// to `null` after any other rule set it to something.
|
||||||
dependencyDashboardHeader: 'This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. Before approving any upgrade: read the description and comments in the [`renovate.json5` file](https://github.com/mastodon/mastodon/blob/main/.github/renovate.json5).',
|
dependencyDashboardHeader: 'This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. Before approving any upgrade: read the description and comments in the [`renovate.json5` file](https://github.com/mastodon/mastodon/blob/main/.github/renovate.json5).',
|
||||||
postUpdateOptions: ['yarnDedupeHighest'],
|
postUpdateOptions: ['yarnDedupeHighest'],
|
||||||
|
// The types are now included in recent versions,we ignore them here until we upgrade and remove the dependency
|
||||||
|
ignoreDeps: ['@types/emoji-mart'],
|
||||||
packageRules: [
|
packageRules: [
|
||||||
{
|
{
|
||||||
// Require Dependency Dashboard Approval for major version bumps of these node packages
|
// Require Dependency Dashboard Approval for major version bumps of these node packages
|
||||||
|
@ -97,7 +99,13 @@
|
||||||
{
|
{
|
||||||
// Group all eslint-related packages with `eslint` in the same PR
|
// Group all eslint-related packages with `eslint` in the same PR
|
||||||
matchManagers: ['npm'],
|
matchManagers: ['npm'],
|
||||||
matchPackageNames: ['eslint', 'eslint-*', '@typescript-eslint/*'],
|
matchPackageNames: [
|
||||||
|
'eslint',
|
||||||
|
'eslint-*',
|
||||||
|
'typescript-eslint',
|
||||||
|
'@eslint/*',
|
||||||
|
'globals',
|
||||||
|
],
|
||||||
matchUpdateTypes: ['patch', 'minor'],
|
matchUpdateTypes: ['patch', 'minor'],
|
||||||
groupName: 'eslint (non-major)',
|
groupName: 'eslint (non-major)',
|
||||||
},
|
},
|
||||||
|
|
6
.github/workflows/lint-js.yml
vendored
6
.github/workflows/lint-js.yml
vendored
|
@ -14,7 +14,7 @@ on:
|
||||||
- 'tsconfig.json'
|
- 'tsconfig.json'
|
||||||
- '.nvmrc'
|
- '.nvmrc'
|
||||||
- '.prettier*'
|
- '.prettier*'
|
||||||
- '.eslint*'
|
- 'eslint.config.mjs'
|
||||||
- '**/*.js'
|
- '**/*.js'
|
||||||
- '**/*.jsx'
|
- '**/*.jsx'
|
||||||
- '**/*.ts'
|
- '**/*.ts'
|
||||||
|
@ -28,7 +28,7 @@ on:
|
||||||
- 'tsconfig.json'
|
- 'tsconfig.json'
|
||||||
- '.nvmrc'
|
- '.nvmrc'
|
||||||
- '.prettier*'
|
- '.prettier*'
|
||||||
- '.eslint*'
|
- 'eslint.config.mjs'
|
||||||
- '**/*.js'
|
- '**/*.js'
|
||||||
- '**/*.jsx'
|
- '**/*.jsx'
|
||||||
- '**/*.ts'
|
- '**/*.ts'
|
||||||
|
@ -47,7 +47,7 @@ jobs:
|
||||||
uses: ./.github/actions/setup-javascript
|
uses: ./.github/actions/setup-javascript
|
||||||
|
|
||||||
- name: ESLint
|
- name: ESLint
|
||||||
run: yarn lint:js --max-warnings 0
|
run: yarn workspaces foreach --all --parallel run lint:js --max-warnings 0
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: yarn typecheck
|
run: yarn typecheck
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
singleQuote: true,
|
singleQuote: true,
|
||||||
jsxSingleQuote: true
|
jsxSingleQuote: true
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# This configuration was generated by
|
# This configuration was generated by
|
||||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||||
# using RuboCop version 1.75.0.
|
# using RuboCop version 1.75.1.
|
||||||
# The point is for the user to remove these configuration records
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the offenses are removed from the code base.
|
# one by one as the offenses are removed from the code base.
|
||||||
# Note that changes in the inspected code, or installation of new
|
# Note that changes in the inspected code, or installation of new
|
||||||
|
@ -68,11 +68,6 @@ Style/HashTransformValues:
|
||||||
- 'app/serializers/rest/web_push_subscription_serializer.rb'
|
- 'app/serializers/rest/web_push_subscription_serializer.rb'
|
||||||
- 'app/services/import_service.rb'
|
- 'app/services/import_service.rb'
|
||||||
|
|
||||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
|
||||||
Style/MapToHash:
|
|
||||||
Exclude:
|
|
||||||
- 'app/models/status.rb'
|
|
||||||
|
|
||||||
# Configuration parameters: AllowedMethods.
|
# Configuration parameters: AllowedMethods.
|
||||||
# AllowedMethods: respond_to_missing?
|
# AllowedMethods: respond_to_missing?
|
||||||
Style/OptionalBooleanParameter:
|
Style/OptionalBooleanParameter:
|
||||||
|
|
1
Gemfile
1
Gemfile
|
@ -62,6 +62,7 @@ gem 'inline_svg'
|
||||||
gem 'irb', '~> 1.8'
|
gem 'irb', '~> 1.8'
|
||||||
gem 'kaminari', '~> 1.2'
|
gem 'kaminari', '~> 1.2'
|
||||||
gem 'link_header', '~> 0.0'
|
gem 'link_header', '~> 0.0'
|
||||||
|
gem 'linzer', '~> 0.6.1'
|
||||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||||
gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar'
|
gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar'
|
||||||
gem 'mutex_m'
|
gem 'mutex_m'
|
||||||
|
|
27
Gemfile.lock
27
Gemfile.lock
|
@ -395,10 +395,16 @@ GEM
|
||||||
rexml
|
rexml
|
||||||
link_header (0.0.8)
|
link_header (0.0.8)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
|
linzer (0.6.3)
|
||||||
|
openssl (~> 3.0, >= 3.0.0)
|
||||||
|
rack (>= 2.2, < 4.0)
|
||||||
|
starry (~> 0.2)
|
||||||
|
stringio (~> 3.1, >= 3.1.2)
|
||||||
|
uri (~> 1.0, >= 1.0.2)
|
||||||
llhttp-ffi (0.5.1)
|
llhttp-ffi (0.5.1)
|
||||||
ffi-compiler (~> 1.0)
|
ffi-compiler (~> 1.0)
|
||||||
rake (~> 13.0)
|
rake (~> 13.0)
|
||||||
logger (1.6.6)
|
logger (1.7.0)
|
||||||
lograge (0.14.0)
|
lograge (0.14.0)
|
||||||
actionpack (>= 4)
|
actionpack (>= 4)
|
||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
|
@ -440,7 +446,7 @@ GEM
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.4)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.18.6)
|
nokogiri (1.18.7)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oj (3.16.10)
|
oj (3.16.10)
|
||||||
|
@ -563,7 +569,7 @@ GEM
|
||||||
opentelemetry-instrumentation-redis (0.26.1)
|
opentelemetry-instrumentation-redis (0.26.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-sidekiq (0.26.0)
|
opentelemetry-instrumentation-sidekiq (0.26.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-registry (0.4.0)
|
opentelemetry-registry (0.4.0)
|
||||||
|
@ -580,7 +586,7 @@ GEM
|
||||||
ox (2.14.22)
|
ox (2.14.22)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
parallel (1.26.3)
|
parallel (1.26.3)
|
||||||
parser (3.3.7.3)
|
parser (3.3.7.4)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
parslet (2.0.0)
|
parslet (2.0.0)
|
||||||
|
@ -754,15 +760,15 @@ GEM
|
||||||
rubocop-i18n (3.2.3)
|
rubocop-i18n (3.2.3)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.72.1)
|
rubocop (>= 1.72.1)
|
||||||
rubocop-performance (1.24.0)
|
rubocop-performance (1.25.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.72.1, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.38.0, < 2.0)
|
rubocop-ast (>= 1.38.0, < 2.0)
|
||||||
rubocop-rails (2.30.3)
|
rubocop-rails (2.31.0)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.72.1, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.38.0, < 2.0)
|
rubocop-ast (>= 1.38.0, < 2.0)
|
||||||
rubocop-rspec (3.5.0)
|
rubocop-rspec (3.5.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
|
@ -829,9 +835,11 @@ GEM
|
||||||
simplecov-lcov (0.8.0)
|
simplecov-lcov (0.8.0)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
|
starry (0.2.0)
|
||||||
|
base64
|
||||||
stoplight (4.1.1)
|
stoplight (4.1.1)
|
||||||
redlock (~> 1.0)
|
redlock (~> 1.0)
|
||||||
stringio (3.1.5)
|
stringio (3.1.6)
|
||||||
strong_migrations (2.2.1)
|
strong_migrations (2.2.1)
|
||||||
activerecord (>= 7)
|
activerecord (>= 7)
|
||||||
swd (2.0.3)
|
swd (2.0.3)
|
||||||
|
@ -980,6 +988,7 @@ DEPENDENCIES
|
||||||
letter_opener (~> 1.8)
|
letter_opener (~> 1.8)
|
||||||
letter_opener_web (~> 3.0)
|
letter_opener_web (~> 3.0)
|
||||||
link_header (~> 0.0)
|
link_header (~> 0.0)
|
||||||
|
linzer (~> 0.6.1)
|
||||||
lograge (~> 0.12)
|
lograge (~> 0.12)
|
||||||
mail (~> 2.8)
|
mail (~> 2.8)
|
||||||
mario-redis-lock (~> 1.2)
|
mario-redis-lock (~> 1.2)
|
||||||
|
|
20
app/controllers/admin/fasp/debug/callbacks_controller.rb
Normal file
20
app/controllers/admin/fasp/debug/callbacks_controller.rb
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Fasp::Debug::CallbacksController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
authorize [:admin, :fasp, :provider], :update?
|
||||||
|
|
||||||
|
@callbacks = Fasp::DebugCallback
|
||||||
|
.includes(:fasp_provider)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
authorize [:admin, :fasp, :provider], :update?
|
||||||
|
|
||||||
|
callback = Fasp::DebugCallback.find(params[:id])
|
||||||
|
callback.destroy
|
||||||
|
|
||||||
|
redirect_to admin_fasp_debug_callbacks_path
|
||||||
|
end
|
||||||
|
end
|
19
app/controllers/admin/fasp/debug_calls_controller.rb
Normal file
19
app/controllers/admin/fasp/debug_calls_controller.rb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Fasp::DebugCallsController < Admin::BaseController
|
||||||
|
before_action :set_provider
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize [:admin, @provider], :update?
|
||||||
|
|
||||||
|
@provider.perform_debug_call
|
||||||
|
|
||||||
|
redirect_to admin_fasp_providers_path
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_provider
|
||||||
|
@provider = Fasp::Provider.find(params[:provider_id])
|
||||||
|
end
|
||||||
|
end
|
47
app/controllers/admin/fasp/providers_controller.rb
Normal file
47
app/controllers/admin/fasp/providers_controller.rb
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Fasp::ProvidersController < Admin::BaseController
|
||||||
|
before_action :set_provider, only: [:show, :edit, :update, :destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
authorize [:admin, :fasp, :provider], :index?
|
||||||
|
|
||||||
|
@providers = Fasp::Provider.order(confirmed: :asc, created_at: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
authorize [:admin, @provider], :show?
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
authorize [:admin, @provider], :update?
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize [:admin, @provider], :update?
|
||||||
|
|
||||||
|
if @provider.update(provider_params)
|
||||||
|
redirect_to admin_fasp_providers_path
|
||||||
|
else
|
||||||
|
render :edit
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
authorize [:admin, @provider], :destroy?
|
||||||
|
|
||||||
|
@provider.destroy
|
||||||
|
|
||||||
|
redirect_to admin_fasp_providers_path
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def provider_params
|
||||||
|
params.expect(fasp_provider: [capabilities_attributes: {}])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_provider
|
||||||
|
@provider = Fasp::Provider.find(params[:id])
|
||||||
|
end
|
||||||
|
end
|
23
app/controllers/admin/fasp/registrations_controller.rb
Normal file
23
app/controllers/admin/fasp/registrations_controller.rb
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Fasp::RegistrationsController < Admin::BaseController
|
||||||
|
before_action :set_provider
|
||||||
|
|
||||||
|
def new
|
||||||
|
authorize [:admin, @provider], :create?
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize [:admin, @provider], :create?
|
||||||
|
|
||||||
|
@provider.update_info!(confirm: true)
|
||||||
|
|
||||||
|
redirect_to edit_admin_fasp_provider_path(@provider)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_provider
|
||||||
|
@provider = Fasp::Provider.find(params[:provider_id])
|
||||||
|
end
|
||||||
|
end
|
81
app/controllers/api/fasp/base_controller.rb
Normal file
81
app/controllers/api/fasp/base_controller.rb
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::Fasp::BaseController < ApplicationController
|
||||||
|
class Error < ::StandardError; end
|
||||||
|
|
||||||
|
DIGEST_PATTERN = /sha-256=:(.*?):/
|
||||||
|
KEYID_PATTERN = /keyid="(.*?)"/
|
||||||
|
|
||||||
|
attr_reader :current_provider
|
||||||
|
|
||||||
|
skip_forgery_protection
|
||||||
|
|
||||||
|
before_action :check_fasp_enabled
|
||||||
|
before_action :require_authentication
|
||||||
|
after_action :sign_response
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def require_authentication
|
||||||
|
validate_content_digest!
|
||||||
|
validate_signature!
|
||||||
|
rescue Error, Linzer::Error, ActiveRecord::RecordNotFound => e
|
||||||
|
logger.debug("FASP Authentication error: #{e}")
|
||||||
|
authentication_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def authentication_error
|
||||||
|
respond_to do |format|
|
||||||
|
format.json { head 401 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_content_digest!
|
||||||
|
content_digest_header = request.headers['content-digest']
|
||||||
|
raise Error, 'content-digest missing' if content_digest_header.blank?
|
||||||
|
|
||||||
|
digest_received = content_digest_header.match(DIGEST_PATTERN)[1]
|
||||||
|
|
||||||
|
digest_computed = OpenSSL::Digest.base64digest('sha256', request.body&.string || '')
|
||||||
|
|
||||||
|
raise Error, 'content-digest does not match' if digest_received != digest_computed
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_signature!
|
||||||
|
signature_input = request.headers['signature-input']&.encode('UTF-8')
|
||||||
|
raise Error, 'signature-input is missing' if signature_input.blank?
|
||||||
|
|
||||||
|
keyid = signature_input.match(KEYID_PATTERN)[1]
|
||||||
|
provider = Fasp::Provider.find(keyid)
|
||||||
|
linzer_request = Linzer.new_request(
|
||||||
|
request.method,
|
||||||
|
request.original_url,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
'content-digest' => request.headers['content-digest'],
|
||||||
|
'signature-input' => signature_input,
|
||||||
|
'signature' => request.headers['signature'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
message = Linzer::Message.new(linzer_request)
|
||||||
|
key = Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid)
|
||||||
|
signature = Linzer::Signature.build(message.headers)
|
||||||
|
Linzer.verify(key, message, signature)
|
||||||
|
@current_provider = provider
|
||||||
|
end
|
||||||
|
|
||||||
|
def sign_response
|
||||||
|
response.headers['content-digest'] = "sha-256=:#{OpenSSL::Digest.base64digest('sha256', response.body || '')}:"
|
||||||
|
|
||||||
|
linzer_response = Linzer.new_response(response.body, response.status, { 'content-digest' => response.headers['content-digest'] })
|
||||||
|
message = Linzer::Message.new(linzer_response)
|
||||||
|
key = Linzer.new_ed25519_key(current_provider.server_private_key_pem)
|
||||||
|
signature = Linzer.sign(key, message, %w(@status content-digest))
|
||||||
|
|
||||||
|
response.headers.merge!(signature.to_h)
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_fasp_enabled
|
||||||
|
raise ActionController::RoutingError unless Mastodon::Feature.fasp_enabled?
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,15 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::Fasp::Debug::V0::Callback::ResponsesController < Api::Fasp::BaseController
|
||||||
|
def create
|
||||||
|
Fasp::DebugCallback.create(
|
||||||
|
fasp_provider: current_provider,
|
||||||
|
ip: request.remote_ip,
|
||||||
|
request_body: request.raw_post
|
||||||
|
)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.json { head 201 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
26
app/controllers/api/fasp/registrations_controller.rb
Normal file
26
app/controllers/api/fasp/registrations_controller.rb
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::Fasp::RegistrationsController < Api::Fasp::BaseController
|
||||||
|
skip_before_action :require_authentication
|
||||||
|
|
||||||
|
def create
|
||||||
|
@current_provider = Fasp::Provider.create!(
|
||||||
|
name: params[:name],
|
||||||
|
base_url: params[:baseUrl],
|
||||||
|
remote_identifier: params[:serverId],
|
||||||
|
provider_public_key_base64: params[:publicKey]
|
||||||
|
)
|
||||||
|
|
||||||
|
render json: registration_confirmation
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def registration_confirmation
|
||||||
|
{
|
||||||
|
faspId: current_provider.id.to_s,
|
||||||
|
publicKey: current_provider.server_public_key_base64,
|
||||||
|
registrationCompletionUri: new_admin_fasp_provider_registration_url(current_provider),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
|
@ -10,8 +10,6 @@ module SignatureVerification
|
||||||
EXPIRATION_WINDOW_LIMIT = 12.hours
|
EXPIRATION_WINDOW_LIMIT = 12.hours
|
||||||
CLOCK_SKEW_MARGIN = 1.hour
|
CLOCK_SKEW_MARGIN = 1.hour
|
||||||
|
|
||||||
class SignatureVerificationError < StandardError; end
|
|
||||||
|
|
||||||
def require_account_signature!
|
def require_account_signature!
|
||||||
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
||||||
end
|
end
|
||||||
|
@ -34,7 +32,7 @@ module SignatureVerification
|
||||||
|
|
||||||
def signature_key_id
|
def signature_key_id
|
||||||
signature_params['keyId']
|
signature_params['keyId']
|
||||||
rescue SignatureVerificationError
|
rescue Mastodon::SignatureVerificationError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -45,17 +43,17 @@ module SignatureVerification
|
||||||
def signed_request_actor
|
def signed_request_actor
|
||||||
return @signed_request_actor if defined?(@signed_request_actor)
|
return @signed_request_actor if defined?(@signed_request_actor)
|
||||||
|
|
||||||
raise SignatureVerificationError, 'Request not signed' unless signed_request?
|
raise Mastodon::SignatureVerificationError, 'Request not signed' unless signed_request?
|
||||||
raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
|
raise Mastodon::SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
|
||||||
raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
|
raise Mastodon::SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
|
||||||
raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
|
raise Mastodon::SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
|
||||||
|
|
||||||
verify_signature_strength!
|
verify_signature_strength!
|
||||||
verify_body_digest!
|
verify_body_digest!
|
||||||
|
|
||||||
actor = actor_from_key_id(signature_params['keyId'])
|
actor = actor_from_key_id(signature_params['keyId'])
|
||||||
|
|
||||||
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
|
||||||
|
|
||||||
signature = Base64.decode64(signature_params['signature'])
|
signature = Base64.decode64(signature_params['signature'])
|
||||||
compare_signed_string = build_signed_string(include_query_string: true)
|
compare_signed_string = build_signed_string(include_query_string: true)
|
||||||
|
@ -68,7 +66,7 @@ module SignatureVerification
|
||||||
|
|
||||||
actor = stoplight_wrapper.run { actor_refresh_key!(actor) }
|
actor = stoplight_wrapper.run { actor_refresh_key!(actor) }
|
||||||
|
|
||||||
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
|
||||||
|
|
||||||
compare_signed_string = build_signed_string(include_query_string: true)
|
compare_signed_string = build_signed_string(include_query_string: true)
|
||||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||||
|
@ -78,7 +76,7 @@ module SignatureVerification
|
||||||
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
|
||||||
|
|
||||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
|
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
|
||||||
rescue SignatureVerificationError => e
|
rescue Mastodon::SignatureVerificationError => e
|
||||||
fail_with! e.message
|
fail_with! e.message
|
||||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||||
fail_with! "Failed to fetch remote data: #{e.message}"
|
fail_with! "Failed to fetch remote data: #{e.message}"
|
||||||
|
@ -104,7 +102,7 @@ module SignatureVerification
|
||||||
def signature_params
|
def signature_params
|
||||||
@signature_params ||= SignatureParser.parse(request.headers['Signature'])
|
@signature_params ||= SignatureParser.parse(request.headers['Signature'])
|
||||||
rescue SignatureParser::ParsingError
|
rescue SignatureParser::ParsingError
|
||||||
raise SignatureVerificationError, 'Error parsing signature parameters'
|
raise Mastodon::SignatureVerificationError, 'Error parsing signature parameters'
|
||||||
end
|
end
|
||||||
|
|
||||||
def signature_algorithm
|
def signature_algorithm
|
||||||
|
@ -116,31 +114,31 @@ module SignatureVerification
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_signature_strength!
|
def verify_signature_strength!
|
||||||
raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
|
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
|
||||||
raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest')
|
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest')
|
||||||
raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host')
|
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host')
|
||||||
raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
|
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_body_digest!
|
def verify_body_digest!
|
||||||
return unless signed_headers.include?('digest')
|
return unless signed_headers.include?('digest')
|
||||||
raise SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest')
|
raise Mastodon::SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest')
|
||||||
|
|
||||||
digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] }
|
digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] }
|
||||||
sha256 = digests.assoc('sha-256')
|
sha256 = digests.assoc('sha-256')
|
||||||
raise SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil?
|
raise Mastodon::SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil?
|
||||||
|
|
||||||
return if body_digest == sha256[1]
|
return if body_digest == sha256[1]
|
||||||
|
|
||||||
digest_size = begin
|
digest_size = begin
|
||||||
Base64.strict_decode64(sha256[1].strip).length
|
Base64.strict_decode64(sha256[1].strip).length
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}"
|
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}"
|
||||||
end
|
end
|
||||||
|
|
||||||
raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32
|
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32
|
||||||
|
|
||||||
raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}"
|
raise Mastodon::SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_signature(actor, signature, compare_signed_string)
|
def verify_signature(actor, signature, compare_signed_string)
|
||||||
|
@ -165,13 +163,13 @@ module SignatureVerification
|
||||||
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||||
end
|
end
|
||||||
when '(created)'
|
when '(created)'
|
||||||
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||||
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
raise Mastodon::SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
||||||
|
|
||||||
"(created): #{signature_params['created']}"
|
"(created): #{signature_params['created']}"
|
||||||
when '(expires)'
|
when '(expires)'
|
||||||
raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||||
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
raise Mastodon::SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
||||||
|
|
||||||
"(expires): #{signature_params['expires']}"
|
"(expires): #{signature_params['expires']}"
|
||||||
else
|
else
|
||||||
|
@ -193,7 +191,7 @@ module SignatureVerification
|
||||||
|
|
||||||
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
|
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
raise SignatureVerificationError, "Invalid Date header: #{e.message}"
|
raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
expires_time ||= created_time + 5.minutes unless created_time.nil?
|
expires_time ||= created_time + 5.minutes unless created_time.nil?
|
||||||
|
@ -233,9 +231,9 @@ module SignatureVerification
|
||||||
account
|
account
|
||||||
end
|
end
|
||||||
rescue Mastodon::PrivateNetworkAddressError => e
|
rescue Mastodon::PrivateNetworkAddressError => e
|
||||||
raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
|
raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
|
||||||
rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, ActivityPub::FetchRemoteKeyService::Error, Webfinger::Error => e
|
rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, ActivityPub::FetchRemoteKeyService::Error, Webfinger::Error => e
|
||||||
raise SignatureVerificationError, e.message
|
raise Mastodon::SignatureVerificationError, e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
def stoplight_wrapper
|
def stoplight_wrapper
|
||||||
|
@ -251,8 +249,8 @@ module SignatureVerification
|
||||||
|
|
||||||
ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false)
|
ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false)
|
||||||
rescue Mastodon::PrivateNetworkAddressError => e
|
rescue Mastodon::PrivateNetworkAddressError => e
|
||||||
raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
|
raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
|
||||||
rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, Webfinger::Error => e
|
rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, Webfinger::Error => e
|
||||||
raise SignatureVerificationError, e.message
|
raise Mastodon::SignatureVerificationError, e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -68,7 +68,7 @@ function loaded() {
|
||||||
|
|
||||||
if (id) message = localeData[id];
|
if (id) message = localeData[id];
|
||||||
|
|
||||||
if (!message) message = defaultMessage as string;
|
message ??= defaultMessage as string;
|
||||||
|
|
||||||
const messageFormat = new IntlMessageFormat(message, locale);
|
const messageFormat = new IntlMessageFormat(message, locale);
|
||||||
return messageFormat.format(values) as string;
|
return messageFormat.format(values) as string;
|
||||||
|
|
|
@ -12,14 +12,6 @@ export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
|
||||||
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
|
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
|
||||||
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
|
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
|
||||||
|
|
||||||
export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
|
|
||||||
export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS';
|
|
||||||
export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST';
|
|
||||||
export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS';
|
|
||||||
export const DOMAIN_BLOCKS_EXPAND_FAIL = 'DOMAIN_BLOCKS_EXPAND_FAIL';
|
|
||||||
|
|
||||||
export function blockDomain(domain) {
|
export function blockDomain(domain) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(blockDomainRequest(domain));
|
dispatch(blockDomainRequest(domain));
|
||||||
|
@ -79,80 +71,6 @@ export function unblockDomainFail(domain, error) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDomainBlocks() {
|
|
||||||
return (dispatch) => {
|
|
||||||
dispatch(fetchDomainBlocksRequest());
|
|
||||||
|
|
||||||
api().get('/api/v1/domain_blocks').then(response => {
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchDomainBlocksFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchDomainBlocksRequest() {
|
|
||||||
return {
|
|
||||||
type: DOMAIN_BLOCKS_FETCH_REQUEST,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchDomainBlocksSuccess(domains, next) {
|
|
||||||
return {
|
|
||||||
type: DOMAIN_BLOCKS_FETCH_SUCCESS,
|
|
||||||
domains,
|
|
||||||
next,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchDomainBlocksFail(error) {
|
|
||||||
return {
|
|
||||||
type: DOMAIN_BLOCKS_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandDomainBlocks() {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
const url = getState().getIn(['domain_lists', 'blocks', 'next']);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(expandDomainBlocksRequest());
|
|
||||||
|
|
||||||
api().get(url).then(response => {
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(expandDomainBlocksFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandDomainBlocksRequest() {
|
|
||||||
return {
|
|
||||||
type: DOMAIN_BLOCKS_EXPAND_REQUEST,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandDomainBlocksSuccess(domains, next) {
|
|
||||||
return {
|
|
||||||
type: DOMAIN_BLOCKS_EXPAND_SUCCESS,
|
|
||||||
domains,
|
|
||||||
next,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandDomainBlocksFail(error) {
|
|
||||||
return {
|
|
||||||
type: DOMAIN_BLOCKS_EXPAND_FAIL,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const initDomainBlockModal = account => dispatch => dispatch(openModal({
|
export const initDomainBlockModal = account => dispatch => dispatch(openModal({
|
||||||
modalType: 'DOMAIN_BLOCK',
|
modalType: 'DOMAIN_BLOCK',
|
||||||
modalProps: {
|
modalProps: {
|
||||||
|
|
|
@ -75,7 +75,7 @@ export function importFetchedStatuses(statuses) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.poll?.id) {
|
if (status.poll?.id) {
|
||||||
pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id)));
|
pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.card) {
|
if (status.card) {
|
||||||
|
|
|
@ -15,7 +15,7 @@ export const importFetchedPoll = createAppAsyncThunk(
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
importPolls({
|
importPolls({
|
||||||
polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))],
|
polls: [createPollFromServerJSON(poll, getState().polls[poll.id])],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
13
app/javascript/mastodon/api/domain_blocks.ts
Normal file
13
app/javascript/mastodon/api/domain_blocks.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import api, { getLinks } from 'mastodon/api';
|
||||||
|
|
||||||
|
export const apiGetDomainBlocks = async (url?: string) => {
|
||||||
|
const response = await api().request<string[]>({
|
||||||
|
method: 'GET',
|
||||||
|
url: url ?? '/api/v1/domain_blocks',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
domains: response.data,
|
||||||
|
links: getLinks(response),
|
||||||
|
};
|
||||||
|
};
|
|
@ -13,7 +13,7 @@ export interface ApiPollJSON {
|
||||||
expired: boolean;
|
expired: boolean;
|
||||||
multiple: boolean;
|
multiple: boolean;
|
||||||
votes_count: number;
|
votes_count: number;
|
||||||
voters_count: number;
|
voters_count: number | null;
|
||||||
|
|
||||||
options: ApiPollOptionJSON[];
|
options: ApiPollOptionJSON[];
|
||||||
emojis: ApiCustomEmojiJSON[];
|
emojis: ApiCustomEmojiJSON[];
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { TransitionMotion, spring } from 'react-motion';
|
import { animated, useSpring, config } from '@react-spring/web';
|
||||||
|
|
||||||
import { reduceMotion } from '../initial_state';
|
import { reduceMotion } from '../initial_state';
|
||||||
|
|
||||||
|
@ -11,53 +11,49 @@ interface Props {
|
||||||
}
|
}
|
||||||
export const AnimatedNumber: React.FC<Props> = ({ value }) => {
|
export const AnimatedNumber: React.FC<Props> = ({ value }) => {
|
||||||
const [previousValue, setPreviousValue] = useState(value);
|
const [previousValue, setPreviousValue] = useState(value);
|
||||||
const [direction, setDirection] = useState<1 | -1>(1);
|
const direction = value > previousValue ? -1 : 1;
|
||||||
|
|
||||||
if (previousValue !== value) {
|
const [styles, api] = useSpring(
|
||||||
|
() => ({
|
||||||
|
from: { transform: `translateY(${100 * direction}%)` },
|
||||||
|
to: { transform: 'translateY(0%)' },
|
||||||
|
onRest() {
|
||||||
setPreviousValue(value);
|
setPreviousValue(value);
|
||||||
setDirection(value > previousValue ? 1 : -1);
|
},
|
||||||
}
|
config: { ...config.gentle, duration: 200 },
|
||||||
|
immediate: true, // This ensures that the animation is not played when the component is first rendered
|
||||||
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
|
}),
|
||||||
const willLeave = useCallback(
|
[value, previousValue],
|
||||||
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
|
|
||||||
[direction],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// When the value changes, start the animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== previousValue) {
|
||||||
|
void api.start({ reset: true });
|
||||||
|
}
|
||||||
|
}, [api, previousValue, value]);
|
||||||
|
|
||||||
if (reduceMotion) {
|
if (reduceMotion) {
|
||||||
return <ShortNumber value={value} />;
|
return <ShortNumber value={value} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = [
|
|
||||||
{
|
|
||||||
key: `${value}`,
|
|
||||||
data: value,
|
|
||||||
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TransitionMotion
|
|
||||||
styles={styles}
|
|
||||||
willEnter={willEnter}
|
|
||||||
willLeave={willLeave}
|
|
||||||
>
|
|
||||||
{(items) => (
|
|
||||||
<span className='animated-number'>
|
<span className='animated-number'>
|
||||||
{items.map(({ key, data, style }) => (
|
<animated.span style={styles}>
|
||||||
<span
|
<ShortNumber value={value} />
|
||||||
key={key}
|
</animated.span>
|
||||||
|
{value !== previousValue && (
|
||||||
|
<animated.span
|
||||||
style={{
|
style={{
|
||||||
position:
|
...styles,
|
||||||
direction * (style.y ?? 0) > 0 ? 'absolute' : 'static',
|
position: 'absolute',
|
||||||
transform: `translateY(${(style.y ?? 0) * 100}%)`,
|
top: `${-100 * direction}%`, // Adds extra space on top of translateY
|
||||||
}}
|
}}
|
||||||
|
role='presentation'
|
||||||
>
|
>
|
||||||
<ShortNumber value={data as number} />
|
<ShortNumber value={previousValue} />
|
||||||
</span>
|
</animated.span>
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</TransitionMotion>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,29 +1,36 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
import { defineMessages } from 'react-intl';
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
|
|
||||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||||
import { showAlert } from 'mastodon/actions/alerts';
|
import { showAlert } from 'mastodon/actions/alerts';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' },
|
copied: {
|
||||||
|
id: 'copy_icon_button.copied',
|
||||||
|
defaultMessage: 'Copied to clipboard',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CopyIconButton = ({ title, value, className }) => {
|
export const CopyIconButton: React.FC<{
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
className: string;
|
||||||
|
}> = ({ title, value, className }) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
navigator.clipboard.writeText(value);
|
void navigator.clipboard.writeText(value);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
dispatch(showAlert({ message: messages.copied }));
|
dispatch(showAlert({ message: messages.copied }));
|
||||||
setTimeout(() => setCopied(false), 700);
|
setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 700);
|
||||||
}, [setCopied, value, dispatch]);
|
}, [setCopied, value, dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -31,13 +38,8 @@ export const CopyIconButton = ({ title, value, className }) => {
|
||||||
className={classNames(className, copied ? 'copied' : 'copyable')}
|
className={classNames(className, copied ? 'copied' : 'copyable')}
|
||||||
title={title}
|
title={title}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
icon=''
|
||||||
iconComponent={ContentCopyIcon}
|
iconComponent={ContentCopyIcon}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
CopyIconButton.propTypes = {
|
|
||||||
title: PropTypes.string,
|
|
||||||
value: PropTypes.string,
|
|
||||||
className: PropTypes.string,
|
|
||||||
};
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
|
|
@ -1,24 +1,15 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
|
|
||||||
import { unblockDomain } from 'mastodon/actions/domain_blocks';
|
import { unblockDomain } from 'mastodon/actions/domain_blocks';
|
||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
import { IconButton } from './icon_button';
|
import { Button } from './button';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
unblockDomain: {
|
|
||||||
id: 'account.unblock_domain',
|
|
||||||
defaultMessage: 'Unblock domain {domain}',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Domain: React.FC<{
|
export const Domain: React.FC<{
|
||||||
domain: string;
|
domain: string;
|
||||||
}> = ({ domain }) => {
|
}> = ({ domain }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const handleDomainUnblock = useCallback(() => {
|
const handleDomainUnblock = useCallback(() => {
|
||||||
|
@ -27,20 +18,17 @@ export const Domain: React.FC<{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='domain'>
|
<div className='domain'>
|
||||||
<div className='domain__wrapper'>
|
<div className='domain__domain-name'>
|
||||||
<span className='domain__domain-name'>
|
|
||||||
<strong>{domain}</strong>
|
<strong>{domain}</strong>
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
<div className='domain__buttons'>
|
<div className='domain__buttons'>
|
||||||
<IconButton
|
<Button onClick={handleDomainUnblock}>
|
||||||
active
|
<FormattedMessage
|
||||||
icon='unlock'
|
id='account.unblock_domain_short'
|
||||||
iconComponent={LockOpenIcon}
|
defaultMessage='Unblock'
|
||||||
title={intl.formatMessage(messages.unblockDomain, { domain })}
|
|
||||||
onClick={handleDomainUnblock}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,248 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
|
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
|
||||||
import spring from 'react-motion/lib/spring';
|
|
||||||
|
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import emojify from 'mastodon/features/emoji/emoji';
|
|
||||||
import Motion from 'mastodon/features/ui/util/optional_motion';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
|
||||||
|
|
||||||
import { RelativeTimestamp } from './relative_timestamp';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
closed: {
|
|
||||||
id: 'poll.closed',
|
|
||||||
defaultMessage: 'Closed',
|
|
||||||
},
|
|
||||||
voted: {
|
|
||||||
id: 'poll.voted',
|
|
||||||
defaultMessage: 'You voted for this answer',
|
|
||||||
},
|
|
||||||
votes: {
|
|
||||||
id: 'poll.votes',
|
|
||||||
defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
class Poll extends ImmutablePureComponent {
|
|
||||||
static propTypes = {
|
|
||||||
identity: identityContextPropShape,
|
|
||||||
poll: ImmutablePropTypes.record.isRequired,
|
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
|
||||||
lang: PropTypes.string,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
refresh: PropTypes.func,
|
|
||||||
onVote: PropTypes.func,
|
|
||||||
onInteractionModal: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
selected: {},
|
|
||||||
expired: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
static getDerivedStateFromProps (props, state) {
|
|
||||||
const { poll } = props;
|
|
||||||
const expires_at = poll.get('expires_at');
|
|
||||||
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < Date.now();
|
|
||||||
return (expired === state.expired) ? null : { expired };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
this._setupTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate () {
|
|
||||||
this._setupTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
clearTimeout(this._timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
_setupTimer () {
|
|
||||||
const { poll } = this.props;
|
|
||||||
clearTimeout(this._timer);
|
|
||||||
if (!this.state.expired) {
|
|
||||||
const delay = (new Date(poll.get('expires_at'))).getTime() - Date.now();
|
|
||||||
this._timer = setTimeout(() => {
|
|
||||||
this.setState({ expired: true });
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_toggleOption = value => {
|
|
||||||
if (this.props.poll.get('multiple')) {
|
|
||||||
const tmp = { ...this.state.selected };
|
|
||||||
if (tmp[value]) {
|
|
||||||
delete tmp[value];
|
|
||||||
} else {
|
|
||||||
tmp[value] = true;
|
|
||||||
}
|
|
||||||
this.setState({ selected: tmp });
|
|
||||||
} else {
|
|
||||||
const tmp = {};
|
|
||||||
tmp[value] = true;
|
|
||||||
this.setState({ selected: tmp });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOptionChange = ({ target: { value } }) => {
|
|
||||||
this._toggleOption(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOptionKeyPress = (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
this._toggleOption(e.target.getAttribute('data-index'));
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleVote = () => {
|
|
||||||
if (this.props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.identity.signedIn) {
|
|
||||||
this.props.onVote(Object.keys(this.state.selected));
|
|
||||||
} else {
|
|
||||||
this.props.onInteractionModal('vote', this.props.status);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleRefresh = () => {
|
|
||||||
if (this.props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleReveal = () => {
|
|
||||||
this.setState({ revealed: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
renderOption (option, optionIndex, showResults) {
|
|
||||||
const { poll, lang, disabled, intl } = this.props;
|
|
||||||
const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
|
|
||||||
const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
|
|
||||||
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
|
|
||||||
const active = !!this.state.selected[`${optionIndex}`];
|
|
||||||
const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
|
|
||||||
|
|
||||||
const title = option.getIn(['translation', 'title']) || option.get('title');
|
|
||||||
let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml');
|
|
||||||
|
|
||||||
if (!titleHtml) {
|
|
||||||
const emojiMap = emojiMap(poll);
|
|
||||||
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={option.get('title')}>
|
|
||||||
<label className={classNames('poll__option', { selectable: !showResults })}>
|
|
||||||
<input
|
|
||||||
name='vote-options'
|
|
||||||
type={poll.get('multiple') ? 'checkbox' : 'radio'}
|
|
||||||
value={optionIndex}
|
|
||||||
checked={active}
|
|
||||||
onChange={this.handleOptionChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!showResults && (
|
|
||||||
<span
|
|
||||||
className={classNames('poll__input', { checkbox: poll.get('multiple'), active })}
|
|
||||||
tabIndex={0}
|
|
||||||
role={poll.get('multiple') ? 'checkbox' : 'radio'}
|
|
||||||
onKeyPress={this.handleOptionKeyPress}
|
|
||||||
aria-checked={active}
|
|
||||||
aria-label={title}
|
|
||||||
lang={lang}
|
|
||||||
data-index={optionIndex}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showResults && (
|
|
||||||
<span
|
|
||||||
className='poll__number'
|
|
||||||
title={intl.formatMessage(messages.votes, {
|
|
||||||
votes: option.get('votes_count'),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{Math.round(percent)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span
|
|
||||||
className='poll__option__text translate'
|
|
||||||
lang={lang}
|
|
||||||
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!!voted && <span className='poll__voted'>
|
|
||||||
<Icon id='check' icon={CheckIcon} className='poll__voted__mark' title={intl.formatMessage(messages.voted)} />
|
|
||||||
</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{showResults && (
|
|
||||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
|
|
||||||
{({ width }) =>
|
|
||||||
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
|
|
||||||
}
|
|
||||||
</Motion>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { poll, intl } = this.props;
|
|
||||||
const { revealed, expired } = this.state;
|
|
||||||
|
|
||||||
if (!poll) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
|
|
||||||
const showResults = poll.get('voted') || revealed || expired;
|
|
||||||
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
|
|
||||||
|
|
||||||
let votesCount = null;
|
|
||||||
|
|
||||||
if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) {
|
|
||||||
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
|
|
||||||
} else {
|
|
||||||
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='poll'>
|
|
||||||
<ul>
|
|
||||||
{poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className='poll__footer'>
|
|
||||||
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
|
|
||||||
{!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>}
|
|
||||||
{showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>}
|
|
||||||
{votesCount}
|
|
||||||
{poll.get('expires_at') && <> · {timeRemaining}</>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(withIdentity(Poll));
|
|
337
app/javascript/mastodon/components/poll.tsx
Normal file
337
app/javascript/mastodon/components/poll.tsx
Normal file
|
@ -0,0 +1,337 @@
|
||||||
|
import type { KeyboardEventHandler } from 'react';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
|
|
||||||
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
import { fetchPoll, vote } from 'mastodon/actions/polls';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import emojify from 'mastodon/features/emoji/emoji';
|
||||||
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
|
import { reduceMotion } from 'mastodon/initial_state';
|
||||||
|
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
|
||||||
|
import type * as Model from 'mastodon/models/poll';
|
||||||
|
import type { Status } from 'mastodon/models/status';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { RelativeTimestamp } from './relative_timestamp';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
closed: {
|
||||||
|
id: 'poll.closed',
|
||||||
|
defaultMessage: 'Closed',
|
||||||
|
},
|
||||||
|
voted: {
|
||||||
|
id: 'poll.voted',
|
||||||
|
defaultMessage: 'You voted for this answer',
|
||||||
|
},
|
||||||
|
votes: {
|
||||||
|
id: 'poll.votes',
|
||||||
|
defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PollProps {
|
||||||
|
pollId: string;
|
||||||
|
status: Status;
|
||||||
|
lang?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
|
||||||
|
// Third party hooks
|
||||||
|
const poll = useAppSelector((state) => state.polls[pollId]);
|
||||||
|
const identity = useIdentity();
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// Derived values
|
||||||
|
const expired = useMemo(() => {
|
||||||
|
if (!poll) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expiresAt = poll.expires_at;
|
||||||
|
return poll.expired || new Date(expiresAt).getTime() < Date.now();
|
||||||
|
}, [poll]);
|
||||||
|
const timeRemaining = useMemo(() => {
|
||||||
|
if (!poll) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (expired) {
|
||||||
|
return intl.formatMessage(messages.closed);
|
||||||
|
}
|
||||||
|
return <RelativeTimestamp timestamp={poll.expires_at} futureDate />;
|
||||||
|
}, [expired, intl, poll]);
|
||||||
|
const votesCount = useMemo(() => {
|
||||||
|
if (!poll) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (poll.voters_count) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='poll.total_people'
|
||||||
|
defaultMessage='{count, plural, one {# person} other {# people}}'
|
||||||
|
values={{ count: poll.voters_count }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='poll.total_votes'
|
||||||
|
defaultMessage='{count, plural, one {# vote} other {# votes}}'
|
||||||
|
values={{ count: poll.votes_count }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [poll]);
|
||||||
|
|
||||||
|
const voteDisabled =
|
||||||
|
disabled || Object.values(selected).every((item) => !item);
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleVote = useCallback(() => {
|
||||||
|
if (voteDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identity.signedIn) {
|
||||||
|
void dispatch(vote({ pollId, choices: Object.keys(selected) }));
|
||||||
|
} else {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'INTERACTION',
|
||||||
|
modalProps: {
|
||||||
|
type: 'vote',
|
||||||
|
accountId: status.getIn(['account', 'id']),
|
||||||
|
url: status.get('uri'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [voteDisabled, dispatch, identity, pollId, selected, status]);
|
||||||
|
|
||||||
|
const handleReveal = useCallback(() => {
|
||||||
|
setRevealed(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void dispatch(fetchPoll({ pollId }));
|
||||||
|
}, [disabled, dispatch, pollId]);
|
||||||
|
|
||||||
|
const handleOptionChange = useCallback(
|
||||||
|
(choiceIndex: number) => {
|
||||||
|
if (!poll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (poll.multiple) {
|
||||||
|
setSelected((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[choiceIndex]: !prev[choiceIndex],
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setSelected({ [choiceIndex]: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[poll],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!poll) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const showResults = poll.voted || revealed || expired;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='poll'>
|
||||||
|
<ul>
|
||||||
|
{poll.options.map((option, i) => (
|
||||||
|
<PollOption
|
||||||
|
key={option.title || i}
|
||||||
|
index={i}
|
||||||
|
poll={poll}
|
||||||
|
option={option}
|
||||||
|
showResults={showResults}
|
||||||
|
active={!!selected[i]}
|
||||||
|
onChange={handleOptionChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className='poll__footer'>
|
||||||
|
{!showResults && (
|
||||||
|
<button
|
||||||
|
className='button button-secondary'
|
||||||
|
disabled={voteDisabled}
|
||||||
|
onClick={handleVote}
|
||||||
|
>
|
||||||
|
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!showResults && (
|
||||||
|
<>
|
||||||
|
<button className='poll__link' onClick={handleReveal}>
|
||||||
|
<FormattedMessage id='poll.reveal' defaultMessage='See results' />
|
||||||
|
</button>{' '}
|
||||||
|
·{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showResults && !disabled && (
|
||||||
|
<>
|
||||||
|
<button className='poll__link' onClick={handleRefresh}>
|
||||||
|
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
|
||||||
|
</button>{' '}
|
||||||
|
·{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{votesCount}
|
||||||
|
{poll.expires_at && <> · {timeRemaining}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type PollOptionProps = Pick<PollProps, 'disabled' | 'lang'> & {
|
||||||
|
active: boolean;
|
||||||
|
onChange: (index: number) => void;
|
||||||
|
poll: Model.Poll;
|
||||||
|
option: Model.PollOption;
|
||||||
|
index: number;
|
||||||
|
showResults?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PollOption: React.FC<PollOptionProps> = (props) => {
|
||||||
|
const { active, lang, disabled, poll, option, index, showResults, onChange } =
|
||||||
|
props;
|
||||||
|
const voted = option.voted || poll.own_votes?.includes(index);
|
||||||
|
const title = option.translation?.title ?? option.title;
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
// Derived values
|
||||||
|
const percent = useMemo(() => {
|
||||||
|
const pollVotesCount = poll.voters_count ?? poll.votes_count;
|
||||||
|
return pollVotesCount === 0
|
||||||
|
? 0
|
||||||
|
: (option.votes_count / pollVotesCount) * 100;
|
||||||
|
}, [option, poll]);
|
||||||
|
const isLeading = useMemo(
|
||||||
|
() =>
|
||||||
|
poll.options
|
||||||
|
.filter((other) => other.title !== option.title)
|
||||||
|
.every((other) => option.votes_count >= other.votes_count),
|
||||||
|
[poll, option],
|
||||||
|
);
|
||||||
|
const titleHtml = useMemo(() => {
|
||||||
|
let titleHtml = option.translation?.titleHtml ?? option.titleHtml;
|
||||||
|
|
||||||
|
if (!titleHtml) {
|
||||||
|
const emojiMap = makeEmojiMap(poll.emojis);
|
||||||
|
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return titleHtml;
|
||||||
|
}, [option, poll, title]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleOptionChange = useCallback(() => {
|
||||||
|
onChange(index);
|
||||||
|
}, [index, onChange]);
|
||||||
|
const handleOptionKeyPress: KeyboardEventHandler = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
onChange(index);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[index, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const widthSpring = useSpring({
|
||||||
|
from: {
|
||||||
|
width: '0%',
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
width: `${percent}%`,
|
||||||
|
},
|
||||||
|
immediate: reduceMotion,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className={classNames('poll__option', { selectable: !showResults })}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name='vote-options'
|
||||||
|
type={poll.multiple ? 'checkbox' : 'radio'}
|
||||||
|
value={index}
|
||||||
|
checked={active}
|
||||||
|
onChange={handleOptionChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!showResults && (
|
||||||
|
<span
|
||||||
|
className={classNames('poll__input', {
|
||||||
|
checkbox: poll.multiple,
|
||||||
|
active,
|
||||||
|
})}
|
||||||
|
tabIndex={0}
|
||||||
|
role={poll.multiple ? 'checkbox' : 'radio'}
|
||||||
|
onKeyDown={handleOptionKeyPress}
|
||||||
|
aria-checked={active}
|
||||||
|
aria-label={title}
|
||||||
|
lang={lang}
|
||||||
|
data-index={index}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showResults && (
|
||||||
|
<span
|
||||||
|
className='poll__number'
|
||||||
|
title={intl.formatMessage(messages.votes, {
|
||||||
|
votes: option.votes_count,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{Math.round(percent)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className='poll__option__text translate'
|
||||||
|
lang={lang}
|
||||||
|
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!voted && (
|
||||||
|
<span className='poll__voted'>
|
||||||
|
<Icon
|
||||||
|
id='check'
|
||||||
|
icon={CheckIcon}
|
||||||
|
className='poll__voted__mark'
|
||||||
|
title={intl.formatMessage(messages.voted)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{showResults && (
|
||||||
|
<animated.span
|
||||||
|
className={classNames('poll__chart', { leading: isLeading })}
|
||||||
|
style={widthSpring}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
import { Router as OriginalRouter, useHistory } from 'react-router';
|
import { Router as OriginalRouter, useHistory } from 'react-router';
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { connect } from 'react-redux';
|
||||||
|
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import PollContainer from 'mastodon/containers/poll_container';
|
import { Poll } from 'mastodon/components/poll';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||||
|
|
||||||
|
@ -250,7 +250,7 @@ class StatusContent extends PureComponent {
|
||||||
);
|
);
|
||||||
|
|
||||||
const poll = !!status.get('poll') && (
|
const poll = !!status.get('poll') && (
|
||||||
<PollContainer pollId={status.get('poll')} status={status} lang={language} />
|
<Poll pollId={status.get('poll')} status={status} lang={language} />
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.props.onClick) {
|
if (this.props.onClick) {
|
||||||
|
|
|
@ -7,12 +7,13 @@ import { fromJS } from 'immutable';
|
||||||
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
import MediaGallery from 'mastodon/components/media_gallery';
|
import MediaGallery from 'mastodon/components/media_gallery';
|
||||||
import ModalRoot from 'mastodon/components/modal_root';
|
import ModalRoot from 'mastodon/components/modal_root';
|
||||||
import Poll from 'mastodon/components/poll';
|
import { Poll } from 'mastodon/components/poll';
|
||||||
import Audio from 'mastodon/features/audio';
|
import Audio from 'mastodon/features/audio';
|
||||||
import Card from 'mastodon/features/status/components/card';
|
import Card from 'mastodon/features/status/components/card';
|
||||||
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||||
import Video from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
import { IntlProvider } from 'mastodon/locales';
|
import { IntlProvider } from 'mastodon/locales';
|
||||||
|
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||||
|
|
||||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
||||||
|
@ -88,7 +89,7 @@ export default class MediaContainer extends PureComponent {
|
||||||
Object.assign(props, {
|
Object.assign(props, {
|
||||||
...(media ? { media: fromJS(media) } : {}),
|
...(media ? { media: fromJS(media) } : {}),
|
||||||
...(card ? { card: fromJS(card) } : {}),
|
...(card ? { card: fromJS(card) } : {}),
|
||||||
...(poll ? { poll: fromJS(poll) } : {}),
|
...(poll ? { poll: createPollFromServerJSON(poll) } : {}),
|
||||||
...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
|
...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
|
||||||
|
|
||||||
...(componentName === 'Video' ? {
|
...(componentName === 'Video' ? {
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { debounce } from 'lodash';
|
|
||||||
|
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
|
||||||
import { fetchPoll, vote } from 'mastodon/actions/polls';
|
|
||||||
import Poll from 'mastodon/components/poll';
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { pollId }) => ({
|
|
||||||
refresh: debounce(
|
|
||||||
() => {
|
|
||||||
dispatch(fetchPoll({ pollId }));
|
|
||||||
},
|
|
||||||
1000,
|
|
||||||
{ leading: true },
|
|
||||||
),
|
|
||||||
|
|
||||||
onVote (choices) {
|
|
||||||
dispatch(vote({ pollId, choices }));
|
|
||||||
},
|
|
||||||
|
|
||||||
onInteractionModal (type, status) {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'INTERACTION',
|
|
||||||
modalProps: {
|
|
||||||
type,
|
|
||||||
accountId: status.getIn(['account', 'id']),
|
|
||||||
url: status.get('uri'),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { pollId }) => ({
|
|
||||||
poll: state.polls.get(pollId),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Poll);
|
|
|
@ -30,7 +30,7 @@ import { Skeleton } from 'mastodon/components/skeleton';
|
||||||
import Audio from 'mastodon/features/audio';
|
import Audio from 'mastodon/features/audio';
|
||||||
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
||||||
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
||||||
import Video, { getPointerPosition } from 'mastodon/features/video';
|
import { Video, getPointerPosition } from 'mastodon/features/video';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
@ -134,17 +134,7 @@ const Preview: React.FC<{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent);
|
||||||
setDragging(true);
|
|
||||||
draggingRef.current = true;
|
|
||||||
onPositionChange([x, y]);
|
|
||||||
},
|
|
||||||
[setDragging, onPositionChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTouchStart = useCallback(
|
|
||||||
(e: React.TouchEvent) => {
|
|
||||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
draggingRef.current = true;
|
draggingRef.current = true;
|
||||||
onPositionChange([x, y]);
|
onPositionChange([x, y]);
|
||||||
|
@ -165,28 +155,12 @@ const Preview: React.FC<{
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
|
||||||
setDragging(false);
|
|
||||||
draggingRef.current = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (e: TouchEvent) => {
|
|
||||||
if (draggingRef.current) {
|
|
||||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
|
||||||
onPositionChange([x, y]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
document.addEventListener('touchend', handleTouchEnd);
|
|
||||||
document.addEventListener('touchmove', handleTouchMove);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('touchend', handleTouchEnd);
|
|
||||||
document.removeEventListener('touchmove', handleTouchMove);
|
|
||||||
};
|
};
|
||||||
}, [setDragging, onPositionChange]);
|
}, [setDragging, onPositionChange]);
|
||||||
|
|
||||||
|
@ -204,7 +178,6 @@ const Preview: React.FC<{
|
||||||
alt=''
|
alt=''
|
||||||
role='presentation'
|
role='presentation'
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className='focal-point__reticle'
|
className='focal-point__reticle'
|
||||||
|
@ -220,7 +193,6 @@ const Preview: React.FC<{
|
||||||
src={media.get('url') as string}
|
src={media.get('url') as string}
|
||||||
alt=''
|
alt=''
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className='focal-point__reticle'
|
className='focal-point__reticle'
|
||||||
|
@ -233,10 +205,10 @@ const Preview: React.FC<{
|
||||||
<Video
|
<Video
|
||||||
preview={media.get('preview_url') as string}
|
preview={media.get('preview_url') as string}
|
||||||
frameRate={media.getIn(['meta', 'original', 'frame_rate']) as string}
|
frameRate={media.getIn(['meta', 'original', 'frame_rate']) as string}
|
||||||
|
aspectRatio={`${media.getIn(['meta', 'original', 'width']) as number} / ${media.getIn(['meta', 'original', 'height']) as number}`}
|
||||||
blurhash={media.get('blurhash') as string}
|
blurhash={media.get('blurhash') as string}
|
||||||
src={media.get('url') as string}
|
src={media.get('url') as string}
|
||||||
detailed
|
detailed
|
||||||
inline
|
|
||||||
editable
|
editable
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -27,8 +27,8 @@ import Visualizer from './visualizer';
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||||
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
|
mute: { id: 'video.mute', defaultMessage: 'Mute' },
|
||||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
|
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
|
||||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||||
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
||||||
});
|
});
|
||||||
|
|
|
@ -25,7 +25,6 @@ import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||||
import SearchabilityDropdownContainer from '../containers/searchability_dropdown_container';
|
import SearchabilityDropdownContainer from '../containers/searchability_dropdown_container';
|
||||||
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
||||||
import UploadButtonContainer from '../containers/upload_button_container';
|
import UploadButtonContainer from '../containers/upload_button_container';
|
||||||
import WarningContainer from '../containers/warning_container';
|
|
||||||
import { countableText } from '../util/counter';
|
import { countableText } from '../util/counter';
|
||||||
|
|
||||||
import { CharacterCounter } from './character_counter';
|
import { CharacterCounter } from './character_counter';
|
||||||
|
@ -35,6 +34,7 @@ import { NavigationBar } from './navigation_bar';
|
||||||
import { PollForm } from "./poll_form";
|
import { PollForm } from "./poll_form";
|
||||||
import { ReplyIndicator } from './reply_indicator';
|
import { ReplyIndicator } from './reply_indicator';
|
||||||
import { UploadForm } from './upload_form';
|
import { UploadForm } from './upload_form';
|
||||||
|
import { Warning } from './warning';
|
||||||
|
|
||||||
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
|
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
|
||||||
|
|
||||||
|
@ -254,7 +254,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||||
<ReplyIndicator />
|
<ReplyIndicator />
|
||||||
{!withoutNavigation && <NavigationBar />}
|
{!withoutNavigation && <NavigationBar />}
|
||||||
<WarningContainer />
|
<Warning />
|
||||||
|
|
||||||
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
|
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
|
||||||
<div className='compose-form__scrollable'>
|
<div className='compose-form__scrollable'>
|
||||||
|
|
|
@ -1,48 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import spring from 'react-motion/lib/spring';
|
|
||||||
|
|
||||||
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
|
|
||||||
import Motion from '../../ui/util/optional_motion';
|
|
||||||
|
|
||||||
export const UploadProgress = ({ active, progress, isProcessing }) => {
|
|
||||||
if (!active) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let message;
|
|
||||||
|
|
||||||
if (isProcessing) {
|
|
||||||
message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
|
|
||||||
} else {
|
|
||||||
message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='upload-progress'>
|
|
||||||
<Icon id='upload' icon={UploadFileIcon} />
|
|
||||||
|
|
||||||
<div className='upload-progress__message'>
|
|
||||||
{message}
|
|
||||||
|
|
||||||
<div className='upload-progress__backdrop'>
|
|
||||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
|
|
||||||
{({ width }) =>
|
|
||||||
<div className='upload-progress__tracker' style={{ width: `${width}%` }} />
|
|
||||||
}
|
|
||||||
</Motion>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
UploadProgress.propTypes = {
|
|
||||||
active: PropTypes.bool,
|
|
||||||
progress: PropTypes.number,
|
|
||||||
isProcessing: PropTypes.bool,
|
|
||||||
};
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
|
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { reduceMotion } from 'mastodon/initial_state';
|
||||||
|
|
||||||
|
interface UploadProgressProps {
|
||||||
|
active: boolean;
|
||||||
|
progress: number;
|
||||||
|
isProcessing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UploadProgress: React.FC<UploadProgressProps> = ({
|
||||||
|
active,
|
||||||
|
progress,
|
||||||
|
isProcessing = false,
|
||||||
|
}) => {
|
||||||
|
const styles = useSpring({
|
||||||
|
from: { width: '0%' },
|
||||||
|
to: { width: `${progress}%` },
|
||||||
|
immediate: reduceMotion || !active, // If this is not active, update the UI immediately.
|
||||||
|
});
|
||||||
|
if (!active) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='upload-progress'>
|
||||||
|
<Icon id='upload' icon={UploadFileIcon} />
|
||||||
|
|
||||||
|
<div className='upload-progress__message'>
|
||||||
|
{isProcessing ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id='upload_progress.processing'
|
||||||
|
defaultMessage='Processing…'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id='upload_progress.label'
|
||||||
|
defaultMessage='Uploading…'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='upload-progress__backdrop'>
|
||||||
|
<animated.div className='upload-progress__tracker' style={styles} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,28 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import spring from 'react-motion/lib/spring';
|
|
||||||
|
|
||||||
import Motion from '../../ui/util/optional_motion';
|
|
||||||
|
|
||||||
export default class Warning extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
message: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { message } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
|
||||||
{({ opacity, scaleX, scaleY }) => (
|
|
||||||
<div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Motion>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
147
app/javascript/mastodon/features/compose/components/warning.tsx
Normal file
147
app/javascript/mastodon/features/compose/components/warning.tsx
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
|
import { me } from 'mastodon/initial_state';
|
||||||
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
import type { RootState } from 'mastodon/store';
|
||||||
|
import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags';
|
||||||
|
import { MENTION_PATTERN_REGEX } from 'mastodon/utils/mentions';
|
||||||
|
|
||||||
|
const selector = createSelector(
|
||||||
|
(state: RootState) => state.compose.get('privacy') as string,
|
||||||
|
(state: RootState) => !!state.accounts.getIn([me, 'locked']),
|
||||||
|
(state: RootState) => state.compose.get('text') as string,
|
||||||
|
(state: RootState) => state.compose.get('searchability') as string,
|
||||||
|
(state: RootState) => state.compose.get('limited_scope') as string,
|
||||||
|
(privacy, locked, text, searchability, limited_scope) => ({
|
||||||
|
needsLockWarning: privacy === 'private' && !locked,
|
||||||
|
hashtagWarning:
|
||||||
|
!['public', 'public_unlisted', 'login'].includes(privacy) &&
|
||||||
|
(privacy !== 'unlisted' || searchability !== 'public') &&
|
||||||
|
HASHTAG_PATTERN_REGEX.test(text),
|
||||||
|
directMessageWarning: privacy === 'direct',
|
||||||
|
searchabilityWarning: searchability === 'limited',
|
||||||
|
mentionWarning:
|
||||||
|
['mutual', 'circle', 'limited'].includes(privacy) &&
|
||||||
|
MENTION_PATTERN_REGEX.test(text),
|
||||||
|
limitedPostWarning:
|
||||||
|
['mutual', 'circle'].includes(privacy) && !limited_scope,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Warning = () => {
|
||||||
|
const {
|
||||||
|
needsLockWarning,
|
||||||
|
hashtagWarning,
|
||||||
|
directMessageWarning,
|
||||||
|
searchabilityWarning,
|
||||||
|
mentionWarning,
|
||||||
|
limitedPostWarning,
|
||||||
|
} = useAppSelector(selector);
|
||||||
|
if (needsLockWarning) {
|
||||||
|
return (
|
||||||
|
<WarningMessage>
|
||||||
|
<FormattedMessage
|
||||||
|
id='compose_form.lock_disclaimer'
|
||||||
|
defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.'
|
||||||
|
values={{
|
||||||
|
locked: (
|
||||||
|
<a href='/settings/profile'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='compose_form.lock_disclaimer.lock'
|
||||||
|
defaultMessage='locked'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</WarningMessage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashtagWarning) {
|
||||||
|
return (
|
||||||
|
<WarningMessage>
|
||||||
|
<FormattedMessage
|
||||||
|
id='compose_form.hashtag_warning'
|
||||||
|
defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag."
|
||||||
|
/>
|
||||||
|
</WarningMessage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directMessageWarning) {
|
||||||
|
return (
|
||||||
|
<WarningMessage>
|
||||||
|
<FormattedMessage
|
||||||
|
id='compose_form.encryption_warning'
|
||||||
|
defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.'
|
||||||
|
/>{' '}
|
||||||
|
<a href='/terms' target='_blank'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='compose_form.direct_message_warning_learn_more'
|
||||||
|
defaultMessage='Learn more'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</WarningMessage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchabilityWarning) {
|
||||||
|
return (
|
||||||
|
<WarningMessage>
|
||||||
|
<FormattedMessage
|
||||||
|
id='compose_form.searchability_warning'
|
||||||
|
defaultMessage='Self only searchability is not available other mastodon servers. Others can search your post.'
|
||||||
|
/>
|
||||||
|
</WarningMessage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mentionWarning) {
|
||||||
|
return (
|
||||||
|
<WarningMessage>
|
||||||
|
<FormattedMessage
|
||||||
|
id='compose_form.mention_warning'
|
||||||
|
defaultMessage='When you add a mention to a limited post, the person you are mentioning can also see this post.'
|
||||||
|
/>
|
||||||
|
</WarningMessage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limitedPostWarning) {
|
||||||
|
return (
|
||||||
|
<WarningMessage>
|
||||||
|
<FormattedMessage
|
||||||
|
id='compose_form.limited_post_warning'
|
||||||
|
defaultMessage='Limited posts are NOT reached Misskey, normal Mastodon or so on.'
|
||||||
|
/>
|
||||||
|
</WarningMessage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WarningMessage: React.FC<React.PropsWithChildren> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const styles = useSpring({
|
||||||
|
from: {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'scale(0.85, 0.75)',
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'scale(1, 1)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<animated.div className='compose-form__warning' style={styles}>
|
||||||
|
{children}
|
||||||
|
</animated.div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,65 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { me } from 'mastodon/initial_state';
|
|
||||||
import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags';
|
|
||||||
import { MENTION_PATTERN_REGEX } from 'mastodon/utils/mentions';
|
|
||||||
|
|
||||||
import Warning from '../components/warning';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
|
|
||||||
hashtagWarning: !['public', 'public_unlisted', 'login'].includes(state.getIn(['compose', 'privacy'])) && state.getIn(['compose', 'searchability']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
|
|
||||||
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
|
|
||||||
searchabilityWarning: state.getIn(['compose', 'searchability']) === 'limited',
|
|
||||||
mentionWarning: ['mutual', 'circle', 'limited'].includes(state.getIn(['compose', 'privacy'])) && MENTION_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
|
|
||||||
limitedPostWarning: ['mutual', 'circle'].includes(state.getIn(['compose', 'privacy'])) && !state.getIn(['compose', 'limited_scope']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, searchabilityWarning, mentionWarning, limitedPostWarning }) => {
|
|
||||||
if (needsLockWarning) {
|
|
||||||
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hashtagWarning) {
|
|
||||||
return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag." />} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (directMessageWarning) {
|
|
||||||
const message = (
|
|
||||||
<span>
|
|
||||||
<FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return <Warning message={message} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchabilityWarning) {
|
|
||||||
return <Warning message={<FormattedMessage id='compose_form.searchability_warning' defaultMessage='Self only searchability is not available other mastodon servers. Others can search your post.' />} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mentionWarning) {
|
|
||||||
return <Warning message={<FormattedMessage id='compose_form.mention_warning' defaultMessage='When you add a mention to a limited post, the person you are mentioning can also see this post.' />} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (limitedPostWarning) {
|
|
||||||
return <Warning message={<FormattedMessage id='compose_form.limited_post_warning' defaultMessage='Limited posts are NOT reached Misskey, normal Mastodon or so on.' />} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
WarningWrapper.propTypes = {
|
|
||||||
needsLockWarning: PropTypes.bool,
|
|
||||||
hashtagWarning: PropTypes.bool,
|
|
||||||
directMessageWarning: PropTypes.bool,
|
|
||||||
searchabilityWarning: PropTypes.bool,
|
|
||||||
mentionWarning: PropTypes.bool,
|
|
||||||
limitedPostWarning: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(WarningWrapper);
|
|
|
@ -1,85 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { debounce } from 'lodash';
|
|
||||||
|
|
||||||
import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react';
|
|
||||||
import { Domain } from 'mastodon/components/domain';
|
|
||||||
|
|
||||||
import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
|
|
||||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
|
||||||
import ScrollableList from '../../components/scrollable_list';
|
|
||||||
import Column from '../ui/components/column';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
domains: state.getIn(['domain_lists', 'blocks', 'items']),
|
|
||||||
hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']),
|
|
||||||
});
|
|
||||||
|
|
||||||
class Blocks extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
params: PropTypes.object.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
hasMore: PropTypes.bool,
|
|
||||||
domains: ImmutablePropTypes.orderedSet,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
multiColumn: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
UNSAFE_componentWillMount () {
|
|
||||||
this.props.dispatch(fetchDomainBlocks());
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoadMore = debounce(() => {
|
|
||||||
this.props.dispatch(expandDomainBlocks());
|
|
||||||
}, 300, { leading: true });
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { intl, domains, hasMore, multiColumn } = this.props;
|
|
||||||
|
|
||||||
if (!domains) {
|
|
||||||
return (
|
|
||||||
<Column>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no blocked domains yet.' />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column bindToDocument={!multiColumn} icon='ban' iconComponent={BlockIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
|
|
||||||
<ScrollableList
|
|
||||||
scrollKey='domain_blocks'
|
|
||||||
onLoadMore={this.handleLoadMore}
|
|
||||||
hasMore={hasMore}
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
bindToDocument={!multiColumn}
|
|
||||||
>
|
|
||||||
{domains.map(domain =>
|
|
||||||
<Domain key={domain} domain={domain} />,
|
|
||||||
)}
|
|
||||||
</ScrollableList>
|
|
||||||
|
|
||||||
<Helmet>
|
|
||||||
<meta name='robots' content='noindex' />
|
|
||||||
</Helmet>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(injectIntl(Blocks));
|
|
113
app/javascript/mastodon/features/domain_blocks/index.tsx
Normal file
113
app/javascript/mastodon/features/domain_blocks/index.tsx
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
|
||||||
|
import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react';
|
||||||
|
import { apiGetDomainBlocks } from 'mastodon/api/domain_blocks';
|
||||||
|
import { Column } from 'mastodon/components/column';
|
||||||
|
import type { ColumnRef } from 'mastodon/components/column';
|
||||||
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import { Domain } from 'mastodon/components/domain';
|
||||||
|
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const Blocks: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [domains, setDomains] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [next, setNext] = useState<string | undefined>();
|
||||||
|
const hasMore = !!next;
|
||||||
|
const columnRef = useRef<ColumnRef>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
void apiGetDomainBlocks()
|
||||||
|
.then(({ domains, links }) => {
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setDomains(domains);
|
||||||
|
setNext(next?.uri);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [setLoading, setDomains, setNext]);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
void apiGetDomainBlocks(next)
|
||||||
|
.then(({ domains, links }) => {
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setDomains((previousDomains) => [...previousDomains, ...domains]);
|
||||||
|
setNext(next?.uri);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [setLoading, setDomains, setNext, next]);
|
||||||
|
|
||||||
|
const handleHeaderClick = useCallback(() => {
|
||||||
|
columnRef.current?.scrollTop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const emptyMessage = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.domain_blocks'
|
||||||
|
defaultMessage='There are no blocked domains yet.'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
ref={columnRef}
|
||||||
|
label={intl.formatMessage(messages.heading)}
|
||||||
|
>
|
||||||
|
<ColumnHeader
|
||||||
|
icon='ban'
|
||||||
|
iconComponent={BlockIcon}
|
||||||
|
title={intl.formatMessage(messages.heading)}
|
||||||
|
onClick={handleHeaderClick}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
showBackButton
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey='domain_blocks'
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
hasMore={hasMore}
|
||||||
|
isLoading={loading}
|
||||||
|
showLoading={loading && domains.length === 0}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
trackScroll={!multiColumn}
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
>
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<Domain key={domain} domain={domain} />
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
|
||||||
|
<Helmet>
|
||||||
|
<title>{intl.formatMessage(messages.heading)}</title>
|
||||||
|
<meta name='robots' content='noindex' />
|
||||||
|
</Helmet>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default Blocks;
|
|
@ -1,5 +1,3 @@
|
||||||
/* eslint-disable import/no-commonjs --
|
|
||||||
We need to use CommonJS here due to preval */
|
|
||||||
// @preval
|
// @preval
|
||||||
// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
|
// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
|
||||||
// This file contains the compressed version of the emoji data from
|
// This file contains the compressed version of the emoji data from
|
||||||
|
@ -22,8 +20,8 @@ const emojiMap = require('./emoji_map.json');
|
||||||
// This json file is downloaded from https://github.com/iamcal/emoji-data/
|
// This json file is downloaded from https://github.com/iamcal/emoji-data/
|
||||||
// and is used to correct the sheet coordinates since we're using that repo's sheet
|
// and is used to correct the sheet coordinates since we're using that repo's sheet
|
||||||
const emojiSheetData = require('./emoji_sheet.json');
|
const emojiSheetData = require('./emoji_sheet.json');
|
||||||
const { unicodeToFilename } = require('./unicode_to_filename_s');
|
const unicodeToFilename = require('./unicode_to_filename_s');
|
||||||
const { unicodeToUnifiedName } = require('./unicode_to_unified_name_s');
|
const unicodeToUnifiedName = require('./unicode_to_unified_name_s');
|
||||||
|
|
||||||
// Grabbed from `emoji_utils` to avoid circular dependency
|
// Grabbed from `emoji_utils` to avoid circular dependency
|
||||||
function unifiedToNative(unified) {
|
function unifiedToNative(unified) {
|
||||||
|
|
|
@ -33,11 +33,8 @@ function processEmojiMapData(
|
||||||
shortCode?: ShortCodesToEmojiDataKey,
|
shortCode?: ShortCodesToEmojiDataKey,
|
||||||
) {
|
) {
|
||||||
const [native, _filename] = emojiMapData;
|
const [native, _filename] = emojiMapData;
|
||||||
let filename = emojiMapData[1];
|
|
||||||
if (!filename) {
|
|
||||||
// filename name can be derived from unicodeToFilename
|
// filename name can be derived from unicodeToFilename
|
||||||
filename = unicodeToFilename(native);
|
const filename = emojiMapData[1] ?? unicodeToFilename(native);
|
||||||
}
|
|
||||||
unicodeMapping[native] = {
|
unicodeMapping[native] = {
|
||||||
shortCode,
|
shortCode,
|
||||||
filename,
|
filename,
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
/* eslint-disable import/no-commonjs --
|
|
||||||
We need to use CommonJS here as its imported into a preval file (`emoji_compressed.js`) */
|
|
||||||
|
|
||||||
// taken from:
|
// taken from:
|
||||||
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
|
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
|
||||||
exports.unicodeToFilename = (str) => {
|
const unicodeToFilename = (str) => {
|
||||||
let result = '';
|
let result = '';
|
||||||
let charCode = 0;
|
let charCode = 0;
|
||||||
let p = 0;
|
let p = 0;
|
||||||
|
@ -27,3 +24,5 @@ exports.unicodeToFilename = (str) => {
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default unicodeToFilename;
|
||||||
|
|
|
@ -1,6 +1,3 @@
|
||||||
/* eslint-disable import/no-commonjs --
|
|
||||||
We need to use CommonJS here as its imported into a preval file (`emoji_compressed.js`) */
|
|
||||||
|
|
||||||
function padLeft(str, num) {
|
function padLeft(str, num) {
|
||||||
while (str.length < num) {
|
while (str.length < num) {
|
||||||
str = '0' + str;
|
str = '0' + str;
|
||||||
|
@ -9,7 +6,7 @@ function padLeft(str, num) {
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.unicodeToUnifiedName = (str) => {
|
const unicodeToUnifiedName = (str) => {
|
||||||
let output = '';
|
let output = '';
|
||||||
|
|
||||||
for (let i = 0; i < str.length; i += 2) {
|
for (let i = 0; i < str.length; i += 2) {
|
||||||
|
@ -22,3 +19,5 @@ exports.unicodeToUnifiedName = (str) => {
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default unicodeToUnifiedName;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
|
||||||
|
|
||||||
|
@ -9,8 +9,7 @@ import { withRouter } from 'react-router-dom';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
import TransitionMotion from 'react-motion/lib/TransitionMotion';
|
import { animated, useTransition } from '@react-spring/web';
|
||||||
import spring from 'react-motion/lib/spring';
|
|
||||||
import ReactSwipeableViews from 'react-swipeable-views';
|
import ReactSwipeableViews from 'react-swipeable-views';
|
||||||
|
|
||||||
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
||||||
|
@ -239,18 +238,70 @@ class Reaction extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
|
<animated.button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
|
||||||
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
|
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
|
||||||
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
|
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
|
||||||
</button>
|
</animated.button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReactionsBar extends ImmutablePureComponent {
|
const ReactionsBar = ({
|
||||||
|
announcementId,
|
||||||
|
reactions,
|
||||||
|
emojiMap,
|
||||||
|
addReaction,
|
||||||
|
removeReaction,
|
||||||
|
}) => {
|
||||||
|
const visibleReactions = useMemo(() => reactions.filter(x => x.get('count') > 0).toArray(), [reactions]);
|
||||||
|
|
||||||
static propTypes = {
|
const handleEmojiPick = useCallback((emoji) => {
|
||||||
|
addReaction(announcementId, emoji.native.replaceAll(/:/g, ''));
|
||||||
|
}, [addReaction, announcementId]);
|
||||||
|
|
||||||
|
const transitions = useTransition(visibleReactions, {
|
||||||
|
from: {
|
||||||
|
scale: 0,
|
||||||
|
},
|
||||||
|
enter: {
|
||||||
|
scale: 1,
|
||||||
|
},
|
||||||
|
leave: {
|
||||||
|
scale: 0,
|
||||||
|
},
|
||||||
|
immediate: reduceMotion,
|
||||||
|
keys: visibleReactions.map(x => x.get('name')),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('reactions-bar', {
|
||||||
|
'reactions-bar--empty': visibleReactions.length === 0
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{transitions(({ scale }, reaction) => (
|
||||||
|
<Reaction
|
||||||
|
key={reaction.get('name')}
|
||||||
|
reaction={reaction}
|
||||||
|
style={{ transform: scale.to((s) => `scale(${s})`) }}
|
||||||
|
addReaction={addReaction}
|
||||||
|
removeReaction={removeReaction}
|
||||||
|
announcementId={announcementId}
|
||||||
|
emojiMap={emojiMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{visibleReactions.length < 8 && (
|
||||||
|
<EmojiPickerDropdown
|
||||||
|
onPickEmoji={handleEmojiPick}
|
||||||
|
button={<Icon id='plus' icon={AddIcon} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
ReactionsBar.propTypes = {
|
||||||
announcementId: PropTypes.string.isRequired,
|
announcementId: PropTypes.string.isRequired,
|
||||||
reactions: ImmutablePropTypes.list.isRequired,
|
reactions: ImmutablePropTypes.list.isRequired,
|
||||||
addReaction: PropTypes.func.isRequired,
|
addReaction: PropTypes.func.isRequired,
|
||||||
|
@ -258,54 +309,6 @@ class ReactionsBar extends ImmutablePureComponent {
|
||||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleEmojiPick = data => {
|
|
||||||
const { addReaction, announcementId } = this.props;
|
|
||||||
addReaction(announcementId, data.native.replace(/:/g, ''));
|
|
||||||
};
|
|
||||||
|
|
||||||
willEnter () {
|
|
||||||
return { scale: reduceMotion ? 1 : 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
willLeave () {
|
|
||||||
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { reactions } = this.props;
|
|
||||||
const visibleReactions = reactions.filter(x => x.get('count') > 0);
|
|
||||||
|
|
||||||
const styles = visibleReactions.map(reaction => ({
|
|
||||||
key: reaction.get('name'),
|
|
||||||
data: reaction,
|
|
||||||
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
|
|
||||||
})).toArray();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
|
|
||||||
{items => (
|
|
||||||
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
|
|
||||||
{items.map(({ key, data, style }) => (
|
|
||||||
<Reaction
|
|
||||||
key={key}
|
|
||||||
reaction={data}
|
|
||||||
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
|
|
||||||
announcementId={this.props.announcementId}
|
|
||||||
addReaction={this.props.addReaction}
|
|
||||||
removeReaction={this.props.removeReaction}
|
|
||||||
emojiMap={this.props.emojiMap}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' icon={AddIcon} />} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TransitionMotion>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class Announcement extends ImmutablePureComponent {
|
class Announcement extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { useCallback } from 'react';
|
||||||
|
|
||||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
||||||
import Audio from 'mastodon/features/audio';
|
import Audio from 'mastodon/features/audio';
|
||||||
import Video from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
import Footer from './components/footer';
|
import Footer from './components/footer';
|
||||||
|
@ -35,6 +35,10 @@ export const PictureInPicture: React.FC = () => {
|
||||||
accentColor,
|
accentColor,
|
||||||
} = pipState;
|
} = pipState;
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let player;
|
let player;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
@ -42,11 +46,10 @@ export const PictureInPicture: React.FC = () => {
|
||||||
player = (
|
player = (
|
||||||
<Video
|
<Video
|
||||||
src={src}
|
src={src}
|
||||||
currentTime={currentTime}
|
startTime={currentTime}
|
||||||
volume={volume}
|
startVolume={volume}
|
||||||
muted={muted}
|
startMuted={muted}
|
||||||
autoPlay
|
startPlaying
|
||||||
inline
|
|
||||||
alwaysVisible
|
alwaysVisible
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -24,6 +24,7 @@ import { IconLogo } from 'mastodon/components/logo';
|
||||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||||
import { SearchabilityIcon } from 'mastodon/components/searchability_icon';
|
import { SearchabilityIcon } from 'mastodon/components/searchability_icon';
|
||||||
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
||||||
|
import { Video } from 'mastodon/features/video';
|
||||||
import { enableEmojiReaction, isHideItem } from 'mastodon/initial_state';
|
import { enableEmojiReaction, isHideItem } from 'mastodon/initial_state';
|
||||||
|
|
||||||
import { Avatar } from '../../../components/avatar';
|
import { Avatar } from '../../../components/avatar';
|
||||||
|
@ -34,7 +35,6 @@ import StatusEmojiReactionsBar from '../../../components/status_emoji_reactions_
|
||||||
import CompactedStatusContainer from '../../../containers/compacted_status_container';
|
import CompactedStatusContainer from '../../../containers/compacted_status_container';
|
||||||
import Audio from '../../audio';
|
import Audio from '../../audio';
|
||||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||||
import Video from '../../video';
|
|
||||||
|
|
||||||
import Card from './card';
|
import Card from './card';
|
||||||
|
|
||||||
|
@ -42,7 +42,6 @@ interface VideoModalOptions {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
defaultVolume: number;
|
defaultVolume: number;
|
||||||
componentIndex: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DetailedStatus: React.FC<{
|
export const DetailedStatus: React.FC<{
|
||||||
|
@ -232,8 +231,6 @@ export const DetailedStatus: React.FC<{
|
||||||
src={attachment.get('url')}
|
src={attachment.get('url')}
|
||||||
alt={description}
|
alt={description}
|
||||||
lang={language}
|
lang={language}
|
||||||
width={300}
|
|
||||||
height={150}
|
|
||||||
onOpenVideo={handleOpenVideo}
|
onOpenVideo={handleOpenVideo}
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.get('sensitive')}
|
||||||
visible={showMedia}
|
visible={showMedia}
|
||||||
|
|
|
@ -19,7 +19,7 @@ import { GIFV } from 'mastodon/components/gifv';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
||||||
import Video from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
import { disableSwiping } from 'mastodon/initial_state';
|
import { disableSwiping } from 'mastodon/initial_state';
|
||||||
|
|
||||||
import { ZoomableImage } from './zoomable_image';
|
import { ZoomableImage } from './zoomable_image';
|
||||||
|
@ -205,9 +205,9 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
height={image.get('height')}
|
height={image.get('height')}
|
||||||
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
|
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
|
||||||
aspectRatio={`${image.getIn(['meta', 'original', 'width'])} / ${image.getIn(['meta', 'original', 'height'])}`}
|
aspectRatio={`${image.getIn(['meta', 'original', 'width'])} / ${image.getIn(['meta', 'original', 'height'])}`}
|
||||||
currentTime={currentTime || 0}
|
startTime={currentTime || 0}
|
||||||
autoPlay={autoPlay || false}
|
startPlaying={autoPlay || false}
|
||||||
volume={volume || 1}
|
startVolume={volume || 1}
|
||||||
onCloseVideo={onClose}
|
onCloseVideo={onClose}
|
||||||
detailed
|
detailed
|
||||||
alt={description}
|
alt={description}
|
||||||
|
|
|
@ -1,55 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import spring from 'react-motion/lib/spring';
|
|
||||||
|
|
||||||
import Motion from '../util/optional_motion';
|
|
||||||
|
|
||||||
export default class UploadArea extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
active: PropTypes.bool,
|
|
||||||
onClose: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleKeyUp = (e) => {
|
|
||||||
const keyCode = e.keyCode;
|
|
||||||
if (this.props.active) {
|
|
||||||
switch(keyCode) {
|
|
||||||
case 27:
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.props.onClose();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
window.addEventListener('keyup', this.handleKeyUp, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
window.removeEventListener('keyup', this.handleKeyUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { active } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
|
|
||||||
{({ backgroundOpacity, backgroundScale }) => (
|
|
||||||
<div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
|
|
||||||
<div className='upload-area__drop'>
|
|
||||||
<div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} />
|
|
||||||
<div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Motion>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { animated, config, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
|
import { reduceMotion } from 'mastodon/initial_state';
|
||||||
|
|
||||||
|
interface UploadAreaProps {
|
||||||
|
active?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UploadArea: React.FC<UploadAreaProps> = ({ active, onClose }) => {
|
||||||
|
const handleKeyUp = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (active && e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[active, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keyup', handleKeyUp, false);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
};
|
||||||
|
}, [handleKeyUp]);
|
||||||
|
|
||||||
|
const wrapperAnimStyles = useSpring({
|
||||||
|
from: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
reverse: !active,
|
||||||
|
immediate: reduceMotion,
|
||||||
|
});
|
||||||
|
const backgroundAnimStyles = useSpring({
|
||||||
|
from: {
|
||||||
|
transform: 'scale(0.95)',
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
transform: 'scale(1)',
|
||||||
|
},
|
||||||
|
reverse: !active,
|
||||||
|
config: config.wobbly,
|
||||||
|
immediate: reduceMotion,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<animated.div
|
||||||
|
className='upload-area'
|
||||||
|
style={{
|
||||||
|
...wrapperAnimStyles,
|
||||||
|
visibility: active ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='upload-area__drop'>
|
||||||
|
<animated.div
|
||||||
|
className='upload-area__background'
|
||||||
|
style={backgroundAnimStyles}
|
||||||
|
/>
|
||||||
|
<div className='upload-area__content'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='upload_area.title'
|
||||||
|
defaultMessage='Drag & drop to upload'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -6,7 +6,7 @@ import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
||||||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
||||||
import Video from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
|
|
||||||
const mapStateToProps = (state, { statusId }) => ({
|
const mapStateToProps = (state, { statusId }) => ({
|
||||||
status: state.getIn(['statuses', statusId]),
|
status: state.getIn(['statuses', statusId]),
|
||||||
|
@ -56,9 +56,9 @@ class VideoModal extends ImmutablePureComponent {
|
||||||
aspectRatio={`${media.getIn(['meta', 'original', 'width'])} / ${media.getIn(['meta', 'original', 'height'])}`}
|
aspectRatio={`${media.getIn(['meta', 'original', 'width'])} / ${media.getIn(['meta', 'original', 'height'])}`}
|
||||||
blurhash={media.get('blurhash')}
|
blurhash={media.get('blurhash')}
|
||||||
src={media.get('url')}
|
src={media.get('url')}
|
||||||
currentTime={options.startTime}
|
startTime={options.startTime}
|
||||||
autoPlay={options.autoPlay}
|
startPlaying={options.autoPlay}
|
||||||
volume={options.defaultVolume}
|
startVolume={options.defaultVolume}
|
||||||
onCloseVideo={onClose}
|
onCloseVideo={onClose}
|
||||||
autoFocus
|
autoFocus
|
||||||
detailed
|
detailed
|
||||||
|
|
|
@ -30,7 +30,7 @@ import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding
|
||||||
|
|
||||||
import BundleColumnError from './components/bundle_column_error';
|
import BundleColumnError from './components/bundle_column_error';
|
||||||
import Header from './components/header';
|
import Header from './components/header';
|
||||||
import UploadArea from './components/upload_area';
|
import { UploadArea } from './components/upload_area';
|
||||||
import ColumnsAreaContainer from './containers/columns_area_container';
|
import ColumnsAreaContainer from './containers/columns_area_container';
|
||||||
import LoadingBarContainer from './containers/loading_bar_container';
|
import LoadingBarContainer from './containers/loading_bar_container';
|
||||||
import ModalContainer from './containers/modal_container';
|
import ModalContainer from './containers/modal_container';
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
// APIs for normalizing fullscreen operations. Note that Edge uses
|
|
||||||
// the WebKit-prefixed APIs currently (as of Edge 16).
|
|
||||||
|
|
||||||
export const isFullscreen = () => document.fullscreenElement ||
|
|
||||||
document.webkitFullscreenElement ||
|
|
||||||
document.mozFullScreenElement;
|
|
||||||
|
|
||||||
export const exitFullscreen = () => {
|
|
||||||
if (document.exitFullscreen) {
|
|
||||||
document.exitFullscreen();
|
|
||||||
} else if (document.webkitExitFullscreen) {
|
|
||||||
document.webkitExitFullscreen();
|
|
||||||
} else if (document.mozCancelFullScreen) {
|
|
||||||
document.mozCancelFullScreen();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requestFullscreen = el => {
|
|
||||||
if (el.requestFullscreen) {
|
|
||||||
el.requestFullscreen();
|
|
||||||
} else if (el.webkitRequestFullscreen) {
|
|
||||||
el.webkitRequestFullscreen();
|
|
||||||
} else if (el.mozRequestFullScreen) {
|
|
||||||
el.mozRequestFullScreen();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const attachFullscreenListener = (listener) => {
|
|
||||||
if ('onfullscreenchange' in document) {
|
|
||||||
document.addEventListener('fullscreenchange', listener);
|
|
||||||
} else if ('onwebkitfullscreenchange' in document) {
|
|
||||||
document.addEventListener('webkitfullscreenchange', listener);
|
|
||||||
} else if ('onmozfullscreenchange' in document) {
|
|
||||||
document.addEventListener('mozfullscreenchange', listener);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const detachFullscreenListener = (listener) => {
|
|
||||||
if ('onfullscreenchange' in document) {
|
|
||||||
document.removeEventListener('fullscreenchange', listener);
|
|
||||||
} else if ('onwebkitfullscreenchange' in document) {
|
|
||||||
document.removeEventListener('webkitfullscreenchange', listener);
|
|
||||||
} else if ('onmozfullscreenchange' in document) {
|
|
||||||
document.removeEventListener('mozfullscreenchange', listener);
|
|
||||||
}
|
|
||||||
};
|
|
80
app/javascript/mastodon/features/ui/util/fullscreen.ts
Normal file
80
app/javascript/mastodon/features/ui/util/fullscreen.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
// APIs for normalizing fullscreen operations. Note that Edge uses
|
||||||
|
// the WebKit-prefixed APIs currently (as of Edge 16).
|
||||||
|
|
||||||
|
interface DocumentWithFullscreen extends Document {
|
||||||
|
mozFullScreenElement?: Element;
|
||||||
|
webkitFullscreenElement?: Element;
|
||||||
|
mozCancelFullScreen?: () => void;
|
||||||
|
webkitExitFullscreen?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HTMLElementWithFullscreen extends HTMLElement {
|
||||||
|
mozRequestFullScreen?: () => void;
|
||||||
|
webkitRequestFullscreen?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isFullscreen = () => {
|
||||||
|
const d = document as DocumentWithFullscreen;
|
||||||
|
|
||||||
|
return !!(
|
||||||
|
d.fullscreenElement ??
|
||||||
|
d.webkitFullscreenElement ??
|
||||||
|
d.mozFullScreenElement
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exitFullscreen = () => {
|
||||||
|
const d = document as DocumentWithFullscreen;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (d.exitFullscreen) {
|
||||||
|
void d.exitFullscreen();
|
||||||
|
} else if (d.webkitExitFullscreen) {
|
||||||
|
d.webkitExitFullscreen();
|
||||||
|
} else if (d.mozCancelFullScreen) {
|
||||||
|
d.mozCancelFullScreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requestFullscreen = (el: HTMLElementWithFullscreen | null) => {
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (el.requestFullscreen) {
|
||||||
|
void el.requestFullscreen();
|
||||||
|
} else if (el.webkitRequestFullscreen) {
|
||||||
|
el.webkitRequestFullscreen();
|
||||||
|
} else if (el.mozRequestFullScreen) {
|
||||||
|
el.mozRequestFullScreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const attachFullscreenListener = (listener: () => void) => {
|
||||||
|
const d = document as DocumentWithFullscreen;
|
||||||
|
|
||||||
|
if ('onfullscreenchange' in d) {
|
||||||
|
d.addEventListener('fullscreenchange', listener);
|
||||||
|
} else if ('onwebkitfullscreenchange' in d) {
|
||||||
|
// @ts-expect-error This is valid on some browsers
|
||||||
|
d.addEventListener('webkitfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||||
|
} else if ('onmozfullscreenchange' in d) {
|
||||||
|
// @ts-expect-error This is valid on some browsers
|
||||||
|
d.addEventListener('mozfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detachFullscreenListener = (listener: () => void) => {
|
||||||
|
const d = document as DocumentWithFullscreen;
|
||||||
|
|
||||||
|
if ('onfullscreenchange' in d) {
|
||||||
|
d.removeEventListener('fullscreenchange', listener);
|
||||||
|
} else if ('onwebkitfullscreenchange' in d) {
|
||||||
|
// @ts-expect-error This is valid on some browsers
|
||||||
|
d.removeEventListener('webkitfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||||
|
} else if ('onmozfullscreenchange' in d) {
|
||||||
|
// @ts-expect-error This is valid on some browsers
|
||||||
|
d.removeEventListener('mozfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,7 +0,0 @@
|
||||||
import Motion from 'react-motion/lib/Motion';
|
|
||||||
|
|
||||||
import { reduceMotion } from '../../../initial_state';
|
|
||||||
|
|
||||||
import ReducedMotion from './reduced_motion';
|
|
||||||
|
|
||||||
export default reduceMotion ? ReducedMotion : Motion;
|
|
|
@ -1,45 +0,0 @@
|
||||||
// Like react-motion's Motion, but reduces all animations to cross-fades
|
|
||||||
// for the benefit of users with motion sickness.
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Component } from 'react';
|
|
||||||
|
|
||||||
import Motion from 'react-motion/lib/Motion';
|
|
||||||
|
|
||||||
const stylesToKeep = ['opacity', 'backgroundOpacity'];
|
|
||||||
|
|
||||||
const extractValue = (value) => {
|
|
||||||
// This is either an object with a "val" property or it's a number
|
|
||||||
return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
|
|
||||||
};
|
|
||||||
|
|
||||||
class ReducedMotion extends Component {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
defaultStyle: PropTypes.object,
|
|
||||||
style: PropTypes.object,
|
|
||||||
children: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
|
|
||||||
const { style, defaultStyle, children } = this.props;
|
|
||||||
|
|
||||||
Object.keys(style).forEach(key => {
|
|
||||||
if (stylesToKeep.includes(key)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If it's setting an x or height or scale or some other value, we need
|
|
||||||
// to preserve the end-state value without actually animating it
|
|
||||||
style[key] = defaultStyle[key] = extractValue(style[key]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Motion style={style} defaultStyle={defaultStyle}>
|
|
||||||
{children}
|
|
||||||
</Motion>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReducedMotion;
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import type { MessageDescriptor } from 'react-intl';
|
||||||
|
|
||||||
|
import { useTransition, animated } from '@react-spring/web';
|
||||||
|
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import type { IconProp } from 'mastodon/components/icon';
|
||||||
|
|
||||||
|
export interface HotkeyEvent {
|
||||||
|
key: number;
|
||||||
|
icon: IconProp;
|
||||||
|
label: MessageDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HotkeyIndicator: React.FC<{
|
||||||
|
events: HotkeyEvent[];
|
||||||
|
onDismiss: (e: HotkeyEvent) => void;
|
||||||
|
}> = ({ events, onDismiss }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const transitions = useTransition(events, {
|
||||||
|
from: { opacity: 0 },
|
||||||
|
keys: (item) => item.key,
|
||||||
|
enter: [{ opacity: 1 }],
|
||||||
|
leave: [{ opacity: 0 }],
|
||||||
|
onRest: (_result, _ctrl, item) => {
|
||||||
|
onDismiss(item);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{transitions((style, item) => (
|
||||||
|
<animated.div className='video-player__hotkey-indicator' style={style}>
|
||||||
|
<Icon id='' icon={item.icon} />
|
||||||
|
<span className='video-player__hotkey-indicator__label'>
|
||||||
|
{intl.formatMessage(item.label)}
|
||||||
|
</span>
|
||||||
|
</animated.div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,650 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { is } from 'immutable';
|
|
||||||
|
|
||||||
import { throttle } from 'lodash';
|
|
||||||
|
|
||||||
import FullscreenIcon from '@/material-icons/400-24px/fullscreen.svg?react';
|
|
||||||
import FullscreenExitIcon from '@/material-icons/400-24px/fullscreen_exit.svg?react';
|
|
||||||
import PauseIcon from '@/material-icons/400-24px/pause.svg?react';
|
|
||||||
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
|
||||||
import RectangleIcon from '@/material-icons/400-24px/rectangle.svg?react';
|
|
||||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
|
||||||
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
|
|
||||||
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
|
|
||||||
import { Blurhash } from 'mastodon/components/blurhash';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
|
||||||
import { playerSettings } from 'mastodon/settings';
|
|
||||||
|
|
||||||
import { displayMedia, useBlurhash } from '../../initial_state';
|
|
||||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
|
||||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
|
||||||
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
|
|
||||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
|
|
||||||
hide: { id: 'video.hide', defaultMessage: 'Hide video' },
|
|
||||||
expand: { id: 'video.expand', defaultMessage: 'Expand video' },
|
|
||||||
close: { id: 'video.close', defaultMessage: 'Close video' },
|
|
||||||
fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
|
|
||||||
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const formatTime = secondsNum => {
|
|
||||||
let hours = Math.floor(secondsNum / 3600);
|
|
||||||
let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
|
|
||||||
let seconds = secondsNum - (hours * 3600) - (minutes * 60);
|
|
||||||
|
|
||||||
if (hours < 10) hours = '0' + hours;
|
|
||||||
if (minutes < 10) minutes = '0' + minutes;
|
|
||||||
if (seconds < 10) seconds = '0' + seconds;
|
|
||||||
|
|
||||||
return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findElementPosition = el => {
|
|
||||||
let box;
|
|
||||||
|
|
||||||
if (el.getBoundingClientRect && el.parentNode) {
|
|
||||||
box = el.getBoundingClientRect();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!box) {
|
|
||||||
return {
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const docEl = document.documentElement;
|
|
||||||
const body = document.body;
|
|
||||||
|
|
||||||
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
|
|
||||||
const scrollLeft = window.pageXOffset || body.scrollLeft;
|
|
||||||
const left = (box.left + scrollLeft) - clientLeft;
|
|
||||||
|
|
||||||
const clientTop = docEl.clientTop || body.clientTop || 0;
|
|
||||||
const scrollTop = window.pageYOffset || body.scrollTop;
|
|
||||||
const top = (box.top + scrollTop) - clientTop;
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: Math.round(left),
|
|
||||||
top: Math.round(top),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPointerPosition = (el, event) => {
|
|
||||||
const position = {};
|
|
||||||
const box = findElementPosition(el);
|
|
||||||
const boxW = el.offsetWidth;
|
|
||||||
const boxH = el.offsetHeight;
|
|
||||||
const boxY = box.top;
|
|
||||||
const boxX = box.left;
|
|
||||||
|
|
||||||
let pageY = event.pageY;
|
|
||||||
let pageX = event.pageX;
|
|
||||||
|
|
||||||
if (event.changedTouches) {
|
|
||||||
pageX = event.changedTouches[0].pageX;
|
|
||||||
pageY = event.changedTouches[0].pageY;
|
|
||||||
}
|
|
||||||
|
|
||||||
position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
|
|
||||||
position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
|
|
||||||
|
|
||||||
return position;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fileNameFromURL = str => {
|
|
||||||
const url = new URL(str);
|
|
||||||
const pathname = url.pathname;
|
|
||||||
const index = pathname.lastIndexOf('/');
|
|
||||||
|
|
||||||
return pathname.slice(index + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
class Video extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
preview: PropTypes.string,
|
|
||||||
frameRate: PropTypes.string,
|
|
||||||
aspectRatio: PropTypes.string,
|
|
||||||
src: PropTypes.string.isRequired,
|
|
||||||
alt: PropTypes.string,
|
|
||||||
lang: PropTypes.string,
|
|
||||||
sensitive: PropTypes.bool,
|
|
||||||
currentTime: PropTypes.number,
|
|
||||||
onOpenVideo: PropTypes.func,
|
|
||||||
onCloseVideo: PropTypes.func,
|
|
||||||
detailed: PropTypes.bool,
|
|
||||||
editable: PropTypes.bool,
|
|
||||||
alwaysVisible: PropTypes.bool,
|
|
||||||
visible: PropTypes.bool,
|
|
||||||
onToggleVisibility: PropTypes.func,
|
|
||||||
deployPictureInPicture: PropTypes.func,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
blurhash: PropTypes.string,
|
|
||||||
autoPlay: PropTypes.bool,
|
|
||||||
volume: PropTypes.number,
|
|
||||||
muted: PropTypes.bool,
|
|
||||||
componentIndex: PropTypes.number,
|
|
||||||
autoFocus: PropTypes.bool,
|
|
||||||
matchedFilters: PropTypes.arrayOf(PropTypes.string),
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
frameRate: '25',
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
currentTime: 0,
|
|
||||||
duration: 0,
|
|
||||||
volume: 0.5,
|
|
||||||
paused: true,
|
|
||||||
dragging: false,
|
|
||||||
fullscreen: false,
|
|
||||||
hovered: false,
|
|
||||||
muted: false,
|
|
||||||
revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
|
|
||||||
};
|
|
||||||
|
|
||||||
setPlayerRef = c => {
|
|
||||||
this.player = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
setVideoRef = c => {
|
|
||||||
this.video = c;
|
|
||||||
|
|
||||||
if (this.video) {
|
|
||||||
this.setState({ volume: this.video.volume, muted: this.video.muted });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setSeekRef = c => {
|
|
||||||
this.seek = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
setVolumeRef = c => {
|
|
||||||
this.volume = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClickRoot = e => e.stopPropagation();
|
|
||||||
|
|
||||||
handlePlay = () => {
|
|
||||||
this.setState({ paused: false });
|
|
||||||
this._updateTime();
|
|
||||||
};
|
|
||||||
|
|
||||||
handlePause = () => {
|
|
||||||
this.setState({ paused: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
_updateTime () {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!this.video) return;
|
|
||||||
|
|
||||||
this.handleTimeUpdate();
|
|
||||||
|
|
||||||
if (!this.state.paused) {
|
|
||||||
this._updateTime();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTimeUpdate = () => {
|
|
||||||
this.setState({
|
|
||||||
currentTime: this.video.currentTime,
|
|
||||||
duration:this.video.duration,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleVolumeMouseDown = e => {
|
|
||||||
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
|
||||||
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
|
||||||
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
|
||||||
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
|
|
||||||
|
|
||||||
this.handleMouseVolSlide(e);
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleVolumeMouseUp = () => {
|
|
||||||
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
|
|
||||||
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
|
|
||||||
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
|
|
||||||
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseVolSlide = throttle(e => {
|
|
||||||
const { x } = getPointerPosition(this.volume, e);
|
|
||||||
|
|
||||||
if(!isNaN(x)) {
|
|
||||||
this.setState((state) => ({ volume: x, muted: state.muted && x === 0 }), () => {
|
|
||||||
this._syncVideoToVolumeState(x);
|
|
||||||
this._saveVolumeState(x);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 15);
|
|
||||||
|
|
||||||
handleMouseDown = e => {
|
|
||||||
document.addEventListener('mousemove', this.handleMouseMove, true);
|
|
||||||
document.addEventListener('mouseup', this.handleMouseUp, true);
|
|
||||||
document.addEventListener('touchmove', this.handleMouseMove, true);
|
|
||||||
document.addEventListener('touchend', this.handleMouseUp, true);
|
|
||||||
|
|
||||||
this.setState({ dragging: true });
|
|
||||||
this.video.pause();
|
|
||||||
this.handleMouseMove(e);
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseUp = () => {
|
|
||||||
document.removeEventListener('mousemove', this.handleMouseMove, true);
|
|
||||||
document.removeEventListener('mouseup', this.handleMouseUp, true);
|
|
||||||
document.removeEventListener('touchmove', this.handleMouseMove, true);
|
|
||||||
document.removeEventListener('touchend', this.handleMouseUp, true);
|
|
||||||
|
|
||||||
this.setState({ dragging: false });
|
|
||||||
this.video.play();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseMove = throttle(e => {
|
|
||||||
const { x } = getPointerPosition(this.seek, e);
|
|
||||||
const currentTime = this.video.duration * x;
|
|
||||||
|
|
||||||
if (!isNaN(currentTime)) {
|
|
||||||
this.setState({ currentTime }, () => {
|
|
||||||
this.video.currentTime = currentTime;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 15);
|
|
||||||
|
|
||||||
seekBy (time) {
|
|
||||||
const currentTime = this.video.currentTime + time;
|
|
||||||
|
|
||||||
if (!isNaN(currentTime)) {
|
|
||||||
this.setState({ currentTime }, () => {
|
|
||||||
this.video.currentTime = currentTime;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleVideoKeyDown = e => {
|
|
||||||
// On the video element or the seek bar, we can safely use the space bar
|
|
||||||
// for playback control because there are no buttons to press
|
|
||||||
|
|
||||||
if (e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.togglePlay();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleKeyDown = e => {
|
|
||||||
const frameTime = 1 / this.getFrameRate();
|
|
||||||
|
|
||||||
switch(e.key) {
|
|
||||||
case 'k':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.togglePlay();
|
|
||||||
break;
|
|
||||||
case 'm':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.toggleMute();
|
|
||||||
break;
|
|
||||||
case 'f':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.toggleFullscreen();
|
|
||||||
break;
|
|
||||||
case 'j':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.seekBy(-10);
|
|
||||||
break;
|
|
||||||
case 'l':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.seekBy(10);
|
|
||||||
break;
|
|
||||||
case ',':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.seekBy(-frameTime);
|
|
||||||
break;
|
|
||||||
case '.':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.seekBy(frameTime);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we are in fullscreen mode, we don't want any hotkeys
|
|
||||||
// interacting with the UI that's not visible
|
|
||||||
|
|
||||||
if (this.state.fullscreen) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
exitFullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
togglePlay = () => {
|
|
||||||
if (this.state.paused) {
|
|
||||||
this.setState({ paused: false }, () => this.video.play());
|
|
||||||
} else {
|
|
||||||
this.setState({ paused: true }, () => this.video.pause());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
toggleFullscreen = () => {
|
|
||||||
if (isFullscreen()) {
|
|
||||||
exitFullscreen();
|
|
||||||
} else {
|
|
||||||
requestFullscreen(this.player);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
|
|
||||||
document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
|
|
||||||
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
|
|
||||||
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
|
||||||
|
|
||||||
window.addEventListener('scroll', this.handleScroll);
|
|
||||||
|
|
||||||
this._syncVideoFromLocalStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
window.removeEventListener('scroll', this.handleScroll);
|
|
||||||
|
|
||||||
document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
|
|
||||||
document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
|
|
||||||
document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
|
|
||||||
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
|
||||||
|
|
||||||
if (!this.state.paused && this.video && this.props.deployPictureInPicture) {
|
|
||||||
this.props.deployPictureInPicture('video', {
|
|
||||||
src: this.props.src,
|
|
||||||
currentTime: this.video.currentTime,
|
|
||||||
muted: this.video.muted,
|
|
||||||
volume: this.video.volume,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
|
||||||
if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
|
|
||||||
this.setState({ revealed: nextProps.visible });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps, prevState) {
|
|
||||||
if (prevState.revealed && !this.state.revealed && this.video) {
|
|
||||||
this.video.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleScroll = throttle(() => {
|
|
||||||
if (!this.video) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { top, height } = this.video.getBoundingClientRect();
|
|
||||||
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
|
|
||||||
|
|
||||||
if (!this.state.paused && !inView) {
|
|
||||||
this.video.pause();
|
|
||||||
|
|
||||||
if (this.props.deployPictureInPicture) {
|
|
||||||
this.props.deployPictureInPicture('video', {
|
|
||||||
src: this.props.src,
|
|
||||||
currentTime: this.video.currentTime,
|
|
||||||
muted: this.video.muted,
|
|
||||||
volume: this.video.volume,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ paused: true });
|
|
||||||
}
|
|
||||||
}, 150, { trailing: true });
|
|
||||||
|
|
||||||
handleFullscreenChange = () => {
|
|
||||||
this.setState({ fullscreen: isFullscreen() });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseEnter = () => {
|
|
||||||
this.setState({ hovered: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseLeave = () => {
|
|
||||||
this.setState({ hovered: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
toggleMute = () => {
|
|
||||||
const muted = !(this.video.muted || this.state.volume === 0);
|
|
||||||
|
|
||||||
this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => {
|
|
||||||
this._syncVideoToVolumeState();
|
|
||||||
this._saveVolumeState();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
_syncVideoToVolumeState = (volume = null, muted = null) => {
|
|
||||||
if (!this.video) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.video.volume = volume ?? this.state.volume;
|
|
||||||
this.video.muted = muted ?? this.state.muted;
|
|
||||||
};
|
|
||||||
|
|
||||||
_saveVolumeState = (volume = null, muted = null) => {
|
|
||||||
playerSettings.set('volume', volume ?? this.state.volume);
|
|
||||||
playerSettings.set('muted', muted ?? this.state.muted);
|
|
||||||
};
|
|
||||||
|
|
||||||
_syncVideoFromLocalStorage = () => {
|
|
||||||
this.setState({ volume: playerSettings.get('volume') ?? 0.5, muted: playerSettings.get('muted') ?? false }, () => {
|
|
||||||
this._syncVideoToVolumeState();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
toggleReveal = () => {
|
|
||||||
if (this.props.onToggleVisibility) {
|
|
||||||
this.props.onToggleVisibility();
|
|
||||||
} else {
|
|
||||||
this.setState({ revealed: !this.state.revealed });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleLoadedData = () => {
|
|
||||||
const { currentTime, volume, muted, autoPlay } = this.props;
|
|
||||||
|
|
||||||
if (currentTime) {
|
|
||||||
this.video.currentTime = currentTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (volume !== undefined) {
|
|
||||||
this.video.volume = volume;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (muted !== undefined) {
|
|
||||||
this.video.muted = muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (autoPlay) {
|
|
||||||
this.video.play();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleProgress = () => {
|
|
||||||
const lastTimeRange = this.video.buffered.length - 1;
|
|
||||||
|
|
||||||
if (lastTimeRange > -1) {
|
|
||||||
this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleVolumeChange = () => {
|
|
||||||
this.setState({ volume: this.video.volume, muted: this.video.muted });
|
|
||||||
this._saveVolumeState(this.video.volume, this.video.muted);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOpenVideo = () => {
|
|
||||||
this.video.pause();
|
|
||||||
|
|
||||||
this.props.onOpenVideo(this.props.lang, {
|
|
||||||
startTime: this.video.currentTime,
|
|
||||||
autoPlay: !this.state.paused,
|
|
||||||
defaultVolume: this.state.volume,
|
|
||||||
componentIndex: this.props.componentIndex,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleCloseVideo = () => {
|
|
||||||
this.video.pause();
|
|
||||||
this.props.onCloseVideo();
|
|
||||||
};
|
|
||||||
|
|
||||||
getFrameRate () {
|
|
||||||
if (this.props.frameRate && isNaN(this.props.frameRate)) {
|
|
||||||
// The frame rate is returned as a fraction string so we
|
|
||||||
// need to convert it to a number
|
|
||||||
|
|
||||||
return this.props.frameRate.split('/').reduce((p, c) => p / c);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.frameRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { preview, src, aspectRatio, onOpenVideo, onCloseVideo, intl, alt, lang, detailed, sensitive, editable, blurhash, autoFocus, matchedFilters } = this.props;
|
|
||||||
const { currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, revealed } = this.state;
|
|
||||||
const progress = Math.min((currentTime / duration) * 100, 100);
|
|
||||||
const muted = this.state.muted || volume === 0;
|
|
||||||
|
|
||||||
let preload;
|
|
||||||
|
|
||||||
if (this.props.currentTime || fullscreen || dragging) {
|
|
||||||
preload = 'auto';
|
|
||||||
} else if (detailed) {
|
|
||||||
preload = 'metadata';
|
|
||||||
} else {
|
|
||||||
preload = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// The outer wrapper is necessary to avoid reflowing the layout when going into full screen
|
|
||||||
return (
|
|
||||||
<div style={{ aspectRatio }}>
|
|
||||||
<div
|
|
||||||
role='menuitem'
|
|
||||||
className={classNames('video-player', { inactive: !revealed, detailed, fullscreen, editable })}
|
|
||||||
style={{ aspectRatio }}
|
|
||||||
ref={this.setPlayerRef}
|
|
||||||
onMouseEnter={this.handleMouseEnter}
|
|
||||||
onMouseLeave={this.handleMouseLeave}
|
|
||||||
onClick={this.handleClickRoot}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<Blurhash
|
|
||||||
hash={blurhash}
|
|
||||||
className={classNames('media-gallery__preview', {
|
|
||||||
'media-gallery__preview--hidden': revealed,
|
|
||||||
})}
|
|
||||||
dummy={!useBlurhash}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(revealed || editable) && <video
|
|
||||||
ref={this.setVideoRef}
|
|
||||||
src={src}
|
|
||||||
poster={preview}
|
|
||||||
preload={preload}
|
|
||||||
role='button'
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={alt}
|
|
||||||
title={alt}
|
|
||||||
lang={lang}
|
|
||||||
onClick={this.togglePlay}
|
|
||||||
onKeyDown={this.handleVideoKeyDown}
|
|
||||||
onPlay={this.handlePlay}
|
|
||||||
onPause={this.handlePause}
|
|
||||||
onLoadedData={this.handleLoadedData}
|
|
||||||
onProgress={this.handleProgress}
|
|
||||||
onVolumeChange={this.handleVolumeChange}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>}
|
|
||||||
|
|
||||||
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
|
|
||||||
|
|
||||||
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
|
||||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
|
||||||
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
|
||||||
<div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={classNames('video-player__seek__handle', { active: dragging })}
|
|
||||||
tabIndex={0}
|
|
||||||
style={{ left: `${progress}%` }}
|
|
||||||
onKeyDown={this.handleVideoKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='video-player__buttons-bar'>
|
|
||||||
<div className='video-player__buttons left'>
|
|
||||||
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay} autoFocus={autoFocus}><Icon id={paused ? 'play' : 'pause'} icon={paused ? PlayArrowIcon : PauseIcon} /></button>
|
|
||||||
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} icon={muted ? VolumeOffIcon : VolumeUpIcon} /></button>
|
|
||||||
|
|
||||||
<div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
|
||||||
<div className='video-player__volume__current' style={{ width: `${muted ? 0 : volume * 100}%` }} />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={classNames('video-player__volume__handle')}
|
|
||||||
tabIndex={0}
|
|
||||||
style={{ left: `${muted ? 0 : volume * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(detailed || fullscreen) && (
|
|
||||||
<span className='video-player__time'>
|
|
||||||
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
|
||||||
<span className='video-player__time-sep'>/</span>
|
|
||||||
<span className='video-player__time-total'>{formatTime(Math.floor(duration))}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='video-player__buttons right'>
|
|
||||||
{(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>}
|
|
||||||
{(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} className='player-button' onClick={this.handleOpenVideo}><Icon id='expand' icon={RectangleIcon} /></button>}
|
|
||||||
{onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} className='player-button' onClick={this.handleCloseVideo}><Icon id='compress' icon={FullscreenExitIcon} /></button>}
|
|
||||||
<button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} className='player-button' onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} icon={fullscreen ? FullscreenExitIcon : FullscreenIcon} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(Video);
|
|
1045
app/javascript/mastodon/features/video/index.tsx
Normal file
1045
app/javascript/mastodon/features/video/index.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
@ -278,7 +278,5 @@
|
||||||
"tabs_bar.notifications": "Kennisgewings",
|
"tabs_bar.notifications": "Kennisgewings",
|
||||||
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {# days}}",
|
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {# days}}",
|
||||||
"upload_progress.label": "Uploading…",
|
"upload_progress.label": "Uploading…",
|
||||||
"video.fullscreen": "Volskerm",
|
"video.fullscreen": "Volskerm"
|
||||||
"video.mute": "Klank afskakel",
|
|
||||||
"video.unmute": "Klank aanskakel"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -506,8 +506,6 @@
|
||||||
"video.expand": "Expandir video",
|
"video.expand": "Expandir video",
|
||||||
"video.fullscreen": "Pantalla completa",
|
"video.fullscreen": "Pantalla completa",
|
||||||
"video.hide": "Amagar video",
|
"video.hide": "Amagar video",
|
||||||
"video.mute": "Silenciar son",
|
|
||||||
"video.pause": "Pausar",
|
"video.pause": "Pausar",
|
||||||
"video.play": "Reproducir",
|
"video.play": "Reproducir"
|
||||||
"video.unmute": "Deixar de silenciar son"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -784,8 +784,6 @@
|
||||||
"video.expand": "توسيع الفيديو",
|
"video.expand": "توسيع الفيديو",
|
||||||
"video.fullscreen": "ملء الشاشة",
|
"video.fullscreen": "ملء الشاشة",
|
||||||
"video.hide": "إخفاء الفيديو",
|
"video.hide": "إخفاء الفيديو",
|
||||||
"video.mute": "كتم الصوت",
|
|
||||||
"video.pause": "إيقاف مؤقت",
|
"video.pause": "إيقاف مؤقت",
|
||||||
"video.play": "تشغيل",
|
"video.play": "تشغيل"
|
||||||
"video.unmute": "تشغيل الصوت"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -577,8 +577,6 @@
|
||||||
"video.expand": "Espander el videu",
|
"video.expand": "Espander el videu",
|
||||||
"video.fullscreen": "Pantalla completa",
|
"video.fullscreen": "Pantalla completa",
|
||||||
"video.hide": "Esconder el videu",
|
"video.hide": "Esconder el videu",
|
||||||
"video.mute": "Desactivar el soníu",
|
|
||||||
"video.pause": "Posar",
|
"video.pause": "Posar",
|
||||||
"video.play": "Reproducir",
|
"video.play": "Reproducir"
|
||||||
"video.unmute": "Activar el soníu"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -504,6 +504,7 @@
|
||||||
"navigation_bar.follows_and_followers": "Падпіскі і падпісчыкі",
|
"navigation_bar.follows_and_followers": "Падпіскі і падпісчыкі",
|
||||||
"navigation_bar.lists": "Спісы",
|
"navigation_bar.lists": "Спісы",
|
||||||
"navigation_bar.logout": "Выйсці",
|
"navigation_bar.logout": "Выйсці",
|
||||||
|
"navigation_bar.moderation": "Мадэрацыя",
|
||||||
"navigation_bar.mutes": "Ігнараваныя карыстальнікі",
|
"navigation_bar.mutes": "Ігнараваныя карыстальнікі",
|
||||||
"navigation_bar.opened_in_classic_interface": "Допісы, уліковыя запісы і іншыя спецыфічныя старонкі па змоўчанні адчыняюцца ў класічным вэб-інтэрфейсе.",
|
"navigation_bar.opened_in_classic_interface": "Допісы, уліковыя запісы і іншыя спецыфічныя старонкі па змоўчанні адчыняюцца ў класічным вэб-інтэрфейсе.",
|
||||||
"navigation_bar.personal": "Асабістае",
|
"navigation_bar.personal": "Асабістае",
|
||||||
|
@ -841,8 +842,6 @@
|
||||||
"video.expand": "Разгарнуць відэа",
|
"video.expand": "Разгарнуць відэа",
|
||||||
"video.fullscreen": "Увесь экран",
|
"video.fullscreen": "Увесь экран",
|
||||||
"video.hide": "Схаваць відэа",
|
"video.hide": "Схаваць відэа",
|
||||||
"video.mute": "Адключыць гук",
|
|
||||||
"video.pause": "Паўза",
|
"video.pause": "Паўза",
|
||||||
"video.play": "Прайграць",
|
"video.play": "Прайграць"
|
||||||
"video.unmute": "Уключыць гук"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,12 @@
|
||||||
"video.expand": "Разгъване на видеото",
|
"video.expand": "Разгъване на видеото",
|
||||||
"video.fullscreen": "Цял екран",
|
"video.fullscreen": "Цял екран",
|
||||||
"video.hide": "Скриване на видеото",
|
"video.hide": "Скриване на видеото",
|
||||||
"video.mute": "Обеззвучаване",
|
"video.mute": "Заглушаване",
|
||||||
"video.pause": "Пауза",
|
"video.pause": "Пауза",
|
||||||
"video.play": "Пускане",
|
"video.play": "Пускане",
|
||||||
"video.unmute": "Включване на звука"
|
"video.skip_backward": "Прескок назад",
|
||||||
|
"video.skip_forward": "Прескок напред",
|
||||||
|
"video.unmute": "Без заглушаване",
|
||||||
|
"video.volume_down": "Намаляване на звука",
|
||||||
|
"video.volume_up": "Увеличаване на звука"
|
||||||
}
|
}
|
||||||
|
|
|
@ -436,8 +436,6 @@
|
||||||
"video.expand": "ভিডিওটি বড়ো করতে",
|
"video.expand": "ভিডিওটি বড়ো করতে",
|
||||||
"video.fullscreen": "পূর্ণ পর্দা করতে",
|
"video.fullscreen": "পূর্ণ পর্দা করতে",
|
||||||
"video.hide": "ভিডিওটি লুকাতে",
|
"video.hide": "ভিডিওটি লুকাতে",
|
||||||
"video.mute": "শব্দ বন্ধ করতে",
|
|
||||||
"video.pause": "থামাতে",
|
"video.pause": "থামাতে",
|
||||||
"video.play": "শুরু করতে",
|
"video.play": "শুরু করতে"
|
||||||
"video.unmute": "শব্দ চালু করতে"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -634,8 +634,6 @@
|
||||||
"video.expand": "Ledanaat ar video",
|
"video.expand": "Ledanaat ar video",
|
||||||
"video.fullscreen": "Skramm a-bezh",
|
"video.fullscreen": "Skramm a-bezh",
|
||||||
"video.hide": "Kuzhat ar video",
|
"video.hide": "Kuzhat ar video",
|
||||||
"video.mute": "Paouez gant ar son",
|
|
||||||
"video.pause": "Paouez",
|
"video.pause": "Paouez",
|
||||||
"video.play": "Lenn",
|
"video.play": "Lenn"
|
||||||
"video.unmute": "Lakaat ar son en-dro"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -907,5 +907,9 @@
|
||||||
"video.mute": "Silencia",
|
"video.mute": "Silencia",
|
||||||
"video.pause": "Pausa",
|
"video.pause": "Pausa",
|
||||||
"video.play": "Reprodueix",
|
"video.play": "Reprodueix",
|
||||||
"video.unmute": "Activa el so"
|
"video.skip_backward": "Salta enrere",
|
||||||
|
"video.skip_forward": "Salta endavant",
|
||||||
|
"video.unmute": "Deixa de silenciar",
|
||||||
|
"video.volume_down": "Abaixa el volum",
|
||||||
|
"video.volume_up": "Apuja el volum"
|
||||||
}
|
}
|
||||||
|
|
|
@ -563,8 +563,6 @@
|
||||||
"video.expand": "ڤیدیۆفراوان بکە",
|
"video.expand": "ڤیدیۆفراوان بکە",
|
||||||
"video.fullscreen": "پڕپیشانگەر",
|
"video.fullscreen": "پڕپیشانگەر",
|
||||||
"video.hide": "شاردنەوەی ڤیدیۆ",
|
"video.hide": "شاردنەوەی ڤیدیۆ",
|
||||||
"video.mute": "دەنگی کپ",
|
|
||||||
"video.pause": "وەستان",
|
"video.pause": "وەستان",
|
||||||
"video.play": "لێی بدە",
|
"video.play": "لێی بدە"
|
||||||
"video.unmute": "بێدەنگی مەکە"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -341,8 +341,6 @@
|
||||||
"video.expand": "Ingrandà a video",
|
"video.expand": "Ingrandà a video",
|
||||||
"video.fullscreen": "Pienu screnu",
|
"video.fullscreen": "Pienu screnu",
|
||||||
"video.hide": "Piattà a video",
|
"video.hide": "Piattà a video",
|
||||||
"video.mute": "Surdina",
|
|
||||||
"video.pause": "Pausa",
|
"video.pause": "Pausa",
|
||||||
"video.play": "Lettura",
|
"video.play": "Lettura"
|
||||||
"video.unmute": "Caccià a surdina"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -669,7 +669,7 @@
|
||||||
"notifications_permission_banner.title": "Nenechte si nic uniknout",
|
"notifications_permission_banner.title": "Nenechte si nic uniknout",
|
||||||
"onboarding.follows.back": "Zpět",
|
"onboarding.follows.back": "Zpět",
|
||||||
"onboarding.follows.done": "Hotovo",
|
"onboarding.follows.done": "Hotovo",
|
||||||
"onboarding.follows.empty": "Bohužel, žádné výsledky nelze momentálně zobrazit. Můžete zkusit vyhledat nebo procházet stránku s průzkumem a najít lidi, kteří budou sledovat, nebo to zkuste znovu později.",
|
"onboarding.follows.empty": "Bohužel, žádné výsledky nelze momentálně zobrazit. Můžete zkusit najít uživatele ke sledování za pomocí vyhledávání nebo na stránce „Objevit“, nebo to zkuste znovu později.",
|
||||||
"onboarding.follows.search": "Hledat",
|
"onboarding.follows.search": "Hledat",
|
||||||
"onboarding.follows.title": "Sledujte lidi a začněte",
|
"onboarding.follows.title": "Sledujte lidi a začněte",
|
||||||
"onboarding.profile.discoverable": "Udělat svůj profil vyhledatelným",
|
"onboarding.profile.discoverable": "Udělat svůj profil vyhledatelným",
|
||||||
|
@ -905,8 +905,12 @@
|
||||||
"video.expand": "Rozbalit video",
|
"video.expand": "Rozbalit video",
|
||||||
"video.fullscreen": "Režim celé obrazovky",
|
"video.fullscreen": "Režim celé obrazovky",
|
||||||
"video.hide": "Skrýt video",
|
"video.hide": "Skrýt video",
|
||||||
"video.mute": "Vypnout zvuk",
|
"video.mute": "Ztlumit",
|
||||||
"video.pause": "Pauza",
|
"video.pause": "Pauza",
|
||||||
"video.play": "Přehrát",
|
"video.play": "Přehrát",
|
||||||
"video.unmute": "Zapnout zvuk"
|
"video.skip_backward": "Přeskočit zpět",
|
||||||
|
"video.skip_forward": "Přeskočit vpřed",
|
||||||
|
"video.unmute": "Zrušit ztlumení",
|
||||||
|
"video.volume_down": "Snížit hlasitost",
|
||||||
|
"video.volume_up": "Zvýšit hlasitost"
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,15 +23,15 @@
|
||||||
"account.copy": "Copïo dolen i'r proffil",
|
"account.copy": "Copïo dolen i'r proffil",
|
||||||
"account.direct": "Crybwyll yn breifat @{name}",
|
"account.direct": "Crybwyll yn breifat @{name}",
|
||||||
"account.disable_notifications": "Stopiwch fy hysbysu pan fydd @{name} yn postio",
|
"account.disable_notifications": "Stopiwch fy hysbysu pan fydd @{name} yn postio",
|
||||||
"account.domain_blocked": "Parth wedi ei flocio",
|
"account.domain_blocked": "Parth wedi'i rwystro",
|
||||||
"account.edit_profile": "Golygu proffil",
|
"account.edit_profile": "Golygu proffil",
|
||||||
"account.enable_notifications": "Rhowch wybod i fi pan fydd @{name} yn postio",
|
"account.enable_notifications": "Rhowch wybod i fi pan fydd @{name} yn postio",
|
||||||
"account.endorse": "Dangos ar fy mhroffil",
|
"account.endorse": "Dangos ar fy mhroffil",
|
||||||
"account.featured_tags.last_status_at": "Y postiad diwethaf ar {date}",
|
"account.featured_tags.last_status_at": "Y postiad olaf ar {date}",
|
||||||
"account.featured_tags.last_status_never": "Dim postiadau",
|
"account.featured_tags.last_status_never": "Dim postiadau",
|
||||||
"account.featured_tags.title": "Prif hashnodau {name}",
|
"account.featured_tags.title": "Prif hashnodau {name}",
|
||||||
"account.follow": "Dilyn",
|
"account.follow": "Dilyn",
|
||||||
"account.follow_back": "Dilyn yn ôl",
|
"account.follow_back": "Dilyn nôl",
|
||||||
"account.followers": "Dilynwyr",
|
"account.followers": "Dilynwyr",
|
||||||
"account.followers.empty": "Does neb yn dilyn y defnyddiwr hwn eto.",
|
"account.followers.empty": "Does neb yn dilyn y defnyddiwr hwn eto.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} dilynwr} two {{counter} ddilynwr} other {{counter} dilynwyr}}",
|
"account.followers_counter": "{count, plural, one {{counter} dilynwr} two {{counter} ddilynwr} other {{counter} dilynwyr}}",
|
||||||
|
@ -60,12 +60,12 @@
|
||||||
"account.report": "Adrodd @{name}",
|
"account.report": "Adrodd @{name}",
|
||||||
"account.requested": "Aros am gymeradwyaeth. Cliciwch er mwyn canslo cais dilyn",
|
"account.requested": "Aros am gymeradwyaeth. Cliciwch er mwyn canslo cais dilyn",
|
||||||
"account.requested_follow": "Mae {name} wedi gwneud cais i'ch dilyn",
|
"account.requested_follow": "Mae {name} wedi gwneud cais i'ch dilyn",
|
||||||
"account.share": "Rhannwch broffil @{name}",
|
"account.share": "Rhannu proffil @{name}",
|
||||||
"account.show_reblogs": "Dangos hybiau gan @{name}",
|
"account.show_reblogs": "Dangos hybiau gan @{name}",
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} postiad} two {{counter} bostiad} few {{counter} phostiad} many {{counter} postiad} other {{counter} postiad}}",
|
"account.statuses_counter": "{count, plural, one {{counter} postiad} two {{counter} bostiad} few {{counter} phostiad} many {{counter} postiad} other {{counter} postiad}}",
|
||||||
"account.unblock": "Dadflocio @{name}",
|
"account.unblock": "Dadrwystro @{name}",
|
||||||
"account.unblock_domain": "Dadflocio parth {domain}",
|
"account.unblock_domain": "Dadrwystro parth {domain}",
|
||||||
"account.unblock_short": "Dadflocio",
|
"account.unblock_short": "Dadrwystro",
|
||||||
"account.unendorse": "Peidio a'i ddangos ar fy mhroffil",
|
"account.unendorse": "Peidio a'i ddangos ar fy mhroffil",
|
||||||
"account.unfollow": "Dad-ddilyn",
|
"account.unfollow": "Dad-ddilyn",
|
||||||
"account.unmute": "Dad-dewi {name}",
|
"account.unmute": "Dad-dewi {name}",
|
||||||
|
@ -95,45 +95,45 @@
|
||||||
"alt_text_modal.done": "Gorffen",
|
"alt_text_modal.done": "Gorffen",
|
||||||
"announcement.announcement": "Cyhoeddiad",
|
"announcement.announcement": "Cyhoeddiad",
|
||||||
"annual_report.summary.archetype.booster": "Y hyrwyddwr",
|
"annual_report.summary.archetype.booster": "Y hyrwyddwr",
|
||||||
"annual_report.summary.archetype.lurker": "Yr arsylwr",
|
"annual_report.summary.archetype.lurker": "Y crwydryn",
|
||||||
"annual_report.summary.archetype.oracle": "Yr oracl",
|
"annual_report.summary.archetype.oracle": "Yr oracl",
|
||||||
"annual_report.summary.archetype.pollster": "Yr arholwr",
|
"annual_report.summary.archetype.pollster": "Yr arholwr",
|
||||||
"annual_report.summary.archetype.replier": "Y sbardunwr",
|
"annual_report.summary.archetype.replier": "Y sbardunwr",
|
||||||
"annual_report.summary.followers.followers": "dilynwyr",
|
"annual_report.summary.followers.followers": "dilynwyr",
|
||||||
"annual_report.summary.followers.total": "{count} cyfanswm",
|
"annual_report.summary.followers.total": "Cyfanswm o{count}",
|
||||||
"annual_report.summary.here_it_is": "Dyma eich {year} yn gryno:",
|
"annual_report.summary.here_it_is": "Dyma eich {year} yn gryno:",
|
||||||
"annual_report.summary.highlighted_post.by_favourites": "postiad wedi'i ffefrynu fwyaf",
|
"annual_report.summary.highlighted_post.by_favourites": "postiad wedi'i ffefrynu fwyaf",
|
||||||
"annual_report.summary.highlighted_post.by_reblogs": "postiad wedi'i hybu fwyaf",
|
"annual_report.summary.highlighted_post.by_reblogs": "postiad wedi'i hybu fwyaf",
|
||||||
"annual_report.summary.highlighted_post.by_replies": "postiad gyda'r ymatebion mwyaf",
|
"annual_report.summary.highlighted_post.by_replies": "postiad gyda'r nifer fwyaf o atebion",
|
||||||
"annual_report.summary.highlighted_post.possessive": "{name}",
|
"annual_report.summary.highlighted_post.possessive": "{name}",
|
||||||
"annual_report.summary.most_used_app.most_used_app": "ap a ddefnyddiwyd fwyaf",
|
"annual_report.summary.most_used_app.most_used_app": "ap a ddefnyddiwyd fwyaf",
|
||||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "hashnod a ddefnyddiwyd fwyaf",
|
"annual_report.summary.most_used_hashtag.most_used_hashtag": "hashnod a ddefnyddiwyd fwyaf",
|
||||||
"annual_report.summary.most_used_hashtag.none": "Dim",
|
"annual_report.summary.most_used_hashtag.none": "Dim",
|
||||||
"annual_report.summary.new_posts.new_posts": "postiadau newydd",
|
"annual_report.summary.new_posts.new_posts": "postiadau newydd",
|
||||||
"annual_report.summary.percentile.text": "<topLabel>Mae hynny'n eich rhoi chi ymysg y</topLabel><percentage></percentage><bottomLabel>uchaf o ddefnyddwyr {domain}.</bottomLabel>",
|
"annual_report.summary.percentile.text": "<topLabel>Mae hynny'n eich rhoi chi ymysg y</topLabel><percentage></percentage><bottomLabel>uchaf o ddefnyddwyr {domain}.</bottomLabel>",
|
||||||
"annual_report.summary.percentile.we_wont_tell_bernie": "Ni fyddwn yn dweud wrth Bernie.",
|
"annual_report.summary.percentile.we_wont_tell_bernie": "Fyddwn ni ddim yn dweud wrth Bernie.",
|
||||||
"annual_report.summary.thanks": "Diolch am fod yn rhan o Mastodon!",
|
"annual_report.summary.thanks": "Diolch am fod yn rhan o Mastodon!",
|
||||||
"attachments_list.unprocessed": "(heb eu prosesu)",
|
"attachments_list.unprocessed": "(heb eu prosesu)",
|
||||||
"audio.hide": "Cuddio sain",
|
"audio.hide": "Cuddio sain",
|
||||||
"block_modal.remote_users_caveat": "Byddwn yn gofyn i'r gweinydd {domain} barchu eich penderfyniad. Fodd bynnag, nid yw cydymffurfiad wedi'i warantu gan y gall rhai gweinyddwyr drin rhwystro mewn ffyrdd gwahanol. Mae'n bosibl y bydd postiadau cyhoeddus yn dal i fod yn weladwy i ddefnyddwyr nad ydynt wedi mewngofnodi.",
|
"block_modal.remote_users_caveat": "Byddwn yn gofyn i'r gweinydd {domain} barchu eich penderfyniad. Fodd bynnag, nid yw cydymffurfiad wedi'i warantu gan y gall rhai gweinyddwyr drin rhwystro mewn ffyrdd gwahanol. Mae'n bosibl y bydd postiadau cyhoeddus yn dal i fod yn weladwy i ddefnyddwyr nad ydynt wedi mewngofnodi.",
|
||||||
"block_modal.show_less": "Dangos llai",
|
"block_modal.show_less": "Dangos llai",
|
||||||
"block_modal.show_more": "Dangos rhagor",
|
"block_modal.show_more": "Dangos rhagor",
|
||||||
"block_modal.they_cant_mention": "Nid ydynt yn gallu eich crybwyll na'ch dilyn.",
|
"block_modal.they_cant_mention": "Dydyn nhw ddim yn gallu eich crybwyll na'ch dilyn.",
|
||||||
"block_modal.they_cant_see_posts": "Nid ydynt yn gallu gweld eich postiadau ac ni fyddwch yn gweld eu rhai hwy.",
|
"block_modal.they_cant_see_posts": "Dydyn nhw ddim yn gallu gweld eich postiadau a fyddwch chi ddim yn gweld eu rhai nhw.",
|
||||||
"block_modal.they_will_know": "Gallant weld eu bod wedi'u rhwystro.",
|
"block_modal.they_will_know": "Gallan nhw weld eu bod wedi'u rhwystro.",
|
||||||
"block_modal.title": "Blocio defnyddiwr?",
|
"block_modal.title": "Blocio defnyddiwr?",
|
||||||
"block_modal.you_wont_see_mentions": "Fyddwch chi ddim yn gweld postiadau sy'n sôn amdanyn nhw.",
|
"block_modal.you_wont_see_mentions": "Fyddwch chi ddim yn gweld postiadau sy'n sôn amdanyn nhw.",
|
||||||
"boost_modal.combo": "Mae modd pwyso {combo} er mwyn hepgor hyn tro nesa",
|
"boost_modal.combo": "Mae modd pwyso {combo} er mwyn hepgor hyn tro nesa",
|
||||||
"boost_modal.reblog": "Hybu postiad?",
|
"boost_modal.reblog": "Hybu postiad?",
|
||||||
"boost_modal.undo_reblog": "Dad-hybu postiad?",
|
"boost_modal.undo_reblog": "Dad-hybu postiad?",
|
||||||
"bundle_column_error.copy_stacktrace": "Copïo'r adroddiad gwall",
|
"bundle_column_error.copy_stacktrace": "Copïo'r adroddiad gwall",
|
||||||
"bundle_column_error.error.body": "Nid oedd modd cynhyrchu'r dudalen honno. Gall fod oherwydd gwall yn ein cod neu fater cydnawsedd porwr.",
|
"bundle_column_error.error.body": "Does dim modd cynhyrchu'r dudalen honno. Gall fod oherwydd gwall yn ein cod neu fater cydnawsedd porwr.",
|
||||||
"bundle_column_error.error.title": "O na!",
|
"bundle_column_error.error.title": "O na!",
|
||||||
"bundle_column_error.network.body": "Bu gwall wrth geisio llwytho'r dudalen hon. Gall hyn fod oherwydd anhawster dros-dro gyda'ch cysylltiad gwe neu'r gweinydd hwn.",
|
"bundle_column_error.network.body": "Bu gwall wrth geisio llwytho'r dudalen hon. Gall hyn fod oherwydd anhawster dros-dro gyda'ch cysylltiad gwe neu'r gweinydd hwn.",
|
||||||
"bundle_column_error.network.title": "Gwall rhwydwaith",
|
"bundle_column_error.network.title": "Gwall rhwydwaith",
|
||||||
"bundle_column_error.retry": "Ceisiwch eto",
|
"bundle_column_error.retry": "Ceisiwch eto",
|
||||||
"bundle_column_error.return": "Mynd i'r ffrwd gartref",
|
"bundle_column_error.return": "Mynd i'r ffrwd gartref",
|
||||||
"bundle_column_error.routing.body": "Nid oedd modd canfod y dudalen honno. Ydych chi'n siŵr fod yr URL yn y bar cyfeiriad yn gywir?",
|
"bundle_column_error.routing.body": "Doedd dim modd canfod y dudalen honno. Ydych chi'n siŵr fod yr URL yn y bar cyfeiriad yn gywir?",
|
||||||
"bundle_column_error.routing.title": "404",
|
"bundle_column_error.routing.title": "404",
|
||||||
"bundle_modal_error.close": "Cau",
|
"bundle_modal_error.close": "Cau",
|
||||||
"bundle_modal_error.message": "Aeth rhywbeth o'i le wrth lwytho'r sgrin hon.",
|
"bundle_modal_error.message": "Aeth rhywbeth o'i le wrth lwytho'r sgrin hon.",
|
||||||
|
@ -144,13 +144,13 @@
|
||||||
"closed_registrations_modal.preamble": "Mae Mastodon wedi'i ddatganoli, felly does dim gwahaniaeth ble rydych chi'n creu eich cyfrif, byddwch chi'n gallu dilyn a rhyngweithio ag unrhyw un ar y gweinydd hwn. Gallwch hyd yn oed ei gynnal un eich hun!",
|
"closed_registrations_modal.preamble": "Mae Mastodon wedi'i ddatganoli, felly does dim gwahaniaeth ble rydych chi'n creu eich cyfrif, byddwch chi'n gallu dilyn a rhyngweithio ag unrhyw un ar y gweinydd hwn. Gallwch hyd yn oed ei gynnal un eich hun!",
|
||||||
"closed_registrations_modal.title": "Cofrestru ar Mastodon",
|
"closed_registrations_modal.title": "Cofrestru ar Mastodon",
|
||||||
"column.about": "Ynghylch",
|
"column.about": "Ynghylch",
|
||||||
"column.blocks": "Defnyddwyr a flociwyd",
|
"column.blocks": "Defnyddwyr wedi'u rhwystro",
|
||||||
"column.bookmarks": "Llyfrnodau",
|
"column.bookmarks": "Llyfrnodau",
|
||||||
"column.community": "Ffrwd lleol",
|
"column.community": "Ffrwd lleol",
|
||||||
"column.create_list": "Creu rhestr",
|
"column.create_list": "Creu rhestr",
|
||||||
"column.direct": "Crybwylliadau preifat",
|
"column.direct": "Crybwylliadau preifat",
|
||||||
"column.directory": "Pori proffiliau",
|
"column.directory": "Pori proffiliau",
|
||||||
"column.domain_blocks": "Parthau wedi'u blocio",
|
"column.domain_blocks": "Parthau wedi'u rhwystro",
|
||||||
"column.edit_list": "Golygu rhestr",
|
"column.edit_list": "Golygu rhestr",
|
||||||
"column.favourites": "Ffefrynnau",
|
"column.favourites": "Ffefrynnau",
|
||||||
"column.firehose": "Ffrydiau byw",
|
"column.firehose": "Ffrydiau byw",
|
||||||
|
@ -163,7 +163,7 @@
|
||||||
"column.pins": "Postiadau wedi eu pinio",
|
"column.pins": "Postiadau wedi eu pinio",
|
||||||
"column.public": "Ffrwd y ffederasiwn",
|
"column.public": "Ffrwd y ffederasiwn",
|
||||||
"column_back_button.label": "Nôl",
|
"column_back_button.label": "Nôl",
|
||||||
"column_header.hide_settings": "Cuddio dewisiadau",
|
"column_header.hide_settings": "Cuddio'r dewisiadau",
|
||||||
"column_header.moveLeft_settings": "Symud y golofn i'r chwith",
|
"column_header.moveLeft_settings": "Symud y golofn i'r chwith",
|
||||||
"column_header.moveRight_settings": "Symud y golofn i'r dde",
|
"column_header.moveRight_settings": "Symud y golofn i'r dde",
|
||||||
"column_header.pin": "Pinio",
|
"column_header.pin": "Pinio",
|
||||||
|
@ -181,7 +181,7 @@
|
||||||
"compose.saved.body": "Postiad wedi'i gadw.",
|
"compose.saved.body": "Postiad wedi'i gadw.",
|
||||||
"compose_form.direct_message_warning_learn_more": "Dysgu mwy",
|
"compose_form.direct_message_warning_learn_more": "Dysgu mwy",
|
||||||
"compose_form.encryption_warning": "Dyw postiadau ar Mastodon ddim wedi'u hamgryptio o ben i ben. Peidiwch â rhannu unrhyw wybodaeth sensitif dros Mastodon.",
|
"compose_form.encryption_warning": "Dyw postiadau ar Mastodon ddim wedi'u hamgryptio o ben i ben. Peidiwch â rhannu unrhyw wybodaeth sensitif dros Mastodon.",
|
||||||
"compose_form.hashtag_warning": "Ni fydd y postiad hwn wedi ei restru o dan unrhyw hashnod gan nad yw'n gyhoeddus. Dim ond postiadau cyhoeddus y mae modd eu chwilio drwy hashnod.",
|
"compose_form.hashtag_warning": "Fydd y postiad hwn ddim wedi'i restru o dan unrhyw hashnod gan nad yw'n gyhoeddus. Dim ond postiadau cyhoeddus y mae modd eu chwilio drwy hashnod.",
|
||||||
"compose_form.lock_disclaimer": "Nid yw eich cyfri wedi'i {locked}. Gall unrhyw un eich dilyn i weld eich postiadau dilynwyr-yn-unig.",
|
"compose_form.lock_disclaimer": "Nid yw eich cyfri wedi'i {locked}. Gall unrhyw un eich dilyn i weld eich postiadau dilynwyr-yn-unig.",
|
||||||
"compose_form.lock_disclaimer.lock": "wedi ei gloi",
|
"compose_form.lock_disclaimer.lock": "wedi ei gloi",
|
||||||
"compose_form.placeholder": "Beth sydd ar eich meddwl?",
|
"compose_form.placeholder": "Beth sydd ar eich meddwl?",
|
||||||
|
@ -200,7 +200,7 @@
|
||||||
"compose_form.spoiler.unmarked": "Ychwanegu rhybudd cynnwys",
|
"compose_form.spoiler.unmarked": "Ychwanegu rhybudd cynnwys",
|
||||||
"compose_form.spoiler_placeholder": "Rhybudd cynnwys (dewisol)",
|
"compose_form.spoiler_placeholder": "Rhybudd cynnwys (dewisol)",
|
||||||
"confirmation_modal.cancel": "Canslo",
|
"confirmation_modal.cancel": "Canslo",
|
||||||
"confirmations.block.confirm": "Blocio",
|
"confirmations.block.confirm": "Rhwystro",
|
||||||
"confirmations.delete.confirm": "Dileu",
|
"confirmations.delete.confirm": "Dileu",
|
||||||
"confirmations.delete.message": "Ydych chi'n sicr eich bod eisiau dileu y post hwn?",
|
"confirmations.delete.message": "Ydych chi'n sicr eich bod eisiau dileu y post hwn?",
|
||||||
"confirmations.delete.title": "Dileu postiad?",
|
"confirmations.delete.title": "Dileu postiad?",
|
||||||
|
@ -250,14 +250,14 @@
|
||||||
"disabled_account_banner.text": "Mae eich cyfrif {disabledAccount} wedi ei analluogi ar hyn o bryd.",
|
"disabled_account_banner.text": "Mae eich cyfrif {disabledAccount} wedi ei analluogi ar hyn o bryd.",
|
||||||
"dismissable_banner.community_timeline": "Dyma'r postiadau cyhoeddus diweddaraf gan bobl y caiff eu cyfrifon eu cynnal ar {domain}.",
|
"dismissable_banner.community_timeline": "Dyma'r postiadau cyhoeddus diweddaraf gan bobl y caiff eu cyfrifon eu cynnal ar {domain}.",
|
||||||
"dismissable_banner.dismiss": "Diystyru",
|
"dismissable_banner.dismiss": "Diystyru",
|
||||||
"dismissable_banner.explore_links": "Y straeon newyddion hyn yw'r rhai sy'n cael eu rhannu fwyaf ar y ffederasiwn heddiw. Mae straeon newyddion mwy diweddar sy'n cael eu postio gan fwy o amrywiaeth o bobl yn cael eu graddio'n uwch.",
|
"dismissable_banner.explore_links": "Y straeon newyddion hyn yw'r rhai sy'n cael eu rhannu fwyaf ar y ffedysawd heddiw. Mae straeon newyddion mwy diweddar sy'n cael eu postio gan fwy o amrywiaeth o bobl yn cael eu graddio'n uwch.",
|
||||||
"dismissable_banner.explore_statuses": "Mae'r postiadau hyn o bob rhan o'r ffedysawd yn cael mwy o sylw heddiw. Mae postiadau mwy diweddar sydd â mwy o hybu a ffefrynnu'n cael eu graddio'n uwch.",
|
"dismissable_banner.explore_statuses": "Mae'r postiadau hyn o bob rhan o'r ffedysawd yn cael mwy o sylw heddiw. Mae postiadau mwy diweddar sydd â mwy o hybu a ffefrynnu'n cael eu graddio'n uwch.",
|
||||||
"dismissable_banner.explore_tags": "Mae'r hashnodau hyn ar gynnydd y ffedysawd heddiw. Mae hashnodau sy'n cael eu defnyddio gan fwy o bobl amrywiol yn cael eu graddio'n uwch.",
|
"dismissable_banner.explore_tags": "Mae'r hashnodau hyn ar gynnydd y ffedysawd heddiw. Mae hashnodau sy'n cael eu defnyddio gan fwy o bobl amrywiol yn cael eu graddio'n uwch.",
|
||||||
"dismissable_banner.public_timeline": "Dyma'r postiadau cyhoeddus diweddaraf gan bobl ar y ffedysawd y mae pobl ar {domain} yn eu dilyn.",
|
"dismissable_banner.public_timeline": "Dyma'r postiadau cyhoeddus diweddaraf gan bobl ar y ffedysawd y mae pobl ar {domain} yn eu dilyn.",
|
||||||
"domain_block_modal.block": "Blocio gweinydd",
|
"domain_block_modal.block": "Blocio gweinydd",
|
||||||
"domain_block_modal.block_account_instead": "Blocio @{name} yn ei le",
|
"domain_block_modal.block_account_instead": "Blocio @{name} yn ei le",
|
||||||
"domain_block_modal.they_can_interact_with_old_posts": "Gall pobl o'r gweinydd hwn ryngweithio â'ch hen bostiadau.",
|
"domain_block_modal.they_can_interact_with_old_posts": "Gall pobl o'r gweinydd hwn ryngweithio â'ch hen bostiadau.",
|
||||||
"domain_block_modal.they_cant_follow": "Ni all neb o'r gweinydd hwn eich dilyn.",
|
"domain_block_modal.they_cant_follow": "All neb o'r gweinydd hwn eich dilyn.",
|
||||||
"domain_block_modal.they_wont_know": "Fyddan nhw ddim yn gwybod eu bod wedi cael eu blocio.",
|
"domain_block_modal.they_wont_know": "Fyddan nhw ddim yn gwybod eu bod wedi cael eu blocio.",
|
||||||
"domain_block_modal.title": "Blocio parth?",
|
"domain_block_modal.title": "Blocio parth?",
|
||||||
"domain_block_modal.you_will_lose_num_followers": "Byddwch yn colli {followersCount, plural, one {{followersCountDisplay} dilynwr} other {{followersCountDisplay} dilynwyr}} a {followingCount, plural, one {{followingCountDisplay} person rydych yn dilyn} other {{followingCountDisplay} o bobl rydych yn eu dilyn}}.",
|
"domain_block_modal.you_will_lose_num_followers": "Byddwch yn colli {followersCount, plural, one {{followersCountDisplay} dilynwr} other {{followersCountDisplay} dilynwyr}} a {followingCount, plural, one {{followingCountDisplay} person rydych yn dilyn} other {{followingCountDisplay} o bobl rydych yn eu dilyn}}.",
|
||||||
|
@ -297,10 +297,10 @@
|
||||||
"empty_column.account_suspended": "Cyfrif wedi'i atal",
|
"empty_column.account_suspended": "Cyfrif wedi'i atal",
|
||||||
"empty_column.account_timeline": "Dim postiadau yma!",
|
"empty_column.account_timeline": "Dim postiadau yma!",
|
||||||
"empty_column.account_unavailable": "Nid yw'r proffil ar gael",
|
"empty_column.account_unavailable": "Nid yw'r proffil ar gael",
|
||||||
"empty_column.blocks": "Nid ydych wedi blocio unrhyw ddefnyddwyr eto.",
|
"empty_column.blocks": "Dydych chi heb rwystro unrhyw ddefnyddwyr eto.",
|
||||||
"empty_column.bookmarked_statuses": "Nid oes gennych unrhyw bostiad wedi'u cadw fel nod tudalen eto. Pan fyddwch yn gosod nod tudalen i un, mi fydd yn ymddangos yma.",
|
"empty_column.bookmarked_statuses": "Does gennych chi ddim unrhyw bostiad wedi'u cadw fel nod tudalen eto. Pan fyddwch yn gosod nod tudalen i un, mi fydd yn ymddangos yma.",
|
||||||
"empty_column.community": "Mae'r ffrwd lleol yn wag. Beth am ysgrifennu rhywbeth cyhoeddus!",
|
"empty_column.community": "Mae'r ffrwd lleol yn wag. Beth am ysgrifennu rhywbeth cyhoeddus!",
|
||||||
"empty_column.direct": "Nid oes gennych unrhyw grybwylliadau preifat eto. Pan fyddwch chi'n anfon neu'n derbyn un, bydd yn ymddangos yma.",
|
"empty_column.direct": "Does gennych chi unrhyw grybwylliadau preifat eto. Pan fyddwch chi'n anfon neu'n derbyn un, bydd yn ymddangos yma.",
|
||||||
"empty_column.domain_blocks": "Nid oes unrhyw barthau wedi'u blocio eto.",
|
"empty_column.domain_blocks": "Nid oes unrhyw barthau wedi'u blocio eto.",
|
||||||
"empty_column.explore_statuses": "Does dim pynciau llosg ar hyn o bryd. Dewch nôl nes ymlaen!",
|
"empty_column.explore_statuses": "Does dim pynciau llosg ar hyn o bryd. Dewch nôl nes ymlaen!",
|
||||||
"empty_column.favourited_statuses": "Rydych chi heb ffafrio unrhyw bostiadau eto. Pan byddwch chi'n ffafrio un, bydd yn ymddangos yma.",
|
"empty_column.favourited_statuses": "Rydych chi heb ffafrio unrhyw bostiadau eto. Pan byddwch chi'n ffafrio un, bydd yn ymddangos yma.",
|
||||||
|
@ -905,8 +905,12 @@
|
||||||
"video.expand": "Ymestyn fideo",
|
"video.expand": "Ymestyn fideo",
|
||||||
"video.fullscreen": "Sgrin llawn",
|
"video.fullscreen": "Sgrin llawn",
|
||||||
"video.hide": "Cuddio fideo",
|
"video.hide": "Cuddio fideo",
|
||||||
"video.mute": "Diffodd sain",
|
"video.mute": "Tewi",
|
||||||
"video.pause": "Oedi",
|
"video.pause": "Oedi",
|
||||||
"video.play": "Chwarae",
|
"video.play": "Chwarae",
|
||||||
"video.unmute": "Dad-dewi sain"
|
"video.skip_backward": "Symud nôl",
|
||||||
|
"video.skip_forward": "Symud ymlaen",
|
||||||
|
"video.unmute": "Dad-dewi",
|
||||||
|
"video.volume_down": "Lefel sain i lawr",
|
||||||
|
"video.volume_up": "Lefel sain i fyny"
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,12 @@
|
||||||
"video.expand": "Udvid video",
|
"video.expand": "Udvid video",
|
||||||
"video.fullscreen": "Fuldskærm",
|
"video.fullscreen": "Fuldskærm",
|
||||||
"video.hide": "Skjul video",
|
"video.hide": "Skjul video",
|
||||||
"video.mute": "Sluk for lyden",
|
"video.mute": "Slå lyd fra",
|
||||||
"video.pause": "Sæt på pause",
|
"video.pause": "Sæt på pause",
|
||||||
"video.play": "Afspil",
|
"video.play": "Afspil",
|
||||||
"video.unmute": "Tænd for lyden"
|
"video.skip_backward": "Overspring baglæns",
|
||||||
|
"video.skip_forward": "Overspring fremad",
|
||||||
|
"video.unmute": "Slå lyd tl",
|
||||||
|
"video.volume_down": "Lydstyrke ned",
|
||||||
|
"video.volume_up": "Lydstyrke op"
|
||||||
}
|
}
|
||||||
|
|
|
@ -591,7 +591,7 @@
|
||||||
"notification.relationships_severance_event.domain_block": "Ein Admin von {from} hat {target} blockiert – darunter {followersCount} deiner Follower und {followingCount, plural, one {# Konto, dem} other {# Konten, denen}} du folgst.",
|
"notification.relationships_severance_event.domain_block": "Ein Admin von {from} hat {target} blockiert – darunter {followersCount} deiner Follower und {followingCount, plural, one {# Konto, dem} other {# Konten, denen}} du folgst.",
|
||||||
"notification.relationships_severance_event.learn_more": "Mehr erfahren",
|
"notification.relationships_severance_event.learn_more": "Mehr erfahren",
|
||||||
"notification.relationships_severance_event.user_domain_block": "Du hast {target} blockiert – {followersCount} deiner Follower und {followingCount, plural, one {# Konto, dem} other {# Konten, denen}} du folgst, wurden entfernt.",
|
"notification.relationships_severance_event.user_domain_block": "Du hast {target} blockiert – {followersCount} deiner Follower und {followingCount, plural, one {# Konto, dem} other {# Konten, denen}} du folgst, wurden entfernt.",
|
||||||
"notification.status": "{name} veröffentlichte gerade",
|
"notification.status": "{name} postete …",
|
||||||
"notification.update": "{name} bearbeitete einen Beitrag",
|
"notification.update": "{name} bearbeitete einen Beitrag",
|
||||||
"notification_requests.accept": "Genehmigen",
|
"notification_requests.accept": "Genehmigen",
|
||||||
"notification_requests.accept_multiple": "{count, plural, one {# Anfrage genehmigen …} other {# Anfragen genehmigen …}}",
|
"notification_requests.accept_multiple": "{count, plural, one {# Anfrage genehmigen …} other {# Anfragen genehmigen …}}",
|
||||||
|
@ -908,5 +908,9 @@
|
||||||
"video.mute": "Stummschalten",
|
"video.mute": "Stummschalten",
|
||||||
"video.pause": "Pausieren",
|
"video.pause": "Pausieren",
|
||||||
"video.play": "Abspielen",
|
"video.play": "Abspielen",
|
||||||
"video.unmute": "Stummschaltung aufheben"
|
"video.skip_backward": "Zurückspulen",
|
||||||
|
"video.skip_forward": "Vorspulen",
|
||||||
|
"video.unmute": "Stummschaltung aufheben",
|
||||||
|
"video.volume_down": "Leiser",
|
||||||
|
"video.volume_up": "Lauter"
|
||||||
}
|
}
|
||||||
|
|
|
@ -903,8 +903,6 @@
|
||||||
"video.expand": "Επέκταση βίντεο",
|
"video.expand": "Επέκταση βίντεο",
|
||||||
"video.fullscreen": "Πλήρης οθόνη",
|
"video.fullscreen": "Πλήρης οθόνη",
|
||||||
"video.hide": "Απόκρυψη βίντεο",
|
"video.hide": "Απόκρυψη βίντεο",
|
||||||
"video.mute": "Σίγαση ήχου",
|
|
||||||
"video.pause": "Παύση",
|
"video.pause": "Παύση",
|
||||||
"video.play": "Αναπαραγωγή",
|
"video.play": "Αναπαραγωγή"
|
||||||
"video.unmute": "Αναπαραγωγή ήχου"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -903,8 +903,6 @@
|
||||||
"video.expand": "Expand video",
|
"video.expand": "Expand video",
|
||||||
"video.fullscreen": "Full screen",
|
"video.fullscreen": "Full screen",
|
||||||
"video.hide": "Hide video",
|
"video.hide": "Hide video",
|
||||||
"video.mute": "Mute sound",
|
|
||||||
"video.pause": "Pause",
|
"video.pause": "Pause",
|
||||||
"video.play": "Play",
|
"video.play": "Play"
|
||||||
"video.unmute": "Unmute sound"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,6 +77,7 @@
|
||||||
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
|
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
|
||||||
"account.unblock": "Unblock @{name}",
|
"account.unblock": "Unblock @{name}",
|
||||||
"account.unblock_domain": "Unblock domain {domain}",
|
"account.unblock_domain": "Unblock domain {domain}",
|
||||||
|
"account.unblock_domain_short": "Unblock",
|
||||||
"account.unblock_short": "Unblock",
|
"account.unblock_short": "Unblock",
|
||||||
"account.unendorse": "Don't feature on profile",
|
"account.unendorse": "Don't feature on profile",
|
||||||
"account.unfollow": "Unfollow",
|
"account.unfollow": "Unfollow",
|
||||||
|
@ -1121,8 +1122,12 @@
|
||||||
"video.expand": "Expand video",
|
"video.expand": "Expand video",
|
||||||
"video.fullscreen": "Full screen",
|
"video.fullscreen": "Full screen",
|
||||||
"video.hide": "Hide video",
|
"video.hide": "Hide video",
|
||||||
"video.mute": "Mute sound",
|
"video.mute": "Mute",
|
||||||
"video.pause": "Pause",
|
"video.pause": "Pause",
|
||||||
"video.play": "Play",
|
"video.play": "Play",
|
||||||
"video.unmute": "Unmute sound"
|
"video.skip_backward": "Skip backward",
|
||||||
|
"video.skip_forward": "Skip forward",
|
||||||
|
"video.unmute": "Unmute",
|
||||||
|
"video.volume_down": "Volume down",
|
||||||
|
"video.volume_up": "Volume up"
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,6 @@
|
||||||
"video.expand": "Pligrandigi la videon",
|
"video.expand": "Pligrandigi la videon",
|
||||||
"video.fullscreen": "Igi plenekrana",
|
"video.fullscreen": "Igi plenekrana",
|
||||||
"video.hide": "Kaŝu la filmeton",
|
"video.hide": "Kaŝu la filmeton",
|
||||||
"video.mute": "Silentigi",
|
|
||||||
"video.pause": "Paŭzigi",
|
"video.pause": "Paŭzigi",
|
||||||
"video.play": "Ekigi",
|
"video.play": "Ekigi"
|
||||||
"video.unmute": "Malsilentigi"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -908,5 +908,9 @@
|
||||||
"video.mute": "Silenciar",
|
"video.mute": "Silenciar",
|
||||||
"video.pause": "Pausar",
|
"video.pause": "Pausar",
|
||||||
"video.play": "Reproducir",
|
"video.play": "Reproducir",
|
||||||
"video.unmute": "Dejar de silenciar"
|
"video.skip_backward": "Saltar atrás",
|
||||||
|
"video.skip_forward": "Adelantar",
|
||||||
|
"video.unmute": "Quitar silenciado",
|
||||||
|
"video.volume_down": "Bajar volumen",
|
||||||
|
"video.volume_up": "Subir volumen"
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,12 @@
|
||||||
"video.expand": "Expandir vídeo",
|
"video.expand": "Expandir vídeo",
|
||||||
"video.fullscreen": "Pantalla completa",
|
"video.fullscreen": "Pantalla completa",
|
||||||
"video.hide": "Ocultar vídeo",
|
"video.hide": "Ocultar vídeo",
|
||||||
"video.mute": "Silenciar sonido",
|
"video.mute": "Silenciar",
|
||||||
"video.pause": "Pausar",
|
"video.pause": "Pausar",
|
||||||
"video.play": "Reproducir",
|
"video.play": "Reproducir",
|
||||||
"video.unmute": "Dejar de silenciar sonido"
|
"video.skip_backward": "Saltar atrás",
|
||||||
|
"video.skip_forward": "Adelantar",
|
||||||
|
"video.unmute": "Dejar de silenciar",
|
||||||
|
"video.volume_down": "Bajar volumen",
|
||||||
|
"video.volume_up": "Subir volumen"
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,12 @@
|
||||||
"video.expand": "Expandir vídeo",
|
"video.expand": "Expandir vídeo",
|
||||||
"video.fullscreen": "Pantalla completa",
|
"video.fullscreen": "Pantalla completa",
|
||||||
"video.hide": "Ocultar vídeo",
|
"video.hide": "Ocultar vídeo",
|
||||||
"video.mute": "Silenciar sonido",
|
"video.mute": "Silenciar",
|
||||||
"video.pause": "Pausar",
|
"video.pause": "Pausar",
|
||||||
"video.play": "Reproducir",
|
"video.play": "Reproducir",
|
||||||
"video.unmute": "Desilenciar sonido"
|
"video.skip_backward": "Saltar atrás",
|
||||||
|
"video.skip_forward": "Adelantar",
|
||||||
|
"video.unmute": "Dejar de silenciar",
|
||||||
|
"video.volume_down": "Bajar volumen",
|
||||||
|
"video.volume_up": "Subir volumen"
|
||||||
}
|
}
|
||||||
|
|
|
@ -897,8 +897,6 @@
|
||||||
"video.expand": "Suurenda video",
|
"video.expand": "Suurenda video",
|
||||||
"video.fullscreen": "Täisekraan",
|
"video.fullscreen": "Täisekraan",
|
||||||
"video.hide": "Peida video",
|
"video.hide": "Peida video",
|
||||||
"video.mute": "Vaigista heli",
|
|
||||||
"video.pause": "Paus",
|
"video.pause": "Paus",
|
||||||
"video.play": "Mängi",
|
"video.play": "Mängi"
|
||||||
"video.unmute": "Taasta heli"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -840,8 +840,6 @@
|
||||||
"video.expand": "Hedatu bideoa",
|
"video.expand": "Hedatu bideoa",
|
||||||
"video.fullscreen": "Pantaila osoa",
|
"video.fullscreen": "Pantaila osoa",
|
||||||
"video.hide": "Ezkutatu bideoa",
|
"video.hide": "Ezkutatu bideoa",
|
||||||
"video.mute": "Mututu soinua",
|
|
||||||
"video.pause": "Pausatu",
|
"video.pause": "Pausatu",
|
||||||
"video.play": "Jo",
|
"video.play": "Jo"
|
||||||
"video.unmute": "Desmututu soinua"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,6 @@
|
||||||
"video.expand": "گسترش ویدیو",
|
"video.expand": "گسترش ویدیو",
|
||||||
"video.fullscreen": "تمامصفحه",
|
"video.fullscreen": "تمامصفحه",
|
||||||
"video.hide": "نهفتن ویدیو",
|
"video.hide": "نهفتن ویدیو",
|
||||||
"video.mute": "خموشی صدا",
|
|
||||||
"video.pause": "مکث",
|
"video.pause": "مکث",
|
||||||
"video.play": "پخش",
|
"video.play": "پخش"
|
||||||
"video.unmute": "لغو خموشی صدا"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,12 @@
|
||||||
"video.expand": "Laajenna video",
|
"video.expand": "Laajenna video",
|
||||||
"video.fullscreen": "Koko näyttö",
|
"video.fullscreen": "Koko näyttö",
|
||||||
"video.hide": "Piilota video",
|
"video.hide": "Piilota video",
|
||||||
"video.mute": "Mykistä ääni",
|
"video.mute": "Mykistä",
|
||||||
"video.pause": "Tauko",
|
"video.pause": "Tauko",
|
||||||
"video.play": "Toista",
|
"video.play": "Toista",
|
||||||
"video.unmute": "Palauta ääni"
|
"video.skip_backward": "Siirry taaksepäin",
|
||||||
|
"video.skip_forward": "Siirry eteenpäin",
|
||||||
|
"video.unmute": "Poista mykistys",
|
||||||
|
"video.volume_down": "Vähennä äänenvoimakkuutta",
|
||||||
|
"video.volume_up": "Lisää äänenvoimakkuutta"
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,12 @@
|
||||||
"video.expand": "Víðka sjónfílu",
|
"video.expand": "Víðka sjónfílu",
|
||||||
"video.fullscreen": "Fullur skermur",
|
"video.fullscreen": "Fullur skermur",
|
||||||
"video.hide": "Fjal sjónfílu",
|
"video.hide": "Fjal sjónfílu",
|
||||||
"video.mute": "Sløkk ljóðið",
|
"video.mute": "Doyv",
|
||||||
"video.pause": "Steðga á",
|
"video.pause": "Steðga á",
|
||||||
"video.play": "Spæl",
|
"video.play": "Spæl",
|
||||||
"video.unmute": "Tendra ljóðið"
|
"video.skip_backward": "Leyp um aftureftir",
|
||||||
|
"video.skip_forward": "Leyp um frameftir",
|
||||||
|
"video.unmute": "Doyv ikki",
|
||||||
|
"video.volume_down": "Minka ljóðstyrki",
|
||||||
|
"video.volume_up": "Øk um ljóðstyrki"
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,6 @@
|
||||||
"video.expand": "Agrandir la vidéo",
|
"video.expand": "Agrandir la vidéo",
|
||||||
"video.fullscreen": "Plein écran",
|
"video.fullscreen": "Plein écran",
|
||||||
"video.hide": "Masquer la vidéo",
|
"video.hide": "Masquer la vidéo",
|
||||||
"video.mute": "Couper le son",
|
|
||||||
"video.pause": "Pause",
|
"video.pause": "Pause",
|
||||||
"video.play": "Lecture",
|
"video.play": "Lecture"
|
||||||
"video.unmute": "Rétablir le son"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,6 @@
|
||||||
"video.expand": "Agrandir la vidéo",
|
"video.expand": "Agrandir la vidéo",
|
||||||
"video.fullscreen": "Plein écran",
|
"video.fullscreen": "Plein écran",
|
||||||
"video.hide": "Masquer la vidéo",
|
"video.hide": "Masquer la vidéo",
|
||||||
"video.mute": "Couper le son",
|
|
||||||
"video.pause": "Pause",
|
"video.pause": "Pause",
|
||||||
"video.play": "Lecture",
|
"video.play": "Lecture"
|
||||||
"video.unmute": "Rétablir le son"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -887,8 +887,6 @@
|
||||||
"video.expand": "Fideo grutter meitsje",
|
"video.expand": "Fideo grutter meitsje",
|
||||||
"video.fullscreen": "Folslein skerm",
|
"video.fullscreen": "Folslein skerm",
|
||||||
"video.hide": "Fideo ferstopje",
|
"video.hide": "Fideo ferstopje",
|
||||||
"video.mute": "Lûd dôvje",
|
|
||||||
"video.pause": "Skoft",
|
"video.pause": "Skoft",
|
||||||
"video.play": "Ofspylje",
|
"video.play": "Ofspylje"
|
||||||
"video.unmute": "Lûd oan"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,6 @@
|
||||||
"video.expand": "Leath físeán",
|
"video.expand": "Leath físeán",
|
||||||
"video.fullscreen": "Lánscáileán",
|
"video.fullscreen": "Lánscáileán",
|
||||||
"video.hide": "Cuir físeán i bhfolach",
|
"video.hide": "Cuir físeán i bhfolach",
|
||||||
"video.mute": "Ciúnaigh fuaim",
|
|
||||||
"video.pause": "Cuir ar sos",
|
"video.pause": "Cuir ar sos",
|
||||||
"video.play": "Cuir ar siúl",
|
"video.play": "Cuir ar siúl"
|
||||||
"video.unmute": "Díchiúnaigh fuaim"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -831,8 +831,6 @@
|
||||||
"video.expand": "Leudaich a’ video",
|
"video.expand": "Leudaich a’ video",
|
||||||
"video.fullscreen": "Làn-sgrìn",
|
"video.fullscreen": "Làn-sgrìn",
|
||||||
"video.hide": "Falaich a’ video",
|
"video.hide": "Falaich a’ video",
|
||||||
"video.mute": "Mùch an fhuaim",
|
|
||||||
"video.pause": "Cuir ’na stad",
|
"video.pause": "Cuir ’na stad",
|
||||||
"video.play": "Cluich",
|
"video.play": "Cluich"
|
||||||
"video.unmute": "Dì-mhùch an fhuaim"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,12 @@
|
||||||
"video.expand": "Estender o vídeo",
|
"video.expand": "Estender o vídeo",
|
||||||
"video.fullscreen": "Pantalla completa",
|
"video.fullscreen": "Pantalla completa",
|
||||||
"video.hide": "Agochar vídeo",
|
"video.hide": "Agochar vídeo",
|
||||||
"video.mute": "Silenciar son",
|
"video.mute": "Acalar",
|
||||||
"video.pause": "Deter",
|
"video.pause": "Deter",
|
||||||
"video.play": "Reproducir",
|
"video.play": "Reproducir",
|
||||||
"video.unmute": "Permitir son"
|
"video.skip_backward": "Retroceder",
|
||||||
|
"video.skip_forward": "Avanzar",
|
||||||
|
"video.unmute": "Non silenciar",
|
||||||
|
"video.volume_down": "Baixar volume",
|
||||||
|
"video.volume_up": "Subir volume"
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,6 @@
|
||||||
"video.expand": "להרחיב וידאו",
|
"video.expand": "להרחיב וידאו",
|
||||||
"video.fullscreen": "מסך מלא",
|
"video.fullscreen": "מסך מלא",
|
||||||
"video.hide": "להסתיר וידאו",
|
"video.hide": "להסתיר וידאו",
|
||||||
"video.mute": "השתקת צליל",
|
|
||||||
"video.pause": "השהיה",
|
"video.pause": "השהיה",
|
||||||
"video.play": "ניגון",
|
"video.play": "ניגון"
|
||||||
"video.unmute": "החזרת צליל"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -483,8 +483,6 @@
|
||||||
"video.expand": "Proširi video",
|
"video.expand": "Proširi video",
|
||||||
"video.fullscreen": "Cijeli zaslon",
|
"video.fullscreen": "Cijeli zaslon",
|
||||||
"video.hide": "Sakrij video",
|
"video.hide": "Sakrij video",
|
||||||
"video.mute": "Utišaj zvuk",
|
|
||||||
"video.pause": "Pauziraj",
|
"video.pause": "Pauziraj",
|
||||||
"video.play": "Reproduciraj",
|
"video.play": "Reproduciraj"
|
||||||
"video.unmute": "Uključi zvuk"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,8 +905,12 @@
|
||||||
"video.expand": "Videó nagyítása",
|
"video.expand": "Videó nagyítása",
|
||||||
"video.fullscreen": "Teljes képernyő",
|
"video.fullscreen": "Teljes képernyő",
|
||||||
"video.hide": "Videó elrejtése",
|
"video.hide": "Videó elrejtése",
|
||||||
"video.mute": "Hang némítása",
|
"video.mute": "Némítás",
|
||||||
"video.pause": "Szünet",
|
"video.pause": "Szünet",
|
||||||
"video.play": "Lejátszás",
|
"video.play": "Lejátszás",
|
||||||
"video.unmute": "Hang némításának feloldása"
|
"video.skip_backward": "Visszaugrás",
|
||||||
|
"video.skip_forward": "Előreugrás",
|
||||||
|
"video.unmute": "Némítás feloldása",
|
||||||
|
"video.volume_down": "Hangerő le",
|
||||||
|
"video.volume_up": "Hangerő fel"
|
||||||
}
|
}
|
||||||
|
|
|
@ -473,8 +473,6 @@
|
||||||
"video.expand": "Ընդարձակել տեսագրութիւնը",
|
"video.expand": "Ընդարձակել տեսագրութիւնը",
|
||||||
"video.fullscreen": "Լիաէկրան",
|
"video.fullscreen": "Լիաէկրան",
|
||||||
"video.hide": "Թաքցնել տեսագրութիւնը",
|
"video.hide": "Թաքցնել տեսագրութիւնը",
|
||||||
"video.mute": "Լռեցնել ձայնը",
|
|
||||||
"video.pause": "Դադար տալ",
|
"video.pause": "Դադար տալ",
|
||||||
"video.play": "Նուագել",
|
"video.play": "Նուագել"
|
||||||
"video.unmute": "Միացնել ձայնը"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -903,8 +903,6 @@
|
||||||
"video.expand": "Expander video",
|
"video.expand": "Expander video",
|
||||||
"video.fullscreen": "Schermo plen",
|
"video.fullscreen": "Schermo plen",
|
||||||
"video.hide": "Celar video",
|
"video.hide": "Celar video",
|
||||||
"video.mute": "Silentiar le sono",
|
|
||||||
"video.pause": "Pausa",
|
"video.pause": "Pausa",
|
||||||
"video.play": "Reproducer",
|
"video.play": "Reproducer"
|
||||||
"video.unmute": "Non plus silentiar le sono"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -610,8 +610,6 @@
|
||||||
"video.expand": "Perbesar video",
|
"video.expand": "Perbesar video",
|
||||||
"video.fullscreen": "Layar penuh",
|
"video.fullscreen": "Layar penuh",
|
||||||
"video.hide": "Sembunyikan video",
|
"video.hide": "Sembunyikan video",
|
||||||
"video.mute": "Bisukan suara",
|
|
||||||
"video.pause": "Jeda",
|
"video.pause": "Jeda",
|
||||||
"video.play": "Putar",
|
"video.play": "Putar"
|
||||||
"video.unmute": "Bunyikan suara"
|
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue