Compare commits
204 commits
Author | SHA1 | Date | |
---|---|---|---|
f61a5a7a02 | |||
|
5f87ae101c | ||
|
4ed9778c85 | ||
|
9b596dbc78 | ||
|
4d3758308a | ||
|
58e3e43e06 | ||
|
5859abf2ff | ||
|
d65c3e95ad | ||
|
e1d6748422 | ||
|
7b9ad2c416 | ||
|
dd23ba9c83 | ||
|
4bbe33e0bd | ||
|
470285d815 | ||
|
361a6a21ba | ||
|
d315a90db7 | ||
|
501ced4239 | ||
|
0653374c34 | ||
|
05fc24c5f9 | ||
|
2c70c28bbb | ||
|
e2ef173b82 | ||
|
324acff572 | ||
|
d49fcb7ff3 | ||
|
063030df82 | ||
|
6e607f97a3 | ||
|
e8270e2807 | ||
|
9686ae7060 | ||
|
2283562ebd | ||
|
b2b532708e | ||
|
6211130054 | ||
|
65c553ab59 | ||
|
b4e56822c7 | ||
|
33f3a4c4c8 | ||
|
70e14c1ed0 | ||
|
19346fd5f8 | ||
|
758d2da887 | ||
|
3b5540a437 | ||
|
1bc28709cc | ||
|
04a9252a93 | ||
|
74ee96505a | ||
|
ee65f77a7e | ||
|
902aab1245 | ||
|
97b9994743 | ||
|
e5fd61a84e | ||
|
e28b64ac2d | ||
|
dfa4a97dd8 | ||
|
c2defe0e4c | ||
|
0479efdbb6 | ||
|
ef879a532f | ||
|
445aa4ac72 | ||
|
1326088110 | ||
|
8a3bed1933 | ||
|
aa575341c2 | ||
|
99f93e675a | ||
|
0e3ff680d3 | ||
|
e9fe01e2a6 | ||
|
c43508b3e0 | ||
|
02db065571 | ||
|
59e189ad3c | ||
|
dd6c573cc3 | ||
|
803a8be998 | ||
|
c93b2c6809 | ||
|
2a181f56e3 | ||
|
94d71c992e | ||
|
e1dbbf6c9d | ||
|
3edac14f02 | ||
|
81b88095b4 | ||
|
2eb6d815d6 | ||
|
8c3eeb4d29 | ||
|
38f5e74122 | ||
|
40bb8ec325 | ||
|
ef8f62c382 | ||
|
9bba2aab33 | ||
|
2453b94198 | ||
|
90bf67f053 | ||
|
2fc4475ea3 | ||
|
2e9b2df570 | ||
|
82acef50b0 | ||
|
a72c7f6cc1 | ||
|
958953a687 | ||
|
5390edc2aa | ||
|
225b18e742 | ||
|
ef870007e9 | ||
|
b892b15ba6 | ||
|
9d3daa847a | ||
|
d15879312e | ||
|
fe4cf75ece | ||
|
0a5bbf5ac6 | ||
|
7825cd1bdb | ||
|
1960aac90b | ||
|
a2981a0997 | ||
|
469cfc5430 | ||
|
0284e77e5f | ||
|
5eba86e2d1 | ||
|
cf25d4fe4b | ||
|
c03e3129a0 | ||
|
57b9dfd53e | ||
|
d232fa5b14 | ||
|
936d3a7de9 | ||
|
e44e5d156d | ||
|
8ddefbd2af | ||
|
290d57d6d9 | ||
|
f7b1769e8a | ||
|
539a06f189 | ||
|
ffc568589c | ||
|
0099907600 | ||
|
547658f086 | ||
|
4ad5d8e6e5 | ||
|
dc21104c04 | ||
|
9d5cbbbf0f | ||
|
6bce43cdb8 | ||
|
795d465f8d | ||
|
8ef546fe6b | ||
|
30e334b51a | ||
|
e30001bc80 | ||
|
2a5853989f | ||
|
e74774e366 | ||
|
8c59fbe41b | ||
|
2f98134ac6 | ||
|
d213c585ff | ||
|
4a6cf67c46 | ||
|
24ec83ee52 | ||
|
2d97215aad | ||
|
7a6a898ca1 | ||
|
f4f444528a | ||
|
9c2d5b534f | ||
|
31250a89b5 | ||
37d017bcf3 | |||
4e4d5bbc09 | |||
06b7ebe45c | |||
707fb0254d | |||
792fbd44bd | |||
f9841db0be | |||
324e356372 | |||
2f5d989bab | |||
9df8594415 | |||
8ff3985e38 | |||
db24d060b1 | |||
0105d08422 | |||
192bc20432 | |||
59ef432b97 | |||
0e80d060dc | |||
f532af92f1 | |||
5c07d5c138 | |||
058ddd61e2 | |||
b8e117e713 | |||
59fd54020d | |||
b782e3b21c | |||
ae4aa86a12 | |||
33ec036619 | |||
46de49cc45 | |||
631378ee1c | |||
c1c0670a04 | |||
6f73d7eedd | |||
0552eda5d9 | |||
e19315b0cd | |||
eae50b2cb3 | |||
5beb356718 | |||
14db7f1a05 | |||
61c71f9c5b | |||
12ef898447 | |||
3b11f173b3 | |||
61cd0f81b5 | |||
32414a51e9 | |||
95a990fb6d | |||
ffae506e12 | |||
2ed4a45528 | |||
2cc89b04b4 | |||
6cb18320c0 | |||
ff5beb6a60 | |||
815809a6a2 | |||
4d23759cf7 | |||
ed4a0bba04 | |||
d5769ec3e9 | |||
19c61afd99 | |||
ee9d63a7a1 | |||
29523f518b | |||
9b8fc472b5 | |||
12bfa9781b | |||
373f1871b9 | |||
f3fefa3dea | |||
9c31391cb9 | |||
6b71734b73 | |||
63a70ea1f1 | |||
7e5ed46553 | |||
5082246ee5 | |||
3dd4a94c57 | |||
2656e00d74 | |||
91a78e0652 | |||
03fdbfe11a | |||
b3377b7883 | |||
2c4f8b71cb | |||
7586c4c6f9 | |||
d2dd0d5e15 | |||
c07ddbe7b1 | |||
05855361cb | |||
0b20085f0e | |||
ba7703207c | |||
4c6fcd3452 | |||
f39c791bbd | |||
bb10a911b5 | |||
d126d231c7 | |||
fb94cd866a | |||
bcbdfc19fd | |||
f3309786f8 |
484 changed files with 9785 additions and 5396 deletions
.devcontainer
.drone.yml.env.production.sample.eslintignore.eslintrc.js.github
.prettierrc.js.rubocop_todo.ymlDockerfileGemfileGemfile.lockapp
controllers
admin/fasp
api
fasp
v1
accounts
accounts_controller.rbfilters_controller.rbinstances_controller.rbsuggestions_controller.rbtrends
v2
auth
backups_controller.rbconcerns
helpers/admin/trends
inputs
javascript
entrypoints
mastodon
actions
api.tsapi
api_types
components
alerts_controller.tsxanimated_number.tsxcopy_icon_button.tsxcounters.tsxdomain.tsx
edited_timestamp
formatted_date.tsxmedia_gallery.jsxpoll.jsxpoll.tsxrouter.tsxspoiler_button.tsxstatus.jsxstatus_content.jsxcontainers
features
account
account_gallery/components
account_timeline/components
alt_text_modal
audio
bookmarked_statuses
compose
components
containers
domain_blocks
emoji
favourited_statuses
getting_started/components
picture_in_picture
privacy_policy
standalone/compose
status
ui
|
@ -21,12 +21,13 @@ services:
|
|||
ES_HOST: es
|
||||
ES_PORT: '9200'
|
||||
LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000
|
||||
LOCAL_DOMAIN: ${LOCAL_DOMAIN:-localhost:3000}
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
ports:
|
||||
- '127.0.0.1:3000:3000'
|
||||
- '127.0.0.1:3035:3035'
|
||||
- '127.0.0.1:4000:4000'
|
||||
- '3000:3000'
|
||||
- '3035:3035'
|
||||
- '4000:4000'
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network
|
||||
|
|
33
.drone.yml
Normal file
33
.drone.yml
Normal file
|
@ -0,0 +1,33 @@
|
|||
kind: pipeline
|
||||
name: mastodon
|
||||
type: kubernetes
|
||||
|
||||
steps:
|
||||
- name: build-mastodon
|
||||
image: plugins/docker
|
||||
environment:
|
||||
DOCKER_BUILDKIT: 1
|
||||
settings:
|
||||
registry: git.greyfox.tech
|
||||
username:
|
||||
from_secret: registry_user
|
||||
password:
|
||||
from_secret: registry_pass
|
||||
repo: git.greyfox.tech/bark/mastodon
|
||||
dockerfile: Dockerfile
|
||||
tags:
|
||||
- latest
|
||||
- name: build-streaming
|
||||
image: plugins/docker
|
||||
environment:
|
||||
DOCKER_BUILDKIT: 1
|
||||
settings:
|
||||
registry: git.greyfox.tech
|
||||
username:
|
||||
from_secret: registry_user
|
||||
password:
|
||||
from_secret: registry_pass
|
||||
repo: git.greyfox.tech/bark/mastodon-streaming
|
||||
dockerfile: streaming/Dockerfile
|
||||
tags:
|
||||
- latest
|
|
@ -79,6 +79,9 @@ AWS_ACCESS_KEY_ID=
|
|||
AWS_SECRET_ACCESS_KEY=
|
||||
S3_ALIAS_HOST=files.example.com
|
||||
|
||||
# Optional list of hosts that are allowed to serve media for your instance
|
||||
# EXTRA_MEDIA_HOSTS=https://data.example1.com,https://data.example2.com
|
||||
|
||||
# IP and session retention
|
||||
# -----------------------
|
||||
# Make sure to modify the scheduling of ip_cleanup_scheduler in config/sidekiq.yml
|
||||
|
|
|
@ -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.
|
||||
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'],
|
||||
// The types are now included in recent versions,we ignore them here until we upgrade and remove the dependency
|
||||
ignoreDeps: ['@types/emoji-mart'],
|
||||
packageRules: [
|
||||
{
|
||||
// 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
|
||||
matchManagers: ['npm'],
|
||||
matchPackageNames: ['eslint', 'eslint-*', '@typescript-eslint/*'],
|
||||
matchPackageNames: [
|
||||
'eslint',
|
||||
'eslint-*',
|
||||
'typescript-eslint',
|
||||
'@eslint/*',
|
||||
'globals',
|
||||
],
|
||||
matchUpdateTypes: ['patch', 'minor'],
|
||||
groupName: 'eslint (non-major)',
|
||||
},
|
||||
|
|
6
.github/workflows/lint-js.yml
vendored
6
.github/workflows/lint-js.yml
vendored
|
@ -11,7 +11,7 @@ on:
|
|||
- 'tsconfig.json'
|
||||
- '.nvmrc'
|
||||
- '.prettier*'
|
||||
- '.eslint*'
|
||||
- 'eslint.config.mjs'
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
- '**/*.ts'
|
||||
|
@ -25,7 +25,7 @@ on:
|
|||
- 'tsconfig.json'
|
||||
- '.nvmrc'
|
||||
- '.prettier*'
|
||||
- '.eslint*'
|
||||
- 'eslint.config.mjs'
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
- '**/*.ts'
|
||||
|
@ -44,7 +44,7 @@ jobs:
|
|||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
- name: ESLint
|
||||
run: yarn lint:js --max-warnings 0
|
||||
run: yarn workspaces foreach --all --parallel run lint:js --max-warnings 0
|
||||
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
module.exports = {
|
||||
singleQuote: true,
|
||||
jsxSingleQuote: true
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.73.2.
|
||||
# using RuboCop version 1.75.1.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
|
@ -45,7 +45,7 @@ Style/FetchEnvVar:
|
|||
- 'lib/tasks/repo.rake'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns.
|
||||
# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, Mode, AllowedMethods, AllowedPatterns.
|
||||
# SupportedStyles: annotated, template, unannotated
|
||||
# AllowedMethods: redirect
|
||||
Style/FormatStringToken:
|
||||
|
@ -64,16 +64,10 @@ Style/HashTransformValues:
|
|||
- 'app/serializers/rest/web_push_subscription_serializer.rb'
|
||||
- 'app/services/import_service.rb'
|
||||
|
||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
||||
Style/MapToHash:
|
||||
Exclude:
|
||||
- 'app/models/status.rb'
|
||||
|
||||
# Configuration parameters: AllowedMethods.
|
||||
# AllowedMethods: respond_to_missing?
|
||||
Style/OptionalBooleanParameter:
|
||||
Exclude:
|
||||
- 'app/helpers/json_ld_helper.rb'
|
||||
- 'app/lib/admin/system_check/message.rb'
|
||||
- 'app/lib/request.rb'
|
||||
- 'app/lib/webfinger.rb'
|
||||
|
|
|
@ -27,9 +27,9 @@ FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
|
|||
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
|
||||
# Example: v4.3.0-nightly.2023.11.09+pr-123456
|
||||
# Overwrite existence of 'alpha.X' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"]
|
||||
ARG MASTODON_VERSION_PRERELEASE=""
|
||||
ARG MASTODON_VERSION_PRERELEASE="bark"
|
||||
# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-123456"]
|
||||
ARG MASTODON_VERSION_METADATA=""
|
||||
ARG MASTODON_VERSION_METADATA="dev"
|
||||
# Will be available as Mastodon::Version.source_commit
|
||||
ARG SOURCE_COMMIT=""
|
||||
|
||||
|
@ -186,7 +186,7 @@ FROM build AS libvips
|
|||
|
||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||
ARG VIPS_VERSION=8.16.0
|
||||
ARG VIPS_VERSION=8.16.1
|
||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -14,6 +14,7 @@ gem 'haml-rails', '~>2.0'
|
|||
gem 'pg', '~> 1.5'
|
||||
gem 'pghero'
|
||||
|
||||
gem 'aws-sdk-core', '< 3.216.0', require: false # TODO: https://github.com/mastodon/mastodon/pull/34173#issuecomment-2733378873
|
||||
gem 'aws-sdk-s3', '~> 1.123', require: false
|
||||
gem 'blurhash', '~> 0.1'
|
||||
gem 'fog-core', '<= 2.6.0'
|
||||
|
@ -61,6 +62,7 @@ gem 'inline_svg'
|
|||
gem 'irb', '~> 1.8'
|
||||
gem 'kaminari', '~> 1.2'
|
||||
gem 'link_header', '~> 0.0'
|
||||
gem 'linzer', '~> 0.6.1'
|
||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||
gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar'
|
||||
gem 'mutex_m'
|
||||
|
|
100
Gemfile.lock
100
Gemfile.lock
|
@ -91,11 +91,11 @@ GEM
|
|||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotaterb (4.14.0)
|
||||
ast (2.4.2)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1032.0)
|
||||
aws-sdk-core (3.214.1)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1080.0)
|
||||
aws-sdk-core (3.215.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
|
@ -107,9 +107,9 @@ GEM
|
|||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.10.1)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-blob (0.5.4)
|
||||
azure-blob (0.5.7)
|
||||
rexml
|
||||
base64 (0.2.0)
|
||||
bcp47_spec (0.2.1)
|
||||
|
@ -126,7 +126,7 @@ GEM
|
|||
blurhash (0.1.8)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.0)
|
||||
brakeman (7.0.1)
|
||||
racc
|
||||
browser (6.2.0)
|
||||
brpoplpush-redis_script (0.1.3)
|
||||
|
@ -168,9 +168,9 @@ GEM
|
|||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
css_parser (1.21.0)
|
||||
css_parser (1.21.1)
|
||||
addressable
|
||||
csv (3.3.2)
|
||||
csv (3.3.3)
|
||||
database_cleaner-active_record (2.2.0)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
|
@ -194,7 +194,7 @@ GEM
|
|||
devise_pam_authenticatable2 (9.2.0)
|
||||
devise (>= 4.0.0)
|
||||
rpam2 (~> 4.0)
|
||||
diff-lcs (1.6.0)
|
||||
diff-lcs (1.6.1)
|
||||
discard (1.4.0)
|
||||
activerecord (>= 4.2, < 9.0)
|
||||
docile (1.4.1)
|
||||
|
@ -222,7 +222,8 @@ GEM
|
|||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
excon (1.2.3)
|
||||
excon (1.2.5)
|
||||
logger
|
||||
fabrication (2.31.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
|
@ -256,7 +257,7 @@ GEM
|
|||
fog-json (1.2.0)
|
||||
fog-core
|
||||
multi_json (~> 1.10)
|
||||
fog-openstack (1.1.4)
|
||||
fog-openstack (1.1.5)
|
||||
fog-core (~> 2.1)
|
||||
fog-json (>= 1.0)
|
||||
formatador (1.1.0)
|
||||
|
@ -265,8 +266,10 @@ GEM
|
|||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (3.25.6)
|
||||
googleapis-common-protos-types (1.18.0)
|
||||
google-protobuf (4.30.2)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
googleapis-common-protos-types (1.19.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
haml (6.3.0)
|
||||
temple (>= 0.8.2)
|
||||
|
@ -277,7 +280,7 @@ GEM
|
|||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.61.0)
|
||||
haml_lint (0.61.1)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
|
@ -302,7 +305,8 @@ GEM
|
|||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
http_accept_language (2.1.1)
|
||||
httpclient (2.8.3)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
httplog (1.7.0)
|
||||
rack (>= 2.0)
|
||||
rainbow (>= 2.0.0)
|
||||
|
@ -324,7 +328,7 @@ GEM
|
|||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.1)
|
||||
irb (1.15.2)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
|
@ -378,7 +382,7 @@ GEM
|
|||
mime-types
|
||||
terrapin (>= 0.6.0, < 2.0)
|
||||
language_server-protocol (3.17.0.4)
|
||||
launchy (3.1.0)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
|
@ -391,10 +395,16 @@ GEM
|
|||
rexml
|
||||
link_header (0.0.8)
|
||||
lint_roller (1.1.0)
|
||||
llhttp-ffi (0.5.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)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
logger (1.6.6)
|
||||
logger (1.7.0)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
|
@ -413,13 +423,13 @@ GEM
|
|||
redis (>= 3.0.5)
|
||||
matrix (0.4.2)
|
||||
memory_profiler (1.1.0)
|
||||
mime-types (3.6.0)
|
||||
mime-types (3.6.2)
|
||||
logger
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2025.0220)
|
||||
mime-types-data (3.2025.0402)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.4)
|
||||
minitest (5.25.5)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
mutex_m (0.3.0)
|
||||
|
@ -436,7 +446,7 @@ GEM
|
|||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.3)
|
||||
nokogiri (1.18.7)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.10)
|
||||
|
@ -559,7 +569,7 @@ GEM
|
|||
opentelemetry-instrumentation-redis (0.26.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-sidekiq (0.26.0)
|
||||
opentelemetry-instrumentation-sidekiq (0.26.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-registry (0.4.0)
|
||||
|
@ -576,14 +586,14 @@ GEM
|
|||
ox (2.14.22)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.1)
|
||||
parser (3.3.7.4)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
parslet (2.0.0)
|
||||
pastel (0.8.0)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.5.9)
|
||||
pghero (3.6.1)
|
||||
pghero (3.6.2)
|
||||
activerecord (>= 6.1)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
|
@ -596,6 +606,7 @@ GEM
|
|||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
prometheus_exporter (2.2.0)
|
||||
webrick
|
||||
propshaft (1.1.0)
|
||||
|
@ -677,7 +688,7 @@ GEM
|
|||
link_header (~> 0.0, >= 0.0.8)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.12.0)
|
||||
rdoc (6.13.1)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
|
@ -729,7 +740,7 @@ GEM
|
|||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.2)
|
||||
rubocop (1.73.2)
|
||||
rubocop (1.75.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
@ -737,26 +748,27 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.1)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-ast (1.44.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-capybara (2.22.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-i18n (3.2.3)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.72.1)
|
||||
rubocop-performance (1.24.0)
|
||||
rubocop-performance (1.25.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails (2.30.3)
|
||||
rubocop-rails (2.31.0)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 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-rspec (3.5.0)
|
||||
lint_roller (~> 1.1)
|
||||
|
@ -785,7 +797,7 @@ GEM
|
|||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.29.1)
|
||||
selenium-webdriver (4.30.1)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
|
@ -823,10 +835,12 @@ GEM
|
|||
simplecov-lcov (0.8.0)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
stackprof (0.2.27)
|
||||
starry (0.2.0)
|
||||
base64
|
||||
stoplight (4.1.1)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.5)
|
||||
strong_migrations (2.2.0)
|
||||
stringio (3.1.6)
|
||||
strong_migrations (2.3.0)
|
||||
activerecord (>= 7)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
|
@ -837,7 +851,7 @@ GEM
|
|||
temple (0.10.3)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
terrapin (1.0.1)
|
||||
terrapin (1.1.0)
|
||||
climate_control
|
||||
test-prof (1.4.4)
|
||||
thor (1.3.2)
|
||||
|
@ -862,7 +876,7 @@ GEM
|
|||
unf (~> 0.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2025.1)
|
||||
tzinfo-data (1.2025.2)
|
||||
tzinfo (>= 1.0.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
|
@ -917,6 +931,7 @@ DEPENDENCIES
|
|||
active_model_serializers (~> 0.10)
|
||||
addressable (~> 2.8)
|
||||
annotaterb (~> 4.13)
|
||||
aws-sdk-core (< 3.216.0)
|
||||
aws-sdk-s3 (~> 1.123)
|
||||
better_errors (~> 2.9)
|
||||
binding_of_caller (~> 1.0)
|
||||
|
@ -973,6 +988,7 @@ DEPENDENCIES
|
|||
letter_opener (~> 1.8)
|
||||
letter_opener_web (~> 3.0)
|
||||
link_header (~> 0.0)
|
||||
linzer (~> 0.6.1)
|
||||
lograge (~> 0.12)
|
||||
mail (~> 2.8)
|
||||
mario-redis-lock (~> 1.2)
|
||||
|
@ -1069,4 +1085,4 @@ RUBY VERSION
|
|||
ruby 3.4.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.5
|
||||
2.6.7
|
||||
|
|
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
|
|
@ -1,6 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::IdentityProofsController < Api::BaseController
|
||||
include DeprecationConcern
|
||||
|
||||
deprecate_api '2022-03-30'
|
||||
|
||||
before_action :require_user!
|
||||
before_action :set_account
|
||||
|
||||
|
|
|
@ -119,7 +119,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code)
|
||||
params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code, :date_of_birth)
|
||||
end
|
||||
|
||||
def invite
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::FiltersController < Api::BaseController
|
||||
include DeprecationConcern
|
||||
|
||||
deprecate_api '2022-11-14'
|
||||
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
|
||||
before_action :require_user!
|
||||
|
|
|
@ -1,15 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::InstancesController < Api::BaseController
|
||||
skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
|
||||
skip_around_action :set_locale
|
||||
class Api::V1::InstancesController < Api::V2::InstancesController
|
||||
include DeprecationConcern
|
||||
|
||||
vary_by ''
|
||||
|
||||
# Override `current_user` to avoid reading session cookies unless in limited federation mode
|
||||
def current_user
|
||||
super if limited_federation_mode?
|
||||
end
|
||||
deprecate_api '2022-11-14'
|
||||
|
||||
def show
|
||||
cache_even_if_authenticated!
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
class Api::V1::SuggestionsController < Api::BaseController
|
||||
include Authorization
|
||||
include DeprecationConcern
|
||||
|
||||
deprecate_api '2021-05-16'
|
||||
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Trends::TagsController < Api::BaseController
|
||||
include DeprecationConcern
|
||||
|
||||
before_action :set_tags
|
||||
|
||||
after_action :insert_pagination_headers
|
||||
|
||||
DEFAULT_TAGS_LIMIT = 10
|
||||
|
||||
deprecate_api '2022-03-30', only: :index, if: -> { request.path == '/api/v1/trends' }
|
||||
|
||||
def index
|
||||
cache_if_unauthenticated!
|
||||
render json: @tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@tags, current_user&.account_id)
|
||||
|
|
|
@ -1,6 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V2::InstancesController < Api::V1::InstancesController
|
||||
class Api::V2::InstancesController < Api::BaseController
|
||||
skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
|
||||
skip_around_action :set_locale
|
||||
|
||||
vary_by ''
|
||||
|
||||
# Override `current_user` to avoid reading session cookies unless in limited federation mode
|
||||
def current_user
|
||||
super if limited_federation_mode?
|
||||
end
|
||||
|
||||
def show
|
||||
cache_even_if_authenticated!
|
||||
render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance'
|
||||
|
|
|
@ -62,7 +62,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||
|
||||
def configure_sign_up_params
|
||||
devise_parameter_sanitizer.permit(:sign_up) do |user_params|
|
||||
user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
|
||||
user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password, :date_of_birth)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -9,13 +9,15 @@ class BackupsController < ApplicationController
|
|||
before_action :authenticate_user!
|
||||
before_action :set_backup
|
||||
|
||||
BACKUP_LINK_TIMEOUT = 1.hour.freeze
|
||||
|
||||
def download
|
||||
case Paperclip::Attachment.default_options[:storage]
|
||||
when :s3, :azure
|
||||
redirect_to @backup.dump.expiring_url(10), allow_other_host: true
|
||||
redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.to_i), allow_other_host: true
|
||||
when :fog
|
||||
if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
|
||||
redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true
|
||||
redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.from_now), allow_other_host: true
|
||||
else
|
||||
redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
|
||||
end
|
||||
|
|
17
app/controllers/concerns/deprecation_concern.rb
Normal file
17
app/controllers/concerns/deprecation_concern.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DeprecationConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def deprecate_api(date, sunset: nil, **kwargs)
|
||||
deprecation_timestamp = "@#{date.to_datetime.to_i}"
|
||||
sunset = sunset&.to_date&.httpdate
|
||||
|
||||
before_action(**kwargs) do
|
||||
response.headers['Deprecation'] = deprecation_timestamp
|
||||
response.headers['Sunset'] = sunset if sunset
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -10,8 +10,6 @@ module SignatureVerification
|
|||
EXPIRATION_WINDOW_LIMIT = 12.hours
|
||||
CLOCK_SKEW_MARGIN = 1.hour
|
||||
|
||||
class SignatureVerificationError < StandardError; end
|
||||
|
||||
def require_account_signature!
|
||||
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
||||
end
|
||||
|
@ -34,7 +32,7 @@ module SignatureVerification
|
|||
|
||||
def signature_key_id
|
||||
signature_params['keyId']
|
||||
rescue SignatureVerificationError
|
||||
rescue Mastodon::SignatureVerificationError
|
||||
nil
|
||||
end
|
||||
|
||||
|
@ -45,17 +43,17 @@ module SignatureVerification
|
|||
def signed_request_actor
|
||||
return @signed_request_actor if defined?(@signed_request_actor)
|
||||
|
||||
raise SignatureVerificationError, 'Request not signed' unless signed_request?
|
||||
raise 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 SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
|
||||
raise Mastodon::SignatureVerificationError, 'Request not signed' unless signed_request?
|
||||
raise Mastodon::SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
|
||||
raise Mastodon::SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
|
||||
raise Mastodon::SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
|
||||
|
||||
verify_signature_strength!
|
||||
verify_body_digest!
|
||||
|
||||
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'])
|
||||
compare_signed_string = build_signed_string(include_query_string: true)
|
||||
|
@ -68,7 +66,7 @@ module SignatureVerification
|
|||
|
||||
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)
|
||||
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?
|
||||
|
||||
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
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
fail_with! "Failed to fetch remote data: #{e.message}"
|
||||
|
@ -104,7 +102,7 @@ module SignatureVerification
|
|||
def signature_params
|
||||
@signature_params ||= SignatureParser.parse(request.headers['Signature'])
|
||||
rescue SignatureParser::ParsingError
|
||||
raise SignatureVerificationError, 'Error parsing signature parameters'
|
||||
raise Mastodon::SignatureVerificationError, 'Error parsing signature parameters'
|
||||
end
|
||||
|
||||
def signature_algorithm
|
||||
|
@ -116,31 +114,31 @@ module SignatureVerification
|
|||
end
|
||||
|
||||
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 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 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 Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
|
||||
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 Mastodon::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 Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
|
||||
end
|
||||
|
||||
def verify_body_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] }
|
||||
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]
|
||||
|
||||
digest_size = begin
|
||||
Base64.strict_decode64(sha256[1].strip).length
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def verify_signature(actor, signature, compare_signed_string)
|
||||
|
@ -165,13 +163,13 @@ module SignatureVerification
|
|||
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||
end
|
||||
when '(created)'
|
||||
raise 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, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||
raise Mastodon::SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
|
||||
|
||||
"(created): #{signature_params['created']}"
|
||||
when '(expires)'
|
||||
raise 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, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
|
||||
raise Mastodon::SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
|
||||
|
||||
"(expires): #{signature_params['expires']}"
|
||||
else
|
||||
|
@ -193,7 +191,7 @@ module SignatureVerification
|
|||
|
||||
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
|
||||
rescue ArgumentError => e
|
||||
raise SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||
raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}"
|
||||
end
|
||||
|
||||
expires_time ||= created_time + 5.minutes unless created_time.nil?
|
||||
|
@ -233,9 +231,9 @@ module SignatureVerification
|
|||
account
|
||||
end
|
||||
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
|
||||
raise SignatureVerificationError, e.message
|
||||
raise Mastodon::SignatureVerificationError, e.message
|
||||
end
|
||||
|
||||
def stoplight_wrapper
|
||||
|
@ -251,8 +249,8 @@ module SignatureVerification
|
|||
|
||||
ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false)
|
||||
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
|
||||
raise SignatureVerificationError, e.message
|
||||
raise Mastodon::SignatureVerificationError, e.message
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,11 +2,18 @@
|
|||
|
||||
module Admin::Trends::StatusesHelper
|
||||
def one_line_preview(status)
|
||||
text = if status.local?
|
||||
status.text.split("\n").first
|
||||
else
|
||||
Nokogiri::HTML5(status.text).css('html > body > *').first&.text
|
||||
end
|
||||
text = begin
|
||||
if status.local?
|
||||
status.text.split("\n").first
|
||||
else
|
||||
Nokogiri::HTML5(status.text).css('html > body > *').first&.text
|
||||
end
|
||||
rescue ArgumentError
|
||||
# This can happen if one of the Nokogumbo limits is encountered
|
||||
# Unfortunately, it does not use a more precise error class
|
||||
# nor allows more graceful handling
|
||||
''
|
||||
end
|
||||
|
||||
return '' if text.blank?
|
||||
|
||||
|
|
31
app/inputs/date_of_birth_input.rb
Normal file
31
app/inputs/date_of_birth_input.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DateOfBirthInput < SimpleForm::Inputs::Base
|
||||
OPTIONS = [
|
||||
{ autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze,
|
||||
{ autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze,
|
||||
{ autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze,
|
||||
].freeze
|
||||
|
||||
def input(wrapper_options = nil)
|
||||
merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
|
||||
merged_input_options[:inputmode] = 'numeric'
|
||||
|
||||
values = (object.public_send(attribute_name) || '').split('.')
|
||||
|
||||
safe_join(Array.new(3) do |index|
|
||||
options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index]
|
||||
@builder.text_field("#{attribute_name}(#{index + 1}i)", options)
|
||||
end)
|
||||
end
|
||||
|
||||
def label_target
|
||||
"#{attribute_name}_1i"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_id(index)
|
||||
"#{object_name}_#{attribute_name}_#{index + 1}i"
|
||||
end
|
||||
end
|
|
@ -68,7 +68,7 @@ function loaded() {
|
|||
|
||||
if (id) message = localeData[id];
|
||||
|
||||
if (!message) message = defaultMessage as string;
|
||||
message ??= defaultMessage as string;
|
||||
|
||||
const messageFormat = new IntlMessageFormat(message, locale);
|
||||
return messageFormat.format(values) as string;
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
interface Alert {
|
||||
title: string | MessageDescriptor;
|
||||
message: string | MessageDescriptor;
|
||||
values?: Record<string, string | number | Date>;
|
||||
}
|
||||
import type { Alert } from 'mastodon/models/alert';
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: string;
|
||||
|
@ -30,24 +27,13 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
export const ALERT_DISMISS = 'ALERT_DISMISS';
|
||||
export const ALERT_CLEAR = 'ALERT_CLEAR';
|
||||
export const ALERT_NOOP = 'ALERT_NOOP';
|
||||
export const dismissAlert = createAction<{ key: number }>('alerts/dismiss');
|
||||
|
||||
export const dismissAlert = (alert: Alert) => ({
|
||||
type: ALERT_DISMISS,
|
||||
alert,
|
||||
});
|
||||
export const clearAlerts = createAction('alerts/clear');
|
||||
|
||||
export const clearAlert = () => ({
|
||||
type: ALERT_CLEAR,
|
||||
});
|
||||
export const showAlert = createAction<Omit<Alert, 'key'>>('alerts/show');
|
||||
|
||||
export const showAlert = (alert: Alert) => ({
|
||||
type: ALERT_SHOW,
|
||||
alert,
|
||||
});
|
||||
const ignoreAlert = createAction('alerts/ignore');
|
||||
|
||||
export const showAlertForError = (error: unknown, skipNotFound = false) => {
|
||||
if (error instanceof AxiosError && error.response) {
|
||||
|
@ -56,7 +42,7 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => {
|
|||
|
||||
// Skip these errors as they are reflected in the UI
|
||||
if (skipNotFound && (status === 404 || status === 410)) {
|
||||
return { type: ALERT_NOOP };
|
||||
return ignoreAlert();
|
||||
}
|
||||
|
||||
// Rate limit errors
|
||||
|
@ -76,9 +62,9 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => {
|
|||
});
|
||||
}
|
||||
|
||||
// An aborted request, e.g. due to reloading the browser window, it not really error
|
||||
// An aborted request, e.g. due to reloading the browser window, is not really an error
|
||||
if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) {
|
||||
return { type: ALERT_NOOP };
|
||||
return ignoreAlert();
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
|
|
@ -12,14 +12,6 @@ export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
|
|||
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
|
||||
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) {
|
||||
return (dispatch, getState) => {
|
||||
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({
|
||||
modalType: 'DOMAIN_BLOCK',
|
||||
modalProps: {
|
||||
|
|
|
@ -71,7 +71,7 @@ export function importFetchedStatuses(statuses) {
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -15,7 +15,7 @@ export const importFetchedPoll = createAppAsyncThunk(
|
|||
|
||||
dispatch(
|
||||
importPolls({
|
||||
polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))],
|
||||
polls: [createPollFromServerJSON(poll, getState().polls[poll.id])],
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
import type { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios';
|
||||
import type {
|
||||
AxiosError,
|
||||
AxiosResponse,
|
||||
Method,
|
||||
RawAxiosRequestHeaders,
|
||||
} from 'axios';
|
||||
import axios from 'axios';
|
||||
import LinkHeader from 'http-link-header';
|
||||
|
||||
|
@ -41,7 +46,7 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
|
|||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function api(withAuthorization = true) {
|
||||
return axios.create({
|
||||
const instance = axios.create({
|
||||
transitional: {
|
||||
clarifyTimeoutError: true,
|
||||
},
|
||||
|
@ -60,6 +65,22 @@ export default function api(withAuthorization = true) {
|
|||
},
|
||||
],
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
if (response.headers.deprecation) {
|
||||
console.warn(
|
||||
`Deprecated request: ${response.config.method} ${response.config.url}`,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
type RequestParamsOrData = Record<string, unknown>;
|
||||
|
|
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;
|
||||
multiple: boolean;
|
||||
votes_count: number;
|
||||
voters_count: number;
|
||||
voters_count: number | null;
|
||||
|
||||
options: ApiPollOptionJSON[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
|
|
105
app/javascript/mastodon/components/alerts_controller.tsx
Normal file
105
app/javascript/mastodon/components/alerts_controller.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import type { IntlShape } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { dismissAlert } from 'mastodon/actions/alerts';
|
||||
import type {
|
||||
Alert,
|
||||
TranslatableString,
|
||||
TranslatableValues,
|
||||
} from 'mastodon/models/alert';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const formatIfNeeded = (
|
||||
intl: IntlShape,
|
||||
message: TranslatableString,
|
||||
values?: TranslatableValues,
|
||||
) => {
|
||||
if (typeof message === 'object') {
|
||||
return intl.formatMessage(message, values);
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const Alert: React.FC<{
|
||||
alert: Alert;
|
||||
dismissAfter: number;
|
||||
}> = ({
|
||||
alert: { key, title, message, values, action, onClick },
|
||||
dismissAfter,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const setActiveTimeout = setTimeout(() => {
|
||||
setActive(true);
|
||||
}, 1);
|
||||
|
||||
return () => {
|
||||
clearTimeout(setActiveTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dismissTimeout = setTimeout(() => {
|
||||
setActive(false);
|
||||
|
||||
// Allow CSS transition to finish before removing from the DOM
|
||||
setTimeout(() => {
|
||||
dispatch(dismissAlert({ key }));
|
||||
}, 500);
|
||||
}, dismissAfter);
|
||||
|
||||
return () => {
|
||||
clearTimeout(dismissTimeout);
|
||||
};
|
||||
}, [dispatch, setActive, key, dismissAfter]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('notification-bar', {
|
||||
'notification-bar-active': active,
|
||||
})}
|
||||
>
|
||||
<div className='notification-bar-wrapper'>
|
||||
{title && (
|
||||
<span className='notification-bar-title'>
|
||||
{formatIfNeeded(intl, title, values)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className='notification-bar-message'>
|
||||
{formatIfNeeded(intl, message, values)}
|
||||
</span>
|
||||
|
||||
{action && (
|
||||
<button className='notification-bar-action' onClick={onClick}>
|
||||
{formatIfNeeded(intl, action, values)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertsController: React.FC = () => {
|
||||
const alerts = useAppSelector((state) => state.alerts);
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='notification-list'>
|
||||
{alerts.map((alert, idx) => (
|
||||
<Alert key={alert.key} alert={alert} dismissAfter={5000 + idx * 1000} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
||||
|
@ -11,53 +11,49 @@ interface Props {
|
|||
}
|
||||
export const AnimatedNumber: React.FC<Props> = ({ value }) => {
|
||||
const [previousValue, setPreviousValue] = useState(value);
|
||||
const [direction, setDirection] = useState<1 | -1>(1);
|
||||
const direction = value > previousValue ? -1 : 1;
|
||||
|
||||
if (previousValue !== value) {
|
||||
setPreviousValue(value);
|
||||
setDirection(value > previousValue ? 1 : -1);
|
||||
}
|
||||
|
||||
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
|
||||
const willLeave = useCallback(
|
||||
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
|
||||
[direction],
|
||||
const [styles, api] = useSpring(
|
||||
() => ({
|
||||
from: { transform: `translateY(${100 * direction}%)` },
|
||||
to: { transform: 'translateY(0%)' },
|
||||
onRest() {
|
||||
setPreviousValue(value);
|
||||
},
|
||||
config: { ...config.gentle, duration: 200 },
|
||||
immediate: true, // This ensures that the animation is not played when the component is first rendered
|
||||
}),
|
||||
[value, previousValue],
|
||||
);
|
||||
|
||||
// When the value changes, start the animation
|
||||
useEffect(() => {
|
||||
if (value !== previousValue) {
|
||||
void api.start({ reset: true });
|
||||
}
|
||||
}, [api, previousValue, value]);
|
||||
|
||||
if (reduceMotion) {
|
||||
return <ShortNumber value={value} />;
|
||||
}
|
||||
|
||||
const styles = [
|
||||
{
|
||||
key: `${value}`,
|
||||
data: value,
|
||||
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<TransitionMotion
|
||||
styles={styles}
|
||||
willEnter={willEnter}
|
||||
willLeave={willLeave}
|
||||
>
|
||||
{(items) => (
|
||||
<span className='animated-number'>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<span
|
||||
key={key}
|
||||
style={{
|
||||
position:
|
||||
direction * (style.y ?? 0) > 0 ? 'absolute' : 'static',
|
||||
transform: `translateY(${(style.y ?? 0) * 100}%)`,
|
||||
}}
|
||||
>
|
||||
<ShortNumber value={data as number} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className='animated-number'>
|
||||
<animated.span style={styles}>
|
||||
<ShortNumber value={value} />
|
||||
</animated.span>
|
||||
{value !== previousValue && (
|
||||
<animated.span
|
||||
style={{
|
||||
...styles,
|
||||
position: 'absolute',
|
||||
top: `${-100 * direction}%`, // Adds extra space on top of translateY
|
||||
}}
|
||||
role='presentation'
|
||||
>
|
||||
<ShortNumber value={previousValue} />
|
||||
</animated.span>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,29 +1,36 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import { showAlert } from 'mastodon/actions/alerts';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
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 dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
navigator.clipboard.writeText(value);
|
||||
void navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
dispatch(showAlert({ message: messages.copied }));
|
||||
setTimeout(() => setCopied(false), 700);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 700);
|
||||
}, [setCopied, value, dispatch]);
|
||||
|
||||
return (
|
||||
|
@ -31,13 +38,8 @@ export const CopyIconButton = ({ title, value, className }) => {
|
|||
className={classNames(className, copied ? 'copied' : 'copyable')}
|
||||
title={title}
|
||||
onClick={handleClick}
|
||||
icon=''
|
||||
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';
|
||||
|
||||
|
|
|
@ -1,24 +1,15 @@
|
|||
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 { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblockDomain: {
|
||||
id: 'account.unblock_domain',
|
||||
defaultMessage: 'Unblock domain {domain}',
|
||||
},
|
||||
});
|
||||
import { Button } from './button';
|
||||
|
||||
export const Domain: React.FC<{
|
||||
domain: string;
|
||||
}> = ({ domain }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleDomainUnblock = useCallback(() => {
|
||||
|
@ -27,20 +18,17 @@ export const Domain: React.FC<{
|
|||
|
||||
return (
|
||||
<div className='domain'>
|
||||
<div className='domain__wrapper'>
|
||||
<span className='domain__domain-name'>
|
||||
<strong>{domain}</strong>
|
||||
</span>
|
||||
<div className='domain__domain-name'>
|
||||
<strong>{domain}</strong>
|
||||
</div>
|
||||
|
||||
<div className='domain__buttons'>
|
||||
<IconButton
|
||||
active
|
||||
icon='unlock'
|
||||
iconComponent={LockOpenIcon}
|
||||
title={intl.formatMessage(messages.unblockDomain, { domain })}
|
||||
onClick={handleDomainUnblock}
|
||||
<div className='domain__buttons'>
|
||||
<Button onClick={handleDomainUnblock}>
|
||||
<FormattedMessage
|
||||
id='account.unblock_domain_short'
|
||||
defaultMessage='Unblock'
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -6,6 +6,7 @@ import { FormattedMessage, injectIntl } from 'react-intl';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import InlineAccount from 'mastodon/components/inline_account';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
|
||||
|
@ -60,12 +61,12 @@ class EditedTimestamp extends PureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { timestamp, intl, statusId } = this.props;
|
||||
const { timestamp, statusId } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
|
||||
<button className='dropdown-menu__text-button'>
|
||||
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <span className='animated-number'>{intl.formatDate(timestamp, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span> }} />
|
||||
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <FormattedDateWrapper className='animated-number' value={timestamp} month='short' day='2-digit' hour='2-digit' minute='2-digit' /> }} />
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
26
app/javascript/mastodon/components/formatted_date.tsx
Normal file
26
app/javascript/mastodon/components/formatted_date.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { FormattedDate } from 'react-intl';
|
||||
|
||||
export const FormattedDateWrapper = (
|
||||
props: ComponentProps<typeof FormattedDate> & { className?: string },
|
||||
) => (
|
||||
<FormattedDate {...props}>
|
||||
{(date) => (
|
||||
<time dateTime={tryIsoString(props.value)} className={props.className}>
|
||||
{date}
|
||||
</time>
|
||||
)}
|
||||
</FormattedDate>
|
||||
);
|
||||
|
||||
const tryIsoString = (date?: string | number | Date): string => {
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return new Date(date).toISOString();
|
||||
} catch {
|
||||
return date.toString();
|
||||
}
|
||||
};
|
|
@ -12,6 +12,7 @@ import { debounce } from 'lodash';
|
|||
|
||||
import { AltTextBadge } from 'mastodon/components/alt_text_badge';
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||
import { formatTime } from 'mastodon/features/video';
|
||||
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
||||
|
@ -38,6 +39,7 @@ class Item extends PureComponent {
|
|||
|
||||
state = {
|
||||
loaded: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
handleMouseEnter = (e) => {
|
||||
|
@ -81,6 +83,10 @@ class Item extends PureComponent {
|
|||
this.setState({ loaded: true });
|
||||
};
|
||||
|
||||
handleImageError = () => {
|
||||
this.setState({ error: true });
|
||||
};
|
||||
|
||||
render () {
|
||||
const { attachment, lang, index, size, standalone, displayWidth, visible } = this.props;
|
||||
|
||||
|
@ -148,6 +154,7 @@ class Item extends PureComponent {
|
|||
lang={lang}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={this.handleImageLoad}
|
||||
onError={this.handleImageError}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
|
@ -183,7 +190,7 @@ class Item extends PureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
|
||||
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--error': this.state.error, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
|
||||
<Blurhash
|
||||
hash={attachment.get('blurhash')}
|
||||
dummy={!useBlurhash}
|
||||
|
@ -219,6 +226,7 @@ class MediaGallery extends PureComponent {
|
|||
visible: PropTypes.bool,
|
||||
autoplay: PropTypes.bool,
|
||||
onToggleVisibility: PropTypes.func,
|
||||
matchedFilters: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -289,11 +297,11 @@ class MediaGallery extends PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { media, lang, sensitive, defaultWidth, autoplay } = this.props;
|
||||
const { media, lang, sensitive, defaultWidth, autoplay, matchedFilters } = this.props;
|
||||
const { visible } = this.state;
|
||||
const width = this.state.width || defaultWidth;
|
||||
|
||||
let children, spoilerButton;
|
||||
let children;
|
||||
|
||||
const style = {};
|
||||
|
||||
|
@ -312,35 +320,11 @@ class MediaGallery extends PureComponent {
|
|||
children = media.map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} />);
|
||||
}
|
||||
|
||||
if (uncached) {
|
||||
spoilerButton = (
|
||||
<button type='button' disabled className='spoiler-button__overlay'>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
<FormattedMessage id='status.uncached_media_warning' defaultMessage='Preview not available' />
|
||||
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.open' defaultMessage='Click to open' /></span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
} else if (!visible) {
|
||||
spoilerButton = (
|
||||
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}
|
||||
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`media-gallery media-gallery--layout-${size}`} style={style} ref={this.handleRef}>
|
||||
{children}
|
||||
|
||||
{(!visible || uncached) && (
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
)}
|
||||
{(!visible || uncached) && <SpoilerButton uncached={uncached} sensitive={sensitive} onClick={this.handleOpen} matchedFilters={matchedFilters} />}
|
||||
|
||||
{(visible && !uncached) && (
|
||||
<div className='media-gallery__actions'>
|
||||
|
|
|
@ -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 React from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
import { Router as OriginalRouter, useHistory } from 'react-router';
|
||||
|
||||
|
|
89
app/javascript/mastodon/components/spoiler_button.tsx
Normal file
89
app/javascript/mastodon/components/spoiler_button.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
hidden?: boolean;
|
||||
sensitive: boolean;
|
||||
uncached?: boolean;
|
||||
matchedFilters?: string[];
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export const SpoilerButton: React.FC<Props> = ({
|
||||
hidden = false,
|
||||
sensitive,
|
||||
uncached = false,
|
||||
matchedFilters,
|
||||
onClick,
|
||||
}) => {
|
||||
let warning;
|
||||
let action;
|
||||
|
||||
if (uncached) {
|
||||
warning = (
|
||||
<FormattedMessage
|
||||
id='status.uncached_media_warning'
|
||||
defaultMessage='Preview not available'
|
||||
/>
|
||||
);
|
||||
action = (
|
||||
<FormattedMessage id='status.media.open' defaultMessage='Click to open' />
|
||||
);
|
||||
} else if (matchedFilters) {
|
||||
warning = (
|
||||
<FormattedMessage
|
||||
id='filter_warning.matches_filter'
|
||||
defaultMessage='Matches filter “<span>{title}</span>”'
|
||||
values={{
|
||||
title: matchedFilters.join(', '),
|
||||
span: (chunks) => <span className='filter-name'>{chunks}</span>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
action = (
|
||||
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
|
||||
);
|
||||
} else if (sensitive) {
|
||||
warning = (
|
||||
<FormattedMessage
|
||||
id='status.sensitive_warning'
|
||||
defaultMessage='Sensitive content'
|
||||
/>
|
||||
);
|
||||
action = (
|
||||
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
|
||||
);
|
||||
} else {
|
||||
warning = (
|
||||
<FormattedMessage
|
||||
id='status.media_hidden'
|
||||
defaultMessage='Media hidden'
|
||||
/>
|
||||
);
|
||||
action = (
|
||||
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('spoiler-button', {
|
||||
'spoiler-button--hidden': hidden,
|
||||
'spoiler-button--click-thru': uncached,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='spoiler-button__overlay'
|
||||
onClick={onClick}
|
||||
disabled={uncached}
|
||||
>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
{warning}
|
||||
<span className='spoiler-button__overlay__action'>{action}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -70,7 +70,7 @@ export const defaultMediaVisibility = (status) => {
|
|||
status = status.get('reblog');
|
||||
}
|
||||
|
||||
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
|
||||
return !status.get('matched_media_filters') && (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -470,6 +470,7 @@ class Status extends ImmutablePureComponent {
|
|||
defaultWidth={this.props.cachedMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
matchedFilters={status.get('matched_media_filters')}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
|
@ -498,6 +499,7 @@ class Status extends ImmutablePureComponent {
|
|||
blurhash={attachment.get('blurhash')}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
matchedFilters={status.get('matched_media_filters')}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
|
@ -522,6 +524,7 @@ class Status extends ImmutablePureComponent {
|
|||
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
matchedFilters={status.get('matched_media_filters')}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
|
|
|
@ -11,7 +11,7 @@ import { connect } from 'react-redux';
|
|||
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
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 { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||
|
||||
|
@ -245,7 +245,7 @@ class StatusContent extends PureComponent {
|
|||
);
|
||||
|
||||
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) {
|
||||
|
|
|
@ -7,12 +7,13 @@ import { fromJS } from 'immutable';
|
|||
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||
import MediaGallery from 'mastodon/components/media_gallery';
|
||||
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 Card from 'mastodon/features/status/components/card';
|
||||
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 { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||
|
||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
||||
|
@ -88,7 +89,7 @@ export default class MediaContainer extends PureComponent {
|
|||
Object.assign(props, {
|
||||
...(media ? { media: fromJS(media) } : {}),
|
||||
...(card ? { card: fromJS(card) } : {}),
|
||||
...(poll ? { poll: fromJS(poll) } : {}),
|
||||
...(poll ? { poll: createPollFromServerJSON(poll) } : {}),
|
||||
...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
|
||||
|
||||
...(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);
|
|
@ -4,7 +4,6 @@ import { PureComponent } from 'react';
|
|||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { is } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
@ -49,7 +48,7 @@ class InlineAlert extends PureComponent {
|
|||
class AccountNote extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
accountId: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
|
@ -66,7 +65,7 @@ class AccountNote extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
const accountWillChange = !is(this.props.account, nextProps.account);
|
||||
const accountWillChange = !is(this.props.accountId, nextProps.accountId);
|
||||
const newState = {};
|
||||
|
||||
if (accountWillChange && this._isDirty()) {
|
||||
|
@ -102,10 +101,10 @@ class AccountNote extends ImmutablePureComponent {
|
|||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
this._save();
|
||||
|
||||
if (this.textarea) {
|
||||
this.textarea.blur();
|
||||
} else {
|
||||
this._save();
|
||||
}
|
||||
} else if (e.keyCode === 27) {
|
||||
e.preventDefault();
|
||||
|
@ -141,21 +140,21 @@ class AccountNote extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { account, intl } = this.props;
|
||||
const { accountId, intl } = this.props;
|
||||
const { value, saved } = this.state;
|
||||
|
||||
if (!account) {
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<label htmlFor={`account-note-${account.get('id')}`}>
|
||||
<label htmlFor={`account-note-${accountId}`}>
|
||||
<FormattedMessage id='account.account_note_header' defaultMessage='Personal note' /> <InlineAlert show={saved} />
|
||||
</label>
|
||||
|
||||
<Textarea
|
||||
id={`account-note-${account.get('id')}`}
|
||||
id={`account-note-${accountId}`}
|
||||
className='account__header__account-note__content'
|
||||
disabled={this.props.value === null || value === null}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
|
|
|
@ -4,14 +4,14 @@ import { submitAccountNote } from 'mastodon/actions/account_notes';
|
|||
|
||||
import AccountNote from '../components/account_note';
|
||||
|
||||
const mapStateToProps = (state, { account }) => ({
|
||||
value: account.getIn(['relationship', 'note']),
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
value: state.relationships.getIn([accountId, 'note']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { account }) => ({
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
|
||||
onSave (value) {
|
||||
dispatch(submitAccountNote({ accountId: account.get('id'), note: value }));
|
||||
dispatch(submitAccountNote({ accountId: accountId, note: value }));
|
||||
},
|
||||
|
||||
});
|
||||
|
|
|
@ -26,11 +26,16 @@ export const MediaItem: React.FC<{
|
|||
displayMedia === 'show_all',
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setLoaded(true);
|
||||
}, [setLoaded]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
setError(true);
|
||||
}, [setError]);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
|
@ -98,6 +103,7 @@ export const MediaItem: React.FC<{
|
|||
alt={description}
|
||||
lang={lang}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
|
||||
<div className='media-gallery__item__overlay media-gallery__item__overlay--corner'>
|
||||
|
@ -118,6 +124,7 @@ export const MediaItem: React.FC<{
|
|||
lang={lang}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
);
|
||||
} else if (['video', 'gifv'].includes(type)) {
|
||||
|
@ -173,7 +180,11 @@ export const MediaItem: React.FC<{
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery__item media-gallery__item--square'>
|
||||
<div
|
||||
className={classNames('media-gallery__item media-gallery__item--square', {
|
||||
'media-gallery__item--error': error,
|
||||
})}
|
||||
>
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
|
|
|
@ -37,6 +37,7 @@ import {
|
|||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from 'mastodon/components/counters';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
|
@ -919,7 +920,7 @@ export const AccountHeader: React.FC<{
|
|||
onClickCapture={handleLinkClick}
|
||||
>
|
||||
{account.id !== me && signedIn && (
|
||||
<AccountNoteContainer account={account} />
|
||||
<AccountNoteContainer accountId={accountId} />
|
||||
)}
|
||||
|
||||
{account.note.length > 0 && account.note !== '<p></p>' && (
|
||||
|
@ -938,11 +939,12 @@ export const AccountHeader: React.FC<{
|
|||
/>
|
||||
</dt>
|
||||
<dd>
|
||||
{intl.formatDate(account.created_at, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
})}
|
||||
<FormattedDateWrapper
|
||||
value={account.created_at}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@ import { Skeleton } from 'mastodon/components/skeleton';
|
|||
import Audio from 'mastodon/features/audio';
|
||||
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
||||
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 type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
@ -134,17 +134,7 @@ const Preview: React.FC<{
|
|||
return;
|
||||
}
|
||||
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||
setDragging(true);
|
||||
draggingRef.current = true;
|
||||
onPositionChange([x, y]);
|
||||
},
|
||||
[setDragging, onPositionChange],
|
||||
);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e);
|
||||
const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent);
|
||||
setDragging(true);
|
||||
draggingRef.current = true;
|
||||
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('mousemove', handleMouseMove);
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
};
|
||||
}, [setDragging, onPositionChange]);
|
||||
|
||||
|
@ -204,7 +178,6 @@ const Preview: React.FC<{
|
|||
alt=''
|
||||
role='presentation'
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
/>
|
||||
<div
|
||||
className='focal-point__reticle'
|
||||
|
@ -220,7 +193,6 @@ const Preview: React.FC<{
|
|||
src={media.get('url') as string}
|
||||
alt=''
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
/>
|
||||
<div
|
||||
className='focal-point__reticle'
|
||||
|
@ -233,10 +205,10 @@ const Preview: React.FC<{
|
|||
<Video
|
||||
preview={media.get('preview_url') 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}
|
||||
src={media.get('url') as string}
|
||||
detailed
|
||||
inline
|
||||
editable
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
|
@ -16,6 +16,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
|
|||
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 { Icon } from 'mastodon/components/icon';
|
||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||
import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
|
||||
|
||||
import { Blurhash } from '../../components/blurhash';
|
||||
|
@ -26,8 +27,8 @@ import Visualizer from './visualizer';
|
|||
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' },
|
||||
mute: { id: 'video.mute', defaultMessage: 'Mute' },
|
||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
|
||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
||||
});
|
||||
|
@ -61,6 +62,7 @@ class Audio extends PureComponent {
|
|||
volume: PropTypes.number,
|
||||
muted: PropTypes.bool,
|
||||
deployPictureInPicture: PropTypes.func,
|
||||
matchedFilters: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -471,19 +473,11 @@ class Audio extends PureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash } = this.props;
|
||||
const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash, matchedFilters } = this.props;
|
||||
const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
|
||||
const progress = Math.min((currentTime / duration) * 100, 100);
|
||||
const muted = this.state.muted || volume === 0;
|
||||
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
|
||||
|
||||
|
@ -521,14 +515,7 @@ class Audio extends PureComponent {
|
|||
lang={lang}
|
||||
/>
|
||||
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
{warning}
|
||||
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
|
||||
|
||||
{(revealed || editable) && <img
|
||||
src={this.props.poster}
|
||||
|
|
|
@ -99,6 +99,7 @@ class Bookmarks extends ImmutablePureComponent {
|
|||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='bookmarks'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
|
|
|
@ -20,7 +20,6 @@ import PollButtonContainer from '../containers/poll_button_container';
|
|||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
||||
import UploadButtonContainer from '../containers/upload_button_container';
|
||||
import WarningContainer from '../containers/warning_container';
|
||||
import { countableText } from '../util/counter';
|
||||
|
||||
import { CharacterCounter } from './character_counter';
|
||||
|
@ -30,6 +29,7 @@ import { NavigationBar } from './navigation_bar';
|
|||
import { PollForm } from "./poll_form";
|
||||
import { ReplyIndicator } from './reply_indicator';
|
||||
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';
|
||||
|
||||
|
@ -233,7 +233,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||
<ReplyIndicator />
|
||||
{!withoutNavigation && <NavigationBar />}
|
||||
<WarningContainer />
|
||||
<Warning />
|
||||
|
||||
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
|
||||
<div className='compose-form__scrollable'>
|
||||
|
|
|
@ -378,6 +378,7 @@ export const LanguageDropdown: React.FC = () => {
|
|||
if (text.length > 20) {
|
||||
debouncedGuess(text, setGuess);
|
||||
} else {
|
||||
debouncedGuess.cancel();
|
||||
setGuess('');
|
||||
}
|
||||
}, [text, setGuess]);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
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';
|
||||
|
||||
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,
|
||||
(privacy, locked, text) => ({
|
||||
needsLockWarning: privacy === 'private' && !locked,
|
||||
hashtagWarning: privacy !== 'public' && HASHTAG_PATTERN_REGEX.test(text),
|
||||
directMessageWarning: privacy === 'direct',
|
||||
}),
|
||||
);
|
||||
|
||||
export const Warning = () => {
|
||||
const { needsLockWarning, hashtagWarning, directMessageWarning } =
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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,46 +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 Warning from '../components/warning';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
|
||||
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
|
||||
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
|
||||
});
|
||||
|
||||
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
|
||||
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} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
WarningWrapper.propTypes = {
|
||||
needsLockWarning: PropTypes.bool,
|
||||
hashtagWarning: PropTypes.bool,
|
||||
directMessageWarning: 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
|
||||
// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
|
||||
// This file contains the compressed version of the emoji data from
|
||||
|
|
|
@ -33,11 +33,8 @@ function processEmojiMapData(
|
|||
shortCode?: ShortCodesToEmojiDataKey,
|
||||
) {
|
||||
const [native, _filename] = emojiMapData;
|
||||
let filename = emojiMapData[1];
|
||||
if (!filename) {
|
||||
// filename name can be derived from unicodeToFilename
|
||||
filename = unicodeToFilename(native);
|
||||
}
|
||||
// filename name can be derived from unicodeToFilename
|
||||
const filename = emojiMapData[1] ?? unicodeToFilename(native);
|
||||
unicodeMapping[native] = {
|
||||
shortCode,
|
||||
filename,
|
||||
|
|
|
@ -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`) */
|
||||
|
||||
// taken from:
|
||||
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
|
||||
exports.unicodeToFilename = (str) => {
|
||||
|
|
|
@ -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) {
|
||||
while (str.length < num) {
|
||||
str = '0' + str;
|
||||
|
|
|
@ -99,6 +99,7 @@ class Favourites extends ImmutablePureComponent {
|
|||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='favourites'
|
||||
/>
|
||||
|
||||
<Helmet>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
import { PureComponent, useCallback, useMemo } from 'react';
|
||||
|
||||
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 ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import TransitionMotion from 'react-motion/lib/TransitionMotion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
||||
|
@ -239,72 +238,76 @@ class Reaction extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
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__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 = {
|
||||
announcementId: PropTypes.string.isRequired,
|
||||
reactions: ImmutablePropTypes.list.isRequired,
|
||||
addReaction: PropTypes.func.isRequired,
|
||||
removeReaction: PropTypes.func.isRequired,
|
||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
const handleEmojiPick = useCallback((emoji) => {
|
||||
addReaction(announcementId, emoji.native.replaceAll(/:/g, ''));
|
||||
}, [addReaction, announcementId]);
|
||||
|
||||
handleEmojiPick = data => {
|
||||
const { addReaction, announcementId } = this.props;
|
||||
addReaction(announcementId, data.native.replace(/:/g, ''));
|
||||
};
|
||||
const transitions = useTransition(visibleReactions, {
|
||||
from: {
|
||||
scale: 0,
|
||||
},
|
||||
enter: {
|
||||
scale: 1,
|
||||
},
|
||||
leave: {
|
||||
scale: 0,
|
||||
},
|
||||
immediate: reduceMotion,
|
||||
keys: visibleReactions.map(x => x.get('name')),
|
||||
});
|
||||
|
||||
willEnter () {
|
||||
return { scale: reduceMotion ? 1 : 0 };
|
||||
}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
{visibleReactions.length < 8 && (
|
||||
<EmojiPickerDropdown
|
||||
onPickEmoji={handleEmojiPick}
|
||||
button={<Icon id='plus' icon={AddIcon} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
ReactionsBar.propTypes = {
|
||||
announcementId: PropTypes.string.isRequired,
|
||||
reactions: ImmutablePropTypes.list.isRequired,
|
||||
addReaction: PropTypes.func.isRequired,
|
||||
removeReaction: PropTypes.func.isRequired,
|
||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
class Announcement extends ImmutablePureComponent {
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useCallback } from 'react';
|
|||
|
||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
||||
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 Footer from './components/footer';
|
||||
|
@ -35,6 +35,10 @@ export const PictureInPicture: React.FC = () => {
|
|||
accentColor,
|
||||
} = pipState;
|
||||
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let player;
|
||||
|
||||
switch (type) {
|
||||
|
@ -42,11 +46,10 @@ export const PictureInPicture: React.FC = () => {
|
|||
player = (
|
||||
<Video
|
||||
src={src}
|
||||
currentTime={currentTime}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
autoPlay
|
||||
inline
|
||||
startTime={currentTime}
|
||||
startVolume={volume}
|
||||
startMuted={muted}
|
||||
startPlaying
|
||||
alwaysVisible
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,17 +1,13 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
FormattedMessage,
|
||||
FormattedDate,
|
||||
useIntl,
|
||||
defineMessages,
|
||||
} from 'react-intl';
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { apiGetPrivacyPolicy } from 'mastodon/api/instance';
|
||||
import type { ApiPrivacyPolicyJSON } from 'mastodon/api_types/instance';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -58,7 +54,7 @@ const PrivacyPolicy: React.FC<{
|
|||
date: loading ? (
|
||||
<Skeleton width='10ch' />
|
||||
) : (
|
||||
<FormattedDate
|
||||
<FormattedDateWrapper
|
||||
value={response?.updated_at}
|
||||
year='numeric'
|
||||
month='short'
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { AlertsController } from 'mastodon/components/alerts_controller';
|
||||
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
|
||||
import LoadingBarContainer from 'mastodon/features/ui/containers/loading_bar_container';
|
||||
import ModalContainer from 'mastodon/features/ui/containers/modal_container';
|
||||
import NotificationsContainer from 'mastodon/features/ui/containers/notifications_container';
|
||||
|
||||
const Compose = () => (
|
||||
<>
|
||||
<ComposeFormContainer autoFocus withoutNavigation />
|
||||
<NotificationsContainer />
|
||||
<AlertsController />
|
||||
<ModalContainer />
|
||||
<LoadingBarContainer className='loading-bar' />
|
||||
</>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import type { CSSProperties } from 'react';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
@ -15,12 +15,15 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
|
|||
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import EditedTimestamp from 'mastodon/components/edited_timestamp';
|
||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import type { StatusLike } from 'mastodon/components/hashtag_bar';
|
||||
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconLogo } from 'mastodon/components/logo';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
|
@ -28,7 +31,6 @@ import MediaGallery from '../../../components/media_gallery';
|
|||
import StatusContent from '../../../components/status_content';
|
||||
import Audio from '../../audio';
|
||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||
import Video from '../../video';
|
||||
|
||||
import Card from './card';
|
||||
|
||||
|
@ -36,7 +38,6 @@ interface VideoModalOptions {
|
|||
startTime: number;
|
||||
autoPlay?: boolean;
|
||||
defaultVolume: number;
|
||||
componentIndex: number;
|
||||
}
|
||||
|
||||
export const DetailedStatus: React.FC<{
|
||||
|
@ -70,6 +71,7 @@ export const DetailedStatus: React.FC<{
|
|||
}) => {
|
||||
const properStatus = status?.get('reblog') ?? status;
|
||||
const [height, setHeight] = useState(0);
|
||||
const [showDespiteFilter, setShowDespiteFilter] = useState(false);
|
||||
const nodeRef = useRef<HTMLDivElement>();
|
||||
|
||||
const handleOpenVideo = useCallback(
|
||||
|
@ -82,6 +84,10 @@ export const DetailedStatus: React.FC<{
|
|||
[onOpenVideo, status],
|
||||
);
|
||||
|
||||
const handleFilterToggle = useCallback(() => {
|
||||
setShowDespiteFilter(!showDespiteFilter);
|
||||
}, [showDespiteFilter, setShowDespiteFilter]);
|
||||
|
||||
const handleExpandedToggle = useCallback(() => {
|
||||
if (onToggleHidden) onToggleHidden(status);
|
||||
}, [onToggleHidden, status]);
|
||||
|
@ -169,6 +175,7 @@ export const DetailedStatus: React.FC<{
|
|||
onOpenMedia={onOpenMedia}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
matchedFilters={status.get('matched_media_filters')}
|
||||
/>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
|
@ -195,6 +202,7 @@ export const DetailedStatus: React.FC<{
|
|||
blurhash={attachment.get('blurhash')}
|
||||
height={150}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
matchedFilters={status.get('matched_media_filters')}
|
||||
/>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
|
@ -212,12 +220,11 @@ export const DetailedStatus: React.FC<{
|
|||
src={attachment.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
width={300}
|
||||
height={150}
|
||||
onOpenVideo={handleOpenVideo}
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
matchedFilters={status.get('matched_media_filters')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -292,8 +299,12 @@ export const DetailedStatus: React.FC<{
|
|||
const { statusContentProps, hashtagBar } = getHashtagBarForStatus(
|
||||
status as StatusLike,
|
||||
);
|
||||
|
||||
const matchedFilters = status.get('matched_filters');
|
||||
|
||||
const expanded =
|
||||
!status.get('hidden') || status.get('spoiler_text').length === 0;
|
||||
(!matchedFilters || showDespiteFilter) &&
|
||||
(!status.get('hidden') || status.get('spoiler_text').length === 0);
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
|
@ -334,17 +345,26 @@ export const DetailedStatus: React.FC<{
|
|||
)}
|
||||
</Link>
|
||||
|
||||
{status.get('spoiler_text').length > 0 && (
|
||||
<ContentWarning
|
||||
text={
|
||||
status.getIn(['translation', 'spoilerHtml']) ||
|
||||
status.get('spoilerHtml')
|
||||
}
|
||||
expanded={expanded}
|
||||
onClick={handleExpandedToggle}
|
||||
{matchedFilters && (
|
||||
<FilterWarning
|
||||
title={matchedFilters.join(', ')}
|
||||
expanded={showDespiteFilter}
|
||||
onClick={handleFilterToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status.get('spoiler_text').length > 0 &&
|
||||
(!matchedFilters || showDespiteFilter) && (
|
||||
<ContentWarning
|
||||
text={
|
||||
status.getIn(['translation', 'spoilerHtml']) ||
|
||||
status.get('spoilerHtml')
|
||||
}
|
||||
expanded={expanded}
|
||||
onClick={handleExpandedToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<StatusContent
|
||||
|
@ -366,7 +386,7 @@ export const DetailedStatus: React.FC<{
|
|||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<FormattedDate
|
||||
<FormattedDateWrapper
|
||||
value={new Date(status.get('created_at') as string)}
|
||||
year='numeric'
|
||||
month='short'
|
||||
|
|
|
@ -138,7 +138,7 @@ const makeMapStateToProps = () => {
|
|||
});
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const status = getStatus(state, { id: props.params.statusId });
|
||||
const status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' });
|
||||
|
||||
let ancestorsIds = ImmutableList();
|
||||
let descendantsIds = ImmutableList();
|
||||
|
|
|
@ -1,175 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LoadingBar } from 'react-redux-loading-bar';
|
||||
|
||||
import ZoomableImage from './zoomable_image';
|
||||
|
||||
export default class ImageLoader extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
alt: PropTypes.string,
|
||||
lang: PropTypes.string,
|
||||
src: PropTypes.string.isRequired,
|
||||
previewSrc: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
onClick: PropTypes.func,
|
||||
zoomedIn: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
alt: '',
|
||||
lang: '',
|
||||
width: null,
|
||||
height: null,
|
||||
};
|
||||
|
||||
state = {
|
||||
loading: true,
|
||||
error: false,
|
||||
width: null,
|
||||
};
|
||||
|
||||
removers = [];
|
||||
canvas = null;
|
||||
|
||||
get canvasContext() {
|
||||
if (!this.canvas) {
|
||||
return null;
|
||||
}
|
||||
this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
|
||||
return this._canvasContext;
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.loadImage(this.props);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (this.props.src !== nextProps.src) {
|
||||
this.loadImage(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.removeEventListeners();
|
||||
}
|
||||
|
||||
loadImage (props) {
|
||||
this.removeEventListeners();
|
||||
this.setState({ loading: true, error: false });
|
||||
Promise.all([
|
||||
props.previewSrc && this.loadPreviewCanvas(props),
|
||||
this.hasSize() && this.loadOriginalImage(props),
|
||||
].filter(Boolean))
|
||||
.then(() => {
|
||||
this.setState({ loading: false, error: false });
|
||||
this.clearPreviewCanvas();
|
||||
})
|
||||
.catch(() => this.setState({ loading: false, error: true }));
|
||||
}
|
||||
|
||||
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const removeEventListeners = () => {
|
||||
image.removeEventListener('error', handleError);
|
||||
image.removeEventListener('load', handleLoad);
|
||||
};
|
||||
const handleError = () => {
|
||||
removeEventListeners();
|
||||
reject();
|
||||
};
|
||||
const handleLoad = () => {
|
||||
removeEventListeners();
|
||||
this.canvasContext.drawImage(image, 0, 0, width, height);
|
||||
resolve();
|
||||
};
|
||||
image.addEventListener('error', handleError);
|
||||
image.addEventListener('load', handleLoad);
|
||||
image.src = previewSrc;
|
||||
this.removers.push(removeEventListeners);
|
||||
});
|
||||
|
||||
clearPreviewCanvas () {
|
||||
const { width, height } = this.canvas;
|
||||
this.canvasContext.clearRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const removeEventListeners = () => {
|
||||
image.removeEventListener('error', handleError);
|
||||
image.removeEventListener('load', handleLoad);
|
||||
};
|
||||
const handleError = () => {
|
||||
removeEventListeners();
|
||||
reject();
|
||||
};
|
||||
const handleLoad = () => {
|
||||
removeEventListeners();
|
||||
resolve();
|
||||
};
|
||||
image.addEventListener('error', handleError);
|
||||
image.addEventListener('load', handleLoad);
|
||||
image.src = src;
|
||||
this.removers.push(removeEventListeners);
|
||||
});
|
||||
|
||||
removeEventListeners () {
|
||||
this.removers.forEach(listeners => listeners());
|
||||
this.removers = [];
|
||||
}
|
||||
|
||||
hasSize () {
|
||||
const { width, height } = this.props;
|
||||
return typeof width === 'number' && typeof height === 'number';
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
if (c) this.setState({ width: c.offsetWidth });
|
||||
};
|
||||
|
||||
render () {
|
||||
const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
|
||||
const { loading } = this.state;
|
||||
|
||||
const className = classNames('image-loader', {
|
||||
'image-loader--loading': loading,
|
||||
'image-loader--amorphous': !this.hasSize(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
|
||||
<LoadingBar className='loading-bar' loading={1} />
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
className='image-loader__preview-canvas'
|
||||
ref={this.setCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ZoomableImage
|
||||
alt={alt}
|
||||
lang={lang}
|
||||
src={src}
|
||||
onClick={onClick}
|
||||
width={width}
|
||||
height={height}
|
||||
zoomedIn={zoomedIn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
||||
import ImageLoader from './image_loader';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
class ImageModal extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
navigationHidden: false,
|
||||
};
|
||||
|
||||
toggleNavigation = () => {
|
||||
this.setState(prevState => ({
|
||||
navigationHidden: !prevState.navigationHidden,
|
||||
}));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, src, alt, onClose } = this.props;
|
||||
const { navigationHidden } = this.state;
|
||||
|
||||
const navigationClassName = classNames('media-modal__navigation', {
|
||||
'media-modal__navigation--hidden': navigationHidden,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal media-modal'>
|
||||
<div className='media-modal__closer' role='presentation' onClick={onClose} >
|
||||
<ImageLoader
|
||||
src={src}
|
||||
width={400}
|
||||
height={400}
|
||||
alt={alt}
|
||||
onClick={this.toggleNavigation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={navigationClassName}>
|
||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(ImageModal);
|
|
@ -0,0 +1,61 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
||||
import { ZoomableImage } from './zoomable_image';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
export const ImageModal: React.FC<{
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}> = ({ src, alt, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const [navigationHidden, setNavigationHidden] = useState(false);
|
||||
|
||||
const toggleNavigation = useCallback(() => {
|
||||
setNavigationHidden((prevState) => !prevState);
|
||||
}, [setNavigationHidden]);
|
||||
|
||||
const navigationClassName = classNames('media-modal__navigation', {
|
||||
'media-modal__navigation--hidden': navigationHidden,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal media-modal'>
|
||||
<div
|
||||
className='media-modal__closer'
|
||||
role='presentation'
|
||||
onClick={onClose}
|
||||
>
|
||||
<ZoomableImage
|
||||
src={src}
|
||||
width={400}
|
||||
height={400}
|
||||
alt={alt}
|
||||
onClick={toggleNavigation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={navigationClassName}>
|
||||
<div className='media-modal__buttons'>
|
||||
<IconButton
|
||||
className='media-modal__close'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -19,10 +19,10 @@ import { GIFV } from 'mastodon/components/gifv';
|
|||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
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 ImageLoader from './image_loader';
|
||||
import { ZoomableImage } from './zoomable_image';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
|
@ -59,6 +59,12 @@ class MediaModal extends ImmutablePureComponent {
|
|||
}));
|
||||
};
|
||||
|
||||
handleZoomChange = (zoomedIn) => {
|
||||
this.setState({
|
||||
zoomedIn,
|
||||
});
|
||||
};
|
||||
|
||||
handleSwipe = (index) => {
|
||||
this.setState({
|
||||
index: index % this.props.media.size,
|
||||
|
@ -165,23 +171,26 @@ class MediaModal extends ImmutablePureComponent {
|
|||
const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
|
||||
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
|
||||
|
||||
const content = media.map((image) => {
|
||||
const content = media.map((image, idx) => {
|
||||
const width = image.getIn(['meta', 'original', 'width']) || null;
|
||||
const height = image.getIn(['meta', 'original', 'height']) || null;
|
||||
const description = image.getIn(['translation', 'description']) || image.get('description');
|
||||
|
||||
if (image.get('type') === 'image') {
|
||||
return (
|
||||
<ImageLoader
|
||||
previewSrc={image.get('preview_url')}
|
||||
<ZoomableImage
|
||||
src={image.get('url')}
|
||||
blurhash={image.get('blurhash')}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={description}
|
||||
lang={lang}
|
||||
key={image.get('url')}
|
||||
onClick={this.handleToggleNavigation}
|
||||
zoomedIn={zoomedIn}
|
||||
onDoubleClick={this.handleZoomClick}
|
||||
onClose={onClose}
|
||||
onZoomChange={this.handleZoomChange}
|
||||
zoomedIn={zoomedIn && idx === index}
|
||||
/>
|
||||
);
|
||||
} else if (image.get('type') === 'video') {
|
||||
|
@ -196,9 +205,9 @@ class MediaModal extends ImmutablePureComponent {
|
|||
height={image.get('height')}
|
||||
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
|
||||
aspectRatio={`${image.getIn(['meta', 'original', 'width'])} / ${image.getIn(['meta', 'original', 'height'])}`}
|
||||
currentTime={currentTime || 0}
|
||||
autoPlay={autoPlay || false}
|
||||
volume={volume || 1}
|
||||
startTime={currentTime || 0}
|
||||
startPlaying={autoPlay || false}
|
||||
startVolume={volume || 1}
|
||||
onCloseVideo={onClose}
|
||||
detailed
|
||||
alt={description}
|
||||
|
@ -262,7 +271,7 @@ class MediaModal extends ImmutablePureComponent {
|
|||
onChangeIndex={this.handleSwipe}
|
||||
onTransitionEnd={this.handleTransitionEnd}
|
||||
index={index}
|
||||
disabled={disableSwiping}
|
||||
disabled={disableSwiping || zoomedIn}
|
||||
>
|
||||
{content}
|
||||
</ReactSwipeableViews>
|
||||
|
|
|
@ -39,7 +39,7 @@ import {
|
|||
ConfirmFollowToListModal,
|
||||
ConfirmMissingAltTextModal,
|
||||
} from './confirmation_modals';
|
||||
import ImageModal from './image_modal';
|
||||
import { ImageModal } from './image_modal';
|
||||
import MediaModal from './media_modal';
|
||||
import { ModalPlaceholder } from './modal_placeholder';
|
||||
import VideoModal from './video_modal';
|
||||
|
|
|
@ -28,6 +28,7 @@ import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
|||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||
import StarActiveIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import FurryIcon from '@/material-icons/400-24px/pets.svg?react';
|
||||
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||
import { WordmarkLogo } from 'mastodon/components/logo';
|
||||
|
@ -48,6 +49,7 @@ const messages = defineMessages({
|
|||
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
|
||||
explore: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
|
||||
furry: { id: 'tabs_bar.furry', defaultMessage: 'Furry' },
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
|
@ -160,7 +162,10 @@ class NavigationPanel extends Component {
|
|||
)}
|
||||
|
||||
{(signedIn || timelinePreview) && (
|
||||
<>
|
||||
<ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
|
||||
<ColumnLink transparent to='/tags/furry' icon='pets' iconComponent={FurryIcon} text={intl.formatMessage(messages.furry)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!signedIn && (
|
||||
|
|
|
@ -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 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 }) => ({
|
||||
status: state.getIn(['statuses', statusId]),
|
||||
|
@ -56,9 +56,9 @@ class VideoModal extends ImmutablePureComponent {
|
|||
aspectRatio={`${media.getIn(['meta', 'original', 'width'])} / ${media.getIn(['meta', 'original', 'height'])}`}
|
||||
blurhash={media.get('blurhash')}
|
||||
src={media.get('url')}
|
||||
currentTime={options.startTime}
|
||||
autoPlay={options.autoPlay}
|
||||
volume={options.defaultVolume}
|
||||
startTime={options.startTime}
|
||||
startPlaying={options.autoPlay}
|
||||
startVolume={options.defaultVolume}
|
||||
onCloseVideo={onClose}
|
||||
autoFocus
|
||||
detailed
|
||||
|
|
|
@ -1,402 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 4;
|
||||
const NAV_BAR_HEIGHT = 66;
|
||||
|
||||
const getMidpoint = (p1, p2) => ({
|
||||
x: (p1.clientX + p2.clientX) / 2,
|
||||
y: (p1.clientY + p2.clientY) / 2,
|
||||
});
|
||||
|
||||
const getDistance = (p1, p2) =>
|
||||
Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
|
||||
|
||||
const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
|
||||
|
||||
// Normalizing mousewheel speed across browsers
|
||||
// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
|
||||
const normalizeWheel = event => {
|
||||
// Reasonable defaults
|
||||
const PIXEL_STEP = 10;
|
||||
const LINE_HEIGHT = 40;
|
||||
const PAGE_HEIGHT = 800;
|
||||
|
||||
let sX = 0,
|
||||
sY = 0, // spinX, spinY
|
||||
pX = 0,
|
||||
pY = 0; // pixelX, pixelY
|
||||
|
||||
// Legacy
|
||||
if ('detail' in event) {
|
||||
sY = event.detail;
|
||||
}
|
||||
if ('wheelDelta' in event) {
|
||||
sY = -event.wheelDelta / 120;
|
||||
}
|
||||
if ('wheelDeltaY' in event) {
|
||||
sY = -event.wheelDeltaY / 120;
|
||||
}
|
||||
if ('wheelDeltaX' in event) {
|
||||
sX = -event.wheelDeltaX / 120;
|
||||
}
|
||||
|
||||
// side scrolling on FF with DOMMouseScroll
|
||||
if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
|
||||
sX = sY;
|
||||
sY = 0;
|
||||
}
|
||||
|
||||
pX = sX * PIXEL_STEP;
|
||||
pY = sY * PIXEL_STEP;
|
||||
|
||||
if ('deltaY' in event) {
|
||||
pY = event.deltaY;
|
||||
}
|
||||
if ('deltaX' in event) {
|
||||
pX = event.deltaX;
|
||||
}
|
||||
|
||||
if ((pX || pY) && event.deltaMode) {
|
||||
if (event.deltaMode === 1) { // delta in LINE units
|
||||
pX *= LINE_HEIGHT;
|
||||
pY *= LINE_HEIGHT;
|
||||
} else { // delta in PAGE units
|
||||
pX *= PAGE_HEIGHT;
|
||||
pY *= PAGE_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall-back if spin cannot be determined
|
||||
if (pX && !sX) {
|
||||
sX = (pX < 1) ? -1 : 1;
|
||||
}
|
||||
if (pY && !sY) {
|
||||
sY = (pY < 1) ? -1 : 1;
|
||||
}
|
||||
|
||||
return {
|
||||
spinX: sX,
|
||||
spinY: sY,
|
||||
pixelX: pX,
|
||||
pixelY: pY,
|
||||
};
|
||||
};
|
||||
|
||||
class ZoomableImage extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
alt: PropTypes.string,
|
||||
lang: PropTypes.string,
|
||||
src: PropTypes.string.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
onClick: PropTypes.func,
|
||||
zoomedIn: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
alt: '',
|
||||
lang: '',
|
||||
width: null,
|
||||
height: null,
|
||||
};
|
||||
|
||||
state = {
|
||||
scale: MIN_SCALE,
|
||||
zoomMatrix: {
|
||||
type: null, // 'width' 'height'
|
||||
fullScreen: null, // bool
|
||||
rate: null, // full screen scale rate
|
||||
clientWidth: null,
|
||||
clientHeight: null,
|
||||
offsetWidth: null,
|
||||
offsetHeight: null,
|
||||
clientHeightFixed: null,
|
||||
scrollTop: null,
|
||||
scrollLeft: null,
|
||||
translateX: null,
|
||||
translateY: null,
|
||||
},
|
||||
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
|
||||
dragged: false,
|
||||
lockScroll: { x: 0, y: 0 },
|
||||
lockTranslate: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
removers = [];
|
||||
container = null;
|
||||
image = null;
|
||||
lastTouchEndTime = 0;
|
||||
lastDistance = 0;
|
||||
|
||||
componentDidMount () {
|
||||
let handler = this.handleTouchStart;
|
||||
this.container.addEventListener('touchstart', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('touchstart', handler));
|
||||
handler = this.handleTouchMove;
|
||||
// on Chrome 56+, touch event listeners will default to passive
|
||||
// https://www.chromestatus.com/features/5093566007214080
|
||||
this.container.addEventListener('touchmove', handler, { passive: false });
|
||||
this.removers.push(() => this.container.removeEventListener('touchend', handler));
|
||||
|
||||
handler = this.mouseDownHandler;
|
||||
this.container.addEventListener('mousedown', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('mousedown', handler));
|
||||
|
||||
handler = this.mouseWheelHandler;
|
||||
this.container.addEventListener('wheel', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('wheel', handler));
|
||||
// Old Chrome
|
||||
this.container.addEventListener('mousewheel', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
|
||||
// Old Firefox
|
||||
this.container.addEventListener('DOMMouseScroll', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
|
||||
|
||||
this._initZoomMatrix();
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this._removeEventListeners();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.zoomedIn !== this.props.zoomedIn) {
|
||||
this._toggleZoom();
|
||||
}
|
||||
}
|
||||
|
||||
_removeEventListeners () {
|
||||
this.removers.forEach(listeners => listeners());
|
||||
this.removers = [];
|
||||
}
|
||||
|
||||
mouseWheelHandler = e => {
|
||||
e.preventDefault();
|
||||
|
||||
const event = normalizeWheel(e);
|
||||
|
||||
if (this.state.zoomMatrix.type === 'width') {
|
||||
// full width, scroll vertical
|
||||
this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
|
||||
} else {
|
||||
// full height, scroll horizontal
|
||||
this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x);
|
||||
}
|
||||
|
||||
// lock horizontal scroll
|
||||
this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x);
|
||||
};
|
||||
|
||||
mouseDownHandler = e => {
|
||||
this.setState({ dragPosition: {
|
||||
left: this.container.scrollLeft,
|
||||
top: this.container.scrollTop,
|
||||
// Get the current mouse position
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
} });
|
||||
|
||||
this.image.addEventListener('mousemove', this.mouseMoveHandler);
|
||||
this.image.addEventListener('mouseup', this.mouseUpHandler);
|
||||
};
|
||||
|
||||
mouseMoveHandler = e => {
|
||||
const dx = e.clientX - this.state.dragPosition.x;
|
||||
const dy = e.clientY - this.state.dragPosition.y;
|
||||
|
||||
this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x);
|
||||
this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y);
|
||||
|
||||
this.setState({ dragged: true });
|
||||
};
|
||||
|
||||
mouseUpHandler = () => {
|
||||
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
|
||||
this.image.removeEventListener('mouseup', this.mouseUpHandler);
|
||||
};
|
||||
|
||||
handleTouchStart = e => {
|
||||
if (e.touches.length !== 2) return;
|
||||
|
||||
this.lastDistance = getDistance(...e.touches);
|
||||
};
|
||||
|
||||
handleTouchMove = e => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = this.container;
|
||||
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
|
||||
// prevent propagating event to MediaModal
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.touches.length !== 2) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const distance = getDistance(...e.touches);
|
||||
const midpoint = getMidpoint(...e.touches);
|
||||
const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
|
||||
const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
|
||||
|
||||
this._zoom(scale, midpoint);
|
||||
|
||||
this.lastMidpoint = midpoint;
|
||||
this.lastDistance = distance;
|
||||
};
|
||||
|
||||
_zoom(nextScale, midpoint) {
|
||||
const { scale, zoomMatrix } = this.state;
|
||||
const { scrollLeft, scrollTop } = this.container;
|
||||
|
||||
// math memo:
|
||||
// x = (scrollLeft + midpoint.x) / scrollWidth
|
||||
// x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
|
||||
// scrollWidth = clientWidth * scale
|
||||
// scrollWidth' = clientWidth * nextScale
|
||||
// Solve x = x' for nextScrollLeft
|
||||
const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
|
||||
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
|
||||
|
||||
this.setState({ scale: nextScale }, () => {
|
||||
this.container.scrollLeft = nextScrollLeft;
|
||||
this.container.scrollTop = nextScrollTop;
|
||||
// reset the translateX/Y constantly
|
||||
if (nextScale < zoomMatrix.rate) {
|
||||
this.setState({
|
||||
lockTranslate: {
|
||||
x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
|
||||
y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
// don't propagate event to MediaModal
|
||||
e.stopPropagation();
|
||||
const dragged = this.state.dragged;
|
||||
this.setState({ dragged: false });
|
||||
if (dragged) return;
|
||||
const handler = this.props.onClick;
|
||||
if (handler) handler();
|
||||
};
|
||||
|
||||
handleMouseDown = e => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
_initZoomMatrix = () => {
|
||||
const { width, height } = this.props;
|
||||
const { clientWidth, clientHeight } = this.container;
|
||||
const { offsetWidth, offsetHeight } = this.image;
|
||||
const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
|
||||
|
||||
const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
|
||||
const fullScreen = type === 'width' ? width > clientWidth : height > clientHeightFixed;
|
||||
const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
|
||||
const scrollTop = type === 'width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
|
||||
const scrollLeft = (clientWidth - offsetWidth) / 2;
|
||||
const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
|
||||
const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
|
||||
|
||||
this.setState({
|
||||
zoomMatrix: {
|
||||
type: type,
|
||||
fullScreen: fullScreen,
|
||||
rate: rate,
|
||||
clientWidth: clientWidth,
|
||||
clientHeight: clientHeight,
|
||||
offsetWidth: offsetWidth,
|
||||
offsetHeight: offsetHeight,
|
||||
clientHeightFixed: clientHeightFixed,
|
||||
scrollTop: scrollTop,
|
||||
scrollLeft: scrollLeft,
|
||||
translateX: translateX,
|
||||
translateY: translateY,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
_toggleZoom () {
|
||||
const { scale, zoomMatrix } = this.state;
|
||||
|
||||
if ( scale >= zoomMatrix.rate ) {
|
||||
this.setState({
|
||||
scale: MIN_SCALE,
|
||||
lockScroll: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
lockTranslate: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
}, () => {
|
||||
this.container.scrollLeft = 0;
|
||||
this.container.scrollTop = 0;
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
scale: zoomMatrix.rate,
|
||||
lockScroll: {
|
||||
x: zoomMatrix.scrollLeft,
|
||||
y: zoomMatrix.scrollTop,
|
||||
},
|
||||
lockTranslate: {
|
||||
x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
|
||||
y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
|
||||
},
|
||||
}, () => {
|
||||
this.container.scrollLeft = zoomMatrix.scrollLeft;
|
||||
this.container.scrollTop = zoomMatrix.scrollTop;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setContainerRef = c => {
|
||||
this.container = c;
|
||||
};
|
||||
|
||||
setImageRef = c => {
|
||||
this.image = c;
|
||||
};
|
||||
|
||||
render () {
|
||||
const { alt, lang, src, width, height } = this.props;
|
||||
const { scale, lockTranslate, dragged } = this.state;
|
||||
const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
|
||||
const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
|
||||
|
||||
return (
|
||||
<div
|
||||
className='zoomable-image'
|
||||
ref={this.setContainerRef}
|
||||
style={{ overflow, cursor, userSelect: 'none' }}
|
||||
>
|
||||
<img
|
||||
role='presentation'
|
||||
ref={this.setImageRef}
|
||||
alt={alt}
|
||||
title={alt}
|
||||
lang={lang}
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
draggable={false}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ZoomableImage;
|
|
@ -0,0 +1,319 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useSpring, animated, config } from '@react-spring/web';
|
||||
import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react';
|
||||
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 4;
|
||||
const DOUBLE_CLICK_THRESHOLD = 250;
|
||||
|
||||
interface ZoomMatrix {
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
initialScale: number;
|
||||
}
|
||||
|
||||
const createZoomMatrix = (
|
||||
container: HTMLElement,
|
||||
image: HTMLImageElement,
|
||||
fullWidth: number,
|
||||
fullHeight: number,
|
||||
): ZoomMatrix => {
|
||||
const { clientWidth, clientHeight } = container;
|
||||
const { offsetWidth, offsetHeight } = image;
|
||||
|
||||
const type =
|
||||
fullWidth / fullHeight < clientWidth / clientHeight ? 'width' : 'height';
|
||||
|
||||
const initialScale =
|
||||
type === 'width'
|
||||
? Math.min(clientWidth, fullWidth) / offsetWidth
|
||||
: Math.min(clientHeight, fullHeight) / offsetHeight;
|
||||
|
||||
return {
|
||||
containerWidth: clientWidth,
|
||||
containerHeight: clientHeight,
|
||||
imageWidth: offsetWidth,
|
||||
imageHeight: offsetHeight,
|
||||
initialScale,
|
||||
};
|
||||
};
|
||||
|
||||
const useGesture = createUseGesture([dragAction, pinchAction]);
|
||||
|
||||
const getBounds = (zoomMatrix: ZoomMatrix | null, scale: number) => {
|
||||
if (!zoomMatrix || scale === MIN_SCALE) {
|
||||
return {
|
||||
left: -Infinity,
|
||||
right: Infinity,
|
||||
top: -Infinity,
|
||||
bottom: Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
const { containerWidth, containerHeight, imageWidth, imageHeight } =
|
||||
zoomMatrix;
|
||||
|
||||
const bounds = {
|
||||
left: -Math.max(imageWidth * scale - containerWidth, 0) / 2,
|
||||
right: Math.max(imageWidth * scale - containerWidth, 0) / 2,
|
||||
top: -Math.max(imageHeight * scale - containerHeight, 0) / 2,
|
||||
bottom: Math.max(imageHeight * scale - containerHeight, 0) / 2,
|
||||
};
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
||||
interface ZoomableImageProps {
|
||||
alt?: string;
|
||||
lang?: string;
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onClose?: () => void;
|
||||
onZoomChange?: (zoomedIn: boolean) => void;
|
||||
zoomedIn?: boolean;
|
||||
blurhash?: string;
|
||||
}
|
||||
|
||||
export const ZoomableImage: React.FC<ZoomableImageProps> = ({
|
||||
alt = '',
|
||||
lang = '',
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onClose,
|
||||
onZoomChange,
|
||||
zoomedIn,
|
||||
blurhash,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener('gesturestart', handler);
|
||||
document.addEventListener('gesturechange', handler);
|
||||
document.addEventListener('gestureend', handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('gesturestart', handler);
|
||||
document.removeEventListener('gesturechange', handler);
|
||||
document.removeEventListener('gestureend', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const doubleClickTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
||||
const zoomMatrixRef = useRef<ZoomMatrix | null>(null);
|
||||
|
||||
const [style, api] = useSpring(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
onRest: {
|
||||
scale({ value }) {
|
||||
if (!onZoomChange) {
|
||||
return;
|
||||
}
|
||||
if (value === MIN_SCALE) {
|
||||
onZoomChange(false);
|
||||
} else {
|
||||
onZoomChange(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
useGesture(
|
||||
{
|
||||
onDrag({
|
||||
pinching,
|
||||
cancel,
|
||||
active,
|
||||
last,
|
||||
offset: [x, y],
|
||||
velocity: [, vy],
|
||||
direction: [, dy],
|
||||
tap,
|
||||
}) {
|
||||
if (tap) {
|
||||
if (!doubleClickTimeoutRef.current) {
|
||||
doubleClickTimeoutRef.current = setTimeout(() => {
|
||||
onClick?.();
|
||||
doubleClickTimeoutRef.current = null;
|
||||
}, DOUBLE_CLICK_THRESHOLD);
|
||||
} else {
|
||||
clearTimeout(doubleClickTimeoutRef.current);
|
||||
doubleClickTimeoutRef.current = null;
|
||||
onDoubleClick?.();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zoomedIn) {
|
||||
// Swipe up/down to dismiss parent
|
||||
if (last) {
|
||||
if ((vy > 0.5 && dy !== 0) || Math.abs(y) > 150) {
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
void api.start({ y: 0, config: config.wobbly });
|
||||
return;
|
||||
} else if (dy !== 0) {
|
||||
void api.start({ y, immediate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pinching) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
setDragging(true);
|
||||
} else {
|
||||
setDragging(false);
|
||||
}
|
||||
|
||||
void api.start({ x, y });
|
||||
},
|
||||
|
||||
onPinch({ origin: [ox, oy], first, movement: [ms], offset: [s], memo }) {
|
||||
if (!imageRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (first) {
|
||||
const { width, height, x, y } =
|
||||
imageRef.current.getBoundingClientRect();
|
||||
const tx = ox - (x + width / 2);
|
||||
const ty = oy - (y + height / 2);
|
||||
|
||||
memo = [style.x.get(), style.y.get(), tx, ty];
|
||||
}
|
||||
|
||||
const x = memo[0] - (ms - 1) * memo[2]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
const y = memo[1] - (ms - 1) * memo[3]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
|
||||
void api.start({ scale: s, x, y });
|
||||
|
||||
return memo as [number, number, number, number];
|
||||
},
|
||||
},
|
||||
{
|
||||
target: imageRef,
|
||||
drag: {
|
||||
from: () => [style.x.get(), style.y.get()],
|
||||
filterTaps: true,
|
||||
bounds: () => getBounds(zoomMatrixRef.current, style.scale.get()),
|
||||
rubberband: true,
|
||||
},
|
||||
pinch: {
|
||||
scaleBounds: {
|
||||
min: MIN_SCALE,
|
||||
max: MAX_SCALE,
|
||||
},
|
||||
rubberband: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded || !containerRef.current || !imageRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
zoomMatrixRef.current = createZoomMatrix(
|
||||
containerRef.current,
|
||||
imageRef.current,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
if (!zoomedIn) {
|
||||
void api.start({ scale: MIN_SCALE, x: 0, y: 0 });
|
||||
} else if (style.scale.get() === MIN_SCALE) {
|
||||
void api.start({ scale: zoomMatrixRef.current.initialScale, x: 0, y: 0 });
|
||||
}
|
||||
}, [api, style.scale, zoomedIn, width, height, loaded]);
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
// This handler exists to cancel the onClick handler on the media modal which would
|
||||
// otherwise close the modal. It cannot be used for actual click handling because
|
||||
// we don't know if the user is about to pan the image or not.
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoaded(true);
|
||||
}, [setLoaded]);
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
setError(true);
|
||||
}, [setError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('zoomable-image', {
|
||||
'zoomable-image--zoomed-in': zoomedIn,
|
||||
'zoomable-image--error': error,
|
||||
'zoomable-image--dragging': dragging,
|
||||
})}
|
||||
ref={containerRef}
|
||||
>
|
||||
{!loaded && blurhash && (
|
||||
<div
|
||||
className='zoomable-image__preview'
|
||||
style={{
|
||||
aspectRatio: `${width}/${height}`,
|
||||
height: `min(${height}px, 100%)`,
|
||||
}}
|
||||
>
|
||||
<Blurhash hash={blurhash} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<animated.img
|
||||
style={style}
|
||||
role='presentation'
|
||||
ref={imageRef}
|
||||
alt={alt}
|
||||
title={alt}
|
||||
lang={lang}
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
draggable={false}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
onClickCapture={handleClick}
|
||||
/>
|
||||
|
||||
{!loaded && !error && <LoadingIndicator />}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,20 +0,0 @@
|
|||
import { injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { NotificationStack } from 'react-notification';
|
||||
|
||||
import { dismissAlert } from 'mastodon/actions/alerts';
|
||||
import { getAlerts } from 'mastodon/selectors';
|
||||
|
||||
const mapStateToProps = (state, { intl }) => ({
|
||||
notifications: getAlerts(state, { intl }),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onDismiss (alert) {
|
||||
dispatch(dismissAlert(alert));
|
||||
},
|
||||
});
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));
|
|
@ -15,6 +15,7 @@ import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
|
|||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
|
||||
import { fetchNotifications } from 'mastodon/actions/notification_groups';
|
||||
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
|
||||
import { AlertsController } from 'mastodon/components/alerts_controller';
|
||||
import { HoverCardController } from 'mastodon/components/hover_card_controller';
|
||||
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
|
@ -29,11 +30,10 @@ import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding
|
|||
|
||||
import BundleColumnError from './components/bundle_column_error';
|
||||
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 LoadingBarContainer from './containers/loading_bar_container';
|
||||
import ModalContainer from './containers/modal_container';
|
||||
import NotificationsContainer from './containers/notifications_container';
|
||||
import {
|
||||
Compose,
|
||||
Status,
|
||||
|
@ -607,7 +607,7 @@ class UI extends PureComponent {
|
|||
</SwitchingColumnsArea>
|
||||
|
||||
{layout !== 'mobile' && <PictureInPicture />}
|
||||
<NotificationsContainer />
|
||||
<AlertsController />
|
||||
{!disableHoverCards && <HoverCardController />}
|
||||
<LoadingBarContainer className='loading-bar' />
|
||||
<ModalContainer />
|
||||
|
|
|
@ -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;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue