diff --git a/app/javascript/flavours/glitch/components/scrollable_list.jsx b/app/javascript/flavours/glitch/components/scrollable_list.jsx index c7108c28e1..836cc5ee20 100644 --- a/app/javascript/flavours/glitch/components/scrollable_list.jsx +++ b/app/javascript/flavours/glitch/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 || hasMore || !emptyMessage) { scrollableArea = (
-
- {prepend} + {prepend} +
{loadPending} {Children.map(this.props.children, (child, index) => ( diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.tsx b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.tsx index 1d2c5e652d..f398505a9a 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.tsx +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.tsx @@ -15,11 +15,15 @@ import { useBlurhash, } from 'flavours/glitch/initial_state'; import type { Status, MediaAttachment } from 'flavours/glitch/models/status'; +import { useAppSelector } from 'flavours/glitch/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,7 @@ export const MediaItem: React.FC<{ attachment.get('description')) as string | undefined; const previewUrl = attachment.get('preview_url') as string; const fullUrl = attachment.get('url') as string; - const avatarUrl = status.getIn(['account', 'avatar_static']) as string; + const avatarUrl = account?.avatar_static; const lang = status.get('language') as string; const blurhash = attachment.get('blurhash') as string; const statusUrl = status.get('url') as string; diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.jsx b/app/javascript/flavours/glitch/features/account_gallery/index.jsx deleted file mode 100644 index b40a0cac41..0000000000 --- a/app/javascript/flavours/glitch/features/account_gallery/index.jsx +++ /dev/null @@ -1,239 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts'; -import { openModal } from 'flavours/glitch/actions/modal'; -import { LoadMore } from 'flavours/glitch/components/load_more'; -import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; -import ScrollContainer from 'flavours/glitch/containers/scroll_container'; -import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; -import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; -import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; -import { getAccountGallery } from 'flavours/glitch/selectors'; - -import { expandAccountMediaTimeline } from '../../actions/timelines'; -import { AccountHeader } from '../account_timeline/components/account_header'; -import Column from '../ui/components/column'; - -import { MediaItem } from './components/media_item'; - -const mapStateToProps = (state, { params: { acct, id } }) => { - 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), - }; -}; - -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, - 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)); - } - } - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - 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(); - }; - - setColumnRef = c => { - this.column = c; - }; - - 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, suspended } = this.props; - const { width } = this.state; - - if (!isAccount) { - return ( - - ); - } - - if (!attachments && isLoading) { - return ( - - - - ); - } - - let loadOlder = null; - - if (hasMore && !(isLoading && attachments.size === 0)) { - loadOlder = ; - } - - return ( - - - - -
- - - {suspended ? ( -
- -
- ) : ( -
- {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/flavours/glitch/features/account_gallery/index.tsx b/app/javascript/flavours/glitch/features/account_gallery/index.tsx new file mode 100644 index 0000000000..c95cda074c --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_gallery/index.tsx @@ -0,0 +1,291 @@ +import { useEffect, useCallback } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } 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 PersonIcon from '@/material-icons/400-24px/person.svg?react'; +import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import { TimelineHint } from 'flavours/glitch/components/timeline_hint'; +import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header'; +import { LimitedAccountHint } from 'flavours/glitch/features/account_timeline/components/limited_account_hint'; +import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; +import Column from 'flavours/glitch/features/ui/components/column'; +import type { MediaAttachment } from 'flavours/glitch/models/media_attachment'; +import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; +import { getAccountHidden } from 'flavours/glitch/selectors/accounts'; +import type { RootState } from 'flavours/glitch/store'; +import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; + +import { MediaItem } from './components/media_item'; + +const messages = defineMessages({ + profile: { id: 'column_header.profile', defaultMessage: 'Profile' }, +}); + +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 intl = useIntl(); + 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 && isAccount) { + 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/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index b686e32402..303f75470d 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -92,25 +92,6 @@ export const makeGetReport = () => createSelector([ (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]), ], (base, targetAccount) => base.set('target_account', targetAccount)); -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) => { - let medias = ImmutableList(); - - statusIds.forEach(statusId => { - let status = statuses.get(statusId); - - if (status) { - status = status.set('account', account); - medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status))); - } - }); - - return medias; -}); - export const getStatusList = createSelector([ (state, type) => state.getIn(['status_lists', type, 'items']), ], (items) => items.toList()); diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 9778d87dd8..1258368dd7 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -7805,7 +7805,8 @@ img.modal-warning { border-radius: 0; } - .load-more { + .load-more, + .timeline-hint { grid-column: span 3; } }