Merge, store Lexical editorState
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
commit
171341ff1a
|
@ -5,4 +5,4 @@
|
|||
/tmp/**
|
||||
/coverage/**
|
||||
/custom/**
|
||||
!.eslintrc.js
|
||||
!.eslintrc.cjs
|
||||
|
|
|
@ -5,6 +5,7 @@ module.exports = {
|
|||
'eslint:recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:compat/recommended',
|
||||
'plugin:tailwindcss/recommended',
|
||||
],
|
||||
|
||||
env: {
|
||||
|
@ -18,11 +19,10 @@ module.exports = {
|
|||
ATTACHMENT_HOST: false,
|
||||
},
|
||||
|
||||
parser: 'babel-eslint',
|
||||
parser: '@babel/eslint-parser',
|
||||
|
||||
plugins: [
|
||||
'react',
|
||||
'jsdoc',
|
||||
'jsx-a11y',
|
||||
'import',
|
||||
'promise',
|
||||
|
@ -43,7 +43,7 @@ module.exports = {
|
|||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
'import/extensions': ['.js', '.jsx', '.ts', '.tsx'],
|
||||
'import/extensions': ['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx'],
|
||||
'import/ignore': [
|
||||
'node_modules',
|
||||
'\\.(css|scss|json)$',
|
||||
|
@ -54,13 +54,16 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
polyfills: [
|
||||
'es:all',
|
||||
'fetch',
|
||||
'IntersectionObserver',
|
||||
'Promise',
|
||||
'URL',
|
||||
'URLSearchParams',
|
||||
'es:all', // core-js
|
||||
'IntersectionObserver', // npm:intersection-observer
|
||||
'Promise', // core-js
|
||||
'ResizeObserver', // npm:resize-observer-polyfill
|
||||
'URL', // core-js
|
||||
'URLSearchParams', // core-js
|
||||
],
|
||||
tailwindcss: {
|
||||
config: 'tailwind.config.cjs',
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
|
@ -235,18 +238,7 @@ module.exports = {
|
|||
},
|
||||
],
|
||||
'import/newline-after-import': 'error',
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
// {
|
||||
// devDependencies: [
|
||||
// 'webpack/**',
|
||||
// 'app/soapbox/test_setup.js',
|
||||
// 'app/soapbox/test_helpers.js',
|
||||
// 'app/**/__tests__/**',
|
||||
// 'app/**/__mocks__/**',
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
'import/no-extraneous-dependencies': 'error',
|
||||
'import/no-unresolved': 'error',
|
||||
'import/no-webpack-loader-syntax': 'error',
|
||||
'import/order': [
|
||||
|
@ -267,10 +259,30 @@ module.exports = {
|
|||
},
|
||||
],
|
||||
'@typescript-eslint/no-duplicate-imports': 'error',
|
||||
'@typescript-eslint/member-delimiter-style': [
|
||||
'error',
|
||||
{
|
||||
multiline: {
|
||||
delimiter: 'none',
|
||||
},
|
||||
singleline: {
|
||||
delimiter: 'comma',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
'promise/catch-or-return': 'error',
|
||||
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
|
||||
'tailwindcss/classnames-order': [
|
||||
'error',
|
||||
{
|
||||
classRegex: '^(base|container|icon|item|list|outer|wrapper)?[c|C]lass(Name)?$',
|
||||
config: 'tailwind.config.cjs',
|
||||
},
|
||||
],
|
||||
'tailwindcss/migration-from-tailwind-2': 'error',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
@ -281,23 +293,5 @@ module.exports = {
|
|||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
},
|
||||
{
|
||||
// Only enforce JSDoc comments on UI components for now.
|
||||
// https://www.npmjs.com/package/eslint-plugin-jsdoc
|
||||
files: ['app/soapbox/components/ui/**/*'],
|
||||
rules: {
|
||||
'jsdoc/require-jsdoc': ['error', {
|
||||
publicOnly: true,
|
||||
require: {
|
||||
ArrowFunctionExpression: true,
|
||||
ClassDeclaration: true,
|
||||
ClassExpression: true,
|
||||
FunctionDeclaration: true,
|
||||
FunctionExpression: true,
|
||||
MethodDefinition: true,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
|
@ -18,6 +18,7 @@ stages:
|
|||
- deps
|
||||
- test
|
||||
- deploy
|
||||
- release
|
||||
|
||||
deps:
|
||||
stage: deps
|
||||
|
@ -47,10 +48,12 @@ lint-js:
|
|||
changes:
|
||||
- "**/*.js"
|
||||
- "**/*.jsx"
|
||||
- "**/*.cjs"
|
||||
- "**/*.mjs"
|
||||
- "**/*.ts"
|
||||
- "**/*.tsx"
|
||||
- ".eslintignore"
|
||||
- ".eslintrc.js"
|
||||
- ".eslintrc.cjs"
|
||||
|
||||
lint-sass:
|
||||
stage: test
|
||||
|
@ -71,7 +74,7 @@ jest:
|
|||
- "app/soapbox/**/*"
|
||||
- "webpack/**/*"
|
||||
- "custom/**/*"
|
||||
- "jest.config.js"
|
||||
- "jest.config.cjs"
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- ".gitlab-ci.yml"
|
||||
|
@ -146,19 +149,27 @@ pages:
|
|||
|
||||
docker:
|
||||
stage: deploy
|
||||
image: docker:20.10.17
|
||||
image: docker:23.0.0
|
||||
services:
|
||||
- docker:20.10.17-dind
|
||||
- docker:23.0.0-dind
|
||||
tags:
|
||||
- dind
|
||||
# https://medium.com/devops-with-valentine/how-to-build-a-docker-image-and-push-it-to-the-gitlab-container-registry-from-a-gitlab-ci-pipeline-acac0d1f26df
|
||||
script:
|
||||
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
|
||||
- docker build -t $CI_REGISTRY_IMAGE .
|
||||
- docker push $CI_REGISTRY_IMAGE
|
||||
only:
|
||||
variables:
|
||||
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
|
||||
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .
|
||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
interruptible: false
|
||||
|
||||
release:
|
||||
stage: release
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
script:
|
||||
- npx ts-node ./scripts/do-release.ts
|
||||
interruptible: false
|
||||
|
||||
include:
|
||||
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"*.js": "eslint --cache",
|
||||
"*.cjs": "eslint --cache",
|
||||
"*.mjs": "eslint --cache",
|
||||
"*.ts": "eslint --cache",
|
||||
"*.tsx": "eslint --cache",
|
||||
"app/styles/**/*.scss": "stylelint"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import sharedConfig from '../webpack/shared';
|
||||
|
||||
import type { StorybookConfig } from '@storybook/core-common';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
'../stories/**/*.stories.mdx',
|
||||
'../stories/**/*.stories.@(js|jsx|ts|tsx)'
|
||||
],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-interactions',
|
||||
'storybook-react-intl',
|
||||
{
|
||||
name: '@storybook/addon-postcss',
|
||||
options: {
|
||||
postcssLoaderOptions: {
|
||||
implementation: require('postcss'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
framework: '@storybook/react',
|
||||
core: {
|
||||
builder: '@storybook/builder-webpack5',
|
||||
},
|
||||
webpackFinal: async (config) => {
|
||||
config.resolve!.alias = {
|
||||
...sharedConfig.resolve!.alias,
|
||||
...config.resolve!.alias,
|
||||
};
|
||||
|
||||
config.resolve!.modules = [
|
||||
...sharedConfig.resolve!.modules!,
|
||||
...config.resolve!.modules!,
|
||||
];
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
|
@ -0,0 +1,22 @@
|
|||
import '../app/styles/tailwind.css';
|
||||
import '../stories/theme.css';
|
||||
|
||||
import { addDecorator, Story } from '@storybook/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
const withProvider = (Story: Story) => (
|
||||
<IntlProvider locale='en'><Story /></IntlProvider>
|
||||
);
|
||||
|
||||
addDecorator(withProvider);
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
|
@ -1,16 +1,22 @@
|
|||
{
|
||||
"extends": ["stylelint-config-standard"],
|
||||
"ignoreFiles": ["app/styles/reset.scss"],
|
||||
"plugins": ["stylelint-scss"],
|
||||
"extends": ["stylelint-config-standard-scss"],
|
||||
"rules": {
|
||||
"alpha-value-notation": null,
|
||||
"at-rule-no-unknown": null,
|
||||
"at-rule-empty-line-before": ["always", { "ignore": ["after-comment", "first-nested", "inside-block", "blockless-after-same-name-blockless", "blockless-after-blockless"] }],
|
||||
"color-function-notation": null,
|
||||
"custom-property-pattern": null,
|
||||
"declaration-block-no-redundant-longhand-properties": null,
|
||||
"declaration-colon-newline-after": null,
|
||||
"declaration-empty-line-before": "never",
|
||||
"font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free", "OpenDyslexic", "soapbox"] }],
|
||||
"font-family-no-missing-generic-family-keyword": [true, { "ignoreFontFamilies": ["ForkAwesome", "Font Awesome 5 Free"] }],
|
||||
"max-line-length": null,
|
||||
"no-descending-specificity": null,
|
||||
"no-duplicate-selectors": null,
|
||||
"scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["/tailwind/", "layer"]}],
|
||||
"no-invalid-position-at-import-rule": null
|
||||
"no-invalid-position-at-import-rule": null,
|
||||
"scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["tailwind", "apply", "layer", "config"]}],
|
||||
"scss/operator-no-unspaced": null,
|
||||
"selector-class-pattern": null,
|
||||
"string-quotes": "single"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
nodejs 18.2.0
|
||||
nodejs 18.14.0
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"css.validate": false,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"files.associations": {
|
||||
|
@ -15,5 +16,6 @@
|
|||
"fileMatch": ["renovate.json"],
|
||||
"url": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
]
|
||||
],
|
||||
"scss.validate": false
|
||||
}
|
||||
|
|
70
CHANGELOG.md
70
CHANGELOG.md
|
@ -6,16 +6,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Posts: fixed emojis being cut off in reactions modal.
|
||||
- Posts: fix audio player progress bar visibility.
|
||||
|
||||
## [3.2.0] - 2023-02-15
|
||||
|
||||
### Added
|
||||
- Admin: redirect the homepage to any URL.
|
||||
- Compatibility: added compatibility with Friendica.
|
||||
- Posts: bot badge on statuses from bot accounts.
|
||||
- Compatibility: improved browser support for older browsers.
|
||||
- Events: allow to repost events in event menu.
|
||||
- Profile: Add RSS link to user profiles.
|
||||
- Reactions: adds support for reacting to chat messages.
|
||||
- Groups: initial support for groups.
|
||||
- Profile: add RSS link to user profiles.
|
||||
- Chats: reset chat message field height after sending a message.
|
||||
- Admin: allow to manage announcements.
|
||||
|
||||
### Changed
|
||||
- Chats: improved display of media attachments.
|
||||
- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away.
|
||||
- Posts: increased font size of focused status in threads.
|
||||
- Posts: let "mute conversation" be clicked from any feed, not just noficiations.
|
||||
- Posts: display all emoji reactions.
|
||||
- Reactions: improved UI of reactions on statuses.
|
||||
- Profile: make verified badge more prominent, overlapping with avatar.
|
||||
|
||||
### Fixed
|
||||
- Admin: fixed hover card in reports modal shows reporter not reportee
|
||||
- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load.
|
||||
- Chats: don't display "copy" button for messages without text.
|
||||
- Posts: don't have to click the play button twice for embedded videos.
|
||||
- index.html: remove `referrer` meta tag so it doesn't conflict with backend's `Referrer-Policy` header.
|
||||
- Modals: fix media modal automatically switching to video.
|
||||
- Navigation: profile dropdown erratic behavior.
|
||||
- Posts: fix posts filtering.
|
||||
|
||||
### Removed
|
||||
- Admin: single user mode. Now the homepage can be redirected to any URL.
|
||||
|
||||
## [3.1.0] - 2023-01-13
|
||||
|
||||
### Added
|
||||
- Compatibility: rudimentary support for Takahē.
|
||||
- UI: added backdrop blur behind modals.
|
||||
- Admin: let admins configure media preview for attachment thumbnails.
|
||||
- Login: accept `?server` param in external login, eg `fe.soapbox.pub/login/external?server=gleasonator.com`.
|
||||
- Backups: restored Pleroma backups functionality.
|
||||
- Export: restored "Export data" to CSV.
|
||||
|
||||
### Changed
|
||||
- Posts: letterbox images to 19:6 again.
|
||||
- Status Info: moved context (repost, pinned) to improve UX.
|
||||
- Posts: remove file icon from empty link previews.
|
||||
- Settings: moved "Import data" under settings.
|
||||
- Composer: add more descriptive discard confirmation message.
|
||||
|
||||
### Fixed
|
||||
- Layout: use accent color for "floating action button" (mobile compose button).
|
||||
- ServiceWorker: don't serve favicon, robots.txt, and others from ServiceWorker.
|
||||
- Datepicker: correctly default to the current year.
|
||||
- Scheduled posts: fix page crashing on deleting a scheduled post.
|
||||
- Events: don't crash when searching for a location.
|
||||
- Search: fixes an abort error when using the navbar search component.
|
||||
- Posts: fix monospace font in Markdown code blocks.
|
||||
- Modals: fix action buttons overflow
|
||||
- Editing: don't insert edited posts to the top of the feed.
|
||||
- Editing: don't display edited posts as pending posts.
|
||||
- Modals: close modal when navigating to a different page.
|
||||
- Modals: fix "View context" button in media modal.
|
||||
- Posts: let unauthenticated users to translate posts if allowed by backend.
|
||||
- Chats: fix jumpy scrollbar.
|
||||
- Composer: fix alignment of icon in submit button.
|
||||
- Login: add a border around QR codes.
|
||||
- Composer: don't display action button in reply indicator.
|
||||
|
||||
## [3.0.0] - 2022-12-25
|
||||
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import loadPolyfills from './soapbox/load-polyfills';
|
||||
|
||||
// Load iframe event listener
|
||||
require('./soapbox/iframe');
|
||||
|
||||
// @ts-ignore
|
||||
require.context('./assets/images/', true);
|
||||
|
||||
// Load stylesheet
|
||||
require('react-datepicker/dist/react-datepicker.css');
|
||||
require('./styles/application.scss');
|
||||
|
||||
loadPolyfills().then(() => {
|
||||
require('./soapbox/main').default();
|
||||
}).catch(e => {
|
||||
console.error(e);
|
||||
});
|
|
@ -1,94 +0,0 @@
|
|||
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
|
||||
with Reserved Font Name OpenDyslexic.
|
||||
Copyright (c) 12/2012 - 2019
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -5,7 +5,6 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="referrer" content="same-origin" />
|
||||
<link href="/manifest.json" rel="manifest">
|
||||
<!--server-generated-meta-->
|
||||
<%= snippets %>
|
||||
|
|
|
@ -2,7 +2,9 @@ import { Map as ImmutableMap } from 'immutable';
|
|||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
||||
import { AccountRecord } from 'soapbox/normalizers';
|
||||
|
||||
import { AuthUserRecord, ReducerRecord } from '../../reducers/auth';
|
||||
import {
|
||||
fetchMe, patchMe,
|
||||
} from '../me';
|
||||
|
@ -38,18 +40,18 @@ describe('fetchMe()', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
const state = rootState
|
||||
.set('auth', ImmutableMap({
|
||||
.set('auth', ReducerRecord({
|
||||
me: accountUrl,
|
||||
users: ImmutableMap({
|
||||
[accountUrl]: ImmutableMap({
|
||||
[accountUrl]: AuthUserRecord({
|
||||
'access_token': token,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
.set('accounts', ImmutableMap({
|
||||
[accountUrl]: {
|
||||
[accountUrl]: AccountRecord({
|
||||
url: accountUrl,
|
||||
},
|
||||
}),
|
||||
}) as any);
|
||||
store = mockStore(state);
|
||||
});
|
||||
|
@ -112,4 +114,4 @@ describe('patchMe()', () => {
|
|||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,10 +10,10 @@ import {
|
|||
} from './importer';
|
||||
|
||||
import type { AxiosError, CancelToken } from 'axios';
|
||||
import type { History } from 'history';
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity, Status } from 'soapbox/types/entities';
|
||||
import type { History } from 'soapbox/types/history';
|
||||
|
||||
const ACCOUNT_CREATE_REQUEST = 'ACCOUNT_CREATE_REQUEST';
|
||||
const ACCOUNT_CREATE_SUCCESS = 'ACCOUNT_CREATE_SUCCESS';
|
||||
|
@ -228,7 +228,7 @@ const fetchAccountFail = (id: string | null, error: AxiosError) => ({
|
|||
});
|
||||
|
||||
type FollowAccountOpts = {
|
||||
reblogs?: boolean,
|
||||
reblogs?: boolean
|
||||
notify?: boolean
|
||||
};
|
||||
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
||||
import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer';
|
||||
import toast from 'soapbox/toast';
|
||||
import { filterBadges, getTagDiff } from 'soapbox/utils/badges';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import { openModal } from './modals';
|
||||
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
import type { APIEntity, Announcement } from 'soapbox/types/entities';
|
||||
|
||||
const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST';
|
||||
const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS';
|
||||
|
@ -77,6 +82,45 @@ const ADMIN_USERS_UNSUGGEST_REQUEST = 'ADMIN_USERS_UNSUGGEST_REQUEST';
|
|||
const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS';
|
||||
const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL';
|
||||
|
||||
const ADMIN_USER_INDEX_EXPAND_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL';
|
||||
const ADMIN_USER_INDEX_EXPAND_REQUEST = 'ADMIN_USER_INDEX_EXPAND_REQUEST';
|
||||
const ADMIN_USER_INDEX_EXPAND_SUCCESS = 'ADMIN_USER_INDEX_EXPAND_SUCCESS';
|
||||
|
||||
const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL';
|
||||
const ADMIN_USER_INDEX_FETCH_REQUEST = 'ADMIN_USER_INDEX_FETCH_REQUEST';
|
||||
const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS';
|
||||
|
||||
const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET';
|
||||
|
||||
const ADMIN_ANNOUNCEMENTS_FETCH_FAIL = 'ADMIN_ANNOUNCEMENTS_FETCH_FAILS';
|
||||
const ADMIN_ANNOUNCEMENTS_FETCH_REQUEST = 'ADMIN_ANNOUNCEMENTS_FETCH_REQUEST';
|
||||
const ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS = 'ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS';
|
||||
|
||||
const ADMIN_ANNOUNCEMENTS_EXPAND_FAIL = 'ADMIN_ANNOUNCEMENTS_EXPAND_FAILS';
|
||||
const ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST = 'ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST';
|
||||
const ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS = 'ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS';
|
||||
|
||||
const ADMIN_ANNOUNCEMENT_CHANGE_CONTENT = 'ADMIN_ANNOUNCEMENT_CHANGE_CONTENT';
|
||||
const ADMIN_ANNOUNCEMENT_CHANGE_START_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_START_TIME';
|
||||
const ADMIN_ANNOUNCEMENT_CHANGE_END_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_END_TIME';
|
||||
const ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY = 'ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY';
|
||||
|
||||
const ADMIN_ANNOUNCEMENT_CREATE_REQUEST = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST';
|
||||
const ADMIN_ANNOUNCEMENT_CREATE_SUCCESS = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST';
|
||||
const ADMIN_ANNOUNCEMENT_CREATE_FAIL = 'ADMIN_ANNOUNCEMENT_CREATE_FAIL';
|
||||
|
||||
const ADMIN_ANNOUNCEMENT_DELETE_REQUEST = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST';
|
||||
const ADMIN_ANNOUNCEMENT_DELETE_SUCCESS = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST';
|
||||
const ADMIN_ANNOUNCEMENT_DELETE_FAIL = 'ADMIN_ANNOUNCEMENT_DELETE_FAIL';
|
||||
|
||||
const ADMIN_ANNOUNCEMENT_MODAL_INIT = 'ADMIN_ANNOUNCEMENT_MODAL_INIT';
|
||||
|
||||
const messages = defineMessages({
|
||||
announcementCreateSuccess: { id: 'admin.edit_announcement.created', defaultMessage: 'Announcement created' },
|
||||
announcementDeleteSuccess: { id: 'admin.edit_announcement.deleted', defaultMessage: 'Announcement deleted' },
|
||||
announcementUpdateSuccess: { id: 'admin.edit_announcement.updated', defaultMessage: 'Announcement edited' },
|
||||
});
|
||||
|
||||
const nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct);
|
||||
|
||||
const fetchConfig = () =>
|
||||
|
@ -544,6 +588,137 @@ const unsuggestUsers = (accountIds: string[]) =>
|
|||
});
|
||||
};
|
||||
|
||||
const setUserIndexQuery = (query: string) => ({ type: ADMIN_USER_INDEX_QUERY_SET, query });
|
||||
|
||||
const fetchUserIndex = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const { filters, page, query, pageSize, isLoading } = getState().admin_user_index;
|
||||
|
||||
if (isLoading) return;
|
||||
|
||||
dispatch({ type: ADMIN_USER_INDEX_FETCH_REQUEST });
|
||||
|
||||
dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize))
|
||||
.then((data: any) => {
|
||||
if (data.error) {
|
||||
dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL });
|
||||
} else {
|
||||
const { users, count, next } = (data);
|
||||
dispatch({ type: ADMIN_USER_INDEX_FETCH_SUCCESS, users, count, next });
|
||||
}
|
||||
}).catch(() => {
|
||||
dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL });
|
||||
});
|
||||
};
|
||||
|
||||
const expandUserIndex = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const { filters, page, query, pageSize, isLoading, next, loaded } = getState().admin_user_index;
|
||||
|
||||
if (!loaded || isLoading) return;
|
||||
|
||||
dispatch({ type: ADMIN_USER_INDEX_EXPAND_REQUEST });
|
||||
|
||||
dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize, next))
|
||||
.then((data: any) => {
|
||||
if (data.error) {
|
||||
dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL });
|
||||
} else {
|
||||
const { users, count, next } = (data);
|
||||
dispatch({ type: ADMIN_USER_INDEX_EXPAND_SUCCESS, users, count, next });
|
||||
}
|
||||
}).catch(() => {
|
||||
dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL });
|
||||
});
|
||||
};
|
||||
|
||||
const fetchAdminAnnouncements = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_REQUEST });
|
||||
return api(getState)
|
||||
.get('/api/pleroma/admin/announcements', { params: { limit: 50 } })
|
||||
.then(({ data }) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, announcements: data });
|
||||
return data;
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_FAIL, error });
|
||||
});
|
||||
};
|
||||
|
||||
const expandAdminAnnouncements = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const page = getState().admin_announcements.page;
|
||||
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST });
|
||||
return api(getState)
|
||||
.get('/api/pleroma/admin/announcements', { params: { limit: 50, offset: page * 50 } })
|
||||
.then(({ data }) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS, announcements: data });
|
||||
return data;
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_FAIL, error });
|
||||
});
|
||||
};
|
||||
|
||||
const changeAnnouncementContent = (content: string) => ({
|
||||
type: ADMIN_ANNOUNCEMENT_CHANGE_CONTENT,
|
||||
value: content,
|
||||
});
|
||||
|
||||
const changeAnnouncementStartTime = (time: Date | null) => ({
|
||||
type: ADMIN_ANNOUNCEMENT_CHANGE_START_TIME,
|
||||
value: time,
|
||||
});
|
||||
|
||||
const changeAnnouncementEndTime = (time: Date | null) => ({
|
||||
type: ADMIN_ANNOUNCEMENT_CHANGE_END_TIME,
|
||||
value: time,
|
||||
});
|
||||
|
||||
const changeAnnouncementAllDay = (allDay: boolean) => ({
|
||||
type: ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY,
|
||||
value: allDay,
|
||||
});
|
||||
|
||||
const handleCreateAnnouncement = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_REQUEST });
|
||||
|
||||
const { id, content, starts_at, ends_at, all_day } = getState().admin_announcements.form;
|
||||
|
||||
return api(getState)[id ? 'patch' : 'post'](
|
||||
id ? `/api/pleroma/admin/announcements/${id}` : '/api/pleroma/admin/announcements',
|
||||
{ content, starts_at, ends_at, all_day },
|
||||
).then(({ data }) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, announcement: data });
|
||||
toast.success(id ? messages.announcementUpdateSuccess : messages.announcementCreateSuccess);
|
||||
dispatch(fetchAdminAnnouncements());
|
||||
return data;
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_FAIL, error });
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAnnouncement = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_REQUEST, id });
|
||||
|
||||
return api(getState).delete(`/api/pleroma/admin/announcements/${id}`).then(({ data }) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, id });
|
||||
toast.success(messages.announcementDeleteSuccess);
|
||||
dispatch(fetchAdminAnnouncements());
|
||||
return data;
|
||||
}).catch(error => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_FAIL, id, error });
|
||||
});
|
||||
};
|
||||
|
||||
const initAnnouncementModal = (announcement?: Announcement) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
dispatch({ type: ADMIN_ANNOUNCEMENT_MODAL_INIT, announcement });
|
||||
dispatch(openModal('EDIT_ANNOUNCEMENT'));
|
||||
};
|
||||
|
||||
export {
|
||||
ADMIN_CONFIG_FETCH_REQUEST,
|
||||
ADMIN_CONFIG_FETCH_SUCCESS,
|
||||
|
@ -596,6 +771,30 @@ export {
|
|||
ADMIN_USERS_UNSUGGEST_REQUEST,
|
||||
ADMIN_USERS_UNSUGGEST_SUCCESS,
|
||||
ADMIN_USERS_UNSUGGEST_FAIL,
|
||||
ADMIN_USER_INDEX_EXPAND_FAIL,
|
||||
ADMIN_USER_INDEX_EXPAND_REQUEST,
|
||||
ADMIN_USER_INDEX_EXPAND_SUCCESS,
|
||||
ADMIN_USER_INDEX_FETCH_FAIL,
|
||||
ADMIN_USER_INDEX_FETCH_REQUEST,
|
||||
ADMIN_USER_INDEX_FETCH_SUCCESS,
|
||||
ADMIN_USER_INDEX_QUERY_SET,
|
||||
ADMIN_ANNOUNCEMENTS_FETCH_FAIL,
|
||||
ADMIN_ANNOUNCEMENTS_FETCH_REQUEST,
|
||||
ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS,
|
||||
ADMIN_ANNOUNCEMENTS_EXPAND_FAIL,
|
||||
ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST,
|
||||
ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS,
|
||||
ADMIN_ANNOUNCEMENT_CHANGE_CONTENT,
|
||||
ADMIN_ANNOUNCEMENT_CHANGE_START_TIME,
|
||||
ADMIN_ANNOUNCEMENT_CHANGE_END_TIME,
|
||||
ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY,
|
||||
ADMIN_ANNOUNCEMENT_CREATE_FAIL,
|
||||
ADMIN_ANNOUNCEMENT_CREATE_REQUEST,
|
||||
ADMIN_ANNOUNCEMENT_CREATE_SUCCESS,
|
||||
ADMIN_ANNOUNCEMENT_DELETE_FAIL,
|
||||
ADMIN_ANNOUNCEMENT_DELETE_REQUEST,
|
||||
ADMIN_ANNOUNCEMENT_DELETE_SUCCESS,
|
||||
ADMIN_ANNOUNCEMENT_MODAL_INIT,
|
||||
fetchConfig,
|
||||
updateConfig,
|
||||
updateSoapboxConfig,
|
||||
|
@ -622,4 +821,16 @@ export {
|
|||
setRole,
|
||||
suggestUsers,
|
||||
unsuggestUsers,
|
||||
setUserIndexQuery,
|
||||
fetchUserIndex,
|
||||
expandUserIndex,
|
||||
fetchAdminAnnouncements,
|
||||
expandAdminAnnouncements,
|
||||
changeAnnouncementContent,
|
||||
changeAnnouncementStartTime,
|
||||
changeAnnouncementEndTime,
|
||||
changeAnnouncementAllDay,
|
||||
handleCreateAnnouncement,
|
||||
deleteAnnouncement,
|
||||
initAnnouncementModal,
|
||||
};
|
||||
|
|
|
@ -20,8 +20,8 @@ import KVStore from 'soapbox/storage/kv-store';
|
|||
import toast from 'soapbox/toast';
|
||||
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
import { normalizeUsername } from 'soapbox/utils/input';
|
||||
import { getScopes } from 'soapbox/utils/scopes';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
|
||||
import api, { baseClient } from '../api';
|
||||
|
@ -29,7 +29,6 @@ import api, { baseClient } from '../api';
|
|||
import { importFetchedAccount } from './importer';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT';
|
||||
|
@ -51,17 +50,12 @@ const customApp = custom('app');
|
|||
|
||||
export const messages = defineMessages({
|
||||
loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' },
|
||||
awaitingApproval: { id: 'auth.awaiting_approval', defaultMessage: 'Your account is awaiting approval' },
|
||||
invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' },
|
||||
});
|
||||
|
||||
const noOp = () => new Promise(f => f(undefined));
|
||||
|
||||
const getScopes = (state: RootState) => {
|
||||
const instance = state.instance;
|
||||
const { scopes } = getFeatures(instance);
|
||||
return scopes;
|
||||
};
|
||||
|
||||
const createAppAndToken = () =>
|
||||
(dispatch: AppDispatch) =>
|
||||
dispatch(getAuthApp()).then(() =>
|
||||
|
@ -94,11 +88,11 @@ const createAuthApp = () =>
|
|||
|
||||
const createAppToken = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const app = getState().auth.get('app');
|
||||
const app = getState().auth.app;
|
||||
|
||||
const params = {
|
||||
client_id: app.get('client_id'),
|
||||
client_secret: app.get('client_secret'),
|
||||
client_id: app.client_id!,
|
||||
client_secret: app.client_secret!,
|
||||
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
||||
grant_type: 'client_credentials',
|
||||
scope: getScopes(getState()),
|
||||
|
@ -111,11 +105,11 @@ const createAppToken = () =>
|
|||
|
||||
const createUserToken = (username: string, password: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const app = getState().auth.get('app');
|
||||
const app = getState().auth.app;
|
||||
|
||||
const params = {
|
||||
client_id: app.get('client_id'),
|
||||
client_secret: app.get('client_secret'),
|
||||
client_id: app.client_id!,
|
||||
client_secret: app.client_secret!,
|
||||
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
||||
grant_type: 'password',
|
||||
username: username,
|
||||
|
@ -127,32 +121,12 @@ const createUserToken = (username: string, password: string) =>
|
|||
.then((token: Record<string, string | number>) => dispatch(authLoggedIn(token)));
|
||||
};
|
||||
|
||||
export const refreshUserToken = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const refreshToken = getState().auth.getIn(['user', 'refresh_token']);
|
||||
const app = getState().auth.get('app');
|
||||
|
||||
if (!refreshToken) return dispatch(noOp);
|
||||
|
||||
const params = {
|
||||
client_id: app.get('client_id'),
|
||||
client_secret: app.get('client_secret'),
|
||||
refresh_token: refreshToken,
|
||||
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
||||
grant_type: 'refresh_token',
|
||||
scope: getScopes(getState()),
|
||||
};
|
||||
|
||||
return dispatch(obtainOAuthToken(params))
|
||||
.then((token: Record<string, string | number>) => dispatch(authLoggedIn(token)));
|
||||
};
|
||||
|
||||
export const otpVerify = (code: string, mfa_token: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const app = getState().auth.get('app');
|
||||
const app = getState().auth.app;
|
||||
return api(getState, 'app').post('/oauth/mfa/challenge', {
|
||||
client_id: app.get('client_id'),
|
||||
client_secret: app.get('client_secret'),
|
||||
client_id: app.client_id,
|
||||
client_secret: app.client_secret,
|
||||
mfa_token: mfa_token,
|
||||
code: code,
|
||||
challenge_type: 'totp',
|
||||
|
@ -211,9 +185,11 @@ export const logIn = (username: string, password: string) =>
|
|||
(dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => {
|
||||
return dispatch(createUserToken(normalizeUsername(username), password));
|
||||
}).catch((error: AxiosError) => {
|
||||
if ((error.response?.data as any).error === 'mfa_required') {
|
||||
if ((error.response?.data as any)?.error === 'mfa_required') {
|
||||
// If MFA is required, throw the error and handle it in the component.
|
||||
throw error;
|
||||
} else if ((error.response?.data as any)?.identifier === 'awaiting_approval') {
|
||||
toast.error(messages.awaitingApproval);
|
||||
} else {
|
||||
// Return "wrong password" message.
|
||||
toast.error(messages.invalidCredentials);
|
||||
|
@ -233,9 +209,9 @@ export const logOut = () =>
|
|||
if (!account) return dispatch(noOp);
|
||||
|
||||
const params = {
|
||||
client_id: state.auth.getIn(['app', 'client_id']),
|
||||
client_secret: state.auth.getIn(['app', 'client_secret']),
|
||||
token: state.auth.getIn(['users', account.url, 'access_token']),
|
||||
client_id: state.auth.app.client_id!,
|
||||
client_secret: state.auth.app.client_secret!,
|
||||
token: state.auth.users.get(account.url)!.access_token,
|
||||
};
|
||||
|
||||
return dispatch(revokeOAuthToken(params))
|
||||
|
@ -263,10 +239,10 @@ export const switchAccount = (accountId: string, background = false) =>
|
|||
export const fetchOwnAccounts = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
return state.auth.get('users').forEach((user: ImmutableMap<string, string>) => {
|
||||
const account = state.accounts.get(user.get('id'));
|
||||
return state.auth.users.forEach((user) => {
|
||||
const account = state.accounts.get(user.id);
|
||||
if (!account) {
|
||||
dispatch(verifyCredentials(user.get('access_token')!, user.get('url')));
|
||||
dispatch(verifyCredentials(user.access_token, user.url));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -6,8 +6,8 @@ import { getFeatures } from 'soapbox/utils/features';
|
|||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import type { History } from 'history';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { History } from 'soapbox/types/history';
|
||||
|
||||
const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST';
|
||||
const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS';
|
||||
|
|
|
@ -19,11 +19,12 @@ import { openModal, closeModal } from './modals';
|
|||
import { getSettings } from './settings';
|
||||
import { createStatus } from './statuses';
|
||||
|
||||
import type { History } from 'history';
|
||||
import type { EditorState } from 'lexical';
|
||||
import type { Emoji } from 'soapbox/components/autosuggest-emoji';
|
||||
import type { AutoSuggestion } from 'soapbox/components/autosuggest-input';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { Account, APIEntity, Status, Tag } from 'soapbox/types/entities';
|
||||
import type { History } from 'soapbox/types/history';
|
||||
|
||||
const { CancelToken, isCancel } = axios;
|
||||
|
||||
|
@ -46,6 +47,7 @@ const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
|||
const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||
const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||
const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||
const COMPOSE_GROUP_POST = 'COMPOSE_GROUP_POST';
|
||||
|
||||
const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||
const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||
|
@ -83,10 +85,12 @@ const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS';
|
|||
|
||||
const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
|
||||
|
||||
const COMPOSE_EDITOR_STATE_SET = 'COMPOSE_EDITOR_STATE_SET';
|
||||
|
||||
const messages = defineMessages({
|
||||
exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' },
|
||||
exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' },
|
||||
exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit} seconds)' },
|
||||
exceededVideoDurationLimit: { id: 'upload_error.video_duration_limit', defaultMessage: 'Video exceeds the current duration limit ({limit, plural, one {# second} other {# seconds}})' },
|
||||
scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' },
|
||||
success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' },
|
||||
editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' },
|
||||
|
@ -279,7 +283,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
|
|||
|
||||
const idempotencyKey = compose.idempotencyKey;
|
||||
|
||||
const params = {
|
||||
const params: Record<string, any> = {
|
||||
status,
|
||||
in_reply_to_id: compose.in_reply_to,
|
||||
quote_id: compose.quote,
|
||||
|
@ -293,6 +297,8 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
|
|||
to,
|
||||
};
|
||||
|
||||
if (compose.privacy === 'group') params.group_id = compose.group_id;
|
||||
|
||||
dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) {
|
||||
if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) {
|
||||
routerHistory.push('/messages');
|
||||
|
@ -473,6 +479,15 @@ const undoUploadCompose = (composeId: string, media_id: string) => ({
|
|||
media_id: media_id,
|
||||
});
|
||||
|
||||
const groupCompose = (composeId: string, groupId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_GROUP_POST,
|
||||
id: composeId,
|
||||
group_id: groupId,
|
||||
});
|
||||
};
|
||||
|
||||
const clearComposeSuggestions = (composeId: string) => {
|
||||
if (cancelFetchComposeSuggestionsAccounts) {
|
||||
cancelFetchComposeSuggestionsAccounts();
|
||||
|
@ -725,7 +740,7 @@ const eventDiscussionCompose = (composeId: string, status: Status) =>
|
|||
const instance = state.instance;
|
||||
const { explicitAddressing } = getFeatures(instance);
|
||||
|
||||
dispatch({
|
||||
return dispatch({
|
||||
type: COMPOSE_EVENT_REPLY,
|
||||
id: composeId,
|
||||
status: status,
|
||||
|
@ -734,6 +749,12 @@ const eventDiscussionCompose = (composeId: string, status: Status) =>
|
|||
});
|
||||
};
|
||||
|
||||
const setEditorState = (composeId: string, editorState: EditorState | string | null) => ({
|
||||
type: COMPOSE_EDITOR_STATE_SET,
|
||||
id: composeId,
|
||||
editorState: editorState,
|
||||
});
|
||||
|
||||
export {
|
||||
COMPOSE_CHANGE,
|
||||
COMPOSE_SUBMIT_REQUEST,
|
||||
|
@ -752,6 +773,7 @@ export {
|
|||
COMPOSE_UPLOAD_FAIL,
|
||||
COMPOSE_UPLOAD_PROGRESS,
|
||||
COMPOSE_UPLOAD_UNDO,
|
||||
COMPOSE_GROUP_POST,
|
||||
COMPOSE_SUGGESTIONS_CLEAR,
|
||||
COMPOSE_SUGGESTIONS_READY,
|
||||
COMPOSE_SUGGESTION_SELECT,
|
||||
|
@ -779,6 +801,7 @@ export {
|
|||
COMPOSE_ADD_TO_MENTIONS,
|
||||
COMPOSE_REMOVE_FROM_MENTIONS,
|
||||
COMPOSE_SET_STATUS,
|
||||
COMPOSE_EDITOR_STATE_SET,
|
||||
setComposeToStatus,
|
||||
changeCompose,
|
||||
replyCompose,
|
||||
|
@ -804,6 +827,7 @@ export {
|
|||
uploadComposeSuccess,
|
||||
uploadComposeFail,
|
||||
undoUploadCompose,
|
||||
groupCompose,
|
||||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions,
|
||||
readyComposeSuggestionsEmojis,
|
||||
|
@ -829,4 +853,5 @@ export {
|
|||
addToMentions,
|
||||
removeFromMentions,
|
||||
eventDiscussionCompose,
|
||||
setEditorState,
|
||||
};
|
||||
|
|
|
@ -3,7 +3,7 @@ import axios from 'axios';
|
|||
import * as BuildConfig from 'soapbox/build-config';
|
||||
import { isURL } from 'soapbox/utils/auth';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
import { getScopes } from 'soapbox/utils/scopes';
|
||||
|
||||
import { createApp } from './apps';
|
||||
|
||||
|
@ -11,8 +11,7 @@ import type { AppDispatch, RootState } from 'soapbox/store';
|
|||
|
||||
const createProviderApp = () => {
|
||||
return async(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const { scopes } = getFeatures(state.instance);
|
||||
const scopes = getScopes(getState());
|
||||
|
||||
const params = {
|
||||
client_name: sourceCode.displayName,
|
||||
|
@ -29,8 +28,7 @@ export const prepareRequest = (provider: string) => {
|
|||
return async(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : '';
|
||||
|
||||
const state = getState();
|
||||
const { scopes } = getFeatures(state.instance);
|
||||
const scopes = getScopes(getState());
|
||||
const app = await dispatch(createProviderApp());
|
||||
const { client_id, redirect_uri } = app;
|
||||
|
||||
|
|
|
@ -1,13 +1,8 @@
|
|||
import type { DropdownPlacement } from 'soapbox/components/dropdown-menu';
|
||||
|
||||
const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
|
||||
const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
|
||||
|
||||
const openDropdownMenu = (id: number, placement: DropdownPlacement, keyboard: boolean) =>
|
||||
({ type: DROPDOWN_MENU_OPEN, id, placement, keyboard });
|
||||
|
||||
const closeDropdownMenu = (id: number) =>
|
||||
({ type: DROPDOWN_MENU_CLOSE, id });
|
||||
const openDropdownMenu = () => ({ type: DROPDOWN_MENU_OPEN });
|
||||
const closeDropdownMenu = () => ({ type: DROPDOWN_MENU_CLOSE });
|
||||
|
||||
export {
|
||||
DROPDOWN_MENU_OPEN,
|
||||
|
|
|
@ -34,8 +34,8 @@ type ExportDataActions = {
|
|||
| typeof EXPORT_BLOCKS_FAIL
|
||||
| typeof EXPORT_MUTES_REQUEST
|
||||
| typeof EXPORT_MUTES_SUCCESS
|
||||
| typeof EXPORT_MUTES_FAIL,
|
||||
error?: any,
|
||||
| typeof EXPORT_MUTES_FAIL
|
||||
error?: any
|
||||
}
|
||||
|
||||
function fileExport(content: string, fileName: string) {
|
||||
|
|
|
@ -15,10 +15,11 @@ import sourceCode from 'soapbox/utils/code';
|
|||
import { getWalletAndSign } from 'soapbox/utils/ethereum';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
import { getQuirks } from 'soapbox/utils/quirks';
|
||||
import { getInstanceScopes } from 'soapbox/utils/scopes';
|
||||
|
||||
import { baseClient } from '../api';
|
||||
|
||||
import type { AppDispatch } from 'soapbox/store';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { Instance } from 'soapbox/types/entities';
|
||||
|
||||
const fetchExternalInstance = (baseURL?: string) => {
|
||||
|
@ -37,25 +38,23 @@ const fetchExternalInstance = (baseURL?: string) => {
|
|||
};
|
||||
|
||||
const createExternalApp = (instance: Instance, baseURL?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
(dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
// Mitra: skip creating the auth app
|
||||
if (getQuirks(instance).noApps) return new Promise(f => f({}));
|
||||
|
||||
const { scopes } = getFeatures(instance);
|
||||
|
||||
const params = {
|
||||
client_name: sourceCode.displayName,
|
||||
client_name: sourceCode.displayName,
|
||||
redirect_uris: `${window.location.origin}/login/external`,
|
||||
website: sourceCode.homepage,
|
||||
scopes,
|
||||
website: sourceCode.homepage,
|
||||
scopes: getInstanceScopes(instance),
|
||||
};
|
||||
|
||||
return dispatch(createApp(params, baseURL));
|
||||
};
|
||||
|
||||
const externalAuthorize = (instance: Instance, baseURL: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
const { scopes } = getFeatures(instance);
|
||||
(dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
const scopes = getInstanceScopes(instance);
|
||||
|
||||
return dispatch(createExternalApp(instance, baseURL)).then((app) => {
|
||||
const { client_id, redirect_uri } = app as Record<string, string>;
|
||||
|
@ -76,7 +75,7 @@ const externalAuthorize = (instance: Instance, baseURL: string) =>
|
|||
};
|
||||
|
||||
const externalEthereumLogin = (instance: Instance, baseURL?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const loginMessage = instance.login_message;
|
||||
|
||||
return getWalletAndSign(loginMessage).then(({ wallet, signature }) => {
|
||||
|
@ -89,7 +88,7 @@ const externalEthereumLogin = (instance: Instance, baseURL?: string) =>
|
|||
client_secret: client_secret,
|
||||
password: signature as string,
|
||||
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
||||
scope: getFeatures(instance).scopes,
|
||||
scope: getInstanceScopes(instance),
|
||||
};
|
||||
|
||||
return dispatch(obtainOAuthToken(params, baseURL))
|
||||
|
|
|
@ -11,25 +11,25 @@ export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCES
|
|||
export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL';
|
||||
|
||||
type FamiliarFollowersFetchRequestAction = {
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST,
|
||||
id: string,
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST
|
||||
id: string
|
||||
}
|
||||
|
||||
type FamiliarFollowersFetchRequestSuccessAction = {
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS,
|
||||
id: string,
|
||||
accounts: Array<APIEntity>,
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS
|
||||
id: string
|
||||
accounts: Array<APIEntity>
|
||||
}
|
||||
|
||||
type FamiliarFollowersFetchRequestFailAction = {
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL,
|
||||
id: string,
|
||||
error: any,
|
||||
type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL
|
||||
id: string
|
||||
error: any
|
||||
}
|
||||
|
||||
type AccountsImportAction = {
|
||||
type: typeof ACCOUNTS_IMPORT,
|
||||
accounts: Array<APIEntity>,
|
||||
type: typeof ACCOUNTS_IMPORT
|
||||
accounts: Array<APIEntity>
|
||||
}
|
||||
|
||||
export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -27,8 +27,8 @@ type ImportDataActions = {
|
|||
| typeof IMPORT_BLOCKS_FAIL
|
||||
| typeof IMPORT_MUTES_REQUEST
|
||||
| typeof IMPORT_MUTES_SUCCESS
|
||||
| typeof IMPORT_MUTES_FAIL,
|
||||
error?: any,
|
||||
| typeof IMPORT_MUTES_FAIL
|
||||
error?: any
|
||||
config?: string
|
||||
}
|
||||
|
||||
|
|
|
@ -5,42 +5,44 @@ import type { APIEntity } from 'soapbox/types/entities';
|
|||
|
||||
const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
|
||||
const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
|
||||
const GROUP_IMPORT = 'GROUP_IMPORT';
|
||||
const GROUPS_IMPORT = 'GROUPS_IMPORT';
|
||||
const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||
const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||
const POLLS_IMPORT = 'POLLS_IMPORT';
|
||||
const ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP = 'ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP';
|
||||
|
||||
export function importAccount(account: APIEntity) {
|
||||
return { type: ACCOUNT_IMPORT, account };
|
||||
}
|
||||
const importAccount = (account: APIEntity) =>
|
||||
({ type: ACCOUNT_IMPORT, account });
|
||||
|
||||
export function importAccounts(accounts: APIEntity[]) {
|
||||
return { type: ACCOUNTS_IMPORT, accounts };
|
||||
}
|
||||
const importAccounts = (accounts: APIEntity[]) =>
|
||||
({ type: ACCOUNTS_IMPORT, accounts });
|
||||
|
||||
export function importStatus(status: APIEntity, idempotencyKey?: string) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importGroup = (group: APIEntity) =>
|
||||
({ type: GROUP_IMPORT, group });
|
||||
|
||||
const importGroups = (groups: APIEntity[]) =>
|
||||
({ type: GROUPS_IMPORT, groups });
|
||||
|
||||
const importStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers });
|
||||
};
|
||||
}
|
||||
|
||||
export function importStatuses(statuses: APIEntity[]) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importStatuses = (statuses: APIEntity[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
|
||||
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers });
|
||||
};
|
||||
}
|
||||
|
||||
export function importPolls(polls: APIEntity[]) {
|
||||
return { type: POLLS_IMPORT, polls };
|
||||
}
|
||||
const importPolls = (polls: APIEntity[]) =>
|
||||
({ type: POLLS_IMPORT, polls });
|
||||
|
||||
export function importFetchedAccount(account: APIEntity) {
|
||||
return importFetchedAccounts([account]);
|
||||
}
|
||||
const importFetchedAccount = (account: APIEntity) =>
|
||||
importFetchedAccounts([account]);
|
||||
|
||||
export function importFetchedAccounts(accounts: APIEntity[], args = { should_refetch: false }) {
|
||||
const importFetchedAccounts = (accounts: APIEntity[], args = { should_refetch: false }) => {
|
||||
const { should_refetch } = args;
|
||||
const normalAccounts: APIEntity[] = [];
|
||||
|
||||
|
@ -61,10 +63,27 @@ export function importFetchedAccounts(accounts: APIEntity[], args = { should_ref
|
|||
accounts.forEach(processAccount);
|
||||
|
||||
return importAccounts(normalAccounts);
|
||||
}
|
||||
};
|
||||
|
||||
export function importFetchedStatus(status: APIEntity, idempotencyKey?: string) {
|
||||
return (dispatch: AppDispatch) => {
|
||||
const importFetchedGroup = (group: APIEntity) =>
|
||||
importFetchedGroups([group]);
|
||||
|
||||
const importFetchedGroups = (groups: APIEntity[]) => {
|
||||
const normalGroups: APIEntity[] = [];
|
||||
|
||||
const processGroup = (group: APIEntity) => {
|
||||
if (!group.id) return;
|
||||
|
||||
normalGroups.push(group);
|
||||
};
|
||||
|
||||
groups.forEach(processGroup);
|
||||
|
||||
return importGroups(normalGroups);
|
||||
};
|
||||
|
||||
const importFetchedStatus = (status: APIEntity, idempotencyKey?: string) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
// Skip broken statuses
|
||||
if (isBroken(status)) return;
|
||||
|
||||
|
@ -96,10 +115,13 @@ export function importFetchedStatus(status: APIEntity, idempotencyKey?: string)
|
|||
dispatch(importFetchedPoll(status.poll));
|
||||
}
|
||||
|
||||
if (status.group?.id) {
|
||||
dispatch(importFetchedGroup(status.group));
|
||||
}
|
||||
|
||||
dispatch(importFetchedAccount(status.account));
|
||||
dispatch(importStatus(status, idempotencyKey));
|
||||
};
|
||||
}
|
||||
|
||||
// Sometimes Pleroma can return an empty account,
|
||||
// or a repost can appear of a deleted account. Skip these statuses.
|
||||
|
@ -117,8 +139,8 @@ const isBroken = (status: APIEntity) => {
|
|||
}
|
||||
};
|
||||
|
||||
export function importFetchedStatuses(statuses: APIEntity[]) {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const importFetchedStatuses = (statuses: APIEntity[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const accounts: APIEntity[] = [];
|
||||
const normalStatuses: APIEntity[] = [];
|
||||
const polls: APIEntity[] = [];
|
||||
|
@ -146,6 +168,10 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
|
|||
if (status.poll?.id) {
|
||||
polls.push(status.poll);
|
||||
}
|
||||
|
||||
if (status.group?.id) {
|
||||
dispatch(importFetchedGroup(status.group));
|
||||
}
|
||||
}
|
||||
|
||||
statuses.forEach(processStatus);
|
||||
|
@ -154,23 +180,37 @@ export function importFetchedStatuses(statuses: APIEntity[]) {
|
|||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importStatuses(normalStatuses));
|
||||
};
|
||||
}
|
||||
|
||||
export function importFetchedPoll(poll: APIEntity) {
|
||||
return (dispatch: AppDispatch) => {
|
||||
const importFetchedPoll = (poll: APIEntity) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
dispatch(importPolls([poll]));
|
||||
};
|
||||
}
|
||||
|
||||
export function importErrorWhileFetchingAccountByUsername(username: string) {
|
||||
return { type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username };
|
||||
}
|
||||
const importErrorWhileFetchingAccountByUsername = (username: string) =>
|
||||
({ type: ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP, username });
|
||||
|
||||
export {
|
||||
ACCOUNT_IMPORT,
|
||||
ACCOUNTS_IMPORT,
|
||||
GROUP_IMPORT,
|
||||
GROUPS_IMPORT,
|
||||
STATUS_IMPORT,
|
||||
STATUSES_IMPORT,
|
||||
POLLS_IMPORT,
|
||||
ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP,
|
||||
importAccount,
|
||||
importAccounts,
|
||||
importGroup,
|
||||
importGroups,
|
||||
importStatus,
|
||||
importStatuses,
|
||||
importPolls,
|
||||
importFetchedAccount,
|
||||
importFetchedAccounts,
|
||||
importFetchedGroup,
|
||||
importFetchedGroups,
|
||||
importFetchedStatus,
|
||||
importFetchedStatuses,
|
||||
importFetchedPoll,
|
||||
importErrorWhileFetchingAccountByUsername,
|
||||
};
|
||||
|
|
|
@ -10,12 +10,12 @@ import api from '../api';
|
|||
|
||||
const getMeUrl = (state: RootState) => {
|
||||
const me = state.me;
|
||||
return state.accounts.getIn([me, 'url']);
|
||||
return state.accounts.get(me)?.url;
|
||||
};
|
||||
|
||||
/** Figure out the appropriate instance to fetch depending on the state */
|
||||
export const getHost = (state: RootState) => {
|
||||
const accountUrl = getMeUrl(state) || getAuthUserUrl(state);
|
||||
const accountUrl = getMeUrl(state) || getAuthUserUrl(state) as string;
|
||||
|
||||
try {
|
||||
return new URL(accountUrl).host;
|
||||
|
|
|
@ -6,7 +6,7 @@ import api from '../api';
|
|||
import { loadCredentials } from './auth';
|
||||
import { importFetchedAccount } from './importer';
|
||||
|
||||
import type { AxiosError, AxiosRequestHeaders } from 'axios';
|
||||
import type { AxiosError, RawAxiosRequestHeaders } from 'axios';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -30,8 +30,8 @@ const getMeUrl = (state: RootState) => {
|
|||
|
||||
const getMeToken = (state: RootState) => {
|
||||
// Fallback for upgrading IDs to URLs
|
||||
const accountUrl = getMeUrl(state) || state.auth.get('me');
|
||||
return state.auth.getIn(['users', accountUrl, 'access_token']);
|
||||
const accountUrl = getMeUrl(state) || state.auth.me;
|
||||
return state.auth.users.get(accountUrl!)?.access_token;
|
||||
};
|
||||
|
||||
const fetchMe = () =>
|
||||
|
@ -46,7 +46,7 @@ const fetchMe = () =>
|
|||
}
|
||||
|
||||
dispatch(fetchMeRequest());
|
||||
return dispatch(loadCredentials(token, accountUrl))
|
||||
return dispatch(loadCredentials(token, accountUrl!))
|
||||
.catch(error => dispatch(fetchMeFail(error)));
|
||||
};
|
||||
|
||||
|
@ -66,7 +66,7 @@ const patchMe = (params: Record<string, any>, isFormData = false) =>
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(patchMeRequest());
|
||||
|
||||
const headers: AxiosRequestHeaders = isFormData ? {
|
||||
const headers: RawAxiosRequestHeaders = isFormData ? {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
} : {};
|
||||
|
||||
|
|
|
@ -47,7 +47,7 @@ const MAX_QUEUED_NOTIFICATIONS = 40;
|
|||
|
||||
defineMessages({
|
||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
||||
group: { id: 'notifications.group', defaultMessage: '{count, plural, one {# notification} other {# notifications}}' },
|
||||
});
|
||||
|
||||
const fetchRelatedRelationships = (dispatch: AppDispatch, notifications: APIEntity[]) => {
|
||||
|
@ -107,7 +107,10 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
|
|||
|
||||
// Desktop notifications
|
||||
try {
|
||||
if (showAlert && !filtered) {
|
||||
// eslint-disable-next-line compat/compat
|
||||
const isNotificationsEnabled = window.Notification?.permission === 'granted';
|
||||
|
||||
if (showAlert && !filtered && isNotificationsEnabled) {
|
||||
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
|
||||
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
|
||||
|
||||
|
|
|
@ -37,8 +37,8 @@ const subscribe = (registration: ServiceWorkerRegistration, getState: () => Root
|
|||
});
|
||||
|
||||
const unsubscribe = ({ registration, subscription }: {
|
||||
registration: ServiceWorkerRegistration,
|
||||
subscription: PushSubscription | null,
|
||||
registration: ServiceWorkerRegistration
|
||||
subscription: PushSubscription | null
|
||||
}) =>
|
||||
subscription ? subscription.unsubscribe().then(() => registration) : new Promise<ServiceWorkerRegistration>(r => r(registration));
|
||||
|
||||
|
@ -82,8 +82,8 @@ const register = () =>
|
|||
.then(getPushSubscription)
|
||||
// @ts-ignore
|
||||
.then(({ registration, subscription }: {
|
||||
registration: ServiceWorkerRegistration,
|
||||
subscription: PushSubscription | null,
|
||||
registration: ServiceWorkerRegistration
|
||||
subscription: PushSubscription | null
|
||||
}) => {
|
||||
if (subscription !== null) {
|
||||
// We have a subscription, check if it is still valid
|
||||
|
|
|
@ -21,7 +21,7 @@ const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
|
|||
const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE';
|
||||
|
||||
type ReportedEntity = {
|
||||
status?: Status,
|
||||
status?: Status
|
||||
chatMessage?: ChatMessage
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import api from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { SearchFilter } from 'soapbox/reducers/search';
|
||||
|
@ -83,6 +83,10 @@ const submitSearch = (filter?: SearchFilter) =>
|
|||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
}
|
||||
|
||||
if (response.data.groups) {
|
||||
dispatch(importFetchedGroups(response.data.groups));
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value, type));
|
||||
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
|
@ -139,6 +143,10 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
|
|||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
if (data.groups) {
|
||||
dispatch(importFetchedGroups(data.groups));
|
||||
}
|
||||
|
||||
dispatch(expandSearchSuccess(data, value, type));
|
||||
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
|
|
|
@ -50,7 +50,7 @@ const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL';
|
|||
const fetchOAuthTokens = () =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: FETCH_TOKENS_REQUEST });
|
||||
return api(getState).get('/api/oauth_tokens.json').then(({ data: tokens }) => {
|
||||
return api(getState).get('/api/oauth_tokens').then(({ data: tokens }) => {
|
||||
dispatch({ type: FETCH_TOKENS_SUCCESS, tokens });
|
||||
}).catch(() => {
|
||||
dispatch({ type: FETCH_TOKENS_FAIL });
|
||||
|
|
|
@ -18,7 +18,7 @@ const FE_NAME = 'soapbox_fe';
|
|||
/** Options when changing/saving settings. */
|
||||
type SettingOpts = {
|
||||
/** Whether to display an alert when settings are saved. */
|
||||
showAlert?: boolean,
|
||||
showAlert?: boolean
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -47,7 +47,6 @@ const defaultSettings = ImmutableMap({
|
|||
autoloadMore: true,
|
||||
|
||||
systemFont: false,
|
||||
dyslexicFont: false,
|
||||
demetricator: false,
|
||||
|
||||
isDeveloper: false,
|
||||
|
@ -157,6 +156,8 @@ const defaultSettings = ImmutableMap({
|
|||
}),
|
||||
}),
|
||||
|
||||
groups: ImmutableMap({}),
|
||||
|
||||
trends: ImmutableMap({
|
||||
show: true,
|
||||
}),
|
||||
|
|
|
@ -32,8 +32,8 @@ const getSoapboxConfig = createSelector([
|
|||
}
|
||||
|
||||
// If RGI reacts aren't supported, strip VS16s
|
||||
// // https://git.pleroma.social/pleroma/pleroma/-/issues/2355
|
||||
if (!features.emojiReactsRGI) {
|
||||
// https://git.pleroma.social/pleroma/pleroma/-/issues/2355
|
||||
if (features.emojiReactsNonRGI) {
|
||||
soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s));
|
||||
}
|
||||
});
|
||||
|
|
|
@ -68,7 +68,7 @@ const createStatus = (params: Record<string, any>, idempotencyKey: string, statu
|
|||
}
|
||||
|
||||
dispatch(importFetchedStatus(status, idempotencyKey));
|
||||
dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey });
|
||||
dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey, editing: !!statusId });
|
||||
|
||||
// Poll the backend for the updated card
|
||||
if (status.expectsCard) {
|
||||
|
|
|
@ -2,7 +2,7 @@ import { getSettings } from 'soapbox/actions/settings';
|
|||
import messages from 'soapbox/locales/messages';
|
||||
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { getUnreadChatsCount, updateChatListItem } from 'soapbox/utils/chats';
|
||||
import { getUnreadChatsCount, updateChatListItem, updateChatMessage } from 'soapbox/utils/chats';
|
||||
import { removePageItem } from 'soapbox/utils/queries';
|
||||
import { play, soundCache } from 'soapbox/utils/sounds';
|
||||
|
||||
|
@ -81,7 +81,7 @@ const updateChatQuery = (chat: IChat) => {
|
|||
};
|
||||
|
||||
interface StreamOpts {
|
||||
statContext?: IStatContext,
|
||||
statContext?: IStatContext
|
||||
}
|
||||
|
||||
const connectTimelineStream = (
|
||||
|
@ -170,6 +170,9 @@ const connectTimelineStream = (
|
|||
}
|
||||
});
|
||||
break;
|
||||
case 'chat_message.reaction': // TruthSocial
|
||||
updateChatMessage(JSON.parse(data.payload));
|
||||
break;
|
||||
case 'pleroma:follow_relationships_update':
|
||||
dispatch(updateFollowRelationships(JSON.parse(data.payload)));
|
||||
break;
|
||||
|
|
|
@ -219,6 +219,9 @@ const expandListTimeline = (id: string, { maxId }: Record<string, any> = {}, don
|
|||
const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, done = noOp) =>
|
||||
expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
|
||||
|
||||
const expandGroupMediaTimeline = (id: string | number, { maxId }: Record<string, any> = {}) =>
|
||||
expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true });
|
||||
|
||||
const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record<string, any> = {}, done = noOp) => {
|
||||
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||
max_id: maxId,
|
||||
|
@ -309,6 +312,7 @@ export {
|
|||
expandAccountMediaTimeline,
|
||||
expandListTimeline,
|
||||
expandGroupTimeline,
|
||||
expandGroupMediaTimeline,
|
||||
expandHashtagTimeline,
|
||||
expandTimelineRequest,
|
||||
expandTimelineSuccess,
|
||||
|
|
|
@ -31,14 +31,14 @@ const AGE: Challenge = 'age';
|
|||
export type Challenge = 'age' | 'sms' | 'email'
|
||||
|
||||
type Challenges = {
|
||||
email?: 0 | 1,
|
||||
sms?: 0 | 1,
|
||||
age?: 0 | 1,
|
||||
email?: 0 | 1
|
||||
sms?: 0 | 1
|
||||
age?: 0 | 1
|
||||
}
|
||||
|
||||
type Verification = {
|
||||
token?: string,
|
||||
challenges?: Challenges,
|
||||
token?: string
|
||||
challenges?: Challenges
|
||||
challengeTypes?: Array<'age' | 'sms' | 'email'>
|
||||
};
|
||||
|
||||
|
|
|
@ -43,7 +43,7 @@ const maybeParseJSON = (data: string) => {
|
|||
|
||||
const getAuthBaseURL = createSelector([
|
||||
(state: RootState, me: string | false | null) => state.accounts.getIn([me, 'url']),
|
||||
(state: RootState, _me: string | false | null) => state.auth.get('me'),
|
||||
(state: RootState, _me: string | false | null) => state.auth.me,
|
||||
], (accountUrl, authUserUrl) => {
|
||||
const baseURL = parseBaseURL(accountUrl) || parseBaseURL(authUserUrl);
|
||||
return baseURL !== window.location.origin ? baseURL : '';
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
import 'intl';
|
||||
import 'intl/locale-data/jsonp/en';
|
||||
import 'es6-symbol/implement';
|
||||
// @ts-ignore: No types
|
||||
import includes from 'array-includes';
|
||||
// @ts-ignore: No types
|
||||
import isNaN from 'is-nan';
|
||||
import assign from 'object-assign';
|
||||
// @ts-ignore: No types
|
||||
import values from 'object.values';
|
||||
|
||||
import { decode as decodeBase64 } from './utils/base64';
|
||||
|
||||
if (!Array.prototype.includes) {
|
||||
includes.shim();
|
||||
}
|
||||
|
||||
if (!Object.assign) {
|
||||
Object.assign = assign;
|
||||
}
|
||||
|
||||
if (!Object.values) {
|
||||
values.shim();
|
||||
}
|
||||
|
||||
if (!Number.isNaN) {
|
||||
Number.isNaN = isNaN;
|
||||
}
|
||||
|
||||
if (!HTMLCanvasElement.prototype.toBlob) {
|
||||
const BASE64_MARKER = ';base64,';
|
||||
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
|
||||
value(callback: any, type = 'image/png', quality: any) {
|
||||
const dataURL = this.toDataURL(type, quality);
|
||||
let data;
|
||||
|
||||
if (dataURL.includes(BASE64_MARKER)) {
|
||||
const [, base64] = dataURL.split(BASE64_MARKER);
|
||||
data = decodeBase64(base64);
|
||||
} else {
|
||||
[, data] = dataURL.split(',');
|
||||
}
|
||||
|
||||
callback(new Blob([data], { type }));
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
interface IInlineSVG {
|
||||
loader?: JSX.Element,
|
||||
loader?: JSX.Element
|
||||
}
|
||||
|
||||
const InlineSVG: React.FC<IInlineSVG> = ({ loader }): JSX.Element => {
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { normalizeAccount } from 'soapbox/normalizers';
|
||||
|
||||
import { render, screen } from '../../jest/test-helpers';
|
||||
import AvatarOverlay from '../avatar-overlay';
|
||||
|
||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||
|
||||
describe('<AvatarOverlay', () => {
|
||||
const account = normalizeAccount({
|
||||
username: 'alice',
|
||||
acct: 'alice',
|
||||
display_name: 'Alice',
|
||||
avatar: '/animated/alice.gif',
|
||||
avatar_static: '/static/alice.jpg',
|
||||
}) as ReducerAccount;
|
||||
|
||||
const friend = normalizeAccount({
|
||||
username: 'eve',
|
||||
acct: 'eve@blackhat.lair',
|
||||
display_name: 'Evelyn',
|
||||
avatar: '/animated/eve.gif',
|
||||
avatar_static: '/static/eve.jpg',
|
||||
}) as ReducerAccount;
|
||||
|
||||
it('renders a overlay avatar', () => {
|
||||
render(<AvatarOverlay account={account} friend={friend} />);
|
||||
expect(screen.queryAllByRole('img')).toHaveLength(2);
|
||||
});
|
||||
});
|
|
@ -1,38 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { normalizeAccount } from 'soapbox/normalizers';
|
||||
|
||||
import { render, screen } from '../../jest/test-helpers';
|
||||
import Avatar from '../avatar';
|
||||
|
||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||
|
||||
describe('<Avatar />', () => {
|
||||
const account = normalizeAccount({
|
||||
username: 'alice',
|
||||
acct: 'alice',
|
||||
display_name: 'Alice',
|
||||
avatar: '/animated/alice.gif',
|
||||
avatar_static: '/static/alice.jpg',
|
||||
}) as ReducerAccount;
|
||||
|
||||
const size = 100;
|
||||
|
||||
// describe('Autoplay', () => {
|
||||
// it('renders an animated avatar', () => {
|
||||
// render(<Avatar account={account} animate size={size} />);
|
||||
|
||||
// expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar'));
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('Still', () => {
|
||||
it('renders a still avatar', () => {
|
||||
render(<Avatar account={account} size={size} />);
|
||||
|
||||
expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar'));
|
||||
});
|
||||
});
|
||||
|
||||
// TODO add autoplay test if possible
|
||||
});
|
|
@ -1,16 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { render, screen } from '../../jest/test-helpers';
|
||||
import EmojiSelector from '../emoji-selector';
|
||||
|
||||
describe('<EmojiSelector />', () => {
|
||||
it('renders correctly', () => {
|
||||
const children = <EmojiSelector />;
|
||||
// @ts-ignore
|
||||
children.__proto__.addEventListener = () => {};
|
||||
|
||||
render(children);
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(6);
|
||||
});
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
|
@ -12,9 +12,9 @@ const messages = defineMessages({
|
|||
|
||||
interface IAccountSearch {
|
||||
/** Callback when a searched account is chosen. */
|
||||
onSelected: (accountId: string) => void,
|
||||
onSelected: (accountId: string) => void
|
||||
/** Override the default placeholder of the input. */
|
||||
placeholder?: string,
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
/** Input to search for accounts. */
|
||||
|
@ -72,17 +72,17 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
|
|||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='absolute inset-y-0 right-0 px-3 flex items-center cursor-pointer'
|
||||
className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-3'
|
||||
onClick={handleClear}
|
||||
>
|
||||
<SvgIcon
|
||||
src={require('@tabler/icons/search.svg')}
|
||||
className={classNames('h-4 w-4 text-gray-400', { hidden: !isEmpty() })}
|
||||
className={clsx('h-4 w-4 text-gray-400', { hidden: !isEmpty() })}
|
||||
/>
|
||||
|
||||
<SvgIcon
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
className={classNames('h-4 w-4 text-gray-400', { hidden: isEmpty() })}
|
||||
className={clsx('h-4 w-4 text-gray-400', { hidden: isEmpty() })}
|
||||
aria-label={intl.formatMessage(messages.placeholder)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,28 +1,38 @@
|
|||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
||||
import ActionButton from 'soapbox/features/ui/components/action-button';
|
||||
import { useAppSelector, useOnScreen } from 'soapbox/hooks';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { getAcct } from 'soapbox/utils/accounts';
|
||||
import { displayFqn } from 'soapbox/utils/state';
|
||||
|
||||
import Badge from './badge';
|
||||
import RelativeTimestamp from './relative-timestamp';
|
||||
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
|
||||
|
||||
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IInstanceFavicon {
|
||||
account: AccountEntity,
|
||||
account: AccountEntity
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account }) => {
|
||||
const messages = defineMessages({
|
||||
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
|
||||
});
|
||||
|
||||
const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const handleClick: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const timelineUrl = `/timeline/${account.domain}`;
|
||||
if (!(e.ctrlKey || e.metaKey)) {
|
||||
history.push(timelineUrl);
|
||||
|
@ -32,44 +42,55 @@ const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<button className='w-4 h-4 flex-none focus:ring-primary-500 focus:ring-2 focus:ring-offset-2' onClick={handleClick}>
|
||||
<img src={account.favicon} alt='' title={account.domain} className='w-full max-h-full' />
|
||||
<button
|
||||
className='h-4 w-4 flex-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<img src={account.favicon} alt='' title={account.domain} className='max-h-full w-full' />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface IProfilePopper {
|
||||
condition: boolean,
|
||||
wrapper: (children: any) => React.ReactElement<any, any>
|
||||
condition: boolean
|
||||
wrapper: (children: React.ReactNode) => React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children }): any =>
|
||||
condition ? wrapper(children) : children;
|
||||
const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children }) => {
|
||||
return (
|
||||
<>
|
||||
{condition ? wrapper(children) : children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IAccount {
|
||||
account: AccountEntity,
|
||||
action?: React.ReactElement,
|
||||
actionAlignment?: 'center' | 'top',
|
||||
actionIcon?: string,
|
||||
actionTitle?: string,
|
||||
export interface IAccount {
|
||||
account: AccountEntity
|
||||
action?: React.ReactElement
|
||||
actionAlignment?: 'center' | 'top'
|
||||
actionIcon?: string
|
||||
actionTitle?: string
|
||||
/** Override other actions for specificity like mute/unmute. */
|
||||
actionType?: 'muting' | 'blocking' | 'follow_request',
|
||||
avatarSize?: number,
|
||||
hidden?: boolean,
|
||||
hideActions?: boolean,
|
||||
id?: string,
|
||||
onActionClick?: (account: any) => void,
|
||||
showProfileHoverCard?: boolean,
|
||||
timestamp?: string,
|
||||
timestampUrl?: string,
|
||||
futureTimestamp?: boolean,
|
||||
withAccountNote?: boolean,
|
||||
withDate?: boolean,
|
||||
withLinkToProfile?: boolean,
|
||||
withRelationship?: boolean,
|
||||
showEdit?: boolean,
|
||||
emoji?: string,
|
||||
note?: string,
|
||||
actionType?: 'muting' | 'blocking' | 'follow_request'
|
||||
avatarSize?: number
|
||||
hidden?: boolean
|
||||
hideActions?: boolean
|
||||
id?: string
|
||||
onActionClick?: (account: any) => void
|
||||
showProfileHoverCard?: boolean
|
||||
timestamp?: string
|
||||
timestampUrl?: string
|
||||
futureTimestamp?: boolean
|
||||
withAccountNote?: boolean
|
||||
withDate?: boolean
|
||||
withLinkToProfile?: boolean
|
||||
withRelationship?: boolean
|
||||
showEdit?: boolean
|
||||
approvalStatus?: StatusApprovalStatus
|
||||
emoji?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const Account = ({
|
||||
|
@ -92,22 +113,18 @@ const Account = ({
|
|||
withLinkToProfile = true,
|
||||
withRelationship = true,
|
||||
showEdit = false,
|
||||
approvalStatus,
|
||||
emoji,
|
||||
note,
|
||||
}: IAccount) => {
|
||||
const overflowRef = React.useRef<HTMLDivElement>(null);
|
||||
const actionRef = React.useRef<HTMLDivElement>(null);
|
||||
// @ts-ignore
|
||||
const isOnScreen = useOnScreen(overflowRef);
|
||||
|
||||
const [style, setStyle] = React.useState<React.CSSProperties>({ visibility: 'hidden' });
|
||||
const overflowRef = useRef<HTMLDivElement>(null);
|
||||
const actionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null);
|
||||
|
||||
const handleAction = () => {
|
||||
// @ts-ignore
|
||||
onActionClick(account);
|
||||
onActionClick!(account);
|
||||
};
|
||||
|
||||
const renderAction = () => {
|
||||
|
@ -125,8 +142,8 @@ const Account = ({
|
|||
src={actionIcon}
|
||||
title={actionTitle}
|
||||
onClick={handleAction}
|
||||
className='bg-transparent text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
|
||||
iconClassName='w-4 h-4'
|
||||
className='bg-transparent text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500'
|
||||
iconClassName='h-4 w-4'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -138,18 +155,7 @@ const Account = ({
|
|||
return null;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const style: React.CSSProperties = {};
|
||||
const actionWidth = actionRef.current?.clientWidth || 0;
|
||||
|
||||
if (overflowRef.current) {
|
||||
style.maxWidth = overflowRef.current.clientWidth - 30 - avatarSize - actionWidth;
|
||||
} else {
|
||||
style.visibility = 'hidden';
|
||||
}
|
||||
|
||||
setStyle(style);
|
||||
}, [isOnScreen, overflowRef, actionRef]);
|
||||
const intl = useIntl();
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
|
@ -169,9 +175,9 @@ const Account = ({
|
|||
const LinkEl: any = withLinkToProfile ? Link : 'div';
|
||||
|
||||
return (
|
||||
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
|
||||
<div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}>
|
||||
<HStack alignItems={actionAlignment} justifyContent='between'>
|
||||
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3}>
|
||||
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3} className='overflow-hidden'>
|
||||
<ProfilePopper
|
||||
condition={showProfileHoverCard}
|
||||
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
|
||||
|
@ -184,14 +190,14 @@ const Account = ({
|
|||
<Avatar src={account.avatar} size={avatarSize} />
|
||||
{emoji && (
|
||||
<Emoji
|
||||
className='w-5 h-5 absolute -bottom-1.5 -right-1.5'
|
||||
className='absolute bottom-0 -right-1.5 h-5 w-5'
|
||||
emoji={emoji}
|
||||
/>
|
||||
)}
|
||||
</LinkEl>
|
||||
</ProfilePopper>
|
||||
|
||||
<div className='flex-grow'>
|
||||
<div className='grow overflow-hidden'>
|
||||
<ProfilePopper
|
||||
condition={showProfileHoverCard}
|
||||
wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
|
||||
|
@ -201,7 +207,7 @@ const Account = ({
|
|||
title={account.acct}
|
||||
onClick={(event: React.MouseEvent) => event.stopPropagation()}
|
||||
>
|
||||
<HStack space={1} alignItems='center' grow style={style}>
|
||||
<HStack space={1} alignItems='center' grow>
|
||||
<Text
|
||||
size='sm'
|
||||
weight='semibold'
|
||||
|
@ -210,16 +216,18 @@ const Account = ({
|
|||
/>
|
||||
|
||||
{account.verified && <VerificationBadge />}
|
||||
|
||||
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
|
||||
</HStack>
|
||||
</LinkEl>
|
||||
</ProfilePopper>
|
||||
|
||||
<Stack space={withAccountNote || note ? 1 : 0}>
|
||||
<HStack alignItems='center' space={1} style={style}>
|
||||
<HStack alignItems='center' space={1}>
|
||||
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
|
||||
|
||||
{account.favicon && (
|
||||
<InstanceFavicon account={account} />
|
||||
<InstanceFavicon account={account} disabled={!withLinkToProfile} />
|
||||
)}
|
||||
|
||||
{(timestamp) ? (
|
||||
|
@ -236,6 +244,18 @@ const Account = ({
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{approvalStatus && ['pending', 'rejected'].includes(approvalStatus) && (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||
|
||||
<Text tag='span' theme='muted' size='sm'>
|
||||
{approvalStatus === 'pending'
|
||||
? <FormattedMessage id='status.approval.pending' defaultMessage='Pending approval' />
|
||||
: <FormattedMessage id='status.approval.rejected' defaultMessage='Rejected' />}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showEdit ? (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||
|
|
|
@ -15,8 +15,8 @@ const obfuscatedCount = (count: number) => {
|
|||
};
|
||||
|
||||
interface IAnimatedNumber {
|
||||
value: number;
|
||||
obfuscate?: boolean;
|
||||
value: number
|
||||
obfuscate?: boolean
|
||||
}
|
||||
|
||||
const AnimatedNumber: React.FC<IAnimatedNumber> = ({ value, obfuscate }) => {
|
||||
|
@ -50,7 +50,7 @@ const AnimatedNumber: React.FC<IAnimatedNumber> = ({ value, obfuscate }) => {
|
|||
return (
|
||||
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
|
||||
{items => (
|
||||
<span className='inline-flex flex-col items-stretch relative overflow-hidden'>
|
||||
<span className='relative inline-flex flex-col items-stretch overflow-hidden'>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
|
||||
))}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
|
|||
import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IAnnouncementContent {
|
||||
announcement: AnnouncementEntity;
|
||||
announcement: AnnouncementEntity
|
||||
}
|
||||
|
||||
const AnnouncementContent: React.FC<IAnnouncementContent> = ({ announcement }) => {
|
||||
|
|
|
@ -11,10 +11,10 @@ import type { Map as ImmutableMap } from 'immutable';
|
|||
import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IAnnouncement {
|
||||
announcement: AnnouncementEntity;
|
||||
addReaction: (id: string, name: string) => void;
|
||||
removeReaction: (id: string, name: string) => void;
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
|
||||
announcement: AnnouncementEntity
|
||||
addReaction: (id: string, name: string) => void
|
||||
removeReaction: (id: string, name: string) => void
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>
|
||||
}
|
||||
|
||||
const Announcement: React.FC<IAnnouncement> = ({ announcement, addReaction, removeReaction, emojiMap }) => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
@ -52,7 +52,7 @@ const AnnouncementsPanel = () => {
|
|||
key={i}
|
||||
tabIndex={0}
|
||||
onClick={() => setIndex(i)}
|
||||
className={classNames({
|
||||
className={clsx({
|
||||
'w-2 h-2 rounded-full focus:ring-primary-600 focus:ring-2 focus:ring-offset-2': true,
|
||||
'bg-gray-200 hover:bg-gray-300': i !== index,
|
||||
'bg-primary-600': i === index,
|
||||
|
|
|
@ -7,9 +7,9 @@ import { joinPublicPath } from 'soapbox/utils/static';
|
|||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
interface IEmoji {
|
||||
emoji: string;
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
|
||||
hovered: boolean;
|
||||
emoji: string
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>
|
||||
hovered: boolean
|
||||
}
|
||||
|
||||
const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => {
|
||||
|
@ -24,7 +24,7 @@ const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => {
|
|||
return (
|
||||
<img
|
||||
draggable='false'
|
||||
className='emojione block m-0'
|
||||
className='emojione m-0 block'
|
||||
alt={emoji}
|
||||
title={title}
|
||||
src={joinPublicPath(`packs/emoji/${filename}.svg`)}
|
||||
|
@ -37,7 +37,7 @@ const Emoji: React.FC<IEmoji> = ({ emoji, emojiMap, hovered }) => {
|
|||
return (
|
||||
<img
|
||||
draggable='false'
|
||||
className='emojione block m-0'
|
||||
className='emojione m-0 block'
|
||||
alt={shortCode}
|
||||
title={shortCode}
|
||||
src={filename as string}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import AnimatedNumber from 'soapbox/components/animated-number';
|
||||
|
@ -10,12 +10,12 @@ import type { Map as ImmutableMap } from 'immutable';
|
|||
import type { AnnouncementReaction } from 'soapbox/types/entities';
|
||||
|
||||
interface IReaction {
|
||||
announcementId: string;
|
||||
reaction: AnnouncementReaction;
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
|
||||
addReaction: (id: string, name: string) => void;
|
||||
removeReaction: (id: string, name: string) => void;
|
||||
style: React.CSSProperties;
|
||||
announcementId: string
|
||||
reaction: AnnouncementReaction
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>
|
||||
addReaction: (id: string, name: string) => void
|
||||
removeReaction: (id: string, name: string) => void
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
const Reaction: React.FC<IReaction> = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => {
|
||||
|
@ -43,7 +43,7 @@ const Reaction: React.FC<IReaction> = ({ announcementId, reaction, addReaction,
|
|||
|
||||
return (
|
||||
<button
|
||||
className={classNames('flex shrink-0 items-center gap-1.5 bg-gray-100 dark:bg-primary-900 rounded-sm px-1.5 py-1 transition-colors', {
|
||||
className={clsx('flex shrink-0 items-center gap-1.5 rounded-sm bg-gray-100 px-1.5 py-1 transition-colors dark:bg-primary-900', {
|
||||
'bg-gray-200 dark:bg-primary-800': hovered,
|
||||
'bg-primary-200 dark:bg-primary-500': reaction.me,
|
||||
})}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { TransitionMotion, spring } from 'react-motion';
|
||||
|
||||
|
@ -13,11 +13,11 @@ import type { Emoji } from 'soapbox/components/autosuggest-emoji';
|
|||
import type { AnnouncementReaction } from 'soapbox/types/entities';
|
||||
|
||||
interface IReactionsBar {
|
||||
announcementId: string;
|
||||
reactions: ImmutableList<AnnouncementReaction>;
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>;
|
||||
addReaction: (id: string, name: string) => void;
|
||||
removeReaction: (id: string, name: string) => void;
|
||||
announcementId: string
|
||||
reactions: ImmutableList<AnnouncementReaction>
|
||||
emojiMap: ImmutableMap<string, ImmutableMap<string, string>>
|
||||
addReaction: (id: string, name: string) => void
|
||||
removeReaction: (id: string, name: string) => void
|
||||
}
|
||||
|
||||
const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => {
|
||||
|
@ -42,7 +42,7 @@ const ReactionsBar: React.FC<IReactionsBar> = ({ announcementId, reactions, addR
|
|||
return (
|
||||
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
|
||||
{items => (
|
||||
<div className={classNames('flex flex-wrap items-center gap-1', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
|
||||
<div className={clsx('flex flex-wrap items-center gap-1', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<Reaction
|
||||
key={key}
|
||||
|
|
|
@ -12,16 +12,16 @@ import type { InputThemes } from 'soapbox/components/ui/input/input';
|
|||
const noOp = () => { };
|
||||
|
||||
interface IAutosuggestAccountInput {
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>,
|
||||
onSelected: (accountId: string) => void,
|
||||
autoFocus?: boolean,
|
||||
value: string,
|
||||
limit?: number,
|
||||
className?: string,
|
||||
autoSelect?: boolean,
|
||||
menu?: Menu,
|
||||
onKeyDown?: React.KeyboardEventHandler,
|
||||
theme?: InputThemes,
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>
|
||||
onSelected: (accountId: string) => void
|
||||
autoFocus?: boolean
|
||||
value: string
|
||||
limit?: number
|
||||
className?: string
|
||||
autoSelect?: boolean
|
||||
menu?: Menu
|
||||
onKeyDown?: React.KeyboardEventHandler
|
||||
theme?: InputThemes
|
||||
}
|
||||
|
||||
const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
|
||||
|
@ -44,7 +44,7 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
|
|||
setAccountIds(ImmutableOrderedSet());
|
||||
};
|
||||
|
||||
const handleAccountSearch = useCallback(throttle(q => {
|
||||
const handleAccountSearch = useCallback(throttle((q) => {
|
||||
const params = { q, limit, resolve: false };
|
||||
|
||||
dispatch(accountSearch(params, controller.current.signal))
|
||||
|
@ -53,7 +53,7 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
|
|||
setAccountIds(ImmutableOrderedSet(accountIds));
|
||||
})
|
||||
.catch(noOp);
|
||||
}, 900, { leading: false, trailing: true }), [limit]);
|
||||
}, 900, { leading: true, trailing: true }), [limit]);
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
refreshCancelToken();
|
||||
|
|
|
@ -4,19 +4,19 @@ import unicodeMapping from 'soapbox/features/emoji/emoji-unicode-mapping-light';
|
|||
import { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
export type Emoji = {
|
||||
id: string,
|
||||
custom: boolean,
|
||||
imageUrl: string,
|
||||
native: string,
|
||||
colons: string,
|
||||
id: string
|
||||
custom: boolean
|
||||
imageUrl: string
|
||||
native: string
|
||||
colons: string
|
||||
}
|
||||
|
||||
type UnicodeMapping = {
|
||||
filename: string,
|
||||
filename: string
|
||||
}
|
||||
|
||||
interface IAutosuggestEmoji {
|
||||
emoji: Emoji,
|
||||
emoji: Emoji
|
||||
}
|
||||
|
||||
const AutosuggestEmoji: React.FC<IAutosuggestEmoji> = ({ emoji }) => {
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import { Portal } from '@reach/portal';
|
||||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import AutosuggestEmoji, { Emoji } from 'soapbox/components/autosuggest-emoji';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Input } from 'soapbox/components/ui';
|
||||
import { Input, Portal } from 'soapbox/components/ui';
|
||||
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
||||
import { isRtl } from 'soapbox/rtl';
|
||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||
|
@ -17,23 +16,23 @@ import type { InputThemes } from 'soapbox/components/ui/input/input';
|
|||
export type AutoSuggestion = string | Emoji;
|
||||
|
||||
export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> {
|
||||
value: string,
|
||||
suggestions: ImmutableList<any>,
|
||||
disabled?: boolean,
|
||||
placeholder?: string,
|
||||
onSuggestionSelected: (tokenStart: number, lastToken: string | null, suggestion: AutoSuggestion) => void,
|
||||
onSuggestionsClearRequested: () => void,
|
||||
onSuggestionsFetchRequested: (token: string) => void,
|
||||
autoFocus: boolean,
|
||||
autoSelect: boolean,
|
||||
className?: string,
|
||||
id?: string,
|
||||
searchTokens: string[],
|
||||
maxLength?: number,
|
||||
menu?: Menu,
|
||||
renderSuggestion?: React.FC<{ id: string }>,
|
||||
hidePortal?: boolean,
|
||||
theme?: InputThemes,
|
||||
value: string
|
||||
suggestions: ImmutableList<any>
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
onSuggestionSelected: (tokenStart: number, lastToken: string | null, suggestion: AutoSuggestion) => void
|
||||
onSuggestionsClearRequested: () => void
|
||||
onSuggestionsFetchRequested: (token: string) => void
|
||||
autoFocus: boolean
|
||||
autoSelect: boolean
|
||||
className?: string
|
||||
id?: string
|
||||
searchTokens: string[]
|
||||
maxLength?: number
|
||||
menu?: Menu
|
||||
renderSuggestion?: React.FC<{ id: string }>
|
||||
hidePortal?: boolean
|
||||
theme?: InputThemes
|
||||
}
|
||||
|
||||
export default class AutosuggestInput extends ImmutablePureComponent<IAutosuggestInput> {
|
||||
|
@ -46,7 +45,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
|
||||
getFirstIndex = () => {
|
||||
return this.props.autoSelect ? 0 : -1;
|
||||
}
|
||||
};
|
||||
|
||||
state = {
|
||||
suggestionsHidden: true,
|
||||
|
@ -76,7 +75,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
if (this.props.onChange) {
|
||||
this.props.onChange(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
|
||||
const { suggestions, menu, disabled } = this.props;
|
||||
|
@ -145,15 +144,15 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
if (this.props.onKeyDown) {
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onBlur = () => {
|
||||
this.setState({ suggestionsHidden: true, focused: false });
|
||||
}
|
||||
};
|
||||
|
||||
onFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
}
|
||||
};
|
||||
|
||||
onSuggestionClick: React.EventHandler<React.MouseEvent | React.TouchEvent> = (e) => {
|
||||
const index = Number(e.currentTarget?.getAttribute('data-index'));
|
||||
|
@ -161,7 +160,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.input?.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: IAutosuggestInput, prevState: any) {
|
||||
const { suggestions } = this.props;
|
||||
|
@ -172,7 +171,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
|
||||
setInput = (c: HTMLInputElement) => {
|
||||
this.input = c;
|
||||
}
|
||||
};
|
||||
|
||||
renderSuggestion = (suggestion: AutoSuggestion, i: number) => {
|
||||
const { selectedSuggestion } = this.state;
|
||||
|
@ -199,7 +198,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
tabIndex={0}
|
||||
key={key}
|
||||
data-index={i}
|
||||
className={classNames({
|
||||
className={clsx({
|
||||
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
|
||||
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
|
||||
})}
|
||||
|
@ -209,21 +208,21 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleMenuItemAction = (item: MenuItem | null, e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
this.onBlur();
|
||||
if (item?.action) {
|
||||
item.action(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleMenuItemClick = (item: MenuItem | null): React.MouseEventHandler => {
|
||||
return e => {
|
||||
e.preventDefault();
|
||||
this.handleMenuItemAction(item, e);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
renderMenu = () => {
|
||||
const { menu, suggestions } = this.props;
|
||||
|
@ -235,7 +234,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
|
||||
return menu.map((item, i) => (
|
||||
<a
|
||||
className={classNames('flex items-center space-x-2 px-4 py-2.5 text-sm cursor-pointer text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800', { selected: suggestions.size - selectedSuggestion === i })}
|
||||
className={clsx('flex cursor-pointer items-center space-x-2 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-100 focus:bg-gray-100 dark:text-gray-500 dark:hover:bg-gray-800 dark:focus:bg-primary-800', { selected: suggestions.size - selectedSuggestion === i })}
|
||||
href='#'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
|
@ -302,7 +301,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
<Portal key='portal'>
|
||||
<div
|
||||
style={this.setPortalPosition()}
|
||||
className={classNames({
|
||||
className={clsx({
|
||||
'fixed w-full z-[1001] shadow bg-white dark:bg-gray-900 rounded-lg py-1 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
|
||||
hidden: !visible,
|
||||
block: visible,
|
||||
|
|
|
@ -19,7 +19,7 @@ export const ADDRESS_ICONS: Record<string, string> = {
|
|||
};
|
||||
|
||||
interface IAutosuggestLocation {
|
||||
id: string,
|
||||
id: string
|
||||
}
|
||||
|
||||
const AutosuggestLocation: React.FC<IAutosuggestLocation> = ({ id }) => {
|
||||
|
@ -32,7 +32,7 @@ const AutosuggestLocation: React.FC<IAutosuggestLocation> = ({ id }) => {
|
|||
<Icon src={ADDRESS_ICONS[location.type] || mapPinIcon} />
|
||||
<Stack>
|
||||
<Text>{location.description}</Text>
|
||||
<Text size='xs' theme='muted'>{[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')}</Text>
|
||||
<Text size='xs' theme='muted'>{[location.street, location.locality, location.country].filter(val => val?.trim()).join(' · ')}</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
);
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { Portal } from '@reach/portal';
|
||||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import { Portal } from 'soapbox/components/ui';
|
||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||
|
||||
import AutosuggestAccount from '../features/compose/components/autosuggest-account';
|
||||
|
@ -14,22 +14,23 @@ import AutosuggestEmoji, { Emoji } from './autosuggest-emoji';
|
|||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
interface IAutosuggesteTextarea {
|
||||
id?: string,
|
||||
value: string,
|
||||
suggestions: ImmutableList<string>,
|
||||
disabled: boolean,
|
||||
placeholder: string,
|
||||
onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void,
|
||||
onSuggestionsClearRequested: () => void,
|
||||
onSuggestionsFetchRequested: (token: string | number) => void,
|
||||
onChange: React.ChangeEventHandler<HTMLTextAreaElement>,
|
||||
onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>,
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>,
|
||||
onPaste: (files: FileList) => void,
|
||||
autoFocus: boolean,
|
||||
onFocus: () => void,
|
||||
onBlur?: () => void,
|
||||
condensed?: boolean,
|
||||
id?: string
|
||||
value: string
|
||||
suggestions: ImmutableList<string>
|
||||
disabled: boolean
|
||||
placeholder: string
|
||||
onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void
|
||||
onSuggestionsClearRequested: () => void
|
||||
onSuggestionsFetchRequested: (token: string | number) => void
|
||||
onChange: React.ChangeEventHandler<HTMLTextAreaElement>
|
||||
onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>
|
||||
onPaste: (files: FileList) => void
|
||||
autoFocus: boolean
|
||||
onFocus: () => void
|
||||
onBlur?: () => void
|
||||
condensed?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> {
|
||||
|
@ -64,7 +65,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
}
|
||||
|
||||
this.props.onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
const { suggestions, disabled } = this.props;
|
||||
|
@ -122,7 +123,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
}
|
||||
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
onBlur = () => {
|
||||
this.setState({ suggestionsHidden: true, focused: false });
|
||||
|
@ -130,7 +131,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
if (this.props.onBlur) {
|
||||
this.props.onBlur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
|
@ -138,14 +139,14 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
if (this.props.onFocus) {
|
||||
this.props.onFocus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onSuggestionClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index') as any);
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.textarea?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps: IAutosuggesteTextarea, nextState: any) {
|
||||
// Skip updating when only the lastToken changes so the
|
||||
|
@ -156,7 +157,8 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
if (lastTokenUpdated && !valueUpdated) {
|
||||
return false;
|
||||
} else {
|
||||
return super.shouldComponentUpdate!(nextProps, nextState, undefined);
|
||||
// https://stackoverflow.com/a/35962835
|
||||
return super.shouldComponentUpdate!.bind(this)(nextProps, nextState, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -169,14 +171,14 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
|
||||
setTextarea: React.Ref<HTMLTextAreaElement> = (c) => {
|
||||
this.textarea = c;
|
||||
}
|
||||
};
|
||||
|
||||
onPaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
this.props.onPaste(e.clipboardData.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderSuggestion = (suggestion: string | Emoji, i: number) => {
|
||||
const { selectedSuggestion } = this.state;
|
||||
|
@ -199,7 +201,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
tabIndex={0}
|
||||
key={key}
|
||||
data-index={i}
|
||||
className={classNames({
|
||||
className={clsx({
|
||||
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
|
||||
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
|
||||
})}
|
||||
|
@ -208,7 +210,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
setPortalPosition() {
|
||||
if (!this.textarea) {
|
||||
|
@ -242,7 +244,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
|
||||
<Textarea
|
||||
ref={this.setTextarea}
|
||||
className={classNames('transition-[min-height] motion-reduce:transition-none dark:bg-transparent px-0 border-0 text-gray-800 dark:text-white placeholder:text-gray-600 dark:placeholder:text-gray-600 resize-none w-full focus:shadow-none focus:border-0 focus:ring-0', {
|
||||
className={clsx('w-full resize-none border-0 px-0 text-gray-800 transition-[min-height] placeholder:text-gray-600 focus:border-0 focus:shadow-none focus:ring-0 motion-reduce:transition-none dark:bg-transparent dark:text-white dark:placeholder:text-gray-600', {
|
||||
'min-h-[40px]': condensed,
|
||||
'min-h-[100px]': !condensed,
|
||||
})}
|
||||
|
@ -269,7 +271,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
<Portal key='portal'>
|
||||
<div
|
||||
style={this.setPortalPosition()}
|
||||
className={classNames({
|
||||
className={clsx({
|
||||
'fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
|
||||
hidden: suggestionsHidden || suggestions.isEmpty(),
|
||||
block: !suggestionsHidden && !suggestions.isEmpty(),
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IAvatarOverlay {
|
||||
account: AccountEntity,
|
||||
friend: AccountEntity,
|
||||
}
|
||||
|
||||
const AvatarOverlay: React.FC<IAvatarOverlay> = ({ account, friend }) => (
|
||||
<div className='account__avatar-overlay'>
|
||||
<StillImage src={account.avatar} className='account__avatar-overlay-base' />
|
||||
<StillImage src={friend.avatar} className='account__avatar-overlay-overlay' />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AvatarOverlay;
|
|
@ -1,38 +0,0 @@
|
|||
import classNames from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
|
||||
import type { Account } from 'soapbox/types/entities';
|
||||
|
||||
interface IAvatar {
|
||||
account?: Account | null,
|
||||
size?: number,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy avatar component.
|
||||
* @see soapbox/components/ui/avatar/avatar.tsx
|
||||
* @deprecated
|
||||
*/
|
||||
const Avatar: React.FC<IAvatar> = ({ account, size, className }) => {
|
||||
if (!account) return null;
|
||||
|
||||
// : TODO : remove inline and change all avatars to be sized using css
|
||||
const style: React.CSSProperties = !size ? {} : {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
};
|
||||
|
||||
return (
|
||||
<StillImage
|
||||
className={classNames('rounded-full overflow-hidden', className)}
|
||||
style={style}
|
||||
src={account.avatar}
|
||||
alt=''
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
|
@ -1,9 +1,9 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
interface IBadge {
|
||||
title: React.ReactNode,
|
||||
slug: string,
|
||||
title: React.ReactNode
|
||||
slug: string
|
||||
}
|
||||
/** Badge to display on a user's profile. */
|
||||
const Badge: React.FC<IBadge> = ({ title, slug }) => {
|
||||
|
@ -12,13 +12,13 @@ const Badge: React.FC<IBadge> = ({ title, slug }) => {
|
|||
return (
|
||||
<span
|
||||
data-testid='badge'
|
||||
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', {
|
||||
className={clsx('inline-flex items-center rounded px-2 py-0.5 text-xs font-medium', {
|
||||
'bg-fuchsia-700 text-white': slug === 'patron',
|
||||
'bg-emerald-800 text-white': slug === 'badge:donor',
|
||||
'bg-black text-white': slug === 'admin',
|
||||
'bg-cyan-600 text-white': slug === 'moderator',
|
||||
'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100': fallback,
|
||||
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque',
|
||||
'bg-white/75 text-gray-900': slug === 'opaque',
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
|
|
|
@ -15,9 +15,9 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
interface IBirthdayInput {
|
||||
value?: string,
|
||||
onChange: (value: string) => void,
|
||||
required?: boolean,
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required }) => {
|
||||
|
@ -56,15 +56,15 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
|
|||
nextYearButtonDisabled,
|
||||
date,
|
||||
}: {
|
||||
decreaseMonth(): void,
|
||||
increaseMonth(): void,
|
||||
prevMonthButtonDisabled: boolean,
|
||||
nextMonthButtonDisabled: boolean,
|
||||
decreaseYear(): void,
|
||||
increaseYear(): void,
|
||||
prevYearButtonDisabled: boolean,
|
||||
nextYearButtonDisabled: boolean,
|
||||
date: Date,
|
||||
decreaseMonth(): void
|
||||
increaseMonth(): void
|
||||
prevMonthButtonDisabled: boolean
|
||||
nextMonthButtonDisabled: boolean
|
||||
decreaseYear(): void
|
||||
increaseYear(): void
|
||||
prevYearButtonDisabled: boolean
|
||||
nextYearButtonDisabled: boolean
|
||||
date: Date
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
|
@ -113,7 +113,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
|
|||
const handleChange = (date: Date) => onChange(date ? new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) : '');
|
||||
|
||||
return (
|
||||
<div className='mt-1 relative rounded-md shadow-sm'>
|
||||
<div className='relative mt-1 rounded-md shadow-sm'>
|
||||
<BundleContainer fetchComponent={DatePicker}>
|
||||
{Component => (<Component
|
||||
selected={selected}
|
||||
|
|
|
@ -3,18 +3,18 @@ import React, { useRef, useEffect } from 'react';
|
|||
|
||||
interface IBlurhash {
|
||||
/** Hash to render */
|
||||
hash: string | null | undefined,
|
||||
hash: string | null | undefined
|
||||
/** Width of the blurred region in pixels. Defaults to 32. */
|
||||
width?: number,
|
||||
width?: number
|
||||
/** Height of the blurred region in pixels. Defaults to width. */
|
||||
height?: number,
|
||||
height?: number
|
||||
/**
|
||||
* Whether dummy mode is enabled. If enabled, nothing is rendered
|
||||
* and canvas left untouched.
|
||||
*/
|
||||
dummy?: boolean,
|
||||
dummy?: boolean
|
||||
/** className of the canvas element. */
|
||||
className?: string,
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Button, HStack, Input } from './ui';
|
|||
|
||||
interface ICopyableInput {
|
||||
/** Text to be copied. */
|
||||
value: string,
|
||||
value: string
|
||||
}
|
||||
|
||||
/** An input with copy abilities. */
|
||||
|
@ -29,7 +29,7 @@ const CopyableInput: React.FC<ICopyableInput> = ({ value }) => {
|
|||
type='text'
|
||||
value={value}
|
||||
className='rounded-r-none rtl:rounded-l-none rtl:rounded-r-lg'
|
||||
outerClassName='flex-grow'
|
||||
outerClassName='grow'
|
||||
onClick={selectInput}
|
||||
readOnly
|
||||
/>
|
||||
|
|
|
@ -5,8 +5,6 @@ import { useSoapboxConfig } from 'soapbox/hooks';
|
|||
|
||||
import { getAcct } from '../utils/accounts';
|
||||
|
||||
import Icon from './icon';
|
||||
import RelativeTimestamp from './relative-timestamp';
|
||||
import { HStack, Text } from './ui';
|
||||
import VerificationBadge from './verification-badge';
|
||||
|
||||
|
@ -15,19 +13,12 @@ import type { Account } from 'soapbox/types/entities';
|
|||
interface IDisplayName {
|
||||
account: Account
|
||||
withSuffix?: boolean
|
||||
withDate?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = true, withDate = false }) => {
|
||||
const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = true }) => {
|
||||
const { displayFqn = false } = useSoapboxConfig();
|
||||
const { created_at: createdAt, verified } = account;
|
||||
|
||||
const joinedAt = createdAt ? (
|
||||
<div className='account__joined-at'>
|
||||
<Icon src={require('@tabler/icons/clock.svg')} />
|
||||
<RelativeTimestamp timestamp={createdAt} />
|
||||
</div>
|
||||
) : null;
|
||||
const { verified } = account;
|
||||
|
||||
const displayName = (
|
||||
<HStack space={1} alignItems='center' grow>
|
||||
|
@ -39,7 +30,6 @@ const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = t
|
|||
/>
|
||||
|
||||
{verified && <VerificationBadge />}
|
||||
{withDate && joinedAt}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { unblockDomain } from 'soapbox/actions/domain-blocks';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import { HStack, IconButton, Text } from './ui';
|
||||
|
||||
|
@ -12,11 +12,11 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
interface IDomain {
|
||||
domain: string,
|
||||
domain: string
|
||||
}
|
||||
|
||||
const Domain: React.FC<IDomain> = ({ domain }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
// const onBlockDomain = () => {
|
||||
|
|
|
@ -1,420 +0,0 @@
|
|||
import classNames from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React from 'react';
|
||||
import { spring } from 'react-motion';
|
||||
// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import { Counter, IconButton } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import Motion from 'soapbox/features/ui/util/optional-motion';
|
||||
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
let id = 0;
|
||||
|
||||
export interface MenuItem {
|
||||
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
|
||||
middleClick?: React.EventHandler<React.MouseEvent>,
|
||||
text: string,
|
||||
href?: string,
|
||||
to?: string,
|
||||
newTab?: boolean,
|
||||
isLogout?: boolean,
|
||||
icon?: string,
|
||||
count?: number,
|
||||
destructive?: boolean,
|
||||
meta?: string,
|
||||
active?: boolean,
|
||||
}
|
||||
|
||||
export type Menu = Array<MenuItem | null>;
|
||||
|
||||
interface IDropdownMenu extends RouteComponentProps {
|
||||
items: Menu,
|
||||
onClose: () => void,
|
||||
style?: React.CSSProperties,
|
||||
placement?: DropdownPlacement,
|
||||
arrowOffsetLeft?: string,
|
||||
arrowOffsetTop?: string,
|
||||
openedViaKeyboard: boolean,
|
||||
}
|
||||
|
||||
interface IDropdownMenuState {
|
||||
mounted: boolean,
|
||||
}
|
||||
|
||||
class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState> {
|
||||
|
||||
static defaultProps: Partial<IDropdownMenu> = {
|
||||
style: {},
|
||||
placement: 'bottom',
|
||||
};
|
||||
|
||||
state = {
|
||||
mounted: false,
|
||||
};
|
||||
|
||||
node: HTMLDivElement | null = null;
|
||||
focusedItem: HTMLAnchorElement | null = null;
|
||||
|
||||
handleDocumentClick = (e: Event) => {
|
||||
if (this.node && !this.node.contains(e.target as Node)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('keydown', this.handleKeyDown, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
if (this.focusedItem && this.props.openedViaKeyboard) {
|
||||
this.focusedItem.focus({ preventScroll: true });
|
||||
}
|
||||
this.setState({ mounted: true });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.handleDocumentClick);
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick);
|
||||
}
|
||||
|
||||
setRef: React.RefCallback<HTMLDivElement> = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
|
||||
this.focusedItem = c;
|
||||
}
|
||||
|
||||
handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!this.node) return;
|
||||
|
||||
const items = Array.from(this.node.getElementsByTagName('a'));
|
||||
const index = items.indexOf(document.activeElement as any);
|
||||
let element = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index + 1] || items[0];
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
} else {
|
||||
element = items[index + 1] || items[0];
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length - 1];
|
||||
break;
|
||||
case 'Escape':
|
||||
this.props.onClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
this.handleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
if (!item) return;
|
||||
const { action, to } = item;
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
if (to) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(to);
|
||||
} else if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
if (!item) return;
|
||||
const { middleClick } = item;
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
if (e.button === 1 && typeof middleClick === 'function') {
|
||||
e.preventDefault();
|
||||
middleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleAuxClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
if (e.button === 1) {
|
||||
this.handleMiddleClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
renderItem(option: MenuItem | null, i: number): JSX.Element {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
|
||||
|
||||
return (
|
||||
<li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
|
||||
<a
|
||||
href={href || to || '#'}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
ref={i === 0 ? this.setFocusRef : null}
|
||||
onClick={this.handleClick}
|
||||
onAuxClick={this.handleAuxClick}
|
||||
onKeyPress={this.handleItemKeyPress}
|
||||
data-index={i}
|
||||
target={newTab ? '_blank' : undefined}
|
||||
data-method={isLogout ? 'delete' : undefined}
|
||||
title={text}
|
||||
>
|
||||
{icon && <SvgIcon src={icon} className='mr-3 rtl:ml-3 rtl:mr-0 h-5 w-5 flex-none' />}
|
||||
|
||||
<span className='truncate'>{text}</span>
|
||||
|
||||
{count ? (
|
||||
<span className='ml-auto h-5 w-5 flex-none'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
|
||||
const { mounted } = this.state;
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} 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 }) => (
|
||||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div
|
||||
className={`dropdown-menu ${placement}`}
|
||||
style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }}
|
||||
ref={this.setRef}
|
||||
data-testid='dropdown-menu'
|
||||
>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
|
||||
<ul>
|
||||
{items.map((option, i) => this.renderItem(option, i))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const RouterDropdownMenu = withRouter(DropdownMenu);
|
||||
|
||||
export interface IDropdown extends RouteComponentProps {
|
||||
icon?: string,
|
||||
src?: string,
|
||||
items: Menu,
|
||||
size?: number,
|
||||
active?: boolean,
|
||||
pressed?: boolean,
|
||||
title?: string,
|
||||
disabled?: boolean,
|
||||
status?: Status,
|
||||
isUserTouching?: () => boolean,
|
||||
isModalOpen?: boolean,
|
||||
onOpen?: (
|
||||
id: number,
|
||||
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
||||
dropdownPlacement: DropdownPlacement,
|
||||
keyboard: boolean,
|
||||
) => void,
|
||||
onClose?: (id: number) => void,
|
||||
dropdownPlacement?: string,
|
||||
openDropdownId?: number | null,
|
||||
openedViaKeyboard?: boolean,
|
||||
text?: string,
|
||||
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
||||
children?: JSX.Element,
|
||||
dropdownMenuStyle?: React.CSSProperties,
|
||||
}
|
||||
|
||||
interface IDropdownState {
|
||||
id: number,
|
||||
open: boolean,
|
||||
}
|
||||
|
||||
export type DropdownPlacement = 'top' | 'bottom';
|
||||
|
||||
class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
|
||||
|
||||
static defaultProps: Partial<IDropdown> = {
|
||||
title: 'Menu',
|
||||
};
|
||||
|
||||
state = {
|
||||
id: id++,
|
||||
open: false,
|
||||
};
|
||||
|
||||
target: HTMLButtonElement | null = null;
|
||||
activeElement: Element | null = null;
|
||||
|
||||
handleClick: React.EventHandler<React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>> = e => {
|
||||
const { onOpen, onShiftClick, openDropdownId } = this.props;
|
||||
e.stopPropagation();
|
||||
|
||||
if (onShiftClick && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onShiftClick(e);
|
||||
} else if (this.state.id === openDropdownId) {
|
||||
this.handleClose();
|
||||
} else if (onOpen) {
|
||||
const { top } = e.currentTarget.getBoundingClientRect();
|
||||
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
|
||||
|
||||
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
|
||||
}
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
if (this.activeElement && this.activeElement === this.target) {
|
||||
(this.activeElement as HTMLButtonElement).focus();
|
||||
this.activeElement = null;
|
||||
}
|
||||
|
||||
if (this.props.onClose) {
|
||||
this.props.onClose(this.state.id);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
|
||||
if (!this.state.open) {
|
||||
this.activeElement = document.activeElement;
|
||||
}
|
||||
}
|
||||
|
||||
handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (e) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleMouseDown(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleClick(e);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = this.props.items[i];
|
||||
if (!item) return;
|
||||
|
||||
const { action, to } = item;
|
||||
|
||||
this.handleClose();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
action(e);
|
||||
} else if (to) {
|
||||
this.props.history?.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
setTargetRef: React.RefCallback<HTMLButtonElement> = c => {
|
||||
this.target = c;
|
||||
}
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
if (this.state.id === this.props.openDropdownId) {
|
||||
this.handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props;
|
||||
const open = this.state.id === openDropdownId;
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
React.cloneElement(children, {
|
||||
disabled,
|
||||
onClick: this.handleClick,
|
||||
onMouseDown: this.handleMouseDown,
|
||||
onKeyDown: this.handleButtonKeyDown,
|
||||
onKeyPress: this.handleKeyPress,
|
||||
ref: this.setTargetRef,
|
||||
})
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
className={classNames({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
|
||||
'text-gray-700 dark:text-gray-500': open,
|
||||
})}
|
||||
title={title}
|
||||
src={src}
|
||||
aria-pressed={pressed}
|
||||
text={text}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleButtonKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
ref={this.setTargetRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
|
||||
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} style={dropdownMenuStyle} />
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(Dropdown);
|
|
@ -0,0 +1,109 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { Counter, Icon } from '../ui';
|
||||
|
||||
export interface MenuItem {
|
||||
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>
|
||||
active?: boolean
|
||||
count?: number
|
||||
destructive?: boolean
|
||||
href?: string
|
||||
icon?: string
|
||||
meta?: string
|
||||
middleClick?(event: React.MouseEvent): void
|
||||
target?: React.HTMLAttributeAnchorTarget
|
||||
text: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface IDropdownMenuItem {
|
||||
index: number
|
||||
item: MenuItem | null
|
||||
onClick?(): void
|
||||
}
|
||||
|
||||
const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
|
||||
const history = useHistory();
|
||||
|
||||
const itemRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!item) return;
|
||||
if (onClick) onClick();
|
||||
|
||||
|
||||
if (item.to) {
|
||||
event.preventDefault();
|
||||
history.push(item.to);
|
||||
} else if (typeof item.action === 'function') {
|
||||
event.preventDefault();
|
||||
item.action(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuxClick: React.EventHandler<React.MouseEvent> = (event) => {
|
||||
if (!item) return;
|
||||
if (onClick) onClick();
|
||||
|
||||
if (event.button === 1 && item.middleClick) {
|
||||
item.middleClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
handleClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const firstItem = index === 0;
|
||||
|
||||
if (itemRef.current && firstItem) {
|
||||
itemRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
}, [itemRef.current, index]);
|
||||
|
||||
if (item === null) {
|
||||
return <li className='my-1 mx-2 h-[2px] bg-gray-100 dark:bg-gray-800' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className='truncate focus-within:ring-2 focus-within:ring-primary-500'>
|
||||
<a
|
||||
href={item.href || item.to || '#'}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
ref={itemRef}
|
||||
data-index={index}
|
||||
onClick={handleClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
onKeyPress={handleItemKeyPress}
|
||||
target={item.target}
|
||||
title={item.text}
|
||||
className={
|
||||
clsx({
|
||||
'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none cursor-pointer': true,
|
||||
'text-danger-600 dark:text-danger-400': item.destructive,
|
||||
})
|
||||
}
|
||||
>
|
||||
{item.icon && <Icon src={item.icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
|
||||
|
||||
<span className='truncate'>{item.text}</span>
|
||||
|
||||
{item.count ? (
|
||||
<span className='ml-auto h-5 w-5 flex-none'>
|
||||
<Counter count={item.count} />
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuItem;
|
|
@ -0,0 +1,342 @@
|
|||
import { offset, Placement, useFloating, flip, arrow } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
closeDropdownMenu as closeDropdownMenuRedux,
|
||||
openDropdownMenu,
|
||||
} from 'soapbox/actions/dropdown-menu';
|
||||
import { closeModal, openModal } from 'soapbox/actions/modals';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { isUserTouching } from 'soapbox/is-mobile';
|
||||
|
||||
import { IconButton, Portal } from '../ui';
|
||||
|
||||
import DropdownMenuItem, { MenuItem } from './dropdown-menu-item';
|
||||
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
||||
export type Menu = Array<MenuItem | null>;
|
||||
|
||||
interface IDropdownMenu {
|
||||
children?: React.ReactElement
|
||||
disabled?: boolean
|
||||
items: Menu
|
||||
onClose?: () => void
|
||||
onOpen?: () => void
|
||||
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>
|
||||
placement?: Placement
|
||||
src?: string
|
||||
status?: Status
|
||||
title?: string
|
||||
}
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
const DropdownMenu = (props: IDropdownMenu) => {
|
||||
const {
|
||||
children,
|
||||
disabled,
|
||||
items,
|
||||
onClose,
|
||||
onOpen,
|
||||
onShiftClick,
|
||||
placement: initialPlacement = 'top',
|
||||
src = require('@tabler/icons/dots.svg'),
|
||||
title = 'Menu',
|
||||
...filteredProps
|
||||
} = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const isOpenRedux = useAppSelector(state => state.dropdown_menu.isOpen);
|
||||
|
||||
const arrowRef = useRef<HTMLDivElement>(null);
|
||||
const activeElement = useRef<Element | null>(null);
|
||||
|
||||
const isOnMobile = isUserTouching();
|
||||
|
||||
const { x, y, strategy, refs, middlewareData, placement } = useFloating<HTMLButtonElement>({
|
||||
placement: initialPlacement,
|
||||
middleware: [
|
||||
offset(12),
|
||||
flip(),
|
||||
arrow({
|
||||
element: arrowRef,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const handleClick: React.EventHandler<
|
||||
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
|
||||
> = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (onShiftClick && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
onShiftClick(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
} else {
|
||||
handleOpen();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* On mobile screens, let's replace the Popper dropdown with a Modal.
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
if (isOnMobile) {
|
||||
dispatch(
|
||||
openModal('ACTIONS', {
|
||||
status: filteredProps.status,
|
||||
actions: items,
|
||||
onClick: handleItemClick,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(openDropdownMenu());
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (activeElement.current && activeElement.current === refs.reference.current) {
|
||||
(activeElement.current as any).focus();
|
||||
activeElement.current = null;
|
||||
}
|
||||
|
||||
if (isOnMobile) {
|
||||
dispatch(closeModal('ACTIONS'));
|
||||
} else {
|
||||
closeDropdownMenu();
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdownMenu = () => {
|
||||
if (isOpenRedux) {
|
||||
dispatch(closeDropdownMenuRedux());
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
|
||||
if (!isOpen) {
|
||||
activeElement.current = document.activeElement;
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (event) => {
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleMouseDown(event);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (event) => {
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleClick(event);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick: React.EventHandler<React.MouseEvent> = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const i = Number(event.currentTarget.getAttribute('data-index'));
|
||||
const item = items[i];
|
||||
if (!item) return;
|
||||
|
||||
const { action, to } = item;
|
||||
|
||||
handleClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
action(event);
|
||||
} else if (to) {
|
||||
history.push(to);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentClick = (event: Event) => {
|
||||
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!refs.floating.current) return;
|
||||
|
||||
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
|
||||
const index = items.indexOf(document.activeElement as any);
|
||||
|
||||
let element = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index + 1] || items[0];
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
} else {
|
||||
element = items[index + 1] || items[0];
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length - 1];
|
||||
break;
|
||||
case 'Escape':
|
||||
handleClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const arrowProps: React.CSSProperties = useMemo(() => {
|
||||
if (middlewareData.arrow) {
|
||||
const { x, y } = middlewareData.arrow;
|
||||
|
||||
const staticPlacement = {
|
||||
top: 'bottom',
|
||||
right: 'left',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
}[placement.split('-')[0]];
|
||||
|
||||
return {
|
||||
left: x !== null ? `${x}px` : '',
|
||||
top: y !== null ? `${y}px` : '',
|
||||
// Ensure the static side gets unset when
|
||||
// flipping to other placements' axes.
|
||||
right: '',
|
||||
bottom: '',
|
||||
[staticPlacement as string]: `${(-(arrowRef.current?.offsetWidth || 0)) / 2}px`,
|
||||
transform: 'rotate(45deg)',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}, [middlewareData.arrow, placement]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closeDropdownMenu();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, false);
|
||||
document.addEventListener('keydown', handleKeyDown, false);
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('touchend', handleDocumentClick);
|
||||
};
|
||||
}, [refs.floating.current]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
React.cloneElement(children, {
|
||||
disabled,
|
||||
onClick: handleClick,
|
||||
onMouseDown: handleMouseDown,
|
||||
onKeyDown: handleButtonKeyDown,
|
||||
onKeyPress: handleKeyPress,
|
||||
ref: refs.setReference,
|
||||
})
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
className={clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
|
||||
'text-gray-700 dark:text-gray-500': isOpen,
|
||||
})}
|
||||
title={title}
|
||||
src={src}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
ref={refs.setReference}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isOpen ? (
|
||||
<Portal>
|
||||
<div
|
||||
data-testid='dropdown-menu'
|
||||
ref={refs.setFloating}
|
||||
className={
|
||||
clsx('z-[1001] w-56 rounded-md bg-white py-1 shadow-lg transition-opacity duration-100 focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
|
||||
'opacity-0 pointer-events-none': !isOpen,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
}}
|
||||
>
|
||||
<ul>
|
||||
{items.map((item, idx) => (
|
||||
<DropdownMenuItem
|
||||
key={idx}
|
||||
item={item}
|
||||
index={idx}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Arrow */}
|
||||
<div
|
||||
ref={arrowRef}
|
||||
style={arrowProps}
|
||||
className='pointer-events-none absolute z-[-1] h-3 w-3 bg-white dark:bg-gray-900'
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenu;
|
|
@ -0,0 +1,3 @@
|
|||
export { default } from './dropdown-menu';
|
||||
export type { Menu } from './dropdown-menu';
|
||||
export type { MenuItem } from './dropdown-menu-item';
|
|
@ -1,142 +0,0 @@
|
|||
// import classNames from 'clsx';
|
||||
import React from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
allowedEmoji: getSoapboxConfig(state).allowedEmoji,
|
||||
});
|
||||
|
||||
interface IEmojiSelector {
|
||||
allowedEmoji: ImmutableList<string>,
|
||||
onReact: (emoji: string) => void,
|
||||
onUnfocus: () => void,
|
||||
visible: boolean,
|
||||
focused?: boolean,
|
||||
}
|
||||
|
||||
class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
|
||||
|
||||
static defaultProps: Partial<IEmojiSelector> = {
|
||||
onReact: () => { },
|
||||
onUnfocus: () => { },
|
||||
visible: false,
|
||||
}
|
||||
|
||||
node?: HTMLDivElement = undefined;
|
||||
|
||||
handleBlur: React.FocusEventHandler<HTMLDivElement> = e => {
|
||||
const { focused, onUnfocus } = this.props;
|
||||
|
||||
if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) {
|
||||
onUnfocus();
|
||||
}
|
||||
}
|
||||
|
||||
_selectPreviousEmoji = (i: number): void => {
|
||||
if (!this.node) return;
|
||||
|
||||
if (i !== 0) {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`);
|
||||
button?.focus();
|
||||
} else {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:last-child');
|
||||
button?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
_selectNextEmoji = (i: number) => {
|
||||
if (!this.node) return;
|
||||
|
||||
if (i !== this.props.allowedEmoji.size - 1) {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`);
|
||||
button?.focus();
|
||||
} else {
|
||||
const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:first-child');
|
||||
button?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (i: number): React.KeyboardEventHandler => e => {
|
||||
const { onUnfocus } = this.props;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) this._selectPreviousEmoji(i);
|
||||
else this._selectNextEmoji(i);
|
||||
break;
|
||||
case 'Left':
|
||||
case 'ArrowLeft':
|
||||
this._selectPreviousEmoji(i);
|
||||
break;
|
||||
case 'Right':
|
||||
case 'ArrowRight':
|
||||
this._selectNextEmoji(i);
|
||||
break;
|
||||
case 'Escape':
|
||||
onUnfocus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleReact = (emoji: string) => (): void => {
|
||||
const { onReact, focused, onUnfocus } = this.props;
|
||||
|
||||
onReact(emoji);
|
||||
|
||||
if (focused) {
|
||||
onUnfocus();
|
||||
}
|
||||
}
|
||||
|
||||
handlers = {
|
||||
open: () => { },
|
||||
};
|
||||
|
||||
setRef = (c: HTMLDivElement): void => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { visible, focused, allowedEmoji, onReact } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.handlers}>
|
||||
{/*<div
|
||||
className={classNames('flex absolute bg-white dark:bg-gray-500 px-2 py-3 rounded-full shadow-md opacity-0 pointer-events-none duration-100 w-max', { 'opacity-100 pointer-events-auto z-[999]': visible || focused })}
|
||||
onBlur={this.handleBlur}
|
||||
ref={this.setRef}
|
||||
>
|
||||
{allowedEmoji.map((emoji, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className='emoji-react-selector__emoji'
|
||||
onClick={this.handleReact(emoji)}
|
||||
onKeyDown={this.handleKeyDown(i)}
|
||||
tabIndex={(visible || focused) ? 0 : -1}
|
||||
>
|
||||
<Emoji emoji={emoji} />
|
||||
</button>
|
||||
))}
|
||||
</div>*/}
|
||||
<RealEmojiSelector
|
||||
emojis={allowedEmoji.toArray()}
|
||||
onReact={onReact}
|
||||
visible={visible}
|
||||
focused={focused}
|
||||
/>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(EmojiSelector);
|
|
@ -26,13 +26,15 @@ const mapStateToProps = (state: RootState) => {
|
|||
};
|
||||
};
|
||||
|
||||
type Props = ReturnType<typeof mapStateToProps>;
|
||||
interface Props extends ReturnType<typeof mapStateToProps> {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type State = {
|
||||
hasError: boolean,
|
||||
error: any,
|
||||
componentStack: any,
|
||||
browser?: Bowser.Parser.Parser,
|
||||
hasError: boolean
|
||||
error: any
|
||||
componentStack: any
|
||||
browser?: Bowser.Parser.Parser
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||
|
@ -42,7 +44,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
error: undefined,
|
||||
componentStack: undefined,
|
||||
browser: undefined,
|
||||
}
|
||||
};
|
||||
|
||||
textarea: HTMLTextAreaElement | null = null;
|
||||
|
||||
|
@ -71,7 +73,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
|
||||
setTextareaRef: React.RefCallback<HTMLTextAreaElement> = c => {
|
||||
this.textarea = c;
|
||||
}
|
||||
};
|
||||
|
||||
handleCopy: React.MouseEventHandler = () => {
|
||||
if (!this.textarea) return;
|
||||
|
@ -80,12 +82,12 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
this.textarea.setSelectionRange(0, 99999);
|
||||
|
||||
document.execCommand('copy');
|
||||
}
|
||||
};
|
||||
|
||||
getErrorText = (): string => {
|
||||
const { error, componentStack } = this.state;
|
||||
return error + componentStack;
|
||||
}
|
||||
};
|
||||
|
||||
clearCookies: React.MouseEventHandler = (e) => {
|
||||
localStorage.clear();
|
||||
|
@ -96,7 +98,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
e.preventDefault();
|
||||
unregisterSw().then(goHome).catch(goHome);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { browser, hasError } = this.state;
|
||||
|
@ -111,17 +113,17 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
const errorText = this.getErrorText();
|
||||
|
||||
return (
|
||||
<div className='h-screen pt-16 pb-12 flex flex-col bg-white dark:bg-primary-900'>
|
||||
<main className='flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'>
|
||||
<div className='flex-shrink-0 flex justify-center'>
|
||||
<div className='flex h-screen flex-col bg-white pt-16 pb-12 dark:bg-primary-900'>
|
||||
<main className='mx-auto flex w-full max-w-7xl grow flex-col justify-center px-4 sm:px-6 lg:px-8'>
|
||||
<div className='flex shrink-0 justify-center'>
|
||||
<a href='/' className='inline-flex'>
|
||||
<SiteLogo alt='Logo' className='h-12 w-auto cursor-pointer' />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className='py-8'>
|
||||
<div className='text-center max-w-xl mx-auto space-y-2'>
|
||||
<h1 className='text-3xl font-extrabold text-gray-900 dark:text-gray-500 tracking-tight sm:text-4xl'>
|
||||
<div className='mx-auto max-w-xl space-y-2 text-center'>
|
||||
<h1 className='text-3xl font-extrabold tracking-tight text-gray-900 dark:text-gray-500 sm:text-4xl'>
|
||||
<FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' />
|
||||
</h1>
|
||||
<p className='text-lg text-gray-700 dark:text-gray-600'>
|
||||
|
@ -130,7 +132,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
defaultMessage="We're sorry for the interruption. If the problem persists, please reach out to our support team. You may also try to {clearCookies} (this will log you out)."
|
||||
values={{
|
||||
clearCookies: (
|
||||
<a href='/' onClick={this.clearCookies} className='text-primary-600 dark:text-accent-blue hover:underline'>
|
||||
<a href='/' onClick={this.clearCookies} className='text-primary-600 hover:underline dark:text-accent-blue'>
|
||||
<FormattedMessage
|
||||
id='alert.unexpected.clear_cookies'
|
||||
defaultMessage='clear cookies and browser data'
|
||||
|
@ -148,7 +150,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
</Text>
|
||||
|
||||
<div className='mt-10'>
|
||||
<a href='/' className='text-base font-medium text-primary-600 dark:text-accent-blue hover:underline'>
|
||||
<a href='/' className='text-base font-medium text-primary-600 hover:underline dark:text-accent-blue'>
|
||||
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
|
||||
<span aria-hidden='true'> →</span>
|
||||
</a>
|
||||
|
@ -156,11 +158,11 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
</div>
|
||||
|
||||
{!isProduction && (
|
||||
<div className='py-16 max-w-lg mx-auto space-y-4'>
|
||||
<div className='mx-auto max-w-lg space-y-4 py-16'>
|
||||
{errorText && (
|
||||
<textarea
|
||||
ref={this.setTextareaRef}
|
||||
className='h-48 p-4 shadow-sm bg-gray-100 text-gray-900 dark:text-gray-100 dark:bg-gray-800 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 dark:border-gray-700 rounded-md font-mono'
|
||||
className='block h-48 w-full rounded-md border-gray-300 bg-gray-100 p-4 font-mono text-gray-900 shadow-sm focus:border-primary-500 focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 sm:text-sm'
|
||||
value={errorText}
|
||||
onClick={this.handleCopy}
|
||||
readOnly
|
||||
|
@ -178,11 +180,11 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
</div>
|
||||
</main>
|
||||
|
||||
<footer className='flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'>
|
||||
<footer className='mx-auto w-full max-w-7xl shrink-0 px-4 sm:px-6 lg:px-8'>
|
||||
<HStack justifyContent='center' space={4} element='nav'>
|
||||
{links.get('status') && (
|
||||
<>
|
||||
<a href={links.get('status')} className='text-sm font-medium text-gray-700 dark:text-gray-600 hover:underline'>
|
||||
<a href={links.get('status')} className='text-sm font-medium text-gray-700 hover:underline dark:text-gray-600'>
|
||||
<FormattedMessage id='alert.unexpected.links.status' defaultMessage='Status' />
|
||||
</a>
|
||||
</>
|
||||
|
@ -191,7 +193,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
{links.get('help') && (
|
||||
<>
|
||||
<span className='inline-block border-l border-gray-300' aria-hidden='true' />
|
||||
<a href={links.get('help')} className='text-sm font-medium text-gray-700 dark:text-gray-600 hover:underline'>
|
||||
<a href={links.get('help')} className='text-sm font-medium text-gray-700 hover:underline dark:text-gray-600'>
|
||||
<FormattedMessage id='alert.unexpected.links.help' defaultMessage='Help Center' />
|
||||
</a>
|
||||
</>
|
||||
|
@ -200,7 +202,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
{links.get('support') && (
|
||||
<>
|
||||
<span className='inline-block border-l border-gray-300' aria-hidden='true' />
|
||||
<a href={links.get('support')} className='text-sm font-medium text-gray-700 dark:text-gray-600 hover:underline'>
|
||||
<a href={links.get('support')} className='text-sm font-medium text-gray-700 hover:underline dark:text-gray-600'>
|
||||
<FormattedMessage id='alert.unexpected.links.support' defaultMessage='Support' />
|
||||
</a>
|
||||
</>
|
||||
|
@ -213,4 +215,4 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
|||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(ErrorBoundary as any);
|
||||
export default connect(mapStateToProps)(ErrorBoundary);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
|
@ -13,7 +13,7 @@ import VerificationBadge from './verification-badge';
|
|||
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||
eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' },
|
||||
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
|
||||
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
|
||||
});
|
||||
|
@ -51,12 +51,12 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
|
|||
));
|
||||
|
||||
return (
|
||||
<div className={classNames('w-full rounded-lg bg-gray-100 dark:bg-primary-800 relative overflow-hidden', className)}>
|
||||
<div className={clsx('relative w-full overflow-hidden rounded-lg bg-gray-100 dark:bg-primary-800', className)}>
|
||||
<div className='absolute top-28 right-3'>
|
||||
{floatingAction && action}
|
||||
</div>
|
||||
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
|
||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />}
|
||||
<div className='h-40 bg-primary-200 dark:bg-gray-600'>
|
||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.eventBanner)} />}
|
||||
</div>
|
||||
<Stack className='p-2.5' space={2}>
|
||||
<HStack space={2} alignItems='center' justifyContent='between'>
|
||||
|
@ -65,7 +65,7 @@ const EventPreview: React.FC<IEventPreview> = ({ status, className, hideAction,
|
|||
{!floatingAction && action}
|
||||
</HStack>
|
||||
|
||||
<div className='flex gap-y-1 gap-x-2 flex-wrap text-gray-700 dark:text-gray-600'>
|
||||
<div className='flex flex-wrap gap-y-1 gap-x-2 text-gray-700 dark:text-gray-600'>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Icon src={require('@tabler/icons/user.svg')} />
|
||||
<HStack space={1} alignItems='center' grow>
|
||||
|
|
|
@ -3,14 +3,14 @@ import React, { useEffect, useRef } from 'react';
|
|||
import { isIOS } from 'soapbox/is-mobile';
|
||||
|
||||
interface IExtendedVideoPlayer {
|
||||
src: string,
|
||||
alt?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
time?: number,
|
||||
controls?: boolean,
|
||||
muted?: boolean,
|
||||
onClick?: () => void,
|
||||
src: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
time?: number
|
||||
controls?: boolean
|
||||
muted?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const ExtendedVideoPlayer: React.FC<IExtendedVideoPlayer> = ({ src, alt, time, controls, muted, onClick }) => {
|
||||
|
|
|
@ -5,13 +5,13 @@
|
|||
* @see soapbox/components/icon
|
||||
*/
|
||||
|
||||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
export interface IForkAwesomeIcon extends React.HTMLAttributes<HTMLLIElement> {
|
||||
id: string,
|
||||
className?: string,
|
||||
fixedWidth?: boolean,
|
||||
id: string
|
||||
className?: string
|
||||
fixedWidth?: boolean
|
||||
}
|
||||
|
||||
const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ id, className, fixedWidth, ...rest }) => {
|
||||
|
@ -25,7 +25,7 @@ const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ id, className, fixedWidth
|
|||
<i
|
||||
role='img'
|
||||
// alt={alt}
|
||||
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
|
||||
className={clsx('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -30,8 +30,8 @@ const GdprBanner: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Banner theme='opaque' className={classNames('transition-transform', { 'translate-y-full': slideout })}>
|
||||
<div className='flex flex-col space-y-4 lg:space-y-0 lg:space-x-4 rtl:space-x-reverse lg:flex-row lg:items-center lg:justify-between'>
|
||||
<Banner theme='opaque' className={clsx('transition-transform', { 'translate-y-full': slideout })}>
|
||||
<div className='flex flex-col space-y-4 rtl:space-x-reverse lg:flex-row lg:items-center lg:justify-between lg:space-y-0 lg:space-x-4'>
|
||||
<Stack space={2}>
|
||||
<Text size='xl' weight='bold'>
|
||||
<FormattedMessage id='gdpr.title' defaultMessage='{siteTitle} uses cookies' values={{ siteTitle: instance.title }} />
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Avatar, HStack, Icon, Stack, Text } from './ui';
|
||||
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' },
|
||||
});
|
||||
|
||||
interface IGroupCard {
|
||||
group: GroupEntity
|
||||
}
|
||||
|
||||
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden'>
|
||||
<Stack className='rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900 sm:rounded-xl'>
|
||||
<div className='relative -m-[1px] mb-0 h-[120px] rounded-t-lg bg-primary-100 dark:bg-gray-800 sm:rounded-t-xl'>
|
||||
{group.header && <img className='h-full w-full rounded-t-lg object-cover sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupCard;
|
|
@ -10,7 +10,7 @@ import { HStack, Stack, Text } from './ui';
|
|||
import type { Tag } from 'soapbox/types/entities';
|
||||
|
||||
interface IHashtag {
|
||||
hashtag: Tag,
|
||||
hashtag: Tag
|
||||
}
|
||||
|
||||
const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
|
||||
|
|
|
@ -15,7 +15,11 @@ const getNotifTotals = (state: RootState): number => {
|
|||
return notifications + reports + approvals;
|
||||
};
|
||||
|
||||
const Helmet: React.FC = ({ children }) => {
|
||||
interface IHelmet {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Helmet: React.FC<IHelmet> = ({ children }) => {
|
||||
const instance = useInstance();
|
||||
const { unreadChatsCount } = useStatContext();
|
||||
const unreadCount = useAppSelector((state) => getNotifTotals(state) + unreadChatsCount);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
|
@ -15,9 +15,10 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
|
|||
}, 600);
|
||||
|
||||
interface IHoverRefWrapper {
|
||||
accountId: string,
|
||||
inline?: boolean,
|
||||
className?: string,
|
||||
accountId: string
|
||||
inline?: boolean
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Makes a profile hover card appear when the wrapped element is hovered. */
|
||||
|
@ -46,7 +47,7 @@ export const HoverRefWrapper: React.FC<IHoverRefWrapper> = ({ accountId, childre
|
|||
return (
|
||||
<Elem
|
||||
ref={ref}
|
||||
className={classNames('hover-ref-wrapper', className)}
|
||||
className={clsx('hover-ref-wrapper', className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
@ -14,9 +14,10 @@ const showStatusHoverCard = debounce((dispatch, ref, statusId) => {
|
|||
}, 300);
|
||||
|
||||
interface IHoverStatusWrapper {
|
||||
statusId: any,
|
||||
inline: boolean,
|
||||
className?: string,
|
||||
statusId: any
|
||||
inline: boolean
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Makes a status hover card appear when the wrapped element is hovered. */
|
||||
|
@ -44,7 +45,7 @@ export const HoverStatusWrapper: React.FC<IHoverStatusWrapper> = ({ statusId, ch
|
|||
return (
|
||||
<Elem
|
||||
ref={ref}
|
||||
className={classNames('hover-status-wrapper', className)}
|
||||
className={clsx('hover-status-wrapper', className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
|
|
|
@ -1,188 +0,0 @@
|
|||
import classNames from 'clsx';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
|
||||
import Motion from '../features/ui/util/optional-motion';
|
||||
|
||||
export default class IconButton extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
iconClassName: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string,
|
||||
src: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
onMouseDown: PropTypes.func,
|
||||
onKeyUp: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
onKeyPress: PropTypes.func,
|
||||
onMouseEnter: PropTypes.func,
|
||||
onMouseLeave: PropTypes.func,
|
||||
size: PropTypes.number,
|
||||
active: PropTypes.bool,
|
||||
pressed: PropTypes.bool,
|
||||
expanded: PropTypes.bool,
|
||||
style: PropTypes.object,
|
||||
activeStyle: PropTypes.object,
|
||||
disabled: PropTypes.bool,
|
||||
inverted: PropTypes.bool,
|
||||
animate: PropTypes.bool,
|
||||
overlay: PropTypes.bool,
|
||||
tabIndex: PropTypes.string,
|
||||
text: PropTypes.string,
|
||||
emoji: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
size: 18,
|
||||
active: false,
|
||||
disabled: false,
|
||||
animate: false,
|
||||
overlay: false,
|
||||
tabIndex: '0',
|
||||
onKeyUp: () => {},
|
||||
onKeyDown: () => {},
|
||||
onClick: () => {},
|
||||
onMouseEnter: () => {},
|
||||
onMouseLeave: () => {},
|
||||
type: 'button',
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.props.disabled) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown = (e) => {
|
||||
if (!this.props.disabled && this.props.onMouseDown) {
|
||||
this.props.onMouseDown(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (!this.props.disabled && this.props.onKeyDown) {
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyUp = (e) => {
|
||||
if (!this.props.disabled && this.props.onKeyUp) {
|
||||
this.props.onKeyUp(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyPress = (e) => {
|
||||
if (this.props.onKeyPress && !this.props.disabled) {
|
||||
this.props.onKeyPress(e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
fontSize: `${this.props.size}px`,
|
||||
width: `${this.props.size * 1.28571429}px`,
|
||||
height: `${this.props.size * 1.28571429}px`,
|
||||
lineHeight: `${this.props.size}px`,
|
||||
...this.props.style,
|
||||
...(this.props.active ? this.props.activeStyle : {}),
|
||||
};
|
||||
|
||||
const {
|
||||
active,
|
||||
animate,
|
||||
className,
|
||||
iconClassName,
|
||||
disabled,
|
||||
expanded,
|
||||
icon,
|
||||
src,
|
||||
inverted,
|
||||
overlay,
|
||||
pressed,
|
||||
tabIndex,
|
||||
title,
|
||||
text,
|
||||
emoji,
|
||||
type,
|
||||
} = this.props;
|
||||
|
||||
const classes = classNames(className, 'icon-button', {
|
||||
active,
|
||||
disabled,
|
||||
inverted,
|
||||
overlayed: overlay,
|
||||
});
|
||||
|
||||
if (!animate) {
|
||||
// Perf optimization: avoid unnecessary <Motion> components unless
|
||||
// we actually need to animate.
|
||||
return (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyUp={this.handleKeyUp}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onMouseEnter={this.props.onMouseEnter}
|
||||
onMouseLeave={this.props.onMouseLeave}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
type={type}
|
||||
>
|
||||
<div style={src ? {} : style}>
|
||||
{emoji
|
||||
? <div className='icon-button__emoji' dangerouslySetInnerHTML={{ __html: emojify(emoji) }} aria-hidden='true' />
|
||||
: <Icon className={iconClassName} id={icon} src={src} fixedWidth aria-hidden='true' />}
|
||||
</div>
|
||||
{text && <span className='icon-button__text'>{text}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
|
||||
{({ rotate }) => (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyUp={this.handleKeyUp}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onMouseEnter={this.props.onMouseEnter}
|
||||
onMouseLeave={this.props.onMouseLeave}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
type={type}
|
||||
>
|
||||
<div style={src ? {} : style}>
|
||||
{emoji
|
||||
? <div className='icon-button__emoji' style={{ transform: `rotate(${rotate}deg)` }} dangerouslySetInnerHTML={{ __html: emojify(emoji) }} aria-hidden='true' />
|
||||
: <Icon className={iconClassName} id={icon} src={src} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />}
|
||||
</div>
|
||||
{text && <span className='icon-button__text'>{text}</span>}
|
||||
</button>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
interface IIconButton extends Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'disabled' | 'onClick' | 'onKeyDown' | 'onKeyPress' | 'onKeyUp' | 'onMouseDown' | 'onMouseEnter' | 'onMouseLeave' | 'tabIndex' | 'title'> {
|
||||
active?: boolean
|
||||
expanded?: boolean
|
||||
iconClassName?: string
|
||||
pressed?: boolean
|
||||
size?: number
|
||||
src: string
|
||||
text?: React.ReactNode
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IIconButton> = ({
|
||||
active,
|
||||
className,
|
||||
disabled,
|
||||
expanded,
|
||||
iconClassName,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
onKeyPress,
|
||||
onMouseDown,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
pressed,
|
||||
size = 18,
|
||||
src,
|
||||
tabIndex = 0,
|
||||
text,
|
||||
title,
|
||||
}) => {
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!disabled && onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (!disabled && onMouseDown) {
|
||||
onMouseDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (!disabled && onKeyDown) {
|
||||
onKeyDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (!disabled && onKeyUp) {
|
||||
onKeyUp(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (onKeyPress && !disabled) {
|
||||
onKeyPress(e);
|
||||
}
|
||||
};
|
||||
|
||||
const classes = clsx(className, 'icon-button', {
|
||||
active,
|
||||
disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyPress={handleKeyPress}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
type='button'
|
||||
>
|
||||
<div>
|
||||
<Icon className={iconClassName} src={src} aria-hidden='true' />
|
||||
</div>
|
||||
{text && <span className='icon-button__text'>{text}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconButton;
|
|
@ -4,10 +4,10 @@ import Icon, { IIcon } from 'soapbox/components/icon';
|
|||
import { Counter } from 'soapbox/components/ui';
|
||||
|
||||
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
||||
count: number,
|
||||
count: number
|
||||
countMax?: number
|
||||
icon?: string;
|
||||
src?: string;
|
||||
icon?: string
|
||||
src?: string
|
||||
}
|
||||
|
||||
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, countMax, ...rest }) => {
|
||||
|
|
|
@ -1,27 +1,28 @@
|
|||
/**
|
||||
* Icon: abstract icon class that can render icons from multiple sets.
|
||||
* Icon: abstact component to render SVG icons.
|
||||
* @module soapbox/components/icon
|
||||
* @see soapbox/components/fork_awesome_icon
|
||||
* @see soapbox/components/svg_icon
|
||||
*/
|
||||
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
|
||||
|
||||
import ForkAwesomeIcon, { IForkAwesomeIcon } from './fork-awesome-icon';
|
||||
import SvgIcon, { ISvgIcon } from './svg-icon';
|
||||
export interface IIcon extends React.HTMLAttributes<HTMLDivElement> {
|
||||
src: string
|
||||
id?: string
|
||||
alt?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type IIcon = IForkAwesomeIcon | ISvgIcon;
|
||||
|
||||
const Icon: React.FC<IIcon> = (props) => {
|
||||
if ((props as ISvgIcon).src) {
|
||||
const { src, ...rest } = (props as ISvgIcon);
|
||||
|
||||
return <SvgIcon src={src} {...rest} />;
|
||||
} else {
|
||||
const { id, fixedWidth, ...rest } = (props as IForkAwesomeIcon);
|
||||
|
||||
return <ForkAwesomeIcon id={id} fixedWidth={fixedWidth} {...rest} />;
|
||||
}
|
||||
const Icon: React.FC<IIcon> = ({ src, alt, className, ...rest }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx('svg-icon', className)}
|
||||
{...rest}
|
||||
>
|
||||
<InlineSVG src={src} title={alt} loader={<></>} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
|
||||
/** Fullscreen gradient used as a backdrop to public pages. */
|
||||
const LandingGradient: React.FC = () => (
|
||||
<div className='fixed h-screen w-full bg-gradient-to-tr from-primary-50 dark:from-primary-900/50 via-white dark:via-primary-900 to-gradient-end/10 dark:to-primary-800/50' />
|
||||
<div className='fixed h-screen w-full bg-gradient-to-tr from-primary-50 via-white to-gradient-end/10 dark:from-primary-900/50 dark:via-primary-900 dark:to-primary-800/50' />
|
||||
);
|
||||
|
||||
export default LandingGradient;
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Link as Comp, LinkProps } from 'react-router-dom';
|
|||
const Link = (props: LinkProps) => (
|
||||
<Comp
|
||||
{...props}
|
||||
className='text-primary-600 dark:text-accent-blue hover:underline'
|
||||
className='text-primary-600 hover:underline dark:text-accent-blue'
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
|
@ -7,16 +7,21 @@ import { SelectDropdown } from '../features/forms';
|
|||
import Icon from './icon';
|
||||
import { HStack, Select } from './ui';
|
||||
|
||||
const List: React.FC = ({ children }) => (
|
||||
interface IList {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const List: React.FC<IList> = ({ children }) => (
|
||||
<div className='space-y-0.5'>{children}</div>
|
||||
);
|
||||
|
||||
interface IListItem {
|
||||
label: React.ReactNode,
|
||||
hint?: React.ReactNode,
|
||||
onClick?(): void,
|
||||
label: React.ReactNode
|
||||
hint?: React.ReactNode
|
||||
onClick?(): void
|
||||
onSelect?(): void
|
||||
isSelected?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelect, isSelected }) => {
|
||||
|
@ -40,7 +45,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
|
|||
|
||||
return React.cloneElement(child, {
|
||||
id: domId,
|
||||
className: classNames({
|
||||
className: clsx({
|
||||
'w-auto': isSelect,
|
||||
}, child.props.className),
|
||||
});
|
||||
|
@ -52,7 +57,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
|
|||
|
||||
return (
|
||||
<Comp
|
||||
className={classNames({
|
||||
className={clsx({
|
||||
'flex items-center justify-between px-3 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/10 to-gradient-end/10': true,
|
||||
'cursor-pointer hover:from-gradient-start/20 hover:to-gradient-end/20 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined',
|
||||
})}
|
||||
|
|
|
@ -8,9 +8,9 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
interface ILoadGap {
|
||||
disabled?: boolean,
|
||||
maxId: string,
|
||||
onClick: (id: string) => void,
|
||||
disabled?: boolean
|
||||
maxId: string
|
||||
onClick: (id: string) => void
|
||||
}
|
||||
|
||||
const LoadGap: React.FC<ILoadGap> = ({ disabled, maxId, onClick }) => {
|
||||
|
|
|
@ -4,9 +4,9 @@ import { FormattedMessage } from 'react-intl';
|
|||
import { Button } from 'soapbox/components/ui';
|
||||
|
||||
interface ILoadMore {
|
||||
onClick: React.MouseEventHandler,
|
||||
disabled?: boolean,
|
||||
visible?: Boolean,
|
||||
onClick: React.MouseEventHandler
|
||||
disabled?: boolean
|
||||
visible?: Boolean
|
||||
}
|
||||
|
||||
const LoadMore: React.FC<ILoadMore> = ({ onClick, disabled, visible = true }) => {
|
||||
|
|
|
@ -9,7 +9,7 @@ const LoadingScreen: React.FC = () => {
|
|||
<div className='fixed h-screen w-screen'>
|
||||
<LandingGradient />
|
||||
|
||||
<div className='fixed d-screen w-screen flex items-center justify-center z-10'>
|
||||
<div className='d-screen fixed z-10 flex w-screen items-center justify-center'>
|
||||
<div className='p-4'>
|
||||
<Spinner size={40} withText={false} />
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import throttle from 'lodash/throttle';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
@ -18,7 +18,7 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
interface ILocationSearch {
|
||||
onSelected: (locationId: string) => void,
|
||||
onSelected: (locationId: string) => void
|
||||
}
|
||||
|
||||
const LocationSearch: React.FC<ILocationSearch> = ({ onSelected }) => {
|
||||
|
@ -100,8 +100,8 @@ const LocationSearch: React.FC<ILocationSearch> = ({ onSelected }) => {
|
|||
renderSuggestion={AutosuggestLocation}
|
||||
/>
|
||||
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}>
|
||||
<Icon src={require('@tabler/icons/search.svg')} className={classNames('svg-icon--search', { active: isEmpty() })} />
|
||||
<Icon src={require('@tabler/icons/backspace.svg')} className={classNames('svg-icon--backspace', { active: !isEmpty() })} aria-label={intl.formatMessage(messages.placeholder)} />
|
||||
<Icon src={require('@tabler/icons/search.svg')} className={clsx('svg-icon--search', { active: isEmpty() })} />
|
||||
<Icon src={require('@tabler/icons/backspace.svg')} className={clsx('svg-icon--backspace', { active: !isEmpty() })} aria-label={intl.formatMessage(messages.placeholder)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useLayoutEffect } from 'react';
|
||||
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
import { useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { Attachment } from 'soapbox/types/entities';
|
||||
import { truncateFilename } from 'soapbox/utils/media';
|
||||
|
||||
|
@ -19,21 +19,21 @@ const ATTACHMENT_LIMIT = 4;
|
|||
const MAX_FILENAME_LENGTH = 45;
|
||||
|
||||
interface Dimensions {
|
||||
w: Property.Width | number,
|
||||
h: Property.Height | number,
|
||||
t?: Property.Top,
|
||||
r?: Property.Right,
|
||||
b?: Property.Bottom,
|
||||
l?: Property.Left,
|
||||
float?: Property.Float,
|
||||
pos?: Property.Position,
|
||||
w: Property.Width | number
|
||||
h: Property.Height | number
|
||||
t?: Property.Top
|
||||
r?: Property.Right
|
||||
b?: Property.Bottom
|
||||
l?: Property.Left
|
||||
float?: Property.Float
|
||||
pos?: Property.Position
|
||||
}
|
||||
|
||||
interface SizeData {
|
||||
style: React.CSSProperties,
|
||||
itemsDimensions: Dimensions[],
|
||||
size: number,
|
||||
width: number,
|
||||
style: React.CSSProperties
|
||||
itemsDimensions: Dimensions[]
|
||||
size: number
|
||||
width: number
|
||||
}
|
||||
|
||||
const withinLimits = (aspectRatio: number) => {
|
||||
|
@ -48,16 +48,16 @@ const shouldLetterbox = (attachment: Attachment): boolean => {
|
|||
};
|
||||
|
||||
interface IItem {
|
||||
attachment: Attachment,
|
||||
standalone?: boolean,
|
||||
index: number,
|
||||
size: number,
|
||||
onClick: (index: number) => void,
|
||||
displayWidth?: number,
|
||||
visible: boolean,
|
||||
dimensions: Dimensions,
|
||||
last?: boolean,
|
||||
total: number,
|
||||
attachment: Attachment
|
||||
standalone?: boolean
|
||||
index: number
|
||||
size: number
|
||||
onClick: (index: number) => void
|
||||
displayWidth?: number
|
||||
visible: boolean
|
||||
dimensions: Dimensions
|
||||
last?: boolean
|
||||
total: number
|
||||
}
|
||||
|
||||
const Item: React.FC<IItem> = ({
|
||||
|
@ -72,6 +72,7 @@ const Item: React.FC<IItem> = ({
|
|||
}) => {
|
||||
const settings = useSettings();
|
||||
const autoPlayGif = settings.get('autoPlayGif') === true;
|
||||
const { mediaPreview } = useSoapboxConfig();
|
||||
|
||||
const handleMouseEnter: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
|
||||
if (hoverToPlay()) {
|
||||
|
@ -151,7 +152,14 @@ const Item: React.FC<IItem> = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
|
||||
<div
|
||||
className={clsx('media-gallery__item', {
|
||||
standalone,
|
||||
'rounded-md': total > 1,
|
||||
})}
|
||||
key={attachment.id}
|
||||
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
|
||||
>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.url} target='_blank' style={{ cursor: 'pointer' }}>
|
||||
<Blurhash hash={attachment.blurhash} className='media-gallery__preview' />
|
||||
<span className='media-gallery__item__icons'>{attachmentIcon}</span>
|
||||
|
@ -170,8 +178,8 @@ const Item: React.FC<IItem> = ({
|
|||
target='_blank'
|
||||
>
|
||||
<StillImage
|
||||
className='w-full h-full'
|
||||
src={attachment.url}
|
||||
className='h-full w-full'
|
||||
src={mediaPreview ? attachment.preview_url : attachment.url}
|
||||
alt={attachment.description}
|
||||
letterboxed={letterboxed}
|
||||
showExt
|
||||
|
@ -188,7 +196,7 @@ const Item: React.FC<IItem> = ({
|
|||
}
|
||||
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlayGif })}>
|
||||
<div className={clsx('media-gallery__gifv', { autoplay: autoPlayGif })}>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.description}
|
||||
|
@ -210,7 +218,7 @@ const Item: React.FC<IItem> = ({
|
|||
const ext = attachment.url.split('.').pop()?.toUpperCase();
|
||||
thumbnail = (
|
||||
<a
|
||||
className={classNames('media-gallery__item-thumbnail')}
|
||||
className={clsx('media-gallery__item-thumbnail')}
|
||||
href={attachment.url}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
|
@ -224,7 +232,7 @@ const Item: React.FC<IItem> = ({
|
|||
const ext = attachment.url.split('.').pop()?.toUpperCase();
|
||||
thumbnail = (
|
||||
<a
|
||||
className={classNames('media-gallery__item-thumbnail')}
|
||||
className={clsx('media-gallery__item-thumbnail')}
|
||||
href={attachment.url}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
|
@ -244,7 +252,14 @@ const Item: React.FC<IItem> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', `media-gallery__item--${attachment.type}`, { standalone })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
|
||||
<div
|
||||
className={clsx('media-gallery__item', `media-gallery__item--${attachment.type}`, {
|
||||
standalone,
|
||||
'rounded-md': total > 1,
|
||||
})}
|
||||
key={attachment.id}
|
||||
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
|
||||
>
|
||||
{last && total > ATTACHMENT_LIMIT && (
|
||||
<div className='media-gallery__item-overflow'>
|
||||
+{total - ATTACHMENT_LIMIT + 1}
|
||||
|
@ -259,23 +274,25 @@ const Item: React.FC<IItem> = ({
|
|||
);
|
||||
};
|
||||
|
||||
interface IMediaGallery {
|
||||
sensitive?: boolean,
|
||||
media: ImmutableList<Attachment>,
|
||||
height?: number,
|
||||
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
|
||||
defaultWidth?: number,
|
||||
cacheWidth?: (width: number) => void,
|
||||
visible?: boolean,
|
||||
onToggleVisibility?: () => void,
|
||||
displayMedia?: string,
|
||||
compact: boolean,
|
||||
export interface IMediaGallery {
|
||||
sensitive?: boolean
|
||||
media: ImmutableList<Attachment>
|
||||
height?: number
|
||||
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void
|
||||
defaultWidth?: number
|
||||
cacheWidth?: (width: number) => void
|
||||
visible?: boolean
|
||||
onToggleVisibility?: () => void
|
||||
displayMedia?: string
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
||||
const {
|
||||
media,
|
||||
defaultWidth = 0,
|
||||
className,
|
||||
onOpenMedia,
|
||||
cacheWidth,
|
||||
compact,
|
||||
|
@ -532,7 +549,7 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
|||
/>
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (node.current) {
|
||||
const { offsetWidth } = node.current;
|
||||
|
||||
|
@ -545,7 +562,11 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
|||
}, [node.current]);
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery', { 'media-gallery--compact': compact })} style={sizeData.style} ref={node}>
|
||||
<div
|
||||
className={clsx(className, 'media-gallery', { 'media-gallery--compact': compact })}
|
||||
style={sizeData.style}
|
||||
ref={node}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import 'wicg-inert';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
@ -11,19 +11,18 @@ import { useAppDispatch, usePrevious } from 'soapbox/hooks';
|
|||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { IPolicy, PolicyKeys } from 'soapbox/queries/policies';
|
||||
|
||||
import type { UnregisterCallback } from 'history';
|
||||
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
|
||||
import type { ReducerCompose } from 'soapbox/reducers/compose';
|
||||
import type { ReducerRecord as ReducerComposeEvent } from 'soapbox/reducers/compose-event';
|
||||
|
||||
const messages = defineMessages({
|
||||
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
confirm: { id: 'confirmations.cancel.confirm', defaultMessage: 'Discard' },
|
||||
cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' },
|
||||
});
|
||||
|
||||
export const checkComposeContent = (compose?: ReturnType<typeof ReducerCompose>) => {
|
||||
return !!compose && [
|
||||
compose.text.length > 0,
|
||||
compose.editorState && compose.editorState.length > 0,
|
||||
compose.spoiler_text.length > 0,
|
||||
compose.media_attachments.size > 0,
|
||||
compose.poll !== null,
|
||||
|
@ -40,9 +39,10 @@ export const checkEventComposeContent = (compose?: ReturnType<typeof ReducerComp
|
|||
};
|
||||
|
||||
interface IModalRoot {
|
||||
onCancel?: () => void,
|
||||
onClose: (type?: ModalType) => void,
|
||||
type: ModalType,
|
||||
onCancel?: () => void
|
||||
onClose: (type?: ModalType) => void
|
||||
type: ModalType
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type }) => {
|
||||
|
@ -55,7 +55,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
const ref = useRef<HTMLDivElement>(null);
|
||||
const activeElement = useRef<HTMLDivElement | null>(revealed ? document.activeElement as HTMLDivElement | null : null);
|
||||
const modalHistoryKey = useRef<number>();
|
||||
const unlistenHistory = useRef<UnregisterCallback>();
|
||||
const unlistenHistory = useRef<ReturnType<typeof history.listen>>();
|
||||
|
||||
const prevChildren = usePrevious(children);
|
||||
const prevType = usePrevious(type);
|
||||
|
@ -80,10 +80,10 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: isEditing
|
||||
? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' />
|
||||
: <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
|
||||
: <FormattedMessage id='confirmations.cancel.heading' defaultMessage='Discard post' />,
|
||||
message: isEditing
|
||||
? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' />
|
||||
: <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
|
||||
: <FormattedMessage id='confirmations.cancel.message' defaultMessage='Are you sure you want to cancel creating this post?' />,
|
||||
confirm: intl.formatMessage(messages.confirm),
|
||||
onConfirm: () => {
|
||||
dispatch(closeModal('COMPOSE'));
|
||||
|
@ -129,10 +129,10 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = Array.from(ref.current!.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
|
||||
const index = focusable.indexOf(e.target);
|
||||
const index = focusable.indexOf(e.target as Element);
|
||||
|
||||
let element;
|
||||
|
||||
|
@ -152,8 +152,10 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
|
||||
const handleModalOpen = () => {
|
||||
modalHistoryKey.current = Date.now();
|
||||
unlistenHistory.current = history.listen((_, action) => {
|
||||
if (action === 'POP') {
|
||||
unlistenHistory.current = history.listen(({ state }, action) => {
|
||||
if (!(state as any)?.soapboxModalKey) {
|
||||
onClose();
|
||||
} else if (action === 'POP') {
|
||||
handleOnClose();
|
||||
|
||||
if (onCancel) onCancel();
|
||||
|
@ -165,11 +167,9 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
if (unlistenHistory.current) {
|
||||
unlistenHistory.current();
|
||||
}
|
||||
if (!['FAVOURITES', 'MENTIONS', 'REACTIONS', 'REBLOGS', 'MEDIA'].includes(type)) {
|
||||
const { state } = history.location;
|
||||
if (state && (state as any).soapboxModalKey === modalHistoryKey.current) {
|
||||
history.goBack();
|
||||
}
|
||||
const { state } = history.location;
|
||||
if (state && (state as any).soapboxModalKey === modalHistoryKey.current) {
|
||||
history.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -221,7 +221,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
|
||||
ensureHistoryBuffer();
|
||||
}
|
||||
});
|
||||
}, [children]);
|
||||
|
||||
if (!visible) {
|
||||
return (
|
||||
|
@ -232,7 +232,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames({
|
||||
className={clsx({
|
||||
'fixed top-0 left-0 z-[100] w-full h-full overflow-x-hidden overflow-y-auto': true,
|
||||
'pointer-events-none': !visible,
|
||||
})}
|
||||
|
@ -241,17 +241,16 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
<div
|
||||
role='presentation'
|
||||
id='modal-overlay'
|
||||
className='fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90'
|
||||
className='fixed inset-0 bg-gray-500/90 backdrop-blur dark:bg-gray-700/90'
|
||||
onClick={handleOnClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
role='dialog'
|
||||
className={classNames({
|
||||
'my-2 mx-auto relative pointer-events-none flex items-center': true,
|
||||
className={clsx({
|
||||
'my-2 mx-auto relative pointer-events-none flex items-center min-h-[calc(100%-3.5rem)]': true,
|
||||
'p-4 md:p-0': type !== 'MEDIA',
|
||||
})}
|
||||
style={{ minHeight: 'calc(100% - 3.5rem)' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue