diff --git a/app/soapbox/actions/account-notes.ts b/app/soapbox/actions/account-notes.ts index bb1cc72ae..f7d28fe57 100644 --- a/app/soapbox/actions/account-notes.ts +++ b/app/soapbox/actions/account-notes.ts @@ -1,10 +1,9 @@ -import { AxiosError } from 'axios'; -import { AnyAction } from 'redux'; - import api from '../api'; import { openModal, closeModal } from './modals'; +import type { AxiosError } from 'axios'; +import type { AnyAction } from 'redux'; import type { Account } from 'soapbox/types/entities'; const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js index efef9c786..fe54c843e 100644 --- a/app/soapbox/actions/compose.js +++ b/app/soapbox/actions/compose.js @@ -86,7 +86,7 @@ const messages = defineMessages({ 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' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, - uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, + uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, view: { id: 'snackbar.view', defaultMessage: 'View' }, }); diff --git a/app/soapbox/components/dropdown_menu.tsx b/app/soapbox/components/dropdown_menu.tsx index 7a59e4932..957583b60 100644 --- a/app/soapbox/components/dropdown_menu.tsx +++ b/app/soapbox/components/dropdown_menu.tsx @@ -23,7 +23,7 @@ export interface MenuItem { to?: string, newTab?: boolean, isLogout?: boolean, - icon: string, + icon?: string, count?: number, destructive?: boolean, meta?: string, diff --git a/app/soapbox/components/load_more.tsx b/app/soapbox/components/load_more.tsx index 90c0f35b0..e36ae116f 100644 --- a/app/soapbox/components/load_more.tsx +++ b/app/soapbox/components/load_more.tsx @@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl'; import { Button } from 'soapbox/components/ui'; interface ILoadMore { - onClick: () => void, + onClick: React.MouseEventHandler, disabled?: boolean, visible?: Boolean, } diff --git a/app/soapbox/features/account_gallery/index.js b/app/soapbox/features/account_gallery/index.js deleted file mode 100644 index 18d06c036..000000000 --- a/app/soapbox/features/account_gallery/index.js +++ /dev/null @@ -1,220 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { - fetchAccount, - fetchAccountByUsername, -} from 'soapbox/actions/accounts'; -import { openModal } from 'soapbox/actions/modals'; -import { expandAccountMediaTimeline } from 'soapbox/actions/timelines'; -import LoadMore from 'soapbox/components/load_more'; -import MissingIndicator from 'soapbox/components/missing_indicator'; -import { Column, Spinner } from 'soapbox/components/ui'; -import { getAccountGallery, findAccountByUsername } from 'soapbox/selectors'; -import { getFeatures } from 'soapbox/utils/features'; - -import MediaItem from './components/media_item'; - -const mapStateToProps = (state, { params, withReplies = false }) => { - const username = params.username || ''; - const me = state.get('me'); - const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase()); - const features = getFeatures(state.get('instance')); - - let accountId = -1; - let accountUsername = username; - if (accountFetchError) { - accountId = null; - } else { - const account = findAccountByUsername(state, username); - accountId = account ? account.getIn(['id'], null) : -1; - accountUsername = account ? account.getIn(['acct'], '') : ''; - } - - const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false); - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - - return { - accountId, - unavailable, - accountUsername, - isAccount: !!state.getIn(['accounts', accountId]), - attachments: getAccountGallery(state, accountId), - isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']), - hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']), - }; -}; - -class LoadMoreMedia extends ImmutablePureComponent { - - static propTypes = { - maxId: PropTypes.string, - onLoadMore: PropTypes.func.isRequired, - }; - - handleLoadMore = () => { - this.props.onLoadMore(this.props.maxId); - } - - render() { - return ( - - ); - } - -} - -export default @connect(mapStateToProps) -class AccountGallery extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - attachments: ImmutablePropTypes.list.isRequired, - isLoading: PropTypes.bool, - hasMore: PropTypes.bool, - isAccount: PropTypes.bool, - unavailable: PropTypes.bool, - }; - - state = { - width: 323, - }; - - componentDidMount() { - const { params: { username }, accountId } = this.props; - - if (accountId && accountId !== -1) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(expandAccountMediaTimeline(accountId)); - } else { - this.props.dispatch(fetchAccountByUsername(username)); - } - } - - componentDidUpdate(prevProps) { - const { accountId, params } = this.props; - if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) { - this.props.dispatch(fetchAccount(params.accountId)); - this.props.dispatch(expandAccountMediaTimeline(accountId)); - } - } - - handleScrollToBottom = () => { - if (this.props.hasMore) { - this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined); - } - } - - handleScroll = e => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - const offset = scrollHeight - scrollTop - clientHeight; - - if (150 > offset && !this.props.isLoading) { - this.handleScrollToBottom(); - } - } - - handleLoadMore = maxId => { - if (this.props.accountId && this.props.accountId !== -1) { - this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId })); - } - }; - - handleLoadOlder = e => { - e.preventDefault(); - this.handleScrollToBottom(); - } - - handleOpenMedia = attachment => { - if (attachment.get('type') === 'video') { - this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status'), account: attachment.get('account') })); - } else { - const media = attachment.getIn(['status', 'media_attachments']); - const index = media.findIndex(x => x.get('id') === attachment.get('id')); - - this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status'), account: attachment.get('account') })); - } - } - - handleRef = c => { - if (c) { - this.setState({ width: c.offsetWidth }); - } - } - - render() { - const { attachments, isLoading, hasMore, isAccount, accountId, unavailable, accountUsername } = this.props; - const { width } = this.state; - - if (!isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1 || (!attachments && isLoading)) { - return ( - - - - ); - } - - let loadOlder = null; - - if (hasMore && !(isLoading && attachments.size === 0)) { - loadOlder = ; - } - - if (unavailable) { - return ( - -
- -
-
- ); - } - - return ( - -
- {attachments.map((attachment, index) => attachment === null ? ( - 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> - ) : ( - - ))} - - { - attachments.size === 0 && -
- -
- } - - {loadOlder} -
- - {isLoading && attachments.size === 0 && ( -
- -
- )} -
- ); - } - -} diff --git a/app/soapbox/features/account_gallery/index.tsx b/app/soapbox/features/account_gallery/index.tsx new file mode 100644 index 000000000..386a8b7bb --- /dev/null +++ b/app/soapbox/features/account_gallery/index.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useParams } from 'react-router-dom'; + +import { + fetchAccount, + fetchAccountByUsername, +} from 'soapbox/actions/accounts'; +import { openModal } from 'soapbox/actions/modals'; +import { expandAccountMediaTimeline } from 'soapbox/actions/timelines'; +import LoadMore from 'soapbox/components/load_more'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import { Column, Spinner } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { getAccountGallery, findAccountByUsername } from 'soapbox/selectors'; +import { getFeatures } from 'soapbox/utils/features'; + +import MediaItem from './components/media_item'; + +import type { List as ImmutableList } from 'immutable'; +import type { Attachment, Status } from 'soapbox/types/entities'; + +interface ILoadMoreMedia { + maxId: string | null, + onLoadMore: (value: string | null) => void, +} + +const LoadMoreMedia: React.FC = ({ maxId, onLoadMore }) => { + const handleLoadMore = () => { + onLoadMore(maxId); + }; + + return ( + + ); +}; + +const AccountGallery = () => { + const dispatch = useAppDispatch(); + const { username } = useParams<{ username: string }>(); + + const { accountId, unavailable, accountUsername } = useAppSelector((state) => { + const me = state.me; + const accountFetchError = (state.accounts.get(-1)?.username || '').toLowerCase() === username.toLowerCase(); + const features = getFeatures(state.instance); + + let accountId: string | number | null = -1; + let accountUsername = username; + if (accountFetchError) { + accountId = null; + } else { + const account = findAccountByUsername(state, username); + accountId = account ? (account.id || null) : -1; + accountUsername = account?.acct || ''; + } + + const isBlocked = state.relationships.get(String(accountId))?.blocked_by || false; + return { + accountId, + unavailable: (me === accountId) ? false : (isBlocked && !features.blockersVisible), + accountUsername, + }; + }); + const isAccount = useAppSelector((state) => !!state.accounts.get(accountId)); + const attachments: ImmutableList = useAppSelector((state) => getAccountGallery(state, accountId as string)); + const isLoading = useAppSelector((state) => state.timelines.getIn([`account:${accountId}:media`, 'isLoading'])); + const hasMore = useAppSelector((state) => state.timelines.getIn([`account:${accountId}:media`, 'hasMore'])); + + const ref = useRef(null); + const [width] = useState(323); + + const handleScrollToBottom = () => { + if (hasMore) { + handleLoadMore(attachments.size > 0 ? attachments.last()!.status.id : undefined); + } + }; + + const handleLoadMore = (maxId: string | null) => { + if (accountId && accountId !== -1) { + dispatch(expandAccountMediaTimeline(accountId, { maxId })); + } + }; + + const handleLoadOlder: React.MouseEventHandler = e => { + e.preventDefault(); + handleScrollToBottom(); + }; + + const handleOpenMedia = (attachment: Attachment) => { + if (attachment.type === 'video') { + dispatch(openModal('VIDEO', { media: attachment, status: attachment.status, account: attachment.account })); + } else { + const media = (attachment.status as Status).media_attachments; + const index = media.findIndex((x) => x.id === attachment.id); + + dispatch(openModal('MEDIA', { media, index, status: attachment.status, account: attachment.account })); + } + }; + + useEffect(() => { + if (accountId && accountId !== -1) { + dispatch(fetchAccount(accountId)); + dispatch(expandAccountMediaTimeline(accountId)); + } else { + dispatch(fetchAccountByUsername(username)); + } + }, [accountId]); + + if (!isAccount && accountId !== -1) { + return ( + + ); + } + + if (accountId === -1 || (!attachments && isLoading)) { + return ( + + + + ); + } + + let loadOlder = null; + + if (hasMore && !(isLoading && attachments.size === 0)) { + loadOlder = ; + } + + if (unavailable) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+ {attachments.map((attachment, index) => attachment === null ? ( + 0 ? (attachments.get(index - 1)?.id || null) : null} onLoadMore={handleLoadMore} /> + ) : ( + + ))} + + {!isLoading && attachments.size === 0 && ( +
+ +
+ )} + + {loadOlder} +
+ + {isLoading && attachments.size === 0 && ( +
+ +
+ )} +
+ ); +}; + +export default AccountGallery; diff --git a/app/soapbox/features/audio/visualizer.js b/app/soapbox/features/audio/visualizer.ts similarity index 70% rename from app/soapbox/features/audio/visualizer.js rename to app/soapbox/features/audio/visualizer.ts index 2ec2bfafe..410879028 100644 --- a/app/soapbox/features/audio/visualizer.js +++ b/app/soapbox/features/audio/visualizer.ts @@ -8,25 +8,30 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -const hex2rgba = (hex, alpha = 1) => { - const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16)); +const hex2rgba = (hex: string, alpha = 1) => { + const [r, g, b] = hex.match(/\w\w/g)!.map(x => parseInt(x, 16)); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }; export default class Visualizer { - constructor(tickSize) { + tickSize: number + canvas?: HTMLCanvasElement + context?: CanvasRenderingContext2D + analyser?: AnalyserNode + + constructor(tickSize: number) { this.tickSize = tickSize; } - setCanvas(canvas) { + setCanvas(canvas: HTMLCanvasElement) { this.canvas = canvas; if (canvas) { - this.context = canvas.getContext('2d'); + this.context = canvas.getContext('2d')!; } } - setAudioContext(context, source) { + setAudioContext(context: AudioContext, source: MediaElementAudioSourceNode) { const analyser = context.createAnalyser(); analyser.smoothingTimeConstant = 0.6; @@ -37,7 +42,7 @@ export default class Visualizer { this.analyser = analyser; } - getTickPoints(count) { + getTickPoints(count: number) { const coords = []; for (let i = 0; i < count; i++) { @@ -48,13 +53,13 @@ export default class Visualizer { return coords; } - drawTick(cx, cy, mainColor, x1, y1, x2, y2) { + drawTick(cx: number, cy: number, mainColor: string, x1: number, y1: number, x2: number, y2: number) { const dx1 = Math.ceil(cx + x1); const dy1 = Math.ceil(cy + y1); const dx2 = Math.ceil(cx + x2); const dy2 = Math.ceil(cy + y2); - const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2); + const gradient = this.context!.createLinearGradient(dx1, dy1, dx2, dy2); const lastColor = hex2rgba(mainColor, 0); @@ -62,21 +67,21 @@ export default class Visualizer { gradient.addColorStop(0.6, mainColor); gradient.addColorStop(1, lastColor); - this.context.beginPath(); - this.context.strokeStyle = gradient; - this.context.lineWidth = 2; - this.context.moveTo(dx1, dy1); - this.context.lineTo(dx2, dy2); - this.context.stroke(); + this.context!.beginPath(); + this.context!.strokeStyle = gradient; + this.context!.lineWidth = 2; + this.context!.moveTo(dx1, dy1); + this.context!.lineTo(dx2, dy2); + this.context!.stroke(); } - getTicks(count, size, radius, scaleCoefficient) { + getTicks(count: number, size: number, radius: number, scaleCoefficient: number) { const ticks = this.getTickPoints(count); const lesser = 200; - const m = []; + const m: Array> = []; const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0; const frequencyData = new Uint8Array(bufferLength); - const allScales = []; + const allScales: Array = []; if (this.analyser) { this.analyser.getByteFrequencyData(frequencyData); @@ -117,20 +122,20 @@ export default class Visualizer { })); } - clear(width, height) { - this.context.clearRect(0, 0, width, height); + clear(width: number, height: number) { + this.context!.clearRect(0, 0, width, height); } - draw(cx, cy, color, radius, coefficient) { - this.context.save(); + draw(cx: number, cy: number, color: string, radius: number, coefficient: number) { + this.context!.save(); - const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient); + const ticks = this.getTicks(parseInt(360 * coefficient as any), this.tickSize, radius, coefficient); ticks.forEach(tick => { this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2); }); - this.context.restore(); + this.context!.restore(); } } diff --git a/app/soapbox/features/auth_login/components/login_page.js b/app/soapbox/features/auth_login/components/login_page.js deleted file mode 100644 index 21d47f18c..000000000 --- a/app/soapbox/features/auth_login/components/login_page.js +++ /dev/null @@ -1,92 +0,0 @@ -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { Redirect } from 'react-router-dom'; - -import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth'; -import { fetchInstance } from 'soapbox/actions/instance'; -import { closeModal } from 'soapbox/actions/modals'; -import { getRedirectUrl } from 'soapbox/utils/redirect'; -import { isStandalone } from 'soapbox/utils/state'; - -import LoginForm from './login_form'; -import OtpAuthForm from './otp_auth_form'; - -const mapStateToProps = state => ({ - me: state.get('me'), - isLoading: false, - standalone: isStandalone(state), -}); - -export default @connect(mapStateToProps) -@injectIntl -class LoginPage extends ImmutablePureComponent { - - constructor(props) { - super(props); - this.handleSubmit = this.handleSubmit.bind(this); - } - - state = { - isLoading: false, - mfa_auth_needed: false, - mfa_token: '', - shouldRedirect: false, - } - - getFormData = (form) => { - return Object.fromEntries( - Array.from(form).map(i => [i.name, i.value]), - ); - } - - componentDidMount() { - const token = new URLSearchParams(window.location.search).get('token'); - - if (token) { - this.setState({ mfa_token: token, mfa_auth_needed: true }); - } - } - - handleSubmit = (event) => { - const { dispatch, intl, me } = this.props; - const { username, password } = this.getFormData(event.target); - dispatch(logIn(intl, username, password)).then(({ access_token }) => { - return dispatch(verifyCredentials(access_token)) - // Refetch the instance for authenticated fetch - .then(() => dispatch(fetchInstance())); - }).then(account => { - dispatch(closeModal()); - this.setState({ shouldRedirect: true }); - if (typeof me === 'string') { - dispatch(switchAccount(account.id)); - } - }).catch(error => { - const data = error.response?.data; - if (data?.error === 'mfa_required') { - this.setState({ mfa_auth_needed: true, mfa_token: data.mfa_token }); - } - this.setState({ isLoading: false }); - }); - this.setState({ isLoading: true }); - event.preventDefault(); - } - - render() { - const { standalone } = this.props; - const { isLoading, mfa_auth_needed, mfa_token, shouldRedirect } = this.state; - - if (standalone) return ; - - if (shouldRedirect) { - const redirectUri = getRedirectUrl(); - return ; - } - - if (mfa_auth_needed) return ; - - return ; - } - -} diff --git a/app/soapbox/features/auth_login/components/login_page.tsx b/app/soapbox/features/auth_login/components/login_page.tsx new file mode 100644 index 000000000..3f7834b7f --- /dev/null +++ b/app/soapbox/features/auth_login/components/login_page.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Redirect } from 'react-router-dom'; + +import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth'; +import { fetchInstance } from 'soapbox/actions/instance'; +import { closeModal } from 'soapbox/actions/modals'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { getRedirectUrl } from 'soapbox/utils/redirect'; +import { isStandalone } from 'soapbox/utils/state'; + +import LoginForm from './login_form'; +import OtpAuthForm from './otp_auth_form'; + +import type { AxiosError } from 'axios'; + +const LoginPage = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const me = useAppSelector((state) => state.me); + const standalone = useAppSelector((state) => isStandalone(state)); + + const token = new URLSearchParams(window.location.search).get('token'); + + const [isLoading, setIsLoading] = useState(false); + const [mfaAuthNeeded, setMfaAuthNeeded] = useState(!!token); + const [mfaToken, setMfaToken] = useState(token || ''); + const [shouldRedirect, setShouldRedirect] = useState(false); + + const getFormData = (form: HTMLFormElement) => { + return Object.fromEntries( + Array.from(form).map((i: any) => [i.name, i.value]), + ); + }; + + const handleSubmit: React.FormEventHandler = (event) => { + const { username, password } = getFormData(event.target as HTMLFormElement); + dispatch(logIn(intl, username, password)).then(({ access_token }: { access_token: string }) => { + return dispatch(verifyCredentials(access_token)) + // Refetch the instance for authenticated fetch + .then(() => dispatch(fetchInstance() as any)); + }).then((account: { id: string }) => { + dispatch(closeModal()); + setShouldRedirect(true); + if (typeof me === 'string') { + dispatch(switchAccount(account.id)); + } + }).catch((error: AxiosError) => { + const data: any = error.response?.data; + if (data?.error === 'mfa_required') { + setMfaAuthNeeded(true); + setMfaToken(data.mfa_token); + } + setIsLoading(false); + }); + setIsLoading(true); + event.preventDefault(); + }; + + if (standalone) return ; + + if (shouldRedirect) { + const redirectUri = getRedirectUrl(); + return ; + } + + if (mfaAuthNeeded) return ; + + return ; +}; + +export default LoginPage; diff --git a/app/soapbox/features/backups/index.js b/app/soapbox/features/backups/index.js deleted file mode 100644 index ac0fb29fd..000000000 --- a/app/soapbox/features/backups/index.js +++ /dev/null @@ -1,96 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; - -import { - fetchBackups, - createBackup, -} from 'soapbox/actions/backups'; -import ScrollableList from 'soapbox/components/scrollable_list'; - -import Column from '../ui/components/better_column'; - -const messages = defineMessages({ - heading: { id: 'column.backups', defaultMessage: 'Backups' }, - create: { id: 'backups.actions.create', defaultMessage: 'Create backup' }, - emptyMessage: { id: 'backups.empty_message', defaultMessage: 'No backups found. {action}' }, - emptyMessageAction: { id: 'backups.empty_message.action', defaultMessage: 'Create one now?' }, - pending: { id: 'backups.pending', defaultMessage: 'Pending' }, -}); - -const mapStateToProps = state => ({ - backups: state.get('backups').toList().sortBy(backup => backup.get('inserted_at')), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Backups extends ImmutablePureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - }; - - state = { - isLoading: true, - } - - handleCreateBackup = e => { - this.props.dispatch(createBackup()); - e.preventDefault(); - } - - componentDidMount() { - this.props.dispatch(fetchBackups()).then(() => { - this.setState({ isLoading: false }); - }).catch(() => {}); - } - - makeColumnMenu = () => { - const { intl } = this.props; - - return [{ - text: intl.formatMessage(messages.create), - action: this.handleCreateBackup, - icon: require('@tabler/icons/icons/plus.svg'), - }]; - } - - render() { - const { intl, backups } = this.props; - const { isLoading } = this.state; - const showLoading = isLoading && backups.count() === 0; - - const emptyMessageAction = ( - - {intl.formatMessage(messages.emptyMessageAction)} - - ); - - return ( - - - {backups.map(backup => ( -
- {backup.get('processed') - ? {backup.get('inserted_at')} - :
{intl.formatMessage(messages.pending)}: {backup.get('inserted_at')}
- } -
- ))} -
-
- ); - } - -} diff --git a/app/soapbox/features/backups/index.tsx b/app/soapbox/features/backups/index.tsx new file mode 100644 index 000000000..d1baf4991 --- /dev/null +++ b/app/soapbox/features/backups/index.tsx @@ -0,0 +1,83 @@ +import classNames from 'classnames'; +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { + fetchBackups, + createBackup, +} from 'soapbox/actions/backups'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import Column from '../ui/components/better_column'; + +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; + +const messages = defineMessages({ + heading: { id: 'column.backups', defaultMessage: 'Backups' }, + create: { id: 'backups.actions.create', defaultMessage: 'Create backup' }, + emptyMessage: { id: 'backups.empty_message', defaultMessage: 'No backups found. {action}' }, + emptyMessageAction: { id: 'backups.empty_message.action', defaultMessage: 'Create one now?' }, + pending: { id: 'backups.pending', defaultMessage: 'Pending' }, +}); + +const Backups = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const backups = useAppSelector>>((state) => state.backups.toList().sortBy((backup: ImmutableMap) => backup.get('inserted_at'))); + + const [isLoading, setIsLoading] = useState(true); + + const handleCreateBackup: React.MouseEventHandler = e => { + dispatch(createBackup()); + e.preventDefault(); + }; + + const makeColumnMenu = () => { + return [{ + text: intl.formatMessage(messages.create), + action: handleCreateBackup, + icon: require('@tabler/icons/icons/plus.svg'), + }]; + }; + + useEffect(() => { + dispatch(fetchBackups()).then(() => { + setIsLoading(true); + }).catch(() => {}); + }, []); + + const showLoading = isLoading && backups.count() === 0; + + const emptyMessageAction = ( + + {intl.formatMessage(messages.emptyMessageAction)} + + ); + + return ( + + + {backups.map((backup) => ( +
+ {backup.get('processed') + ? {backup.get('inserted_at')} + :
{intl.formatMessage(messages.pending)}: {backup.get('inserted_at')}
+ } +
+ ))} +
+
+ ); +}; + +export default Backups; diff --git a/app/soapbox/features/community_timeline/index.js b/app/soapbox/features/community_timeline/index.js deleted file mode 100644 index cb64b49b3..000000000 --- a/app/soapbox/features/community_timeline/index.js +++ /dev/null @@ -1,96 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { getSettings } from 'soapbox/actions/settings'; -import { connectCommunityStream } from 'soapbox/actions/streaming'; -import { expandCommunityTimeline } from 'soapbox/actions/timelines'; -import SubNavigation from 'soapbox/components/sub_navigation'; -import { Column } from 'soapbox/components/ui'; - -import Timeline from '../ui/components/timeline'; - -import ColumnSettings from './containers/column_settings_container'; - -const messages = defineMessages({ - title: { id: 'column.community', defaultMessage: 'Local timeline' }, -}); - -const mapStateToProps = state => { - const onlyMedia = getSettings(state).getIn(['community', 'other', 'onlyMedia']); - - const timelineId = 'community'; - - return { - timelineId, - onlyMedia, - hasUnread: state.getIn(['timelines', `${timelineId}${onlyMedia ? ':media' : ''}`, 'unread']) > 0, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class CommunityTimeline extends React.PureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - hasUnread: PropTypes.bool, - onlyMedia: PropTypes.bool, - timelineId: PropTypes.string, - }; - - componentDidMount() { - const { dispatch, onlyMedia } = this.props; - dispatch(expandCommunityTimeline({ onlyMedia })); - this.disconnect = dispatch(connectCommunityStream({ onlyMedia })); - } - - componentDidUpdate(prevProps) { - if (prevProps.onlyMedia !== this.props.onlyMedia) { - const { dispatch, onlyMedia } = this.props; - - this.disconnect(); - dispatch(expandCommunityTimeline({ onlyMedia })); - this.disconnect = dispatch(connectCommunityStream({ onlyMedia })); - } - } - - componentWillUnmount() { - if (this.disconnect) { - this.disconnect(); - this.disconnect = null; - } - } - - handleLoadMore = maxId => { - const { dispatch, onlyMedia } = this.props; - dispatch(expandCommunityTimeline({ maxId, onlyMedia })); - } - - handleRefresh = () => { - const { dispatch, onlyMedia } = this.props; - return dispatch(expandCommunityTimeline({ onlyMedia })); - } - - - render() { - const { intl, onlyMedia, timelineId } = this.props; - - return ( - - - } - divideType='space' - /> - - ); - } - -} diff --git a/app/soapbox/features/community_timeline/index.tsx b/app/soapbox/features/community_timeline/index.tsx new file mode 100644 index 000000000..4cc9ad65e --- /dev/null +++ b/app/soapbox/features/community_timeline/index.tsx @@ -0,0 +1,59 @@ +import React, { useEffect } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { connectCommunityStream } from 'soapbox/actions/streaming'; +import { expandCommunityTimeline } from 'soapbox/actions/timelines'; +import SubNavigation from 'soapbox/components/sub_navigation'; +import { Column } from 'soapbox/components/ui'; +import { useAppDispatch, useSettings } from 'soapbox/hooks'; + +import Timeline from '../ui/components/timeline'; + +import ColumnSettings from './containers/column_settings_container'; + +const messages = defineMessages({ + title: { id: 'column.community', defaultMessage: 'Local timeline' }, +}); + +const CommunityTimeline = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const settings = useSettings(); + const onlyMedia = settings.getIn(['community', 'other', 'onlyMedia']); + + const timelineId = 'community'; + + const handleLoadMore = (maxId: string) => { + dispatch(expandCommunityTimeline({ maxId, onlyMedia })); + }; + + const handleRefresh = () => { + return dispatch(expandCommunityTimeline({ onlyMedia } as any)); + }; + + useEffect(() => { + dispatch(expandCommunityTimeline({ onlyMedia } as any)); + const disconnect = dispatch(connectCommunityStream({ onlyMedia } as any)); + + return () => { + disconnect(); + }; + }, [onlyMedia]); + + return ( + + + } + divideType='space' + /> + + ); +}; + +export default CommunityTimeline; diff --git a/app/soapbox/features/notifications/containers/filter_bar_container.js b/app/soapbox/features/notifications/containers/filter_bar_container.js index 6aa62b7d4..cfb4345cc 100644 --- a/app/soapbox/features/notifications/containers/filter_bar_container.js +++ b/app/soapbox/features/notifications/containers/filter_bar_container.js @@ -13,7 +13,7 @@ const makeMapStateToProps = state => { return { selectedFilter: settings.getIn(['notifications', 'quickFilter', 'active']), - advancedMode: settings.getIn(['notifications', 'quickFilter', 'advanced']), + advancedMode: settings.getIn(['notifications', 'quickFilter', 'advanced']), supportsEmojiReacts: features.emojiReacts, }; }; diff --git a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx index 08cfb07bd..5278ed529 100644 --- a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx +++ b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx @@ -1,4 +1,3 @@ -import { AxiosError } from 'axios'; import classNames from 'classnames'; import * as React from 'react'; import { FormattedMessage } from 'react-intl'; @@ -10,6 +9,8 @@ import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soap import { useOwnAccount } from 'soapbox/hooks'; import resizeImage from 'soapbox/utils/resize_image'; +import type { AxiosError } from 'axios'; + /** Default avatar filenames from various backends */ const DEFAULT_AVATARS = [ '/avatars/original/missing.png', // Mastodon diff --git a/app/soapbox/features/public_timeline/index.js b/app/soapbox/features/public_timeline/index.js deleted file mode 100644 index 0473352fa..000000000 --- a/app/soapbox/features/public_timeline/index.js +++ /dev/null @@ -1,145 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; - -import { changeSetting, getSettings } from 'soapbox/actions/settings'; -import { connectPublicStream } from 'soapbox/actions/streaming'; -import { expandPublicTimeline } from 'soapbox/actions/timelines'; -import SubNavigation from 'soapbox/components/sub_navigation'; -import { Column } from 'soapbox/components/ui'; -import Accordion from 'soapbox/features/ui/components/accordion'; - -import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker'; -import Timeline from '../ui/components/timeline'; - -import ColumnSettings from './containers/column_settings_container'; - -const messages = defineMessages({ - title: { id: 'column.public', defaultMessage: 'Fediverse timeline' }, - dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: 'Don\'t show again' }, -}); - -const mapStateToProps = state => { - const settings = getSettings(state); - const onlyMedia = settings.getIn(['public', 'other', 'onlyMedia']); - - const timelineId = 'public'; - - return { - timelineId, - onlyMedia, - hasUnread: state.getIn(['timelines', `${timelineId}${onlyMedia ? ':media' : ''}`, 'unread']) > 0, - siteTitle: state.getIn(['instance', 'title']), - explanationBoxExpanded: settings.get('explanationBox'), - showExplanationBox: settings.get('showExplanationBox'), - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class CommunityTimeline extends React.PureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - hasUnread: PropTypes.bool, - onlyMedia: PropTypes.bool, - timelineId: PropTypes.string, - siteTitle: PropTypes.string, - showExplanationBox: PropTypes.bool, - explanationBoxExpanded: PropTypes.bool, - }; - - componentDidMount() { - const { dispatch, onlyMedia } = this.props; - dispatch(expandPublicTimeline({ onlyMedia })); - this.disconnect = dispatch(connectPublicStream({ onlyMedia })); - } - - componentDidUpdate(prevProps) { - if (prevProps.onlyMedia !== this.props.onlyMedia) { - const { dispatch, onlyMedia } = this.props; - this.disconnect(); - - dispatch(expandPublicTimeline({ onlyMedia })); - this.disconnect = dispatch(connectPublicStream({ onlyMedia })); - } - } - - componentWillUnmount() { - if (this.disconnect) { - this.disconnect(); - this.disconnect = null; - } - } - - explanationBoxMenu = () => { - const { intl } = this.props; - return [{ text: intl.formatMessage(messages.dismiss), action: this.dismissExplanationBox }]; - } - - dismissExplanationBox = () => { - this.props.dispatch(changeSetting(['showExplanationBox'], false)); - } - - toggleExplanationBox = (setting) => { - this.props.dispatch(changeSetting(['explanationBox'], setting)); - } - - handleLoadMore = maxId => { - const { dispatch, onlyMedia } = this.props; - dispatch(expandPublicTimeline({ maxId, onlyMedia })); - } - - handleRefresh = () => { - const { dispatch, onlyMedia } = this.props; - return dispatch(expandPublicTimeline({ onlyMedia })); - } - - render() { - const { intl, onlyMedia, timelineId, siteTitle, showExplanationBox, explanationBoxExpanded } = this.props; - - return ( - - - - {showExplanationBox &&
- } - menu={this.explanationBoxMenu()} - expanded={explanationBoxExpanded} - onToggle={this.toggleExplanationBox} - > - - - - ), - }} - /> - -
} - } - divideType='space' - /> -
- ); - } - -} diff --git a/app/soapbox/features/public_timeline/index.tsx b/app/soapbox/features/public_timeline/index.tsx new file mode 100644 index 000000000..948c19b6a --- /dev/null +++ b/app/soapbox/features/public_timeline/index.tsx @@ -0,0 +1,106 @@ +import React, { useEffect } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { changeSetting } from 'soapbox/actions/settings'; +import { connectPublicStream } from 'soapbox/actions/streaming'; +import { expandPublicTimeline } from 'soapbox/actions/timelines'; +import SubNavigation from 'soapbox/components/sub_navigation'; +import { Column } from 'soapbox/components/ui'; +import Accordion from 'soapbox/features/ui/components/accordion'; +import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks'; + +import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker'; +import Timeline from '../ui/components/timeline'; + +import ColumnSettings from './containers/column_settings_container'; + +const messages = defineMessages({ + title: { id: 'column.public', defaultMessage: 'Fediverse timeline' }, + dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: 'Don\'t show again' }, +}); + +const CommunityTimeline = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const settings = useSettings(); + const onlyMedia = settings.getIn(['public', 'other', 'onlyMedia']); + + const timelineId = 'public'; + + const siteTitle = useAppSelector((state) => state.instance.title); + const explanationBoxExpanded = settings.get('explanationBox'); + const showExplanationBox = settings.get('showExplanationBox'); + + const explanationBoxMenu = () => { + return [{ text: intl.formatMessage(messages.dismiss), action: dismissExplanationBox }]; + }; + + const dismissExplanationBox = () => { + dispatch(changeSetting(['showExplanationBox'], false)); + }; + + const toggleExplanationBox = (setting: boolean) => { + dispatch(changeSetting(['explanationBox'], setting)); + }; + + const handleLoadMore = (maxId: string) => { + dispatch(expandPublicTimeline({ maxId, onlyMedia })); + }; + + const handleRefresh = () => { + return dispatch(expandPublicTimeline({ onlyMedia } as any)); + }; + + useEffect(() => { + dispatch(expandPublicTimeline({ onlyMedia } as any)); + const disconnect = dispatch(connectPublicStream({ onlyMedia })); + + return () => { + disconnect(); + }; + }, [onlyMedia]); + + return ( + + + + {showExplanationBox &&
+ } + menu={explanationBoxMenu()} + expanded={explanationBoxExpanded} + onToggle={toggleExplanationBox} + > + + + + ), + }} + /> + +
} + } + divideType='space' + /> +
+ ); +}; + +export default CommunityTimeline; diff --git a/app/soapbox/features/remote_timeline/components/pinned_hosts_picker.tsx b/app/soapbox/features/remote_timeline/components/pinned_hosts_picker.tsx index 3f08e94d6..6530e4131 100644 --- a/app/soapbox/features/remote_timeline/components/pinned_hosts_picker.tsx +++ b/app/soapbox/features/remote_timeline/components/pinned_hosts_picker.tsx @@ -7,7 +7,7 @@ import { useSettings } from 'soapbox/hooks'; interface IPinnedHostsPicker { /** The active host among pinned hosts. */ - host: string, + host?: string, } const PinnedHostsPicker: React.FC = ({ host: activeHost }) => { diff --git a/app/soapbox/features/report/components/status_check_box.js b/app/soapbox/features/report/components/status_check_box.js deleted file mode 100644 index ebee7a3ee..000000000 --- a/app/soapbox/features/report/components/status_check_box.js +++ /dev/null @@ -1,91 +0,0 @@ -import noop from 'lodash/noop'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import Toggle from 'react-toggle'; - -import StatusContent from '../../../components/status_content'; -import Bundle from '../../ui/components/bundle'; -import { MediaGallery, Video, Audio } from '../../ui/util/async-components'; - -export default class StatusCheckBox extends React.PureComponent { - - static propTypes = { - status: ImmutablePropTypes.record.isRequired, - checked: PropTypes.bool, - onToggle: PropTypes.func.isRequired, - disabled: PropTypes.bool, - }; - - render() { - const { status, checked, onToggle, disabled } = this.props; - let media = null; - - if (status.get('reblog')) { - return null; - } - - if (status.get('media_attachments').size > 0) { - if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { - // Do nothing - } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - const video = status.getIn(['media_attachments', 0]); - - media = ( - - {Component => ( - - )} - - ); - } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { - const audio = status.getIn(['media_attachments', 0]); - - media = ( - - {Component => ( - - )} - - ); - } else { - media = ( - - {Component => } - - ); - } - } - - return ( -
-
- - {media} -
- -
- -
-
- ); - } - -} diff --git a/app/soapbox/features/report/components/status_check_box.tsx b/app/soapbox/features/report/components/status_check_box.tsx new file mode 100644 index 000000000..cb2424ea7 --- /dev/null +++ b/app/soapbox/features/report/components/status_check_box.tsx @@ -0,0 +1,97 @@ +import noop from 'lodash/noop'; +import React from 'react'; +import Toggle from 'react-toggle'; + +import { toggleStatusReport } from 'soapbox/actions/reports'; +import StatusContent from 'soapbox/components/status_content'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import Bundle from '../../ui/components/bundle'; +import { MediaGallery, Video, Audio } from '../../ui/util/async-components'; + +interface IStatusCheckBox { + id: string, + disabled?: boolean, +} + +const StatusCheckBox: React.FC = ({ id, disabled }) => { + const dispatch = useAppDispatch(); + const status = useAppSelector((state) => state.statuses.get(id)); + const checked = useAppSelector((state) => state.reports.new.status_ids.includes(id)); + + const onToggle: React.ChangeEventHandler = (e) => dispatch(toggleStatusReport(id, e.target.checked)); + + if (!status || status.reblog) { + return null; + } + + let media; + + if (status.media_attachments.size > 0) { + if (status.media_attachments.some(item => item.type === 'unknown')) { + // Do nothing + } else if (status.media_attachments.get(0)?.type === 'video') { + const video = status.media_attachments.get(0); + + if (video) { + media = ( + + {(Component: any) => ( + + )} + + ); + } + } else if (status.media_attachments.get(0)?.type === 'audio') { + const audio = status.media_attachments.get(0); + + if (audio) { + media = ( + + {(Component: any) => ( + + )} + + ); + } + } else { + media = ( + + {(Component: any) => } + + ); + } + } + + return ( +
+
+ + {media} +
+ +
+ +
+
+ ); +}; + +export default StatusCheckBox; diff --git a/app/soapbox/features/report/containers/status_check_box_container.js b/app/soapbox/features/report/containers/status_check_box_container.js deleted file mode 100644 index 49bcf70f3..000000000 --- a/app/soapbox/features/report/containers/status_check_box_container.js +++ /dev/null @@ -1,19 +0,0 @@ -import { connect } from 'react-redux'; - -import { toggleStatusReport } from '../../../actions/reports'; -import StatusCheckBox from '../components/status_check_box'; - -const mapStateToProps = (state, { id }) => ({ - status: state.getIn(['statuses', id]), - checked: state.reports.new.status_ids.includes(id), -}); - -const mapDispatchToProps = (dispatch, { id }) => ({ - - onToggle(e) { - dispatch(toggleStatusReport(id, e.target.checked)); - }, - -}); - -export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox); diff --git a/app/soapbox/features/ui/components/modals/report-modal/steps/other-actions-step.tsx b/app/soapbox/features/ui/components/modals/report-modal/steps/other-actions-step.tsx index a9c32e775..7c3499bd1 100644 --- a/app/soapbox/features/ui/components/modals/report-modal/steps/other-actions-step.tsx +++ b/app/soapbox/features/ui/components/modals/report-modal/steps/other-actions-step.tsx @@ -7,7 +7,7 @@ import Toggle from 'react-toggle'; import { changeReportBlock, changeReportForward } from 'soapbox/actions/reports'; import { fetchRules } from 'soapbox/actions/rules'; import { Button, FormGroup, HStack, Stack, Text } from 'soapbox/components/ui'; -import StatusCheckBox from 'soapbox/features/report/containers/status_check_box_container'; +import StatusCheckBox from 'soapbox/features/report/components/status_check_box'; import { useAppSelector, useFeatures } from 'soapbox/hooks'; import { isRemote, getDomain } from 'soapbox/utils/accounts'; @@ -61,7 +61,7 @@ const OtherActionsStep = ({ account }: IOtherActionsStep) => { {showAdditionalStatuses ? ( -
+
{statusIds.map((statusId) => )}
diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts index 7e631c11e..41b9edf11 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -366,7 +366,7 @@ type ColumnQuery = { type: string, prefix?: string }; export const makeGetStatusIds = () => createSelector([ (state: RootState, { type, prefix }: ColumnQuery) => getSettings(state).get(prefix || type, ImmutableMap()), (state: RootState, { type }: ColumnQuery) => state.timelines.getIn([type, 'items'], ImmutableOrderedSet()), - (state: RootState) => state.statuses, + (state: RootState) => state.statuses, ], (columnSettings, statusIds: ImmutableOrderedSet, statuses) => { return statusIds.filter((id: string) => { const status = statuses.get(id); diff --git a/app/styles/fonts.scss b/app/styles/fonts.scss index 6f8da5e30..bd8f1bec5 100644 --- a/app/styles/fonts.scss +++ b/app/styles/fonts.scss @@ -63,7 +63,7 @@ @font-face { font-family: 'soapbox'; src: url('../fonts/soapbox/soapbox.eot?pryg6i'); - src: url('../fonts/soapbox/soapbox.eot?pryg6i#iefix') format('embedded-opentype'), + src: url('../fonts/soapbox/soapbox.eot?pryg6i#iefix') format('embedded-opentype'), url('../fonts/soapbox/soapbox.ttf?pryg6i') format('truetype'), url('../fonts/soapbox/soapbox.woff?pryg6i') format('woff'), url('../fonts/soapbox/soapbox.svg?pryg6i#soapbox') format('svg');