diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 35cd86ea1a..22ec18afa9 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -81,6 +81,7 @@ class ScrollableList extends PureComponent { bindToDocument: PropTypes.bool, preventScroll: PropTypes.bool, footer: PropTypes.node, + className: PropTypes.string, }; static defaultProps = { @@ -325,7 +326,7 @@ class ScrollableList extends PureComponent { }; render () { - const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, className, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = Children.count(children); @@ -336,9 +337,9 @@ class ScrollableList extends PureComponent { if (showLoading) { scrollableArea = (
-
- {prepend} -
+ {prepend} + +
@@ -350,9 +351,9 @@ class ScrollableList extends PureComponent { } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) { scrollableArea = (
-
- {prepend} + {prepend} +
{loadPending} {Children.map(this.props.children, (child, index) => ( diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.tsx b/app/javascript/mastodon/features/account_gallery/components/media_item.tsx index fef8a1300d..80704c3388 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.tsx +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.tsx @@ -11,11 +11,15 @@ import { Icon } from 'mastodon/components/icon'; import { formatTime } from 'mastodon/features/video'; import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state'; import type { Status, MediaAttachment } from 'mastodon/models/status'; +import { useAppSelector } from 'mastodon/store'; export const MediaItem: React.FC<{ attachment: MediaAttachment; onOpenMedia: (arg0: MediaAttachment) => void; }> = ({ attachment, onOpenMedia }) => { + const account = useAppSelector((state) => + state.accounts.get(attachment.getIn(['status', 'account']) as string), + ); const [visible, setVisible] = useState( (displayMedia !== 'hide_all' && !attachment.getIn(['status', 'sensitive'])) || @@ -70,7 +74,6 @@ export const MediaItem: React.FC<{ const lang = status.get('language') as string; const blurhash = attachment.get('blurhash') as string; const statusId = status.get('id') as string; - const acct = status.getIn(['account', 'acct']) as string; const type = attachment.get('type') as string; let thumbnail; @@ -181,7 +184,7 @@ export const MediaItem: React.FC<{ { - const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]); - - if (!accountId) { - return { - isLoading: true, - }; - } - - return { - accountId, - 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']), - suspended: state.getIn(['accounts', accountId, 'suspended'], false), - blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), - }; -}; - -class LoadMoreMedia extends ImmutablePureComponent { - - static propTypes = { - maxId: PropTypes.string, - onLoadMore: PropTypes.func.isRequired, - }; - - handleLoadMore = () => { - this.props.onLoadMore(this.props.maxId); - }; - - render () { - return ( - - ); - } - -} - -class AccountGallery extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.shape({ - acct: PropTypes.string, - id: PropTypes.string, - }).isRequired, - accountId: PropTypes.string, - dispatch: PropTypes.func.isRequired, - attachments: ImmutablePropTypes.list.isRequired, - isLoading: PropTypes.bool, - hasMore: PropTypes.bool, - isAccount: PropTypes.bool, - blockedBy: PropTypes.bool, - suspended: PropTypes.bool, - multiColumn: PropTypes.bool, - }; - - state = { - width: 323, - }; - - _load () { - const { accountId, isAccount, dispatch } = this.props; - - if (!isAccount) dispatch(fetchAccount(accountId)); - dispatch(expandAccountMediaTimeline(accountId)); - } - - componentDidMount () { - const { params: { acct }, accountId, dispatch } = this.props; - - if (accountId) { - this._load(); - } else { - dispatch(lookupAccount(acct)); - } - } - - componentDidUpdate (prevProps) { - const { params: { acct }, accountId, dispatch } = this.props; - - if (prevProps.accountId !== accountId && accountId) { - this._load(); - } else if (prevProps.params.acct !== acct) { - dispatch(lookupAccount(acct)); - } - } - - 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 => { - this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId })); - }; - - handleLoadOlder = e => { - e.preventDefault(); - this.handleScrollToBottom(); - }; - - handleOpenMedia = attachment => { - const { dispatch } = this.props; - const statusId = attachment.getIn(['status', 'id']); - const lang = attachment.getIn(['status', 'language']); - - if (attachment.get('type') === 'video') { - dispatch(openModal({ - modalType: 'VIDEO', - modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } }, - })); - } else if (attachment.get('type') === 'audio') { - dispatch(openModal({ - modalType: 'AUDIO', - modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } }, - })); - } else { - const media = attachment.getIn(['status', 'media_attachments']); - const index = media.findIndex(x => x.get('id') === attachment.get('id')); - - dispatch(openModal({ - modalType: 'MEDIA', - modalProps: { media, index, statusId, lang }, - })); - } - }; - - handleRef = c => { - if (c) { - this.setState({ width: c.offsetWidth }); - } - }; - - render () { - const { attachments, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props; - const { width } = this.state; - - if (!isAccount) { - return ( - - ); - } - - if (!attachments && isLoading) { - return ( - - - - ); - } - - let loadOlder = null; - - if (hasMore && !(isLoading && attachments.size === 0)) { - loadOlder = ; - } - - let emptyMessage; - - if (suspended) { - emptyMessage = ; - } else if (blockedBy) { - emptyMessage = ; - } - - return ( - - - - -
- - - {(suspended || blockedBy) ? ( -
- {emptyMessage} -
- ) : ( -
- {attachments.map((attachment, index) => attachment === null ? ( - 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> - ) : ( - - ))} - - {loadOlder} -
- )} - - {isLoading && attachments.size === 0 && ( -
- -
- )} -
-
-
- ); - } - -} - -export default connect(mapStateToProps)(AccountGallery); diff --git a/app/javascript/mastodon/features/account_gallery/index.tsx b/app/javascript/mastodon/features/account_gallery/index.tsx new file mode 100644 index 0000000000..228adca6c9 --- /dev/null +++ b/app/javascript/mastodon/features/account_gallery/index.tsx @@ -0,0 +1,283 @@ +import { useEffect, useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useParams } from 'react-router-dom'; + +import { createSelector } from '@reduxjs/toolkit'; +import type { Map as ImmutableMap } from 'immutable'; +import { List as ImmutableList } from 'immutable'; + +import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts'; +import { openModal } from 'mastodon/actions/modal'; +import { expandAccountMediaTimeline } from 'mastodon/actions/timelines'; +import { ColumnBackButton } from 'mastodon/components/column_back_button'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import { TimelineHint } from 'mastodon/components/timeline_hint'; +import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; +import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint'; +import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; +import Column from 'mastodon/features/ui/components/column'; +import type { MediaAttachment } from 'mastodon/models/media_attachment'; +import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; +import { getAccountHidden } from 'mastodon/selectors/accounts'; +import type { RootState } from 'mastodon/store'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +import { MediaItem } from './components/media_item'; + +const getAccountGallery = createSelector( + [ + (state: RootState, accountId: string) => + (state.timelines as ImmutableMap).getIn( + [`account:${accountId}:media`, 'items'], + ImmutableList(), + ) as ImmutableList, + (state: RootState) => state.statuses, + ], + (statusIds, statuses) => { + let items = ImmutableList(); + + statusIds.forEach((statusId) => { + const status = statuses.get(statusId) as + | ImmutableMap + | undefined; + + if (status) { + items = items.concat( + ( + status.get('media_attachments') as ImmutableList + ).map((media) => media.set('status', status)), + ); + } + }); + + return items; + }, +); + +interface Params { + acct?: string; + id?: string; +} + +const RemoteHint: React.FC<{ + accountId: string; +}> = ({ accountId }) => { + const account = useAppSelector((state) => state.accounts.get(accountId)); + const acct = account?.acct; + const url = account?.url; + const domain = acct ? acct.split('@')[1] : undefined; + + if (!url) { + return null; + } + + return ( + + } + label={ + {domain} }} + /> + } + /> + ); +}; + +export const AccountGallery: React.FC<{ + multiColumn: boolean; +}> = ({ multiColumn }) => { + const { acct, id } = useParams(); + const dispatch = useAppDispatch(); + const accountId = useAppSelector( + (state) => + id ?? + (state.accounts_map.get(normalizeForLookup(acct)) as string | undefined), + ); + const attachments = useAppSelector((state) => + accountId + ? getAccountGallery(state, accountId) + : ImmutableList(), + ); + const isLoading = useAppSelector((state) => + (state.timelines as ImmutableMap).getIn([ + `account:${accountId}:media`, + 'isLoading', + ]), + ); + const hasMore = useAppSelector((state) => + (state.timelines as ImmutableMap).getIn([ + `account:${accountId}:media`, + 'hasMore', + ]), + ); + const account = useAppSelector((state) => + accountId ? state.accounts.get(accountId) : undefined, + ); + const blockedBy = useAppSelector( + (state) => + state.relationships.getIn([accountId, 'blocked_by'], false) as boolean, + ); + const suspended = useAppSelector( + (state) => state.accounts.getIn([accountId, 'suspended'], false) as boolean, + ); + const isAccount = !!account; + const remote = account?.acct !== account?.username; + const hidden = useAppSelector((state) => + accountId ? getAccountHidden(state, accountId) : false, + ); + const maxId = attachments.last()?.getIn(['status', 'id']) as + | string + | undefined; + + useEffect(() => { + if (!accountId) { + dispatch(lookupAccount(acct)); + } + }, [dispatch, accountId, acct]); + + useEffect(() => { + if (accountId && !isAccount) { + dispatch(fetchAccount(accountId)); + } + + if (accountId) { + void dispatch(expandAccountMediaTimeline(accountId)); + } + }, [dispatch, accountId, isAccount]); + + const handleLoadMore = useCallback(() => { + if (maxId) { + void dispatch(expandAccountMediaTimeline(accountId, { maxId })); + } + }, [dispatch, accountId, maxId]); + + const handleOpenMedia = useCallback( + (attachment: MediaAttachment) => { + const statusId = attachment.getIn(['status', 'id']); + const lang = attachment.getIn(['status', 'language']); + + if (attachment.get('type') === 'video') { + dispatch( + openModal({ + modalType: 'VIDEO', + modalProps: { + media: attachment, + statusId, + lang, + options: { autoPlay: true }, + }, + }), + ); + } else if (attachment.get('type') === 'audio') { + dispatch( + openModal({ + modalType: 'AUDIO', + modalProps: { + media: attachment, + statusId, + lang, + options: { autoPlay: true }, + }, + }), + ); + } else { + const media = attachment.getIn([ + 'status', + 'media_attachments', + ]) as ImmutableList; + const index = media.findIndex( + (x) => x.get('id') === attachment.get('id'), + ); + + dispatch( + openModal({ + modalType: 'MEDIA', + modalProps: { media, index, statusId, lang }, + }), + ); + } + }, + [dispatch], + ); + + if (accountId && !isAccount) { + return ; + } + + let emptyMessage; + + if (accountId) { + if (suspended) { + emptyMessage = ( + + ); + } else if (hidden) { + emptyMessage = ; + } else if (blockedBy) { + emptyMessage = ( + + ); + } else if (remote && attachments.isEmpty()) { + emptyMessage = ; + } else { + emptyMessage = ( + + ); + } + } + + const forceEmptyState = suspended || blockedBy || hidden; + + return ( + + + + + ) + } + alwaysPrepend + append={remote && accountId && } + scrollKey='account_gallery' + isLoading={isLoading} + hasMore={!forceEmptyState && hasMore} + onLoadMore={handleLoadMore} + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {attachments.map((attachment) => ( + + ))} + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default AccountGallery; diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 6d787272ea..2d0a893608 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -94,15 +94,13 @@ export const makeGetReport = () => createSelector([ export const getAccountGallery = createSelector([ (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()), state => state.get('statuses'), - (state, id) => state.getIn(['accounts', id]), -], (statusIds, statuses, account) => { +], (statusIds, statuses) => { let medias = ImmutableList(); statusIds.forEach(statusId => { - let status = statuses.get(statusId); + const status = statuses.get(statusId); if (status) { - status = status.set('account', account); medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status))); } }); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 75c38d91f2..5e44553da8 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -7398,7 +7398,8 @@ a.status-card { border-radius: 0; } - .load-more { + .load-more, + .timeline-hint { grid-column: span 3; } }