about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/javascript/mastodon/actions/accounts.js110
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js14
-rw-r--r--app/javascript/mastodon/containers/mastodon.js2
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js3
-rw-r--r--app/javascript/mastodon/features/account_gallery/components/media_item.js39
-rw-r--r--app/javascript/mastodon/features/account_gallery/index.js99
-rw-r--r--app/javascript/mastodon/features/blocks/index.js7
-rw-r--r--app/javascript/mastodon/features/favourites/index.js2
-rw-r--r--app/javascript/mastodon/features/mutes/index.js7
-rw-r--r--app/javascript/mastodon/locales/ja.json2
-rw-r--r--app/javascript/mastodon/reducers/statuses.js4
-rw-r--r--app/javascript/mastodon/reducers/timelines.js46
-rw-r--r--app/javascript/mastodon/selectors/index.js14
-rw-r--r--app/javascript/styles/components.scss52
14 files changed, 381 insertions, 20 deletions
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index eac5c78bb..2b7bce681 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -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));
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 4b6168f43..03595318c 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -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) => {
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index 48648d2ca..165a6f045 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -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} />
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
index 1997bf7b7..3a47a1686 100644
--- a/app/javascript/mastodon/features/account/components/action_bar.js
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -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' });
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js
new file mode 100644
index 000000000..7d8dddf87
--- /dev/null
+++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js
@@ -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;
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
new file mode 100644
index 000000000..28b19a269
--- /dev/null
+++ b/app/javascript/mastodon/features/account_gallery/index.js
@@ -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);
diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js
index 4af48cf9c..d8fd29325 100644
--- a/app/javascript/mastodon/features/blocks/index.js
+++ b/app/javascript/mastodon/features/blocks/index.js
@@ -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) {
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
index 479eef0a1..9571c2b0b 100644
--- a/app/javascript/mastodon/features/favourites/index.js
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -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)));
     }
diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js
index 884b3b3e7..01d4ac8f7 100644
--- a/app/javascript/mastodon/features/mutes/index.js
+++ b/app/javascript/mastodon/features/mutes/index.js
@@ -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) {
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 38745e20f..b4445719d 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -7,7 +7,7 @@
   "account.followers": "フォロワー",
   "account.follows": "フォロー",
   "account.follows_you": "フォローされています",
-  "account.media": "Media",
+  "account.media": "メディア",
   "account.mention": "返信",
   "account.mute": "ミュート",
   "account.posts": "投稿",
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 282380c37..6d38d905d 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -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:
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index 31e79f9f6..5c37b0dcb 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -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);
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index c2a8fd4cf..f1697cc2d 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -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;
+});
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index a2109838c..71453fe0e 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -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;
+  }
+}