Add account media gallery view to web UI (#3120)

* Add account media gallery view to web UI

* Link media view from account dropdown

* Adjust link
lolsob-rspec
Eugen Rochko 2017-05-20 01:28:25 +02:00 committed by GitHub
parent eeb78b394c
commit 94b26e90fa
14 changed files with 381 additions and 20 deletions

View File

@ -37,6 +37,14 @@ export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST'
export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS';
export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL';
export const ACCOUNT_MEDIA_TIMELINE_FETCH_REQUEST = 'ACCOUNT_MEDIA_TIMELINE_FETCH_REQUEST';
export const ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS';
export const ACCOUNT_MEDIA_TIMELINE_FETCH_FAIL = 'ACCOUNT_MEDIA_TIMELINE_FETCH_FAIL';
export const ACCOUNT_MEDIA_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_MEDIA_TIMELINE_EXPAND_REQUEST';
export const ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS';
export const ACCOUNT_MEDIA_TIMELINE_EXPAND_FAIL = 'ACCOUNT_MEDIA_TIMELINE_EXPAND_FAIL';
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
@ -96,17 +104,17 @@ export function fetchAccountTimeline(id, replace = false) {
const ids = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
let params = {};
let skipLoading = false;
if (newestId !== null && !replace) {
params = `?since_id=${newestId}`;
params.since_id = newestId;
skipLoading = true;
}
dispatch(fetchAccountTimelineRequest(id, skipLoading));
api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
api(getState).get(`/api/v1/accounts/${id}/statuses`, { params }).then(response => {
dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading));
}).catch(error => {
dispatch(fetchAccountTimelineFail(id, error, skipLoading));
@ -114,6 +122,29 @@ export function fetchAccountTimeline(id, replace = false) {
};
};
export function fetchAccountMediaTimeline(id, replace = false) {
return (dispatch, getState) => {
const ids = getState().getIn(['timelines', 'accounts_media_timelines', id, 'items'], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = { only_media: 'true', limit: 12 };
let skipLoading = false;
if (newestId !== null && !replace) {
params.since_id = newestId;
skipLoading = true;
}
dispatch(fetchAccountMediaTimelineRequest(id, skipLoading));
api(getState).get(`/api/v1/accounts/${id}/statuses`, { params }).then(response => {
dispatch(fetchAccountMediaTimelineSuccess(id, response.data, replace, skipLoading));
}).catch(error => {
dispatch(fetchAccountMediaTimelineFail(id, error, skipLoading));
});
};
};
export function expandAccountTimeline(id) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last();
@ -134,6 +165,27 @@ export function expandAccountTimeline(id) {
};
};
export function expandAccountMediaTimeline(id) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', 'accounts_media_timelines', id, 'items'], Immutable.List()).last();
dispatch(expandAccountMediaTimelineRequest(id));
api(getState).get(`/api/v1/accounts/${id}/statuses`, {
params: {
limit: 12,
only_media: 'true',
max_id: lastId
}
}).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandAccountMediaTimelineSuccess(id, response.data, next));
}).catch(error => {
dispatch(expandAccountMediaTimelineFail(id, error));
});
};
};
export function fetchAccountRequest(id) {
return {
type: ACCOUNT_FETCH_REQUEST,
@ -251,6 +303,34 @@ export function fetchAccountTimelineFail(id, error, skipLoading) {
};
};
export function fetchAccountMediaTimelineRequest(id, skipLoading) {
return {
type: ACCOUNT_MEDIA_TIMELINE_FETCH_REQUEST,
id,
skipLoading
};
};
export function fetchAccountMediaTimelineSuccess(id, statuses, replace, skipLoading) {
return {
type: ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS,
id,
statuses,
replace,
skipLoading
};
};
export function fetchAccountMediaTimelineFail(id, error, skipLoading) {
return {
type: ACCOUNT_MEDIA_TIMELINE_FETCH_FAIL,
id,
error,
skipLoading,
skipAlert: error.response.status === 404
};
};
export function expandAccountTimelineRequest(id) {
return {
type: ACCOUNT_TIMELINE_EXPAND_REQUEST,
@ -275,6 +355,30 @@ export function expandAccountTimelineFail(id, error) {
};
};
export function expandAccountMediaTimelineRequest(id) {
return {
type: ACCOUNT_MEDIA_TIMELINE_EXPAND_REQUEST,
id
};
};
export function expandAccountMediaTimelineSuccess(id, statuses, next) {
return {
type: ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS,
id,
statuses,
next
};
};
export function expandAccountMediaTimelineFail(id, error) {
return {
type: ACCOUNT_MEDIA_TIMELINE_EXPAND_FAIL,
id,
error
};
};
export function blockAccount(id) {
return (dispatch, getState) => {
dispatch(blockAccountRequest(id));

View File

@ -4,6 +4,10 @@ import PropTypes from 'prop-types';
class DropdownMenu extends React.PureComponent {
static contextTypes = {
router: PropTypes.object
};
static propTypes = {
icon: PropTypes.string.isRequired,
items: PropTypes.array.isRequired,
@ -26,13 +30,17 @@ class DropdownMenu extends React.PureComponent {
handleClick = (e) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action } = this.props.items[i];
const { action, to } = this.props.items[i];
e.preventDefault();
if (typeof action === 'function') {
e.preventDefault();
action();
this.dropdown.hide();
} else if (to) {
this.context.router.push(to);
}
this.dropdown.hide();
}
renderItem = (item, i) => {

View File

@ -26,6 +26,7 @@ import GettingStarted from '../features/getting_started';
import PublicTimeline from '../features/public_timeline';
import CommunityTimeline from '../features/community_timeline';
import AccountTimeline from '../features/account_timeline';
import AccountGallery from '../features/account_gallery';
import HomeTimeline from '../features/home_timeline';
import Compose from '../features/compose';
import Followers from '../features/followers';
@ -204,6 +205,7 @@ class Mastodon extends React.PureComponent {
<Route path='accounts/:accountId' component={AccountTimeline} />
<Route path='accounts/:accountId/followers' component={Followers} />
<Route path='accounts/:accountId/following' component={Following} />
<Route path='accounts/:accountId/media' component={AccountGallery} />
<Route path='follow_requests' component={FollowRequests} />
<Route path='blocks' component={Blocks} />

View File

@ -15,6 +15,7 @@ const messages = defineMessages({
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
media: { id: 'account.media', defaultMessage: 'Media' },
disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' },
blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
@ -43,6 +44,8 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` });
menu.push(null);
if (account.get('id') === me) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });

View File

@ -0,0 +1,39 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Permalink from '../../../components/permalink';
class MediaItem extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired
};
render () {
const { media } = this.props;
const status = media.get('status');
let content, style;
if (media.get('type') === 'gifv') {
content = <span className='media-gallery__gifv__label'>GIF</span>;
}
if (!status.get('sensitive')) {
style = { backgroundImage: `url(${media.get('preview_url')})` };
}
return (
<div className='account-gallery__item'>
<Permalink
to={`/statuses/${status.get('id')}`}
href={status.get('url')}
style={style}>
{content}
</Permalink>
</div>
);
}
}
export default MediaItem;

View File

@ -0,0 +1,99 @@
import React from 'react';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import {
fetchAccount,
fetchAccountMediaTimeline,
expandAccountMediaTimeline
} from '../../actions/accounts';
import LoadingIndicator from '../../components/loading_indicator';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
import Immutable from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { getAccountGallery } from '../../selectors';
import MediaItem from './components/media_item';
import HeaderContainer from '../account_timeline/containers/header_container';
import { FormattedMessage } from 'react-intl';
import { ScrollContainer } from 'react-router-scroll';
const mapStateToProps = (state, props) => ({
medias: getAccountGallery(state, Number(props.params.accountId)),
isLoading: state.getIn(['timelines', 'accounts_media_timelines', Number(props.params.accountId), 'isLoading']),
hasMore: !!state.getIn(['timelines', 'accounts_media_timelines', Number(props.params.accountId), 'next']),
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
});
class AccountGallery extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
medias: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
autoPlayGif: PropTypes.bool,
};
componentDidMount () {
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
this.props.dispatch(fetchAccountMediaTimeline(Number(this.props.params.accountId)));
}
componentWillReceiveProps (nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
this.props.dispatch(fetchAccountMediaTimeline(Number(this.props.params.accountId)));
}
}
handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandAccountMediaTimeline(Number(this.props.params.accountId)));
}
}
render () {
const { medias, autoPlayGif, isLoading } = this.props;
if (!medias && isLoading) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column>
<ColumnBackButton />
<ScrollContainer scrollKey='account_gallery'>
<div className='scrollable' onScroll={this.handleScroll}>
<HeaderContainer accountId={this.props.params.accountId} />
<div className='account-section-headline'>
<FormattedMessage id='account.media' defaultMessage='Media' />
</div>
<div className='account-gallery__container'>
{medias.map(media =>
<MediaItem
key={media.get('id')}
media={media}
autoPlayGif={autoPlayGif}
/>
)}
</div>
</div>
</ScrollContainer>
</Column>
);
}
}
export default connect(mapStateToProps)(AccountGallery);

View File

@ -28,16 +28,11 @@ class Blocks extends ImmutablePureComponent {
intl: PropTypes.object.isRequired
};
constructor (props, context) {
super(props, context);
this.handleScroll = this.handleScroll.bind(this);
}
componentWillMount () {
this.props.dispatch(fetchBlocks());
}
handleScroll (e) {
handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {

View File

@ -26,7 +26,7 @@ class Favourites extends ImmutablePureComponent {
this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
}
componentWillReceiveProps(nextProps) {
componentWillReceiveProps (nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
}

View File

@ -21,16 +21,11 @@ const mapStateToProps = state => ({
class Mutes extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleScroll = this.handleScroll.bind(this);
}
componentWillMount () {
this.props.dispatch(fetchMutes());
}
handleScroll (e) {
handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {

View File

@ -7,7 +7,7 @@
"account.followers": "フォロワー",
"account.follows": "フォロー",
"account.follows_you": "フォローされています",
"account.media": "Media",
"account.media": "メディア",
"account.mention": "返信",
"account.mute": "ミュート",
"account.posts": "投稿",

View File

@ -23,6 +23,8 @@ import {
import {
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS,
ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS,
ACCOUNT_BLOCK_SUCCESS
} from '../actions/accounts';
import {
@ -113,6 +115,8 @@ export default function statuses(state = initialState, action) {
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:

View File

@ -24,6 +24,12 @@ import {
ACCOUNT_TIMELINE_EXPAND_REQUEST,
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_FAIL,
ACCOUNT_MEDIA_TIMELINE_FETCH_REQUEST,
ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS,
ACCOUNT_MEDIA_TIMELINE_FETCH_FAIL,
ACCOUNT_MEDIA_TIMELINE_EXPAND_REQUEST,
ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS,
ACCOUNT_MEDIA_TIMELINE_EXPAND_FAIL,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS
} from '../actions/accounts';
@ -79,6 +85,7 @@ const initialState = Immutable.Map({
}),
accounts_timelines: Immutable.Map(),
accounts_media_timelines: Immutable.Map(),
ancestors: Immutable.Map(),
descendants: Immutable.Map()
});
@ -148,6 +155,20 @@ const normalizeAccountTimeline = (state, accountId, statuses, replace = false) =
.update('items', Immutable.List(), list => (replace ? ids : ids.concat(list))));
};
const normalizeAccountMediaTimeline = (state, accountId, statuses, next) => {
let ids = Immutable.List();
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
ids = ids.set(i, status.get('id'));
});
return state.updateIn(['accounts_media_timelines', accountId], Immutable.Map(), map => map
.set('isLoading', false)
.set('next', next)
.update('items', Immutable.List(), list => ids.concat(list)));
};
const appendNormalizedAccountTimeline = (state, accountId, statuses, next) => {
let moreIds = Immutable.List([]);
@ -162,6 +183,20 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses, next) => {
.update('items', list => list.concat(moreIds)));
};
const appendNormalizedAccountMediaTimeline = (state, accountId, statuses, next) => {
let moreIds = Immutable.List([]);
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
moreIds = moreIds.set(i, status.get('id'));
});
return state.updateIn(['accounts_media_timelines', accountId], Immutable.Map(), map => map
.set('isLoading', false)
.set('next', next)
.update('items', list => list.concat(moreIds)));
};
const updateTimeline = (state, timeline, status, references) => {
const top = state.getIn([timeline, 'top']);
@ -205,6 +240,7 @@ const deleteStatus = (state, id, accountId, references, reblogOf) => {
// Remove references from account timelines
state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id));
state = state.updateIn(['accounts_media_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id));
// Remove references from context
state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
@ -302,6 +338,16 @@ export default function timelines(state = initialState, action) {
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.next);
case ACCOUNT_MEDIA_TIMELINE_FETCH_REQUEST:
case ACCOUNT_MEDIA_TIMELINE_EXPAND_REQUEST:
return state.updateIn(['accounts_media_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true));
case ACCOUNT_MEDIA_TIMELINE_FETCH_FAIL:
case ACCOUNT_MEDIA_TIMELINE_EXPAND_FAIL:
return state.updateIn(['accounts_media_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false));
case ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS:
return normalizeAccountMediaTimeline(state, action.id, Immutable.fromJS(action.statuses), action.next);
case ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountMediaTimeline(state, action.id, Immutable.fromJS(action.statuses), action.next);
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
return filterTimelines(state, action.relationship, action.statuses);

View File

@ -74,3 +74,17 @@ export const makeGetNotification = () => {
return base.set('account', account);
});
};
export const getAccountGallery = createSelector([
(state, id) => state.getIn(['timelines', 'accounts_media_timelines', id, 'items'], Immutable.List()),
state => state.get('statuses'),
], (statusIds, statuses) => {
let medias = Immutable.List();
statusIds.forEach(statusId => {
const status = statuses.get(statusId);
medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status)));
});
return medias;
});

View File

@ -3427,3 +3427,55 @@ button.icon-button.active i.fa-retweet {
transform: translate(-50%, -50%);
}
/* End Video Player */
.account-gallery__container {
margin: -2px;
padding: 4px;
}
.account-gallery__item {
float: left;
width: 96px;
height: 96px;
margin: 2px;
a {
display: block;
width: 100%;
height: 100%;
background-color: $base-overlay-background;
background-size: cover;
background-position: center;
position: relative;
}
}
.account-section-headline {
color: lighten($ui-base-color, 26%);
background: lighten($ui-base-color, 2%);
border-bottom: 1px solid lighten($ui-base-color, 4%);
padding: 15px 10px;
font-size: 14px;
font-weight: 500;
position: relative;
cursor: default;
&::before,
&::after {
display: block;
content: "";
position: absolute;
bottom: 0;
left: 18px;
width: 0;
height: 0;
border-style: solid;
border-width: 0 10px 10px;
border-color: transparent transparent lighten($ui-base-color, 4%);
}
&::after {
bottom: -1px;
border-color: transparent transparent $ui-base-color;
}
}