logo

mastofe

My custom branche(s) on git.pleroma.social/pleroma/mastofe
commit: de475cf8d32744330f8029f13c539237a6567029
parent: b369fc2de4ab0242775a56fb6208d9dbf2109d91
Author: Eugen Rochko <eugen@zeonfederated.com>
Date:   Sat, 20 May 2017 01:28:25 +0200

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

Diffstat:

Mapp/javascript/mastodon/actions/accounts.js110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mapp/javascript/mastodon/components/dropdown_menu.js14+++++++++++---
Mapp/javascript/mastodon/containers/mastodon.js2++
Mapp/javascript/mastodon/features/account/components/action_bar.js3+++
Aapp/javascript/mastodon/features/account_gallery/components/media_item.js39+++++++++++++++++++++++++++++++++++++++
Aapp/javascript/mastodon/features/account_gallery/index.js99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/javascript/mastodon/features/blocks/index.js7+------
Mapp/javascript/mastodon/features/favourites/index.js2+-
Mapp/javascript/mastodon/features/mutes/index.js7+------
Mapp/javascript/mastodon/locales/ja.json2+-
Mapp/javascript/mastodon/reducers/statuses.js4++++
Mapp/javascript/mastodon/reducers/timelines.js46++++++++++++++++++++++++++++++++++++++++++++++
Mapp/javascript/mastodon/selectors/index.js14++++++++++++++
Mapp/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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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; + } +}