Merge remote-tracking branch 'soapbox/develop' into mastodon-groups

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-mastodon-g-0qbqe2/deployments/2457
marcin mikołajczak 2023-01-27 23:04:42 +01:00
commit 1f39ddf0ec
443 zmienionych plików z 10101 dodań i 30568 usunięć

Wyświetl plik

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

Wyświetl plik

@ -18,7 +18,7 @@ module.exports = {
ATTACHMENT_HOST: false,
},
parser: 'babel-eslint',
parser: '@babel/eslint-parser',
plugins: [
'react',
@ -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,12 +54,12 @@ 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
],
},

Wyświetl plik

@ -3,6 +3,9 @@ image: node:18
variables:
NODE_ENV: test
default:
interruptible: true
cache: &cache
key:
files:
@ -15,6 +18,7 @@ stages:
- deps
- test
- deploy
- release
deps:
stage: deps
@ -25,7 +29,6 @@ deps:
cache:
<<: *cache
policy: push
interruptible: true
danger:
stage: test
@ -33,8 +36,10 @@ danger:
# https://github.com/danger/danger-js/issues/1029#issuecomment-998915436
- export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!}
- npx danger ci
except:
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
allow_failure: true
interruptible: true
lint-js:
stage: test
@ -43,11 +48,12 @@ lint-js:
changes:
- "**/*.js"
- "**/*.jsx"
- "**/*.cjs"
- "**/*.mjs"
- "**/*.ts"
- "**/*.tsx"
- ".eslintignore"
- ".eslintrc.js"
interruptible: true
- ".eslintrc.cjs"
lint-sass:
stage: test
@ -57,7 +63,6 @@ lint-sass:
- "**/*.scss"
- "**/*.css"
- ".stylelintrc.json"
interruptible: true
jest:
stage: test
@ -69,7 +74,7 @@ jest:
- "app/soapbox/**/*"
- "webpack/**/*"
- "custom/**/*"
- "jest.config.js"
- "jest.config.cjs"
- "package.json"
- "yarn.lock"
- ".gitlab-ci.yml"
@ -80,27 +85,30 @@ jest:
coverage_report:
coverage_format: cobertura
path: .coverage/cobertura-coverage.xml
interruptible: true
nginx-test:
stage: test
image: nginx:latest
before_script: cp installation/mastodon.conf /etc/nginx/conf.d/default.conf
before_script:
- cp installation/mastodon.conf /etc/nginx/conf.d/default.conf
script: nginx -t
only:
changes:
- "installation/mastodon.conf"
interruptible: true
build-production:
stage: test
script: yarn build
script:
- yarn build
- yarn manage:translations en
# Fail if files got changed.
# https://stackoverflow.com/a/9066385
- git diff --quiet
variables:
NODE_ENV: production
artifacts:
paths:
- static
interruptible: true
docs-deploy:
stage: deploy
@ -110,22 +118,10 @@ docs-deploy:
script:
- curl -X POST -F"token=$CI_JOB_TOKEN" -F'ref=master' https://gitlab.com/api/v4/projects/15685485/trigger/pipeline
only:
refs:
- develop
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
changes:
- "docs/**/*"
interruptible: true
# Supposed to fail when translations are outdated, instead always passes
#
# i18n:
# stage: build
# script: yarn manage:translations
# variables:
# NODE_ENV: development
# before_script:
# - yarn
# - yarn build
review:
stage: deploy
@ -135,7 +131,6 @@ review:
script:
- npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub
allow_failure: true
interruptible: true
pages:
stage: deploy
@ -149,15 +144,14 @@ pages:
paths:
- public
only:
refs:
- develop
interruptible: true
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
docker:
stage: deploy
image: docker:20.10.17
image: docker:20.10.23
services:
- docker:20.10.17-dind
- docker:20.10.23-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
@ -166,6 +160,17 @@ docker:
- docker build -t $CI_REGISTRY_IMAGE .
- docker push $CI_REGISTRY_IMAGE
only:
refs:
- develop
interruptible: true
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
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
- template: Security/License-Scanning.gitlab-ci.yml

Wyświetl plik

@ -0,0 +1,8 @@
## Summary
<!-- Describe your changes in detail -->
## Screenshots (if appropriate):
| Before | After |
| ------ | ----- |
| | |

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1 +1 @@
nodejs 18.2.0
nodejs 18.13.0

Wyświetl plik

@ -3,6 +3,7 @@
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"stylelint.vscode-stylelint",
"wix.vscode-import-cost"
"wix.vscode-import-cost",
"redhat.vscode-yaml"
]
}

14
.vscode/settings.json vendored
Wyświetl plik

@ -1,9 +1,21 @@
{
"css.validate": false,
"editor.insertSpaces": true,
"editor.tabSize": 2,
"files.associations": {
"*.conf.template": "properties"
},
"files.eol": "\n",
"files.insertFinalNewline": false
"files.insertFinalNewline": false,
"json.schemas": [
{
"fileMatch": [".lintstagedrc.json"],
"url": "https://json.schemastore.org/lintstagedrc.schema.json"
},
{
"fileMatch": ["renovate.json"],
"url": "https://docs.renovatebot.com/renovate-schema.json"
}
],
"scss.validate": false
}

Wyświetl plik

@ -5,6 +5,67 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### 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.
- Groups: Initial support for groups.
### Changed
- Chats: improved display of media attachments.
- ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away.
### Fixed
- 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.
### 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
### Added
- Editing: ability to edit posts and view edit history (on Rebased, Pleroma, and Mastodon).
- Events: ability to create, view, and comment on Events (on Rebased).
@ -20,6 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Theme: auto-detect system theme by default.
- Profile: remove a specific user from your followers (on Rebased, Mastodon).
- Suggestions: ability to view all suggested profiles.
- Feeds: display suggested accounts in Home feed (optional by admin).
- Compatibility: added compatibility with Truth Social, Fedibird, Pixelfed, Akkoma, and Glitch.
- Developers: added Test feed, Service Worker debugger, and Network Error preview.
- Reports: display server rules in reports. Let users select rule violations when submitting a report.
@ -27,6 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Admin: custom badges. Admins can add non-federating badges to any user's profile (on Rebased, Pleroma).
- Admin: consolidated user dropdown actions (verify/suggest/etc) into a unified "Moderate User" modal.
- i18n: updated translations for Italian, Polish, Arabic, Hebrew, and German.
- Toast: added the ability to dismiss toast notifications.
### Changed
- UI: the whole UI has been overhauled both inside and out. 97% of the codebase has been rewritten to TypeScript, and a new component library has been introduced with Tailwind CSS.
@ -37,12 +100,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Posts: move instance favicon beside username instead of post timestamp.
- Posts: changed the behavior of content warnings. CWs and sensitive media are unified into one design.
- Posts: redesigned interaction counters to use text instead of icons.
- Posts: letterbox images taller than 1:1.
- Profile: overhauled user profiles to be consistent with the rest of the UI.
- Composer: move emoji button alongside other composer buttons, add numerical counter.
- Birthdays: move today's birthdays out of notifications into right sidebar.
- Performance: improve scrolling/navigation between feeds by using a virtual window library.
- Admin: reorganize UI into 3-column layout.
- Admin: include external link to frontend repo for the running commit.
- Toast: redesigned toast notifications.
### Removed
- Theme: Halloween theme.

Wyświetl plik

@ -74,6 +74,7 @@ One disadvantage of this approach is that it does not help the software spread.
© Alex Gleason & other Soapbox contributors
© Eugen Rochko & other Mastodon contributors
© Trump Media & Technology Group
© Gab AI, Inc.
Soapbox is free software: you can redistribute it and/or modify

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,166 @@
import { render } from '@testing-library/react';
import { AxiosError } from 'axios';
import React from 'react';
import { IntlProvider } from 'react-intl';
import { act, screen } from 'soapbox/jest/test-helpers';
function renderApp() {
const { Toaster } = require('react-hot-toast');
const toast = require('../toast').default;
return {
toast,
...render(
<IntlProvider locale='en'>
<Toaster />,
</IntlProvider>,
),
};
}
beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
(console.error as any).mockClear();
});
afterAll(() => {
(console.error as any).mockRestore();
});
describe('toasts', () =>{
it('renders successfully', async() => {
const { toast } = renderApp();
act(() => {
toast.success('hello');
});
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast-message')).toHaveTextContent('hello');
});
describe('actionable button', () => {
it('renders the button', async() => {
const { toast } = renderApp();
act(() => {
toast.success('hello', { action: () => null, actionLabel: 'click me' });
});
expect(screen.getByTestId('toast-action')).toHaveTextContent('click me');
});
it('does not render the button', async() => {
const { toast } = renderApp();
act(() => {
toast.success('hello');
});
expect(screen.queryAllByTestId('toast-action')).toHaveLength(0);
});
});
describe('showAlertForError()', () => {
const buildError = (message: string, status: number) => new AxiosError<any>(message, String(status), undefined, null, {
data: {
error: message,
},
statusText: String(status),
status,
headers: {},
config: {},
});
describe('with a 502 status code', () => {
it('renders the correct message', async() => {
const message = 'The server is down';
const error = buildError(message, 502);
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast-message')).toHaveTextContent('The server is down');
});
});
describe('with a 404 status code', () => {
it('renders the correct message', async() => {
const error = buildError('', 404);
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.queryAllByTestId('toast')).toHaveLength(0);
});
});
describe('with a 410 status code', () => {
it('renders the correct message', async() => {
const error = buildError('', 410);
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.queryAllByTestId('toast')).toHaveLength(0);
});
});
describe('with an accepted status code', () => {
describe('with a message from the server', () => {
it('renders the correct message', async() => {
const message = 'custom message';
const error = buildError(message, 200);
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast-message')).toHaveTextContent(message);
});
});
describe('without a message from the server', () => {
it('renders the correct message', async() => {
const message = 'The request has been accepted for processing';
const error = buildError(message, 202);
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast-message')).toHaveTextContent(message);
});
});
});
describe('without a response', () => {
it('renders the default message', async() => {
const error = new AxiosError();
const { toast } = renderApp();
act(() => {
toast.showAlertForError(error);
});
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast-message')).toHaveTextContent('An unexpected error occurred.');
});
});
});
});

Wyświetl plik

@ -1,146 +0,0 @@
import { AxiosError } from 'axios';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { dismissAlert, showAlert, showAlertForError } from '../alerts';
const buildError = (message: string, status: number) => new AxiosError<any>(message, String(status), undefined, null, {
data: {
error: message,
},
statusText: String(status),
status,
headers: {},
config: {},
});
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
const state = rootState;
store = mockStore(state);
});
describe('dismissAlert()', () => {
it('dispatches the proper actions', async() => {
const alert = 'hello world';
const expectedActions = [
{ type: 'ALERT_DISMISS', alert },
];
await store.dispatch(dismissAlert(alert as any));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('showAlert()', () => {
it('dispatches the proper actions', async() => {
const title = 'title';
const message = 'msg';
const severity = 'info';
const expectedActions = [
{ type: 'ALERT_SHOW', title, message, severity },
];
await store.dispatch(showAlert(title, message, severity));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('showAlert()', () => {
describe('with a 502 status code', () => {
it('dispatches the proper actions', async() => {
const message = 'The server is down';
const error = buildError(message, 502);
const expectedActions = [
{ type: 'ALERT_SHOW', title: '', message, severity: 'error' },
];
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with a 404 status code', () => {
it('dispatches the proper actions', async() => {
const error = buildError('', 404);
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('with a 410 status code', () => {
it('dispatches the proper actions', async() => {
const error = buildError('', 410);
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('with an accepted status code', () => {
describe('with a message from the server', () => {
it('dispatches the proper actions', async() => {
const message = 'custom message';
const error = buildError(message, 200);
const expectedActions = [
{ type: 'ALERT_SHOW', title: '', message, severity: 'error' },
];
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('without a message from the server', () => {
it('dispatches the proper actions', async() => {
const message = 'The request has been accepted for processing';
const error = buildError(message, 202);
const expectedActions = [
{ type: 'ALERT_SHOW', title: '', message, severity: 'error' },
];
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
describe('without a response', () => {
it('dispatches the proper actions', async() => {
const error = new AxiosError();
const expectedActions = [
{
type: 'ALERT_SHOW',
title: {
defaultMessage: 'Oops!',
id: 'alert.unexpected.title',
},
message: {
defaultMessage: 'An unexpected error occurred.',
id: 'alert.unexpected.message',
},
severity: 'error',
},
];
await store.dispatch(showAlertForError(error));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});

Wyświetl plik

@ -46,13 +46,6 @@ describe('uploadCompose()', () => {
const expectedActions = [
{ type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true },
{
type: 'ALERT_SHOW',
message: 'Image exceeds the current file size limit (10 Bytes)',
actionLabel: undefined,
actionLink: undefined,
severity: 'error',
},
{ type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true },
];
@ -99,13 +92,6 @@ describe('uploadCompose()', () => {
const expectedActions = [
{ type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true },
{
type: 'ALERT_SHOW',
message: 'Video exceeds the current file size limit (10 Bytes)',
actionLabel: undefined,
actionLink: undefined,
severity: 'error',
},
{ type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true },
];

Wyświetl plik

@ -2,7 +2,9 @@ import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { AccountRecord } from 'soapbox/normalizers';
import { AuthUserRecord, ReducerRecord } from '../../reducers/auth';
import {
fetchMe, patchMe,
} from '../me';
@ -38,18 +40,18 @@ describe('fetchMe()', () => {
beforeEach(() => {
const state = rootState
.set('auth', ImmutableMap({
.set('auth', ReducerRecord({
me: accountUrl,
users: ImmutableMap({
[accountUrl]: ImmutableMap({
[accountUrl]: AuthUserRecord({
'access_token': token,
}),
}),
}))
.set('accounts', ImmutableMap({
[accountUrl]: {
[accountUrl]: AccountRecord({
url: accountUrl,
},
}),
}) as any);
store = mockStore(state);
});
@ -112,4 +114,4 @@ describe('patchMe()', () => {
expect(actions).toEqual(expectedActions);
});
});
});
});

Wyświetl plik

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

Wyświetl plik

@ -77,6 +77,16 @@ 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 nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct);
const fetchConfig = () =>
@ -544,6 +554,50 @@ 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 });
});
};
export {
ADMIN_CONFIG_FETCH_REQUEST,
ADMIN_CONFIG_FETCH_SUCCESS,
@ -596,6 +650,13 @@ 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,
fetchConfig,
updateConfig,
updateSoapboxConfig,
@ -622,4 +683,7 @@ export {
setRole,
suggestUsers,
unsuggestUsers,
setUserIndexQuery,
fetchUserIndex,
expandUserIndex,
};

Wyświetl plik

@ -1,75 +0,0 @@
import { defineMessages, MessageDescriptor } from 'react-intl';
import { httpErrorMessages } from 'soapbox/utils/errors';
import type { SnackbarActionSeverity } from './snackbar';
import type { AnyAction } from '@reduxjs/toolkit';
import type { AxiosError } from 'axios';
import type { NotificationObject } from 'react-notification';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
});
export const ALERT_SHOW = 'ALERT_SHOW';
export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
const noOp = () => { };
function dismissAlert(alert: NotificationObject) {
return {
type: ALERT_DISMISS,
alert,
};
}
function showAlert(
title: MessageDescriptor | string = messages.unexpectedTitle,
message: MessageDescriptor | string = messages.unexpectedMessage,
severity: SnackbarActionSeverity = 'info',
) {
return {
type: ALERT_SHOW,
title,
message,
severity,
};
}
const showAlertForError = (error: AxiosError<any>) => (dispatch: React.Dispatch<AnyAction>, _getState: any) => {
if (error?.response) {
const { data, status, statusText } = error.response;
if (status === 502) {
return dispatch(showAlert('', 'The server is down', 'error'));
}
if (status === 404 || status === 410) {
// Skip these errors as they are reflected in the UI
return dispatch(noOp as any);
}
let message: string | undefined = statusText;
if (data?.error) {
message = data.error;
}
if (!message) {
message = httpErrorMessages.find((httpError) => httpError.code === status)?.description;
}
return dispatch(showAlert('', message, 'error'));
} else {
console.error(error);
return dispatch(showAlert(undefined, undefined, 'error'));
}
};
export {
dismissAlert,
showAlert,
showAlertForError,
};

Wyświetl plik

@ -1,14 +1,13 @@
import { defineMessages } from 'react-intl';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import api from '../api';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer';
import { patchMeSuccess } from './me';
import snackbar from './snackbar';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
@ -80,7 +79,7 @@ const fetchAliasesSuggestions = (q: string) =>
api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchAliasesSuggestionsReady(q, data));
}).catch(error => dispatch(showAlertForError(error)));
}).catch(error => toast.showAlertForError(error));
};
const fetchAliasesSuggestionsReady = (query: string, accounts: APIEntity[]) => ({
@ -114,7 +113,7 @@ const addToAliases = (account: Account) =>
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma.get('ap_id')] })
.then((response => {
dispatch(snackbar.success(messages.createSuccess));
toast.success(messages.createSuccess);
dispatch(addToAliasesSuccess);
dispatch(patchMeSuccess(response.data));
}))
@ -129,7 +128,7 @@ const addToAliases = (account: Account) =>
alias: account.acct,
})
.then(() => {
dispatch(snackbar.success(messages.createSuccess));
toast.success(messages.createSuccess);
dispatch(addToAliasesSuccess);
dispatch(fetchAliases);
})
@ -165,7 +164,7 @@ const removeFromAliases = (account: string) =>
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter((id: string) => id !== account) })
.then(response => {
dispatch(snackbar.success(messages.removeSuccess));
toast.success(messages.removeSuccess);
dispatch(removeFromAliasesSuccess);
dispatch(patchMeSuccess(response.data));
})
@ -182,7 +181,7 @@ const removeFromAliases = (account: string) =>
},
})
.then(response => {
dispatch(snackbar.success(messages.removeSuccess));
toast.success(messages.removeSuccess);
dispatch(removeFromAliasesSuccess);
dispatch(fetchAliases);
})

Wyświetl plik

@ -14,14 +14,14 @@ import { createApp } from 'soapbox/actions/apps';
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me';
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
import { startOnboarding } from 'soapbox/actions/onboarding';
import snackbar from 'soapbox/actions/snackbar';
import { custom } from 'soapbox/custom';
import { queryClient } from 'soapbox/queries/client';
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,12 +185,14 @@ 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.
dispatch(snackbar.error(messages.invalidCredentials));
toast.error(messages.invalidCredentials);
}
throw error;
});
@ -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))
@ -246,7 +222,7 @@ export const logOut = () =>
dispatch({ type: AUTH_LOGGED_OUT, account, standalone });
return dispatch(snackbar.success(messages.loggedOut));
toast.success(messages.loggedOut);
});
};
@ -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));
}
});
};

Wyświetl plik

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

Wyświetl plik

@ -3,16 +3,15 @@ import { List as ImmutableList } from 'immutable';
import throttle from 'lodash/throttle';
import { defineMessages, IntlShape } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import api from 'soapbox/api';
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
import { tagHistory } from 'soapbox/settings';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion } from 'soapbox/utils/features';
import { formatBytes, getVideoDuration } from 'soapbox/utils/media';
import resizeImage from 'soapbox/utils/resize-image';
import { showAlert, showAlertForError } from './alerts';
import { useEmoji } from './emojis';
import { importFetchedAccounts } from './importer';
import { uploadMedia, fetchMedia, updateMedia } from './media';
@ -20,11 +19,11 @@ import { openModal, closeModal } from './modals';
import { getSettings } from './settings';
import { createStatus } from './statuses';
import type { History } from 'history';
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;
@ -88,13 +87,13 @@ const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
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' },
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
view: { id: 'snackbar.view', defaultMessage: 'View' },
view: { id: 'toast.view', defaultMessage: 'View' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
@ -212,7 +211,10 @@ const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, c
dispatch(insertIntoTagHistory(composeId, data.tags || [], status));
dispatch(submitComposeSuccess(composeId, { ...data }));
dispatch(snackbar.success(edit ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`));
toast.success(edit ? messages.editSuccess : messages.success, {
actionLabel: messages.view,
actionLink: `/@${data.account.acct}/posts/${data.id}`,
});
};
const needsDescriptions = (state: RootState, composeId: string) => {
@ -246,7 +248,7 @@ const submitCompose = (composeId: string, routerHistory?: History, force = false
let to = compose.to;
if (!validateSchedule(state, composeId)) {
dispatch(snackbar.error(messages.scheduleError));
toast.error(messages.scheduleError);
return;
}
@ -332,7 +334,7 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
const mediaCount = media ? media.size : 0;
if (files.length + mediaCount > attachmentLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error'));
toast.error(messages.uploadErrorLimit);
return;
}
@ -348,18 +350,18 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
if (isImage && maxImageSize && (f.size > maxImageSize)) {
const limit = formatBytes(maxImageSize);
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
dispatch(snackbar.error(message));
toast.error(message);
dispatch(uploadComposeFail(composeId, true));
return;
} else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) {
const limit = formatBytes(maxVideoSize);
const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
dispatch(snackbar.error(message));
toast.error(message);
dispatch(uploadComposeFail(composeId, true));
return;
} else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
dispatch(snackbar.error(message));
toast.error(message);
dispatch(uploadComposeFail(composeId, true));
return;
}
@ -507,7 +509,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId,
dispatch(readyComposeSuggestionsAccounts(composeId, token, response.data));
}).catch(error => {
if (!isCancel(error)) {
dispatch(showAlertForError(error));
toast.showAlertForError(error);
}
});
}, 200, { leading: true, trailing: true });

Wyświetl plik

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

Wyświetl plik

@ -1,13 +1,13 @@
import { defineMessages, IntlShape } from 'react-intl';
import api, { getLinks } from 'soapbox/api';
import toast from 'soapbox/toast';
import { formatBytes } from 'soapbox/utils/media';
import resizeImage from 'soapbox/utils/resize-image';
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
import { fetchMedia, uploadMedia } from './media';
import { closeModal, openModal } from './modals';
import snackbar from './snackbar';
import {
STATUS_FETCH_SOURCE_FAIL,
STATUS_FETCH_SOURCE_REQUEST,
@ -91,7 +91,7 @@ const messages = defineMessages({
editSuccess: { id: 'compose_event.edit_success', defaultMessage: 'Your event was edited' },
joinSuccess: { id: 'join_event.success', defaultMessage: 'Joined the event' },
joinRequestSuccess: { id: 'join_event.request_success', defaultMessage: 'Requested to join the event' },
view: { id: 'snackbar.view', defaultMessage: 'View' },
view: { id: 'toast.view', defaultMessage: 'View' },
authorized: { id: 'compose_event.participation_requests.authorize_success', defaultMessage: 'User accepted' },
rejected: { id: 'compose_event.participation_requests.reject_success', defaultMessage: 'User rejected' },
});
@ -163,7 +163,7 @@ const uploadEventBanner = (file: File, intl: IntlShape) =>
if (maxImageSize && (file.size > maxImageSize)) {
const limit = formatBytes(maxImageSize);
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
dispatch(snackbar.error(message));
toast.error(message);
dispatch(uploadEventBannerFail(true));
return;
}
@ -264,7 +264,13 @@ const submitEvent = () =>
dispatch(closeModal('COMPOSE_EVENT'));
dispatch(importFetchedStatus(data));
dispatch(submitEventSuccess(data));
dispatch(snackbar.success(id ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/events/${data.id}`));
toast.success(
id ? messages.editSuccess : messages.success,
{
actionLabel: messages.view,
actionLink: `/@${data.account.acct}/events/${data.id}`,
},
);
}).catch(function(error) {
dispatch(submitEventFail(error));
});
@ -299,11 +305,13 @@ const joinEvent = (id: string, participationMessage?: string) =>
}).then(({ data }) => {
dispatch(importFetchedStatus(data));
dispatch(joinEventSuccess(data));
dispatch(snackbar.success(
toast.success(
data.pleroma.event?.join_state === 'pending' ? messages.joinRequestSuccess : messages.joinSuccess,
messages.view,
`/@${data.account.acct}/events/${data.id}`,
));
{
actionLabel: messages.view,
actionLink: `/@${data.account.acct}/events/${data.id}`,
},
);
}).catch(function(error) {
dispatch(joinEventFail(error, status, status?.event?.join_state || null));
});
@ -504,7 +512,7 @@ const authorizeEventParticipationRequest = (id: string, accountId: string) =>
.post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/authorize`)
.then(() => {
dispatch(authorizeEventParticipationRequestSuccess(id, accountId));
dispatch(snackbar.success(messages.authorized));
toast.success(messages.authorized);
})
.catch(error => dispatch(authorizeEventParticipationRequestFail(id, accountId, error)));
};
@ -536,7 +544,7 @@ const rejectEventParticipationRequest = (id: string, accountId: string) =>
.post(`/api/v1/pleroma/events/${id}/participation_requests/${accountId}/reject`)
.then(() => {
dispatch(rejectEventParticipationRequestSuccess(id, accountId));
dispatch(snackbar.success(messages.rejected));
toast.success(messages.rejected);
})
.catch(error => dispatch(rejectEventParticipationRequestFail(id, accountId, error)));
};

Wyświetl plik

@ -1,10 +1,9 @@
import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import api, { getLinks } from 'soapbox/api';
import { normalizeAccount } from 'soapbox/normalizers';
import toast from 'soapbox/toast';
import type { SnackbarAction } from './snackbar';
import type { AxiosResponse } from 'axios';
import type { RootState } from 'soapbox/store';
@ -37,7 +36,7 @@ type ExportDataActions = {
| typeof EXPORT_MUTES_SUCCESS
| typeof EXPORT_MUTES_FAIL,
error?: any,
} | SnackbarAction
}
function fileExport(content: string, fileName: string) {
const fileToDownload = document.createElement('a');
@ -75,7 +74,7 @@ export const exportFollows = () => (dispatch: React.Dispatch<ExportDataActions>,
followings.unshift('Account address,Show boosts');
fileExport(followings.join('\n'), 'export_followings.csv');
dispatch(snackbar.success(messages.followersSuccess));
toast.success(messages.followersSuccess);
dispatch({ type: EXPORT_FOLLOWS_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_FOLLOWS_FAIL, error });
@ -90,7 +89,7 @@ export const exportBlocks = () => (dispatch: React.Dispatch<ExportDataActions>,
.then((blocks) => {
fileExport(blocks.join('\n'), 'export_block.csv');
dispatch(snackbar.success(messages.blocksSuccess));
toast.success(messages.blocksSuccess);
dispatch({ type: EXPORT_BLOCKS_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_BLOCKS_FAIL, error });
@ -105,7 +104,7 @@ export const exportMutes = () => (dispatch: React.Dispatch<ExportDataActions>, g
.then((mutes) => {
fileExport(mutes.join('\n'), 'export_mutes.csv');
dispatch(snackbar.success(messages.mutesSuccess));
toast.success(messages.mutesSuccess);
dispatch({ type: EXPORT_MUTES_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_MUTES_FAIL, error });

Wyświetl plik

@ -15,10 +15,11 @@ import sourceCode from 'soapbox/utils/code';
import { getWalletAndSign } from 'soapbox/utils/ethereum';
import { getFeatures } from 'soapbox/utils/features';
import { getQuirks } from 'soapbox/utils/quirks';
import { getInstanceScopes } from 'soapbox/utils/scopes';
import { baseClient } from '../api';
import type { AppDispatch } from 'soapbox/store';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Instance } from 'soapbox/types/entities';
const fetchExternalInstance = (baseURL?: string) => {
@ -37,25 +38,23 @@ const fetchExternalInstance = (baseURL?: string) => {
};
const createExternalApp = (instance: Instance, baseURL?: string) =>
(dispatch: AppDispatch) => {
(dispatch: AppDispatch, _getState: () => RootState) => {
// Mitra: skip creating the auth app
if (getQuirks(instance).noApps) return new Promise(f => f({}));
const { scopes } = getFeatures(instance);
const params = {
client_name: sourceCode.displayName,
client_name: sourceCode.displayName,
redirect_uris: `${window.location.origin}/login/external`,
website: sourceCode.homepage,
scopes,
website: sourceCode.homepage,
scopes: getInstanceScopes(instance),
};
return dispatch(createApp(params, baseURL));
};
const externalAuthorize = (instance: Instance, baseURL: string) =>
(dispatch: AppDispatch) => {
const { scopes } = getFeatures(instance);
(dispatch: AppDispatch, _getState: () => RootState) => {
const scopes = getInstanceScopes(instance);
return dispatch(createExternalApp(instance, baseURL)).then((app) => {
const { client_id, redirect_uri } = app as Record<string, string>;
@ -76,7 +75,7 @@ const externalAuthorize = (instance: Instance, baseURL: string) =>
};
const externalEthereumLogin = (instance: Instance, baseURL?: string) =>
(dispatch: AppDispatch) => {
(dispatch: AppDispatch, getState: () => RootState) => {
const loginMessage = instance.login_message;
return getWalletAndSign(loginMessage).then(({ wallet, signature }) => {
@ -89,7 +88,7 @@ const externalEthereumLogin = (instance: Instance, baseURL?: string) =>
client_secret: client_secret,
password: signature as string,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
scope: getFeatures(instance).scopes,
scope: getInstanceScopes(instance),
};
return dispatch(obtainOAuthToken(params, baseURL))

Wyświetl plik

@ -1,6 +1,6 @@
import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
@ -66,7 +66,7 @@ const createFilter = (phrase: string, expires_at: string, context: Array<string>
expires_at,
}).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
dispatch(snackbar.success(messages.added));
toast.success(messages.added);
}).catch(error => {
dispatch({ type: FILTERS_CREATE_FAIL, error });
});
@ -77,7 +77,7 @@ const deleteFilter = (id: string) =>
dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete(`/api/v1/filters/${id}`).then(response => {
dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data });
dispatch(snackbar.success(messages.removed));
toast.success(messages.removed);
}).catch(error => {
dispatch({ type: FILTERS_DELETE_FAIL, error });
});

Wyświetl plik

@ -1,11 +1,12 @@
import { defineMessages } from 'react-intl';
import toast from 'soapbox/toast';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedGroups, importFetchedAccounts } from './importer';
import { closeModal, openModal } from './modals';
import snackbar from './snackbar';
import { deleteFromTimelines } from './timelines';
import type { AxiosError } from 'axios';
@ -139,7 +140,10 @@ const createGroup = (params: Record<string, any>, shouldReset?: boolean) =>
.then(({ data }) => {
dispatch(importFetchedGroups([data]));
dispatch(createGroupSuccess(data));
dispatch(snackbar.success(messages.success, messages.view, `/groups/${data.id}`));
toast.success(messages.success, {
actionLabel: messages.view,
actionLink: `/groups/${data.id}`,
});
if (shouldReset) {
dispatch(resetGroupEditor());
@ -170,7 +174,7 @@ const updateGroup = (id: string, params: Record<string, any>, shouldReset?: bool
.then(({ data }) => {
dispatch(importFetchedGroups([data]));
dispatch(updateGroupSuccess(data));
dispatch(snackbar.success(messages.editSuccess));
toast.success(messages.editSuccess);
if (shouldReset) {
dispatch(resetGroupEditor());
@ -316,7 +320,7 @@ const joinGroup = (id: string) =>
return api(getState).post(`/api/v1/groups/${id}/join`).then(response => {
dispatch(joinGroupSuccess(response.data));
dispatch(snackbar.success(locked ? messages.joinRequestSuccess : messages.joinSuccess));
toast.success(locked ? messages.joinRequestSuccess : messages.joinSuccess);
}).catch(error => {
dispatch(joinGroupFail(error, locked));
});
@ -328,7 +332,7 @@ const leaveGroup = (id: string) =>
return api(getState).post(`/api/v1/groups/${id}/leave`).then(response => {
dispatch(leaveGroupSuccess(response.data));
dispatch(snackbar.success(messages.leaveSuccess));
toast.success(messages.leaveSuccess);
}).catch(error => {
dispatch(leaveGroupFail(error));
});

Wyświetl plik

@ -1,10 +1,9 @@
import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import toast from 'soapbox/toast';
import api from '../api';
import type { SnackbarAction } from './snackbar';
import type { RootState } from 'soapbox/store';
export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST';
@ -31,7 +30,7 @@ type ImportDataActions = {
| typeof IMPORT_MUTES_FAIL,
error?: any,
config?: string
} | SnackbarAction
}
const messages = defineMessages({
blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' },
@ -45,7 +44,7 @@ export const importFollows = (params: FormData) =>
return api(getState)
.post('/api/pleroma/follow_import', params)
.then(response => {
dispatch(snackbar.success(messages.followersSuccess));
toast.success(messages.followersSuccess);
dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: response.data });
}).catch(error => {
dispatch({ type: IMPORT_FOLLOWS_FAIL, error });
@ -58,7 +57,7 @@ export const importBlocks = (params: FormData) =>
return api(getState)
.post('/api/pleroma/blocks_import', params)
.then(response => {
dispatch(snackbar.success(messages.blocksSuccess));
toast.success(messages.blocksSuccess);
dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: response.data });
}).catch(error => {
dispatch({ type: IMPORT_BLOCKS_FAIL, error });
@ -71,7 +70,7 @@ export const importMutes = (params: FormData) =>
return api(getState)
.post('/api/pleroma/mutes_import', params)
.then(response => {
dispatch(snackbar.success(messages.mutesSuccess));
toast.success(messages.mutesSuccess);
dispatch({ type: IMPORT_MUTES_SUCCESS, config: response.data });
}).catch(error => {
dispatch({ type: IMPORT_MUTES_FAIL, error });

Wyświetl plik

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

Wyświetl plik

@ -1,6 +1,6 @@
import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import api from '../api';
@ -63,7 +63,7 @@ const REMOTE_INTERACTION_FAIL = 'REMOTE_INTERACTION_FAIL';
const messages = defineMessages({
bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' },
bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' },
view: { id: 'snackbar.view', defaultMessage: 'View' },
view: { id: 'toast.view', defaultMessage: 'View' },
});
const reblog = (status: StatusEntity) =>
@ -222,7 +222,10 @@ const bookmark = (status: StatusEntity) =>
api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function(response) {
dispatch(importFetchedStatus(response.data));
dispatch(bookmarkSuccess(status, response.data));
dispatch(snackbar.success(messages.bookmarkAdded, messages.view, '/bookmarks'));
toast.success(messages.bookmarkAdded, {
actionLabel: messages.view,
actionLink: '/bookmarks',
});
}).catch(function(error) {
dispatch(bookmarkFail(status, error));
});
@ -235,7 +238,7 @@ const unbookmark = (status: StatusEntity) =>
api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => {
dispatch(importFetchedStatus(response.data));
dispatch(unbookmarkSuccess(status, response.data));
dispatch(snackbar.success(messages.bookmarkRemoved));
toast.success(messages.bookmarkRemoved);
}).catch(error => {
dispatch(unbookmarkFail(status, error));
});

Wyświetl plik

@ -1,8 +1,8 @@
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import api from '../api';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer';
import type { AxiosError } from 'axios';
@ -265,7 +265,7 @@ const fetchListSuggestions = (q: string) => (dispatch: AppDispatch, getState: ()
api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchListSuggestionsReady(q, data));
}).catch(error => dispatch(showAlertForError(error)));
}).catch(error => toast.showAlertForError(error));
};
const fetchListSuggestionsReady = (query: string, accounts: APIEntity[]) => ({

Wyświetl plik

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

Wyświetl plik

@ -4,10 +4,10 @@ import { defineMessages, IntlShape } from 'react-intl';
import { fetchAccountByUsername } from 'soapbox/actions/accounts';
import { deactivateUsers, deleteUsers, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin';
import { openModal } from 'soapbox/actions/modals';
import snackbar from 'soapbox/actions/snackbar';
import OutlineBox from 'soapbox/components/outline-box';
import { Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import toast from 'soapbox/toast';
import { isLocal } from 'soapbox/utils/accounts';
import type { AppDispatch, RootState } from 'soapbox/store';
@ -65,7 +65,7 @@ const deactivateUserModal = (intl: IntlShape, accountId: string, afterConfirm =
onConfirm: () => {
dispatch(deactivateUsers([accountId])).then(() => {
const message = intl.formatMessage(messages.userDeactivated, { acct });
dispatch(snackbar.success(message));
toast.success(message);
afterConfirm();
}).catch(() => {});
},
@ -105,7 +105,7 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () =
dispatch(deleteUsers([accountId])).then(() => {
const message = intl.formatMessage(messages.userDeleted, { acct });
dispatch(fetchAccountByUsername(acct));
dispatch(snackbar.success(message));
toast.success(message);
afterConfirm();
}).catch(() => {});
},
@ -147,7 +147,7 @@ const toggleStatusSensitivityModal = (intl: IntlShape, statusId: string, sensiti
onConfirm: () => {
dispatch(toggleStatusSensitivity(statusId, sensitive)).then(() => {
const message = intl.formatMessage(sensitive === false ? messages.statusMarkedSensitive : messages.statusMarkedNotSensitive, { acct });
dispatch(snackbar.success(message));
toast.success(message);
}).catch(() => {});
afterConfirm();
},
@ -168,7 +168,7 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = ()
onConfirm: () => {
dispatch(deleteStatus(statusId)).then(() => {
const message = intl.formatMessage(messages.statusDeleted, { acct });
dispatch(snackbar.success(message));
toast.success(message);
}).catch(() => {});
afterConfirm();
},

Wyświetl plik

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

Wyświetl plik

@ -1,3 +1,5 @@
import { getFeatures } from 'soapbox/utils/features';
import api, { getLinks } from '../api';
import type { AxiosError } from 'axios';
@ -18,10 +20,17 @@ const SCHEDULED_STATUS_CANCEL_FAIL = 'SCHEDULED_STATUS_CANCEL_FAIL';
const fetchScheduledStatuses = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (getState().status_lists.get('scheduled_statuses')?.isLoading) {
const state = getState();
if (state.status_lists.get('scheduled_statuses')?.isLoading) {
return;
}
const instance = state.instance;
const features = getFeatures(instance);
if (!features.scheduledStatuses) return;
dispatch(fetchScheduledStatusesRequest());
api(getState).get('/api/v1/scheduled_statuses').then(response => {

Wyświetl plik

@ -4,7 +4,7 @@
* @see module:soapbox/actions/auth
*/
import snackbar from 'soapbox/actions/snackbar';
import toast from 'soapbox/toast';
import { getLoggedInAccount } from 'soapbox/utils/auth';
import { parseVersion, TRUTHSOCIAL } from 'soapbox/utils/features';
import { normalizeUsername } from 'soapbox/utils/input';
@ -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 });
@ -152,7 +152,7 @@ const deleteAccount = (password: string) =>
if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure
dispatch({ type: DELETE_ACCOUNT_SUCCESS, response });
dispatch({ type: AUTH_LOGGED_OUT, account });
dispatch(snackbar.success(messages.loggedOut));
toast.success(messages.loggedOut);
}).catch(error => {
dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true });
throw error;

Wyświetl plik

@ -4,11 +4,9 @@ import { createSelector } from 'reselect';
import { v4 as uuid } from 'uuid';
import { patchMe } from 'soapbox/actions/me';
import toast from 'soapbox/toast';
import { isLoggedIn } from 'soapbox/utils/auth';
import { showAlertForError } from './alerts';
import snackbar from './snackbar';
import type { AppDispatch, RootState } from 'soapbox/store';
const SETTING_CHANGE = 'SETTING_CHANGE';
@ -49,7 +47,6 @@ const defaultSettings = ImmutableMap({
autoloadMore: true,
systemFont: false,
dyslexicFont: false,
demetricator: false,
isDeveloper: false,
@ -224,10 +221,10 @@ const saveSettingsImmediate = (opts?: SettingOpts) =>
dispatch({ type: SETTING_SAVE });
if (opts?.showAlert) {
dispatch(snackbar.success(messages.saveSuccess));
toast.success(messages.saveSuccess);
}
}).catch(error => {
dispatch(showAlertForError(error));
toast.showAlertForError(error);
});
};

Wyświetl plik

@ -1,50 +0,0 @@
import { ALERT_SHOW } from './alerts';
import type { MessageDescriptor } from 'react-intl';
export type SnackbarActionSeverity = 'info' | 'success' | 'error';
type SnackbarMessage = string | MessageDescriptor;
export type SnackbarAction = {
type: typeof ALERT_SHOW,
message: SnackbarMessage,
actionLabel?: SnackbarMessage,
actionLink?: string,
action?: () => void,
severity: SnackbarActionSeverity,
};
type SnackbarOpts = {
actionLabel?: SnackbarMessage,
actionLink?: string,
action?: () => void,
dismissAfter?: number | false,
};
export const show = (
severity: SnackbarActionSeverity,
message: SnackbarMessage,
opts?: SnackbarOpts,
): SnackbarAction => ({
type: ALERT_SHOW,
message,
severity,
...opts,
});
export const info = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) =>
show('info', message, { actionLabel, actionLink });
export const success = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) =>
show('success', message, { actionLabel, actionLink });
export const error = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) =>
show('error', message, { actionLabel, actionLink });
export default {
info,
success,
error,
show,
};

Wyświetl plik

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

Wyświetl plik

@ -17,6 +17,8 @@ const fetchTrendingStatuses = () =>
const instance = state.instance;
const features = getFeatures(instance);
if (!features.trendingStatuses && !features.trendingTruths) return;
dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST });
return api(getState).get(features.trendingTruths ? '/api/v1/truth/trending/truths' : '/api/v1/trends/statuses').then(({ data: statuses }) => {
dispatch(importFetchedStatuses(statuses));

Wyświetl plik

@ -32,13 +32,14 @@ export type Challenge = 'age' | 'sms' | 'email'
type Challenges = {
email?: 0 | 1,
sms?: number,
age?: number,
sms?: 0 | 1,
age?: 0 | 1,
}
type Verification = {
token?: string,
challenges?: Challenges,
challengeTypes?: Array<'age' | 'sms' | 'email'>
};
/**
@ -83,6 +84,18 @@ const fetchStoredChallenges = () => {
}
};
/**
* Fetch and return the state of the verification challenge types.
*/
const fetchStoredChallengeTypes = () => {
try {
const verification: Verification | null = fetchStoredVerification();
return verification!.challengeTypes;
} catch {
return null;
}
};
/**
* Update the verification object in local storage.
*
@ -131,7 +144,10 @@ function saveChallenges(challenges: Array<'age' | 'sms' | 'email'>) {
}
}
updateStorage({ challenges: currentChallenges });
updateStorage({
challenges: currentChallenges,
challengeTypes: challenges,
});
}
/**
@ -267,13 +283,29 @@ const confirmEmailVerification = (emailToken: string) =>
return api(getState).post('/api/v1/pepe/verify_email/confirm', { token: emailToken }, {
headers: { Authorization: `Bearer ${token}` },
})
.then(() => {
finishChallenge(EMAIL);
dispatchNextChallenge(dispatch);
.then((response) => {
updateStorageFromEmailConfirmation(dispatch, response.data.token);
})
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
const updateStorageFromEmailConfirmation = (dispatch: AppDispatch, token: string) => {
const challengeTypes = fetchStoredChallengeTypes();
if (!challengeTypes) {
return;
}
const indexOfEmail = challengeTypes.indexOf('email');
const challenges: Challenges = {};
challengeTypes?.forEach((challengeType, idx) => {
const value = idx <= indexOfEmail ? 1 : 0;
challenges[challengeType] = value;
});
updateStorage({ token, challengeTypes, challenges });
dispatchNextChallenge(dispatch);
};
const postEmailVerification = () =>
(dispatch: AppDispatch) => {
finishChallenge(EMAIL);

Wyświetl plik

@ -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 : '';
@ -62,7 +62,6 @@ export const baseClient = (accessToken?: string | null, baseURL: string = ''): A
headers: Object.assign(accessToken ? {
'Authorization': `Bearer ${accessToken}`,
} : {}),
transformResponse: [maybeParseJSON],
});
};

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,5 +1,5 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
@ -9,6 +9,7 @@ import { useAppSelector, useOnScreen } 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';
@ -17,14 +18,21 @@ import type { Account as AccountEntity } from 'soapbox/types/entities';
interface IInstanceFavicon {
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);
@ -34,7 +42,11 @@ 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}>
<button
className='w-4 h-4 flex-none focus:ring-primary-500 focus:ring-2 focus:ring-offset-2'
onClick={handleClick}
disabled={disabled}
>
<img src={account.favicon} alt='' title={account.domain} className='w-full max-h-full' />
</button>
);
@ -42,13 +54,19 @@ const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account }) => {
interface IProfilePopper {
condition: boolean,
wrapper: (children: any) => React.ReactElement<any, any>
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 {
export interface IAccount {
account: AccountEntity,
action?: React.ReactElement,
actionAlignment?: 'center' | 'top',
@ -142,6 +160,8 @@ const Account = ({
return null;
};
const intl = useIntl();
React.useEffect(() => {
const style: React.CSSProperties = {};
const actionWidth = actionRef.current?.clientWidth || 0;
@ -214,16 +234,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}>
<Text theme='muted' size='sm' truncate>@{username}</Text>
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
{account.favicon && (
<InstanceFavicon account={account} />
<InstanceFavicon account={account} disabled={!withLinkToProfile} />
)}
{(timestamp) ? (

Wyświetl plik

@ -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,6 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
setAccountIds(ImmutableOrderedSet(accountIds));
})
.catch(noOp);
}, 900, { leading: true, trailing: true }), [limit]);
const handleChange: React.ChangeEventHandler<HTMLInputElement> = e => {

Wyświetl plik

@ -46,7 +46,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
getFirstIndex = () => {
return this.props.autoSelect ? 0 : -1;
}
};
state = {
suggestionsHidden: true,
@ -76,7 +76,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 +145,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 +161,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 +172,7 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
setInput = (c: HTMLInputElement) => {
this.input = c;
}
};
renderSuggestion = (suggestion: AutoSuggestion, i: number) => {
const { selectedSuggestion } = this.state;
@ -209,21 +209,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;
@ -268,7 +268,8 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
if (isRtl(value)) {
// TODO: convert to functional component and use `useLocale()` hook instead of checking placeholder text.
if (isRtl(value) || (!value && placeholder && isRtl(placeholder))) {
style.direction = 'rtl';
}

Wyświetl plik

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

Wyświetl plik

@ -30,6 +30,7 @@ interface IAutosuggesteTextarea {
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;
@ -208,7 +210,7 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
{inner}
</div>
);
}
};
setPortalPosition() {
if (!this.textarea) {
@ -229,7 +231,8 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr', minRows: 10 };
if (isRtl(value)) {
// TODO: convert to functional component and use `useLocale()` hook instead of checking placeholder text.
if (isRtl(value) || (!value && placeholder && isRtl(placeholder))) {
style.direction = 'rtl';
}

Wyświetl plik

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

Wyświetl plik

@ -70,7 +70,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
<div className='flex flex-col gap-2'>
<div className='flex items-center justify-between'>
<IconButton
className='datepicker__button'
className='datepicker__button rtl:rotate-180'
src={require('@tabler/icons/chevron-left.svg')}
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
@ -79,7 +79,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
/>
{intl.formatDate(date, { month: 'long' })}
<IconButton
className='datepicker__button'
className='datepicker__button rtl:rotate-180'
src={require('@tabler/icons/chevron-right.svg')}
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
@ -89,7 +89,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
</div>
<div className='flex items-center justify-between'>
<IconButton
className='datepicker__button'
className='datepicker__button rtl:rotate-180'
src={require('@tabler/icons/chevron-left.svg')}
onClick={decreaseYear}
disabled={prevYearButtonDisabled}
@ -98,7 +98,7 @@ const BirthdayInput: React.FC<IBirthdayInput> = ({ value, onChange, required })
/>
{intl.formatDate(date, { year: 'numeric' })}
<IconButton
className='datepicker__button'
className='datepicker__button rtl:rotate-180'
src={require('@tabler/icons/chevron-right.svg')}
onClick={increaseYear}
disabled={nextYearButtonDisabled}

Wyświetl plik

@ -16,6 +16,7 @@ interface IDisplayName {
account: Account
withSuffix?: boolean
withDate?: boolean
children?: React.ReactNode
}
const DisplayName: React.FC<IDisplayName> = ({ account, children, withSuffix = true, withDate = false }) => {

Wyświetl plik

@ -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';
@ -16,7 +16,7 @@ interface IDomain {
}
const Domain: React.FC<IDomain> = ({ domain }) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
// const onBlockDomain = () => {

Wyświetl plik

@ -64,7 +64,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
if (this.node && !this.node.contains(e.target as Node)) {
this.props.onClose();
}
}
};
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
@ -84,11 +84,11 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
setRef: React.RefCallback<HTMLDivElement> = c => {
this.node = c;
}
};
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
this.focusedItem = c;
}
};
handleKeyDown = (e: KeyboardEvent) => {
if (!this.node) return;
@ -127,13 +127,13 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
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'));
@ -152,7 +152,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
e.preventDefault();
action(e);
}
}
};
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
@ -166,13 +166,13 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
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) {
@ -303,7 +303,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
}
}
};
handleClose = () => {
if (this.activeElement && this.activeElement === this.target) {
@ -314,13 +314,13 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
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) {
@ -329,7 +329,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
this.handleMouseDown(e);
break;
}
}
};
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
switch (e.key) {
@ -340,7 +340,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
e.preventDefault();
break;
}
}
};
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
@ -358,21 +358,21 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
} 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;

Wyświetl plik

@ -1,12 +1,11 @@
import classNames from 'clsx';
import React, { useState, useEffect, useRef } from 'react';
import { usePopper } from 'react-popper';
import { useDispatch } from 'react-redux';
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
import { openModal } from 'soapbox/actions/modals';
import { EmojiSelector } from 'soapbox/components/ui';
import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is-mobile';
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
@ -17,7 +16,7 @@ interface IEmojiButtonWrapper {
/** Provides emoji reaction functionality to the underlying button component */
const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const ownAccount = useOwnAccount();
const status = useAppSelector(state => state.statuses.get(statusId));
const soapboxConfig = useSoapboxConfig();

Wyświetl plik

@ -28,7 +28,7 @@ class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
onReact: () => { },
onUnfocus: () => { },
visible: false,
}
};
node?: HTMLDivElement = undefined;
@ -38,7 +38,7 @@ class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) {
onUnfocus();
}
}
};
_selectPreviousEmoji = (i: number): void => {
if (!this.node) return;
@ -85,7 +85,7 @@ class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
onUnfocus();
break;
}
}
};
handleReact = (emoji: string) => (): void => {
const { onReact, focused, onUnfocus } = this.props;
@ -95,7 +95,7 @@ class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
if (focused) {
onUnfocus();
}
}
};
handlers = {
open: () => { },
@ -103,7 +103,7 @@ class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
setRef = (c: HTMLDivElement): void => {
this.node = c;
}
};
render() {
const { visible, focused, allowedEmoji, onReact } = this.props;

Wyświetl plik

@ -26,7 +26,9 @@ const mapStateToProps = (state: RootState) => {
};
};
type Props = ReturnType<typeof mapStateToProps>;
interface Props extends ReturnType<typeof mapStateToProps> {
children: React.ReactNode
}
type State = {
hasError: boolean,
@ -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;
@ -213,4 +215,4 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
}
export default connect(mapStateToProps)(ErrorBoundary as any);
export default connect(mapStateToProps)(ErrorBoundary);

Wyświetl plik

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

Wyświetl plik

@ -18,6 +18,7 @@ interface IHoverRefWrapper {
accountId: string,
inline?: boolean,
className?: string,
children: React.ReactNode,
}
/** Makes a profile hover card appear when the wrapped element is hovered. */

Wyświetl plik

@ -17,6 +17,7 @@ interface IHoverStatusWrapper {
statusId: any,
inline: boolean,
className?: string,
children: React.ReactNode,
}
/** Makes a status hover card appear when the wrapped element is hovered. */

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,100 @@
import classNames 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 = classNames(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;

Wyświetl plik

@ -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 classNames from 'clsx';
import React from 'react';
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
import ForkAwesomeIcon, { IForkAwesomeIcon } from './fork-awesome-icon';
import SvgIcon, { ISvgIcon } from './svg-icon';
export interface IIcon extends React.HTMLAttributes<HTMLDivElement> {
src: string,
id?: string,
alt?: string,
className?: string,
}
export type IIcon = IForkAwesomeIcon | ISvgIcon;
const Icon: React.FC<IIcon> = (props) => {
if ((props as ISvgIcon).src) {
const { src, ...rest } = (props as ISvgIcon);
return <SvgIcon src={src} {...rest} />;
} else {
const { id, fixedWidth, ...rest } = (props as IForkAwesomeIcon);
return <ForkAwesomeIcon id={id} fixedWidth={fixedWidth} {...rest} />;
}
const Icon: React.FC<IIcon> = ({ src, alt, className, ...rest }) => {
return (
<div
className={classNames('svg-icon', className)}
{...rest}
>
<InlineSVG src={src} title={alt} loader={<></>} />
</div>
);
};
export default Icon;

Wyświetl plik

@ -7,7 +7,11 @@ 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>
);
@ -17,6 +21,7 @@ interface IListItem {
onClick?(): void,
onSelect?(): void
isSelected?: boolean
children?: React.ReactNode
}
const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelect, isSelected }) => {
@ -70,7 +75,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
<HStack space={1} alignItems='center' className='text-gray-700 dark:text-gray-600'>
{children}
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1' />
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1 rtl:rotate-180' />
</HStack>
) : null}

Wyświetl plik

@ -1,11 +1,11 @@
import classNames from 'clsx';
import React, { useState, useRef, useEffect } from 'react';
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';
@ -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()) {
@ -171,7 +172,7 @@ const Item: React.FC<IItem> = ({
>
<StillImage
className='w-full h-full'
src={attachment.url}
src={mediaPreview ? attachment.preview_url : attachment.url}
alt={attachment.description}
letterboxed={letterboxed}
showExt
@ -532,7 +533,7 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
/>
));
useEffect(() => {
useLayoutEffect(() => {
if (node.current) {
const { offsetWidth } = node.current;

Wyświetl plik

@ -11,13 +11,12 @@ 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' },
});
@ -43,6 +42,7 @@ interface IModalRoot {
onCancel?: () => void,
onClose: (type?: ModalType) => void,
type: ModalType,
children: React.ReactNode,
}
const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type }) => {
@ -55,7 +55,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
const ref = useRef<HTMLDivElement>(null);
const activeElement = useRef<HTMLDivElement | null>(revealed ? document.activeElement as HTMLDivElement | null : null);
const modalHistoryKey = useRef<number>();
const unlistenHistory = useRef<UnregisterCallback>();
const unlistenHistory = useRef<ReturnType<typeof history.listen>>();
const prevChildren = usePrevious(children);
const prevType = usePrevious(type);
@ -80,10 +80,10 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
icon: require('@tabler/icons/trash.svg'),
heading: isEditing
? <FormattedMessage id='confirmations.cancel_editing.heading' defaultMessage='Cancel post editing' />
: <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
: <FormattedMessage id='confirmations.cancel.heading' defaultMessage='Discard post' />,
message: isEditing
? <FormattedMessage id='confirmations.cancel_editing.message' defaultMessage='Are you sure you want to cancel editing this post? All changes will be lost.' />
: <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
: <FormattedMessage id='confirmations.cancel.message' defaultMessage='Are you sure you want to cancel creating this post?' />,
confirm: intl.formatMessage(messages.confirm),
onConfirm: () => {
dispatch(closeModal('COMPOSE'));
@ -129,10 +129,10 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
});
};
const handleKeyDown = useCallback((e) => {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Tab') {
const focusable = Array.from(ref.current!.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
const index = focusable.indexOf(e.target);
const index = focusable.indexOf(e.target as Element);
let element;
@ -152,8 +152,10 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
const handleModalOpen = () => {
modalHistoryKey.current = Date.now();
unlistenHistory.current = history.listen((_, action) => {
if (action === 'POP') {
unlistenHistory.current = history.listen(({ state }, action) => {
if (!(state as any)?.soapboxModalKey) {
onClose();
} else if (action === 'POP') {
handleOnClose();
if (onCancel) onCancel();
@ -165,11 +167,9 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
if (unlistenHistory.current) {
unlistenHistory.current();
}
if (!['FAVOURITES', 'MENTIONS', 'REACTIONS', 'REBLOGS', 'MEDIA'].includes(type)) {
const { state } = history.location;
if (state && (state as any).soapboxModalKey === modalHistoryKey.current) {
history.goBack();
}
const { state } = history.location;
if (state && (state as any).soapboxModalKey === modalHistoryKey.current) {
history.goBack();
}
};
@ -221,7 +221,7 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
ensureHistoryBuffer();
}
});
}, [children]);
if (!visible) {
return (
@ -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 dark:bg-gray-700/90 backdrop-blur'
onClick={handleOnClose}
/>
<div
role='dialog'
className={classNames({
'my-2 mx-auto relative pointer-events-none flex items-center': true,
'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>

Wyświetl plik

@ -7,6 +7,7 @@ interface IPullToRefresh {
onRefresh?: () => Promise<any>;
refreshingContent?: JSX.Element | string;
pullingContent?: JSX.Element | string;
children: React.ReactNode;
}
/**

Wyświetl plik

@ -24,13 +24,13 @@ type SavedScrollPosition = {
// NOTE: It's crucial to space lists with **padding** instead of margin!
// Pass an `itemClassName` like `pb-3`, NOT a `space-y-3` className
// https://virtuoso.dev/troubleshooting#list-does-not-scroll-to-the-bottom--items-jump-around
const Item: Components<Context>['Item'] = ({ context, ...rest }) => (
const Item: Components<JSX.Element, Context>['Item'] = ({ context, ...rest }) => (
<div className={context?.itemClassName} {...rest} />
);
/** Custom Virtuoso List component for the outer container. */
// Ensure the className winds up here
const List: Components<Context>['List'] = React.forwardRef((props, ref) => {
const List: Components<JSX.Element, Context>['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} className={context?.listClassName} {...rest} />;
});

Wyświetl plik

@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/interactive-supports-focus */
import classNames from 'clsx';
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link, NavLink } from 'react-router-dom';
import { fetchOwnAccounts, logOut, switchAccount } from 'soapbox/actions/auth';
@ -10,7 +10,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
import Account from 'soapbox/components/account';
import { Stack } from 'soapbox/components/ui';
import ProfileStats from 'soapbox/features/ui/components/profile-stats';
import { useAppSelector, useFeatures } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors';
import { Divider, HStack, Icon, IconButton, Text } from './ui';
@ -28,7 +28,6 @@ const messages = defineMessages({
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
soapboxConfig: { id: 'navigation_bar.soapbox_config', defaultMessage: 'Soapbox config' },
importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
accountMigration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' },
accountAliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
@ -81,7 +80,7 @@ const getOtherAccounts = makeGetOtherAccounts();
const SidebarMenu: React.FC = (): JSX.Element | null => {
const intl = useIntl();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const features = useFeatures();
const getAccount = makeGetAccount();
@ -137,227 +136,234 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
return (
<div
className={classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
})}
aria-expanded={sidebarOpen}
className={
classNames({
'z-[1000]': sidebarOpen,
hidden: !sidebarOpen,
})
}
>
<div
className={classNames({
'fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90 z-1000': true,
'hidden': !sidebarOpen,
})}
className='fixed inset-0 bg-gray-500/90 dark:bg-gray-700/90'
role='button'
onClick={handleClose}
>
<IconButton
title={intl.formatMessage(messages.close)}
onClick={handleClose}
src={require('@tabler/icons/x.svg')}
ref={closeButtonRef}
iconClassName='h-6 w-6'
className='fixed top-5 right-5 text-gray-600 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
/>
</div>
/>
<div className='sidebar-menu'>
<div className='relative overflow-y-scroll overflow-auto h-full w-full'>
<div className='p-4'>
<Stack space={4}>
<Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} />
</Link>
<ProfileStats
account={account}
onClickHandler={handleClose}
/>
<div className='fixed inset-0 z-[1000] flex'>
<div
className={
classNames({
'flex flex-col flex-1 bg-white dark:bg-primary-900 -translate-x-full rtl:translate-x-full w-full max-w-xs': true,
'!translate-x-0': sidebarOpen,
})
}
>
<IconButton
title={intl.formatMessage(messages.close)}
onClick={handleClose}
src={require('@tabler/icons/x.svg')}
ref={closeButtonRef}
iconClassName='h-6 w-6'
className='absolute top-0 right-0 -mr-11 mt-2 text-gray-600 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
/>
<div className='relative overflow-y-scroll overflow-auto h-full w-full'>
<div className='p-4'>
<Stack space={4}>
<Divider />
<Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} />
</Link>
<SidebarLink
to={`/@${account.acct}`}
icon={require('@tabler/icons/user.svg')}
text={intl.formatMessage(messages.profile)}
onClick={onClose}
<ProfileStats
account={account}
onClickHandler={handleClose}
/>
{(account.locked || followRequestsCount > 0) && (
<SidebarLink
to='/follow_requests'
icon={require('@tabler/icons/user-plus.svg')}
text={intl.formatMessage(messages.followRequests)}
onClick={onClose}
/>
)}
{features.bookmarks && (
<SidebarLink
to='/bookmarks'
icon={require('@tabler/icons/bookmark.svg')}
text={intl.formatMessage(messages.bookmarks)}
onClick={onClose}
/>
)}
{features.groups && (
<SidebarLink
to='/groups'
icon={require('@tabler/icons/circles.svg')}
text={intl.formatMessage(messages.groups)}
onClick={onClose}
/>
)}
{features.lists && (
<SidebarLink
to='/lists'
icon={require('@tabler/icons/list.svg')}
text={intl.formatMessage(messages.lists)}
onClick={onClose}
/>
)}
{features.events && (
<SidebarLink
to='/events'
icon={require('@tabler/icons/calendar-event.svg')}
text={intl.formatMessage(messages.events)}
onClick={onClose}
/>
)}
{settings.get('isDeveloper') && (
<SidebarLink
to='/developers'
icon={require('@tabler/icons/code.svg')}
text={intl.formatMessage(messages.developers)}
onClick={onClose}
/>
)}
{features.publicTimeline && <>
<Stack space={4}>
<Divider />
<SidebarLink
to='/timeline/local'
icon={features.federating ? require('@tabler/icons/affiliate.svg') : require('@tabler/icons/world.svg')}
text={features.federating ? <FormattedMessage id='tabs_bar.local' defaultMessage='Local' /> : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
to={`/@${account.acct}`}
icon={require('@tabler/icons/user.svg')}
text={intl.formatMessage(messages.profile)}
onClick={onClose}
/>
{(account.locked || followRequestsCount > 0) && (
<SidebarLink
to='/follow_requests'
icon={require('@tabler/icons/user-plus.svg')}
text={intl.formatMessage(messages.followRequests)}
onClick={onClose}
/>
)}
{features.bookmarks && (
<SidebarLink
to='/bookmarks'
icon={require('@tabler/icons/bookmark.svg')}
text={intl.formatMessage(messages.bookmarks)}
onClick={onClose}
/>
)}
{features.groups && (
<SidebarLink
to='/groups'
icon={require('@tabler/icons/circles.svg')}
text={intl.formatMessage(messages.groups)}
onClick={onClose}
/>
)}
{features.lists && (
<SidebarLink
to='/lists'
icon={require('@tabler/icons/list.svg')}
text={intl.formatMessage(messages.lists)}
onClick={onClose}
/>
)}
{features.events && (
<SidebarLink
to='/events'
icon={require('@tabler/icons/calendar-event.svg')}
text={intl.formatMessage(messages.events)}
onClick={onClose}
/>
)}
{settings.get('isDeveloper') && (
<SidebarLink
to='/developers'
icon={require('@tabler/icons/code.svg')}
text={intl.formatMessage(messages.developers)}
onClick={onClose}
/>
)}
{features.publicTimeline && <>
<Divider />
<SidebarLink
to='/timeline/local'
icon={features.federating ? require('@tabler/icons/affiliate.svg') : require('@tabler/icons/world.svg')}
text={features.federating ? <FormattedMessage id='tabs_bar.local' defaultMessage='Local' /> : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/timeline/fediverse'
icon={require('@tabler/icons/topology-star-ring-3.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
onClick={onClose}
/>
)}
</>}
<Divider />
<SidebarLink
to='/blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.blocks)}
onClick={onClose}
/>
<SidebarLink
to='/mutes'
icon={require('@tabler/icons/circle-x.svg')}
text={intl.formatMessage(messages.mutes)}
onClick={onClose}
/>
<SidebarLink
to='/settings/preferences'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.preferences)}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/timeline/fediverse'
icon={require('@tabler/icons/topology-star-ring-3.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
to='/domain_blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.domainBlocks)}
onClick={onClose}
/>
)}
</>}
<Divider />
<SidebarLink
to='/blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.blocks)}
onClick={onClose}
/>
<SidebarLink
to='/mutes'
icon={require('@tabler/icons/circle-x.svg')}
text={intl.formatMessage(messages.mutes)}
onClick={onClose}
/>
<SidebarLink
to='/settings/preferences'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.preferences)}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/domain_blocks'
icon={require('@tabler/icons/ban.svg')}
text={intl.formatMessage(messages.domainBlocks)}
onClick={onClose}
/>
)}
{features.filters && (
<SidebarLink
to='/filters'
icon={require('@tabler/icons/filter.svg')}
text={intl.formatMessage(messages.filters)}
onClick={onClose}
/>
)}
{account.admin && (
<SidebarLink
to='/soapbox/config'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.soapboxConfig)}
onClick={onClose}
/>
)}
{features.import && (
<SidebarLink
to='/settings/import'
icon={require('@tabler/icons/cloud-upload.svg')}
text={intl.formatMessage(messages.importData)}
onClick={onClose}
/>
)}
<Divider />
<SidebarLink
to='/logout'
icon={require('@tabler/icons/logout.svg')}
text={intl.formatMessage(messages.logout)}
onClick={onClickLogOut}
/>
<Divider />
<Stack space={4}>
<button type='button' onClick={handleSwitcherClick} className='py-1'>
<HStack alignItems='center' justifyContent='between'>
<Text tag='span'>
<FormattedMessage id='profile_dropdown.switch_account' defaultMessage='Switch accounts' />
</Text>
<Icon
src={require('@tabler/icons/chevron-down.svg')}
className={classNames('w-4 h-4 text-gray-900 dark:text-gray-100 transition-transform', {
'rotate-180': switcher,
})}
/>
</HStack>
</button>
{switcher && (
<div className='border-t-2 border-gray-100 dark:border-gray-800 border-solid'>
{otherAccounts.map(account => renderAccount(account))}
<NavLink className='flex items-center py-2 space-x-1' to='/login/add' onClick={handleClose}>
<Icon className='text-primary-500 w-4 h-4' src={require('@tabler/icons/plus.svg')} />
<Text size='sm' weight='medium'>{intl.formatMessage(messages.addAccount)}</Text>
</NavLink>
</div>
{features.filters && (
<SidebarLink
to='/filters'
icon={require('@tabler/icons/filter.svg')}
text={intl.formatMessage(messages.filters)}
onClick={onClose}
/>
)}
{account.admin && (
<SidebarLink
to='/soapbox/config'
icon={require('@tabler/icons/settings.svg')}
text={intl.formatMessage(messages.soapboxConfig)}
onClick={onClose}
/>
)}
<Divider />
<SidebarLink
to='/logout'
icon={require('@tabler/icons/logout.svg')}
text={intl.formatMessage(messages.logout)}
onClick={onClickLogOut}
/>
<Divider />
<Stack space={4}>
<button type='button' onClick={handleSwitcherClick} className='py-1'>
<HStack alignItems='center' justifyContent='between'>
<Text tag='span'>
<FormattedMessage id='profile_dropdown.switch_account' defaultMessage='Switch accounts' />
</Text>
<Icon
src={require('@tabler/icons/chevron-down.svg')}
className={classNames('w-4 h-4 text-gray-900 dark:text-gray-100 transition-transform', {
'rotate-180': switcher,
})}
/>
</HStack>
</button>
{switcher && (
<div className='border-t-2 border-gray-100 dark:border-gray-800 border-solid'>
{otherAccounts.map(account => renderAccount(account))}
<NavLink className='flex items-center py-2 space-x-1' to='/login/add' onClick={handleClose}>
<Icon className='text-primary-500 w-4 h-4' src={require('@tabler/icons/plus.svg')} />
<Text size='sm' weight='medium'>{intl.formatMessage(messages.addAccount)}</Text>
</NavLink>
</div>
)}
</Stack>
</Stack>
</Stack>
</Stack>
</div>
</div>
</div>
{/* Dummy element to keep Close Icon visible */}
<div
aria-hidden
className='w-14 flex-shrink-0'
onClick={handleClose}
/>
</div>
</div>
);

Wyświetl plik

@ -1,6 +1,7 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Stack } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useStatContext } from 'soapbox/contexts/stat-context';
import ComposeButton from 'soapbox/features/ui/components/compose-button';
@ -109,8 +110,8 @@ const SidebarNavigation = () => {
};
return (
<div>
<div className='flex flex-col space-y-2'>
<Stack space={4}>
<Stack space={2}>
<SidebarNavigationLink
to='/'
icon={require('@tabler/icons/home.svg')}
@ -191,12 +192,12 @@ const SidebarNavigation = () => {
/>
</DropdownMenu>
)}
</div>
</Stack>
{account && (
<ComposeButton />
)}
</div>
</Stack>
);
};

Wyświetl plik

@ -4,7 +4,6 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { blockAccount } from 'soapbox/actions/accounts';
import { showAlertForError } from 'soapbox/actions/alerts';
import { launchChat } from 'soapbox/actions/chats';
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
import { editEvent } from 'soapbox/actions/events';
@ -20,6 +19,7 @@ import StatusActionButton from 'soapbox/components/status-action-button';
import { HStack } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { isLocal, isRemote } from 'soapbox/utils/accounts';
import copy from 'soapbox/utils/copy';
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts';
@ -268,7 +268,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const handleEmbed = () => {
dispatch(openModal('EMBED', {
url: status.get('url'),
onError: (error: any) => dispatch(showAlertForError(error)),
onError: (error: any) => toast.showAlertForError(error),
}));
};

Wyświetl plik

@ -79,8 +79,8 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
ref={ref}
type='button'
className={classNames(
'flex items-center p-1 rounded-full',
'text-gray-600',
'flex items-center p-1 rounded-full rtl:space-x-reverse',
'text-gray-600 hover:text-gray-600 dark:hover:text-white',
'bg-white dark:bg-transparent',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:ring-offset-0',
{

Wyświetl plik

@ -1,5 +1,5 @@
import classNames from 'clsx';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import React, { useState, useRef, useLayoutEffect, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
@ -26,7 +26,7 @@ interface IReadMoreButton {
const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick }) => (
<button className='flex items-center text-gray-900 dark:text-gray-300 border-0 bg-transparent p-0 pt-2 hover:underline active:underline' onClick={onClick}>
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
<Icon className='inline-block h-5 w-5' src={require('@tabler/icons/chevron-right.svg')} fixedWidth />
<Icon className='inline-block h-5 w-5' src={require('@tabler/icons/chevron-right.svg')} />
</button>
);
@ -103,7 +103,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
const maybeSetCollapsed = (): void => {
if (!node.current) return;
if (collapsable && onClick && !collapsed && status.spoiler_text.length === 0) {
if (collapsable && onClick && !collapsed) {
if (node.current.clientHeight > MAX_HEIGHT) {
setCollapsed(true);
}
@ -119,7 +119,7 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
}
};
useEffect(() => {
useLayoutEffect(() => {
maybeSetCollapsed();
maybeSetOnlyEmoji();
updateStatusLinks();

Wyświetl plik

@ -79,6 +79,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
defaultMessage='<hover>Replying to</hover> {accounts}'
values={{
accounts: <FormattedList type='conjunction' value={accounts} />,
// @ts-ignore wtf?
hover: (children: React.ReactNode) => {
if (hoverable) {
return (

Wyświetl plik

@ -2,7 +2,7 @@ import classNames from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { HotKeys } from 'react-hotkeys';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
@ -21,7 +21,8 @@ import StatusContent from './status-content';
import StatusMedia from './status-media';
import StatusReplyMentions from './status-reply-mentions';
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
import { Card, HStack, Stack, Text } from './ui';
import StatusInfo from './statuses/status-info';
import { Card, Stack, Text } from './ui';
import type {
Account as AccountEntity,
@ -38,6 +39,7 @@ const messages = defineMessages({
export interface IStatus {
id?: string,
avatarSize?: number,
status: StatusEntity,
onClick?: () => void,
muted?: boolean,
@ -58,6 +60,8 @@ export interface IStatus {
const Status: React.FC<IStatus> = (props) => {
const {
status,
accountAction,
avatarSize = 42,
focusable = true,
hoverable = true,
onClick,
@ -87,8 +91,9 @@ const Status: React.FC<IStatus> = (props) => {
const [minHeight, setMinHeight] = useState(208);
const actualStatus = getActualStatus(status);
const isReblog = status.reblog && typeof status.reblog === 'object';
const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`;
const group = actualStatus.group as GroupEntity | null;
// Track height changes we know about to compensate scrolling.
useEffect(() => {
@ -204,14 +209,76 @@ const Status: React.FC<IStatus> = (props) => {
firstEmoji?.focus();
};
const renderStatusInfo = () => {
if (isReblog) {
return (
<StatusInfo
avatarSize={avatarSize}
to={`/@${status.getIn(['account', 'acct'])}`}
icon={<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />}
text={
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: (
<bdi className='truncate pr-1 rtl:pl-1'>
<strong
className='text-gray-800 dark:text-gray-200'
dangerouslySetInnerHTML={{
__html: String(status.getIn(['account', 'display_name_html'])),
}}
/>
</bdi>
),
}}
/>
}
/>
);
} else if (featured) {
return (
<StatusInfo
avatarSize={avatarSize}
icon={<Icon src={require('@tabler/icons/pinned.svg')} className='text-gray-600 dark:text-gray-400' />}
text={
<Text size='xs' theme='muted' weight='medium'>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
</Text>
}
/>
);
} else if (showGroup && group) {
return (
<StatusInfo
avatarSize={avatarSize}
to={`/groups/${group.id}`}
icon={<Icon src={require('@tabler/icons/circles.svg')} className='text-gray-600 dark:text-gray-400' />}
text={
<Text size='xs' theme='muted' weight='medium'>
<FormattedMessage
id='status.group'
defaultMessage='Posted in {group}'
values={{ group: (
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
) }}
/>
</Text>
}
/>
);
}
};
if (!status) return null;
let rebloggedByText, reblogElement, reblogElementMobile;
if (hidden) {
return (
<div ref={node}>
{actualStatus.getIn(['account', 'display_name']) || actualStatus.getIn(['account', 'username'])}
{actualStatus.content}
<>
{actualStatus.getIn(['account', 'display_name']) || actualStatus.getIn(['account', 'username'])}
{actualStatus.content}
</>
</div>
);
}
@ -231,55 +298,8 @@ const Status: React.FC<IStatus> = (props) => {
);
}
let rebloggedByText;
if (status.reblog && typeof status.reblog === 'object') {
const displayNameHtml = { __html: String(status.getIn(['account', 'display_name_html'])) };
reblogElement = (
<Link
to={`/@${status.getIn(['account', 'acct'])}`}
onClick={(event) => event.stopPropagation()}
className='hidden sm:flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline'
>
<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />
<HStack alignItems='center'>
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi className='max-w-[100px] truncate pr-1'>
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={displayNameHtml} />
</bdi>,
}}
/>
</HStack>
</Link>
);
reblogElementMobile = (
<div className='pb-5 -mt-2 sm:hidden truncate'>
<Link
to={`/@${status.getIn(['account', 'acct'])}`}
onClick={(event) => event.stopPropagation()}
className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline'
>
<Icon src={require('@tabler/icons/repeat.svg')} className='text-green-600' />
<span>
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: <bdi>
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={displayNameHtml} />
</bdi>,
}}
/>
</span>
</Link>
</div>
);
rebloggedByText = intl.formatMessage(
messages.reblogged_by,
{ name: String(status.getIn(['account', 'acct'])) },
@ -300,8 +320,6 @@ const Status: React.FC<IStatus> = (props) => {
}
}
const group = actualStatus.group as GroupEntity | null;
const handlers = muted ? undefined : {
reply: handleHotkeyReply,
favourite: handleHotkeyFavourite,
@ -317,8 +335,6 @@ const Status: React.FC<IStatus> = (props) => {
react: handleHotkeyReact,
};
const accountAction = props.accountAction || reblogElement;
const isUnderReview = actualStatus.visibility === 'self';
const isSensitive = actualStatus.hidden;
@ -333,21 +349,9 @@ const Status: React.FC<IStatus> = (props) => {
onClick={handleClick}
role='link'
>
{featured && (
<div className='pt-4 px-4'>
<HStack alignItems='center' space={1}>
<Icon src={require('@tabler/icons/pinned.svg')} className='text-gray-600 dark:text-gray-400' />
<Text size='sm' theme='muted' weight='medium'>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
</Text>
</HStack>
</div>
)}
<Card
variant={variant}
className={classNames('status__wrapper', `status-${actualStatus.visibility}`, {
className={classNames('status__wrapper space-y-4', `status-${actualStatus.visibility}`, {
'py-6 sm:p-5': variant === 'rounded',
'status-reply': !!status.in_reply_to_id,
muted,
@ -355,42 +359,21 @@ const Status: React.FC<IStatus> = (props) => {
})}
data-id={status.id}
>
{showGroup && group && (
<div className='mb-4'>
<HStack alignItems='center' space={1}>
<Icon src={require('@tabler/icons/circles.svg')} className='text-gray-600 dark:text-gray-400' />
{renderStatusInfo()}
<Text size='sm' theme='muted' weight='medium'>
<FormattedMessage
id='status.group'
defaultMessage='Posted in {group}'
values={{ group: (
<Link className='hover:underline' to={`/groups/${group.id}`} onClick={(e) => e.stopPropagation()}>
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
</Link>
) }}
/>
</Text>
</HStack>
</div>
)}
{reblogElementMobile}
<div className='mb-4'>
<AccountContainer
key={String(actualStatus.getIn(['account', 'id']))}
id={String(actualStatus.getIn(['account', 'id']))}
timestamp={actualStatus.created_at}
timestampUrl={statusUrl}
action={accountAction}
hideActions={!accountAction}
showEdit={!!actualStatus.edited_at}
showProfileHoverCard={hoverable}
withLinkToProfile={hoverable}
approvalStatus={actualStatus.approval_status}
/>
</div>
<AccountContainer
key={String(actualStatus.getIn(['account', 'id']))}
id={String(actualStatus.getIn(['account', 'id']))}
timestamp={actualStatus.created_at}
timestampUrl={statusUrl}
action={accountAction}
hideActions={!accountAction}
showEdit={!!actualStatus.edited_at}
showProfileHoverCard={hoverable}
withLinkToProfile={hoverable}
approvalStatus={actualStatus.approval_status}
avatarSize={avatarSize}
/>
<div className='status__content-wrapper'>
<StatusReplyMentions status={actualStatus} hoverable={hoverable} />

Wyświetl plik

@ -92,7 +92,7 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
return (
<div
className={classNames('absolute z-40', {
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center items-center': !visible,
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center': !visible,
'bg-gray-800/75 inset-0': !visible,
'bottom-1 right-1': visible,
})}
@ -107,64 +107,66 @@ const SensitiveContentOverlay = React.forwardRef<HTMLDivElement, ISensitiveConte
size='sm'
/>
) : (
<div className='text-center w-3/4 mx-auto space-y-4' ref={ref}>
<div className='space-y-1'>
<Text theme='white' weight='semibold'>
{intl.formatMessage(isUnderReview ? messages.underReviewTitle : messages.sensitiveTitle)}
</Text>
<div className='flex justify-center items-center max-h-screen'>
<div className='text-center w-3/4 mx-auto space-y-4' ref={ref}>
<div className='space-y-1'>
<Text theme='white' weight='semibold'>
{intl.formatMessage(isUnderReview ? messages.underReviewTitle : messages.sensitiveTitle)}
</Text>
<Text theme='white' size='sm' weight='medium'>
{intl.formatMessage(isUnderReview ? messages.underReviewSubtitle : messages.sensitiveSubtitle)}
</Text>
<Text theme='white' size='sm' weight='medium'>
{intl.formatMessage(isUnderReview ? messages.underReviewSubtitle : messages.sensitiveSubtitle)}
</Text>
{status.spoiler_text && (
<div className='py-4 italic'>
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
&ldquo;<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />&rdquo;
</Text>
</div>
)}
</div>
{status.spoiler_text && (
<div className='py-4 italic'>
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
&ldquo;<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} />&rdquo;
</Text>
</div>
)}
</div>
<HStack alignItems='center' justifyContent='center' space={2}>
{isUnderReview ? (
<>
{links.get('support') && (
<a
href={links.get('support')}
target='_blank'
onClick={(event) => event.stopPropagation()}
>
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/headset.svg')}
<HStack alignItems='center' justifyContent='center' space={2}>
{isUnderReview ? (
<>
{links.get('support') && (
<a
href={links.get('support')}
target='_blank'
onClick={(event) => event.stopPropagation()}
>
{intl.formatMessage(messages.contact)}
</Button>
</a>
)}
</>
) : null}
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/headset.svg')}
>
{intl.formatMessage(messages.contact)}
</Button>
</a>
)}
</>
) : null}
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/eye.svg')}
onClick={toggleVisibility}
>
{intl.formatMessage(messages.show)}
</Button>
<Button
type='button'
theme='outline'
size='sm'
icon={require('@tabler/icons/eye.svg')}
onClick={toggleVisibility}
>
{intl.formatMessage(messages.show)}
</Button>
{(isUnderReview && isOwnStatus) ? (
<DropdownMenu
items={menu}
src={require('@tabler/icons/dots.svg')}
/>
) : null}
</HStack>
{(isUnderReview && isOwnStatus) ? (
<DropdownMenu
items={menu}
src={require('@tabler/icons/dots.svg')}
/>
) : null}
</HStack>
</div>
</div>
)}
</div>

Wyświetl plik

@ -0,0 +1,38 @@
import React from 'react';
import { Link } from 'react-router-dom';
interface IStatusInfo {
avatarSize: number
to?: string
icon: React.ReactNode
text: React.ReactNode
}
const StatusInfo = (props: IStatusInfo) => {
const { avatarSize, to, icon, text } = props;
const onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
event.stopPropagation();
};
const Container = to ? Link : 'div';
const containerProps: any = to ? { onClick, to } : {};
return (
<Container
{...containerProps}
className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-3 rtl:space-x-reverse hover:underline'
>
<div
className='flex justify-end'
style={{ width: avatarSize }}
>
{icon}
</div>
{text}
</Container>
);
};
export default StatusInfo;

Wyświetl plik

@ -1,29 +0,0 @@
/**
* SvgIcon: abstact component to render SVG icons.
* @module soapbox/components/svg_icon
* @see soapbox/components/icon
*/
import classNames from 'clsx';
import React from 'react';
import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports
export interface ISvgIcon extends React.HTMLAttributes<HTMLDivElement> {
src: string,
id?: string,
alt?: string,
className?: string,
}
const SvgIcon: React.FC<ISvgIcon> = ({ src, alt, className, ...rest }) => {
return (
<div
className={classNames('svg-icon', className)}
{...rest}
>
<InlineSVG src={src} title={alt} loader={<></>} />
</div>
);
};
export default SvgIcon;

Wyświetl plik

@ -4,10 +4,11 @@ import { FormattedMessage, useIntl } from 'react-intl';
import { translateStatus, undoStatusTranslation } from 'soapbox/actions/statuses';
import { useAppDispatch, useAppSelector, useFeatures, useInstance } from 'soapbox/hooks';
import { isLocal } from 'soapbox/utils/accounts';
import { Stack } from './ui';
import { Icon, Stack } from './ui';
import type { Status } from 'soapbox/types/entities';
import type { Account, Status } from 'soapbox/types/entities';
interface ITranslateButton {
status: Status,
@ -21,10 +22,13 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const me = useAppSelector((state) => state.me);
const allowUnauthenticated = instance.pleroma.getIn(['metadata', 'translation', 'allow_unauthenticated'], false);
const allowRemote = instance.pleroma.getIn(['metadata', 'translation', 'allow_remote'], true);
const sourceLanguages = instance.pleroma.getIn(['metadata', 'translation', 'source_languages']) as ImmutableList<string>;
const targetLanguages = instance.pleroma.getIn(['metadata', 'translation', 'target_languages']) as ImmutableList<string>;
const renderTranslate = me && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language;
const renderTranslate = (me || allowUnauthenticated) && (allowRemote || isLocal(status.account as Account)) && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language;
const supportsLanguages = (!sourceLanguages || sourceLanguages.includes(status.language!)) && (!targetLanguages || targetLanguages.includes(intl.locale));
@ -40,16 +44,21 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
if (!features.translations || !renderTranslate || !supportsLanguages) return null;
const buttonClassName = 'flex items-center gap-0.5 w-fit px-2 py-1 border-gray-600 hover:border-gray-700 dark:hover:border-gray-500 border-solid border text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 text-start text-sm rounded-full';
if (status.translation) {
const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' });
const languageName = languageNames.of(status.language!);
const provider = status.translation.get('provider');
return (
<Stack className='text-gray-700 dark:text-gray-600 text-sm' alignItems='start'>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
<Stack className='text-gray-700 dark:text-gray-600 text-sm' space={1} alignItems='start'>
<span>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
</span>
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue hover:underline' onClick={handleTranslate}>
<button className={buttonClassName} onClick={handleTranslate}>
<Icon className='h-5 w-5 stroke-[1.25]' src={require('@tabler/icons/language.svg')} strokeWidth={1.25} />
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
</button>
</Stack>
@ -57,7 +66,8 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
}
return (
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue text-start text-sm hover:underline' onClick={handleTranslate}>
<button className={buttonClassName} onClick={handleTranslate}>
<Icon className='h-5 w-5' src={require('@tabler/icons/language.svg')} strokeWidth={1.25} />
<FormattedMessage id='status.translate' defaultMessage='Translate' />
</button>
);

Wyświetl plik

@ -2,9 +2,12 @@ import classNames from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { HStack, Icon, Text } from 'soapbox/components/ui';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import HStack from '../hstack/hstack';
import Icon from '../icon/icon';
import Text from '../text/text';
import type { Menu } from 'soapbox/components/dropdown-menu';
const messages = defineMessages({

Wyświetl plik

@ -49,6 +49,8 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
className,
} = props;
const body = text || children;
const themeClass = useButtonStyles({
theme,
block,
@ -61,10 +63,10 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
return null;
}
return <Icon src={icon} className='mr-2 w-4 h-4' />;
return <Icon src={icon} className='w-4 h-4' />;
};
const handleClick = React.useCallback((event) => {
const handleClick: React.MouseEventHandler<HTMLButtonElement> = React.useCallback((event) => {
if (onClick && !disabled) {
onClick(event);
}
@ -72,7 +74,7 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
const renderButton = () => (
<button
className={classNames(themeClass, className)}
className={classNames('space-x-2 rtl:space-x-reverse', themeClass, className)}
disabled={disabled}
onClick={handleClick}
ref={ref}
@ -80,7 +82,10 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
data-testid='button'
>
{renderIcon()}
{text || children}
{body && (
<span>{body}</span>
)}
</button>
);

Wyświetl plik

@ -45,6 +45,7 @@ interface ICardHeader {
backHref?: string,
onBackClick?: (event: React.MouseEvent) => void
className?: string
children?: React.ReactNode
}
/**
@ -64,7 +65,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
return (
<Comp {...backAttributes} className='text-gray-900 dark:text-gray-100 focus:ring-primary-500 focus:ring-2' aria-label={intl.formatMessage(messages.back)}>
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6' />
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' />
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
</Comp>
);
@ -91,6 +92,8 @@ const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
interface ICardBody {
/** Classnames for the <div> element. */
className?: string
/** Children to appear inside the card. */
children: React.ReactNode
}
/** A card's body. */

Wyświetl plik

@ -46,6 +46,8 @@ export interface IColumn {
className?: string,
/** Ref forwarded to column. */
ref?: React.Ref<HTMLDivElement>
/** Children to display in the column. */
children?: React.ReactNode
}
/** A backdrop for the main section of the UI. */

Wyświetl plik

@ -20,7 +20,7 @@ const Datepicker = ({ onChange }: IDatepicker) => {
const [month, setMonth] = useState<number>(new Date().getMonth());
const [day, setDay] = useState<number>(new Date().getDate());
const [year, setYear] = useState<number>(2022);
const [year, setYear] = useState<number>(new Date().getFullYear());
const numberOfDays = useMemo(() => {
return getDaysInMonth(month, year);

Wyświetl plik

@ -2,8 +2,12 @@ import React from 'react';
import HStack from '../hstack/hstack';
interface IFormActions {
children: React.ReactNode
}
/** Container element to house form actions. */
const FormActions: React.FC = ({ children }) => (
const FormActions: React.FC<IFormActions> = ({ children }) => (
<HStack space={2} justifyContent='end'>
{children}
</HStack>

Wyświetl plik

@ -3,8 +3,6 @@ import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import FormGroup from '../form-group';
jest.mock('uuid', () => jest.requireActual('uuid'));
describe('<FormGroup />', () => {
it('connects the label and input', () => {
render(

Wyświetl plik

@ -14,6 +14,8 @@ interface IFormGroup {
hintText?: React.ReactNode,
/** Input errors. */
errors?: string[]
/** Elements to display within the FormGroup. */
children: React.ReactNode
}
/** Input container with label. Renders the child. */
@ -27,7 +29,7 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
if (React.isValidElement(inputChildren[0])) {
firstChild = React.cloneElement(
inputChildren[0],
{ id: formFieldId, hasError },
{ id: formFieldId },
);
}
const isCheckboxFormGroup = firstChild?.type === Checkbox;

Wyświetl plik

@ -5,11 +5,13 @@ interface IForm {
onSubmit?: (event: React.FormEvent) => void,
/** Class name override for the <form> element. */
className?: string,
/** Elements to display within the Form. */
children: React.ReactNode,
}
/** Form element with custom styles. */
const Form: React.FC<IForm> = ({ onSubmit, children, ...filteredProps }) => {
const handleSubmit = React.useCallback((event) => {
const handleSubmit: React.FormEventHandler = React.useCallback((event) => {
event.preventDefault();
if (onSubmit) {

Wyświetl plik

@ -49,6 +49,7 @@ export { default as Tabs } from './tabs/tabs';
export { default as TagInput } from './tag-input/tag-input';
export { default as Text } from './text/text';
export { default as Textarea } from './textarea/textarea';
export { default as Toast } from './toast/toast';
export { default as Toggle } from './toggle/toggle';
export { default as Tooltip } from './tooltip/tooltip';
export { default as Widget } from './widget/widget';

Wyświetl plik

@ -33,8 +33,6 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
value?: string | number,
/** Change event handler for the input. */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
/** Whether to display the input in red. */
hasError?: boolean,
/** An element to display as prefix to input. Cannot be used with icon. */
prepend?: React.ReactElement,
/** An element to display as suffix to input. Cannot be used with password type. */
@ -48,7 +46,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
(props, ref) => {
const intl = useIntl();
const { type = 'text', icon, className, outerClassName, hasError, append, prepend, theme = 'normal', ...filteredProps } = props;
const { type = 'text', icon, className, outerClassName, append, prepend, theme = 'normal', ...filteredProps } = props;
const [revealed, setRevealed] = React.useState(false);
@ -91,7 +89,6 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append,
'text-red-600 border-red-600': hasError,
'pl-8': typeof icon !== 'undefined',
'pl-16': typeof prepend !== 'undefined',
}, className)}

Wyświetl plik

@ -2,10 +2,21 @@ import classNames from 'clsx';
import React from 'react';
import StickyBox from 'react-sticky-box';
interface LayoutComponent extends React.FC {
Sidebar: React.FC,
interface ISidebar {
children: React.ReactNode
}
interface IAside {
children?: React.ReactNode
}
interface ILayout {
children: React.ReactNode
}
interface LayoutComponent extends React.FC<ILayout> {
Sidebar: React.FC<ISidebar>,
Main: React.FC<React.HTMLAttributes<HTMLDivElement>>,
Aside: React.FC,
Aside: React.FC<IAside>,
}
/** Layout container, to hold Sidebar, Main, and Aside. */
@ -18,7 +29,7 @@ const Layout: LayoutComponent = ({ children }) => (
);
/** Left sidebar container in the UI. */
const Sidebar: React.FC = ({ children }) => (
const Sidebar: React.FC<ISidebar> = ({ children }) => (
<div className='hidden lg:block lg:col-span-3'>
<StickyBox offsetTop={80} className='pb-4'>
{children}
@ -38,7 +49,7 @@ const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, classN
);
/** Right sidebar container in the UI. */
const Aside: React.FC = ({ children }) => (
const Aside: React.FC<IAside> = ({ children }) => (
<aside className='hidden xl:block xl:col-span-3'>
<StickyBox offsetTop={80} className='space-y-6 pb-12'>
{children}

Wyświetl plik

@ -54,6 +54,7 @@ interface IModal {
/** Title text for the modal. */
title?: React.ReactNode,
width?: keyof typeof widths,
children?: React.ReactNode,
}
/** Displays a modal dialog box. */
@ -104,7 +105,7 @@ const Modal: React.FC<IModal> = ({
src={closeIcon}
title={intl.formatMessage(messages.close)}
onClick={onClose}
className='text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200'
className='text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200 rtl:rotate-180'
/>
)}
</div>

Some files were not shown because too many files have changed in this diff Show More