Merge, store Lexical editorState

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-03-02 19:42:31 +01:00
commit 171341ff1a
777 changed files with 25645 additions and 29568 deletions

View File

@ -5,4 +5,4 @@
/tmp/**
/coverage/**
/custom/**
!.eslintrc.js
!.eslintrc.cjs

View File

@ -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,
},
}],
},
},
],
};

View File

@ -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

View File

@ -1,5 +1,8 @@
{
"*.js": "eslint --cache",
"*.cjs": "eslint --cache",
"*.mjs": "eslint --cache",
"*.ts": "eslint --cache",
"*.tsx": "eslint --cache",
"app/styles/**/*.scss": "stylelint"
}

43
.storybook/main.ts Normal file
View File

@ -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;

22
.storybook/preview.tsx Normal file
View File

@ -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$/,
},
},
};

View File

@ -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"
}
}

View File

@ -1 +1 @@
nodejs 18.2.0
nodejs 18.14.0

View File

@ -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
}

View File

@ -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

View File

@ -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);
});

View File

@ -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.

View File

@ -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 %>

View File

@ -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);
});

View File

@ -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
};

View File

@ -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,
};

View File

@ -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));
}
});
};

View File

@ -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';

View File

@ -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,
};

View File

@ -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;

View File

@ -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,

View File

@ -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) {

View File

@ -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,
redirect_uris: `${window.location.origin}/login/external`,
website: sourceCode.homepage,
scopes,
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))

View File

@ -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

View File

@ -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
}

View File

@ -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,
};

View File

@ -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;

View File

@ -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',
} : {};

View File

@ -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 : '');

View File

@ -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

View File

@ -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
}

View File

@ -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 => {

View File

@ -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 });

View File

@ -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,
}),

View File

@ -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));
}
});

View File

@ -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) {

View File

@ -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;

View File

@ -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,

View File

@ -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'>
};

View File

@ -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 : '';

View File

@ -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 }));
},
});
}

View File

@ -1,7 +1,7 @@
import React from 'react';
interface IInlineSVG {
loader?: JSX.Element,
loader?: JSX.Element
}
const InlineSVG: React.FC<IInlineSVG> = ({ loader }): JSX.Element => {

View File

@ -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);
});
});

View File

@ -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
});

View File

@ -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);
});
});

View File

@ -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>

View File

@ -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'>&middot;</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'>&middot;</Text>

View File

@ -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>
))}

View File

@ -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 }) => {

View File

@ -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 }) => {

View File

@ -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,

View File

@ -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}

View File

@ -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,
})}

View File

@ -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}

View File

@ -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();

View File

@ -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 }) => {

View File

@ -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,

View File

@ -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>
);

View File

@ -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(),

View File

@ -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;

View File

@ -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;

View File

@ -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}

View File

@ -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}

View File

@ -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
}
/**

View File

@ -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
/>

View File

@ -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>
);

View File

@ -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 = () => {

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,3 @@
export { default } from './dropdown-menu';
export type { Menu } from './dropdown-menu';
export type { MenuItem } from './dropdown-menu-item';

View File

@ -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);

View File

@ -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'> &rarr;</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);

View File

@ -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>

View File

@ -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 }) => {

View File

@ -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}
/>
);

View File

@ -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 }} />

View File

@ -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;

View File

@ -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 }) => {

View File

@ -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);

View File

@ -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}

View File

@ -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}

View File

@ -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>
);
}
}

View File

@ -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;

View File

@ -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 }) => {

View File

@ -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 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} />;
export interface IIcon extends React.HTMLAttributes<HTMLDivElement> {
src: string
id?: string
alt?: string
className?: string
}
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;

View File

@ -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;

View File

@ -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'
/>
);

View File

@ -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',
})}

View File

@ -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 }) => {

View File

@ -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 }) => {

View File

@ -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>

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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,12 +167,10 @@ 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 ensureHistoryBuffer = () => {
@ -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