Updated all basic components

removed unnecessary components, combined where necessary
added each component to a folder, added individual css style modules
optimized some component rendering flows
removed functional components in favor of pure components
linted and formatted all of the files
This commit is contained in:
mgabdev 2019-08-03 02:00:45 -04:00
parent 16a9bc6e93
commit 42917806e9
84 changed files with 2833 additions and 1558 deletions

View File

@ -1,12 +1,14 @@
import React, { Fragment } from 'react';
import { Fragment } from 'react';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from './avatar';
import DisplayName from './display_name';
import Permalink from './permalink';
import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state';
import Avatar from '../avatar';
import DisplayName from '../display_name';
import IconButton from '../icon_button';
import { me } from '../../initial_state';
import './index.scss';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
@ -81,8 +83,8 @@ class Account extends ImmutablePureComponent {
} else if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
@ -95,6 +97,7 @@ class Account extends ImmutablePureComponent {
} else {
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />;
}
buttons = (
<Fragment>
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
@ -109,10 +112,10 @@ class Account extends ImmutablePureComponent {
return (
<div className='account'>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={`/${account.get('acct')}`} to={`/${account.get('acct')}`}>
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Permalink>
</Link>
<div className='account__relationship'>
{buttons}

View File

@ -0,0 +1,42 @@
.account {
padding: 10px;
&:not(:last-of-type) {
border-bottom: 1px solid lighten($ui-base-color, 8%);
}
&__display-name {
display: block;
flex: 1 1 auto;
color: $darker-text-color;
overflow: hidden;
text-decoration: none;
font-size: 14px;
strong {
display: block;
color: $primary-text-color;
@include text-overflow(nowrap);
}
}
&__wrapper {
display: flex;
}
&__relationship {
height: auto;
position: relative;
padding: 0 0 0 5px;
}
&__avatar-wrapper {
float: left;
margin-right: 12px;
}
&__avatar {
display: block;
}
}

View File

@ -1,56 +0,0 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'gabsocial/components/icon';
const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
export default class AttachmentList extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.list.isRequired,
compact: PropTypes.bool,
};
render () {
const { media, compact } = this.props;
if (compact) {
return (
<div className='attachment-list compact'>
<ul className='attachment-list__list'>
{media.map(attachment => {
const displayUrl = attachment.get('remote_url') || attachment.get('url');
return (
<li key={attachment.get('id')}>
<a href={displayUrl} target='_blank' rel='noopener'><Icon id='link' /> {filename(displayUrl)}</a>
</li>
);
})}
</ul>
</div>
);
}
return (
<div className='attachment-list'>
<div className='attachment-list__icon'>
<Icon id='link' />
</div>
<ul className='attachment-list__list'>
{media.map(attachment => {
const displayUrl = attachment.get('remote_url') || attachment.get('url');
return (
<li key={attachment.get('id')}>
<a href={displayUrl} target='_blank' rel='noopener'>{filename(displayUrl)}</a>
</li>
);
})}
</ul>
</div>
);
}
}

View File

@ -1,4 +1,6 @@
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
import unicodeMapping from '../../features/emoji/emoji_unicode_mapping_light';
import './index.scss';
const assetHost = process.env.CDN_HOST || '';
@ -17,21 +19,14 @@ export default class AutosuggestEmoji extends PureComponent {
} else {
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
if (!mapping) {
return null;
}
if (!mapping) return null;
url = `${assetHost}/emoji/${mapping.filename}.svg`;
}
return (
<div className='autosuggest-emoji'>
<img
className='emojione'
src={url}
alt={emoji.native || emoji.colons}
/>
<img className='emojione' src={url} alt={emoji.native || emoji.colons} />
{emoji.colons}
</div>
);

View File

@ -0,0 +1,28 @@
.autosuggest-emoji {
display: flex;
justify-items: center;
align-content: flex-start;
flex-direction: row;
@include text-sizing(14px, 400, 18px);
img {
display: block;
margin-right: 8px;
@include size(16px);
}
}
.emojione {
font-size: inherit;
vertical-align: middle;
object-fit: contain;
margin: -.2ex .15em .2ex;
@include size(16px);
img {
width: auto;
}
}

View File

@ -1,242 +0,0 @@
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { isRtl } from '../utils/rtl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import classNames from 'classnames';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
let left = str.slice(0, caretPosition).search(/\S+$/);
let right = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
export default class AutosuggestTextarea extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
};
static defaultProps = {
autoFocus: true,
};
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange = (e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
} else if (token === null) {
this.setState({ lastToken: null });
this.props.onSuggestionsClearRequested();
}
this.props.onChange(e);
}
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
if (disabled) {
e.preventDefault();
return;
}
if (e.which === 229 || e.isComposing) {
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
return;
}
switch(e.key) {
case 'Escape':
if (suggestions.size === 0 || suggestionsHidden) {
document.querySelector('.ui').parentElement.focus();
} else {
e.preventDefault();
this.setState({ suggestionsHidden: true });
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
}
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
}
onBlur = () => {
this.setState({ suggestionsHidden: true, focused: false });
if (this.props.onBlur) {
this.props.onBlur();
}
}
onFocus = () => {
this.setState({ focused: true });
if (this.props.onFocus) {
this.props.onFocus();
}
}
onSuggestionClick = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea.focus();
}
componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
this.setState({ suggestionsHidden: false });
}
}
setTextarea = (c) => {
this.textarea = c;
}
onPaste = (e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
}
renderSuggestion = (suggestion, i) => {
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} else {
inner = <AutosuggestAccountContainer id={suggestion} />;
key = suggestion;
}
return (
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
{inner}
</div>
);
}
render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
return [
<div className='compose-form__autosuggest-wrapper' key='compose-form__autosuggest-wrapper'>
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
inputRef={this.setTextarea}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style}
aria-autocomplete='list'
/>
</label>
</div>
{children}
</div>,
<div className='autosuggest-textarea__suggestions-wrapper' key='autosuggest-textarea__suggestions-wrapper'>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>,
];
}
}

View File

@ -1,37 +1,15 @@
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { isRtl } from '../utils/rtl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames';
import { List as ImmutableList } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from '../autosuggest_emoji';
import { isRtl } from '../../utils/rtl';
import { textAtCursorMatchesToken } from '../../utils/cursor_token_match';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;
import './index.scss';
let left = str.slice(0, caretPosition).search(/\S+$/);
let right = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
export default class AutosuggestInput extends ImmutablePureComponent {
export default class AutosuggestTextbox extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
@ -49,11 +27,16 @@ export default class AutosuggestInput extends ImmutablePureComponent {
id: PropTypes.string,
searchTokens: PropTypes.arrayOf(PropTypes.string),
maxLength: PropTypes.number,
onPaste: PropTypes.func,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
textarea: PropTypes.bool,
};
static defaultProps = {
autoFocus: true,
searchTokens: ImmutableList(['@', ':', '#']),
searchTokens: ['@', ':', '#'],
textarea: false,
};
state = {
@ -87,11 +70,9 @@ export default class AutosuggestInput extends ImmutablePureComponent {
return;
}
if (e.which === 229 || e.isComposing) {
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
return;
}
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
if (e.which === 229 || e.isComposing) return;
switch(e.key) {
case 'Escape':
@ -129,26 +110,39 @@ export default class AutosuggestInput extends ImmutablePureComponent {
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
if (e.defaultPrevented || !this.props.onKeyDown) return;
this.props.onKeyDown(e);
}
onBlur = () => {
this.setState({ suggestionsHidden: true, focused: false });
if (this.props.onBlur) {
this.props.onBlur();
}
}
onFocus = () => {
this.setState({ focused: true });
if (this.props.onFocus) {
this.props.onFocus();
}
}
onPaste = (e) => {
if (this.props.onPaste && e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
}
onSuggestionClick = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.input.focus();
this.textbox.focus();
}
componentWillReceiveProps (nextProps) {
@ -157,8 +151,8 @@ export default class AutosuggestInput extends ImmutablePureComponent {
}
}
setInput = (c) => {
this.input = c;
setTextbox = (c) => {
this.textbox = c;
}
renderSuggestion = (suggestion, i) => {
@ -167,24 +161,35 @@ export default class AutosuggestInput extends ImmutablePureComponent {
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
key = suggestion;
} else {
inner = <AutosuggestAccountContainer id={suggestion} />;
key = suggestion;
key = suggestion;
}
const classes = classNames('autosuggest-textarea__suggestions__item', {
selected: i === selectedSuggestion,
});
return (
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
<div
role='button'
tabIndex='0'
key={key}
data-index={i}
className={classes}
onMouseDown={this.onSuggestionClick}
>
{inner}
</div>
);
}
render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children, className, id, maxLength, textarea } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
@ -192,6 +197,41 @@ export default class AutosuggestInput extends ImmutablePureComponent {
style.direction = 'rtl';
}
if (textarea) {
return [
<div className='autosuggest-textarea__wrapper' key='autosuggest-textarea__wrapper'>
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
inputRef={this.setTextbox}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style}
aria-autocomplete='list'
/>
</label>
</div>
{children}
</div>,
<div className='autosuggest-textarea__suggestions-wrapper' key='autosuggest-textarea__suggestions-wrapper'>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>,
];
}
return (
<div className='autosuggest-input'>
<label>
@ -199,7 +239,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
<input
type='text'
ref={this.setInput}
ref={this.setTextbox}
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}

View File

@ -0,0 +1,86 @@
.autosuggest-input {
position: relative;
}
.autosuggest-textarea {
&__suggestions-wrapper {
position: relative;
height: 0;
}
&__wrapper {
position: relative;
}
&__textarea {
display: block;
box-sizing: border-box;
min-height: 100px;
border-radius: 5px 5px 0 0;
resize: none;
scrollbar-color: initial;
padding: 14px 32px 13px 10px !important;
width: 100%;
margin: 0;
color: $inverted-text-color;
background: $simple-background-color;
font-family: inherit;
font-size: 14px;
resize: vertical;
border: 0;
outline: 0;
body.theme-gabsocial-light & {
background: $gab-background-base-light;
}
&:focus {
outline: 0;
}
@media screen and (max-width: 600px) {
font-size: 16px;
}
&::-webkit-scrollbar {
all: unset;
}
@media screen and (max-width: 600px) {
max-height: 100px !important; // prevent auto-resize textarea
resize: vertical;
}
}
&__suggestions {
display: none;
box-sizing: border-box;
position: absolute;
top: 100%;
width: 100%;
z-index: 9999;
box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
background: $ui-secondary-color;
color: $inverted-text-color;
border-radius: 0 0 4px 4px;
font-size: 14px;
padding: 6px;
&--visible {
display: block;
}
&__item {
padding: 10px;
cursor: pointer;
border-radius: 4px;
&:hover,
&:focus,
&:active,
&.selected {
background: darken($ui-secondary-color, 10%);
}
}
}
}

View File

@ -1,32 +1,40 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Map as ImmutableMap } from 'immutable';
import classNames from 'classnames';
import { autoPlayGif } from '../../initial_state';
export default class Avatar extends React.PureComponent {
import './index.scss';
export default class Avatar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
size: PropTypes.number,
style: PropTypes.object,
inline: PropTypes.bool,
animate: PropTypes.bool,
};
static defaultProps = {
account: ImmutableMap(),
animate: autoPlayGif,
inline: false,
};
state = {
hovering: false,
sameImg: this.props.account.get('avatar') === this.props.account.get('avatar_static'),
};
handleMouseEnter = () => {
if (this.props.animate) return;
if (this.props.animate || this.state.sameImg) return;
this.setState({ hovering: true });
}
handleMouseLeave = () => {
if (this.props.animate) return;
if (this.props.animate || this.state.sameImg) return;
this.setState({ hovering: false });
}
@ -34,33 +42,26 @@ export default class Avatar extends React.PureComponent {
const { account, size, animate, inline } = this.props;
const { hovering } = this.state;
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
let className = 'account__avatar';
if (inline) {
className = className + ' account__avatar-inline';
}
// : TODO : remove inline and change all avatars to be sized using css
const style = !size ? {} : {
width: `${size}px`,
height: `${size}px`,
};
if (hovering || animate) {
style.backgroundImage = `url(${src})`;
} else {
style.backgroundImage = `url(${staticSrc})`;
}
const theSrc = account.get((hovering || animate) ? 'avatar' : 'avatar_static');
const className = classNames('account__avatar', {
'account__avatar--inline': inline,
});
return (
<div
<img
className={className}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={style}
src={theSrc}
alt={account.get('display_name')}
/>
);
}

View File

@ -0,0 +1,25 @@
.avatar {
// @include avatar-radius();
display: block;
position: relative;
// background-color: $ui-base-color;
object-fit: cover;
&--inline {
display: inline-block;
vertical-align: middle;
margin-right: 5px;
}
&-composite {
// @include avatar-radius();
overflow: hidden;
&>div {
// @include avatar-radius();
float: left;
position: relative;
box-sizing: border-box;
}
}
}

View File

@ -1,96 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
export default class AvatarComposite extends React.PureComponent {
static propTypes = {
accounts: ImmutablePropTypes.list.isRequired,
animate: PropTypes.bool,
size: PropTypes.number.isRequired,
};
static defaultProps = {
animate: autoPlayGif,
};
renderItem (account, size, index) {
const { animate } = this.props;
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && index > 0)) {
height = 50;
}
if (size === 2) {
if (index === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
} else if (index > 0) {
left = '2px';
}
if (index === 1) {
bottom = '2px';
} else if (index > 1) {
top = '2px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
}
if (index === 1 || index === 3) {
left = '2px';
}
if (index < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
const style = {
left: left,
top: top,
right: right,
bottom: bottom,
width: `${width}%`,
height: `${height}%`,
backgroundSize: 'cover',
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
};
return (
<div key={account.get('id')} style={style} />
);
}
render() {
const { accounts, size } = this.props;
return (
<div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}>
{accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))}
</div>
);
}
}

View File

@ -1,37 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
export default class AvatarOverlay extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
};
static defaultProps = {
animate: autoPlayGif,
};
render() {
const { account, friend, animate } = this.props;
const baseStyle = {
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
};
const overlayStyle = {
backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`,
};
return (
<div className='account__avatar-overlay'>
<div className='account__avatar-overlay-base' style={baseStyle} />
<div className='account__avatar-overlay-overlay' style={overlayStyle} />
</div>
);
}
}

View File

@ -0,0 +1,33 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { autoPlayGif } from '../../initial_state';
import './index.scss';
export default class AvatarOverlay extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
};
static defaultProps = {
animate: autoPlayGif,
};
render() {
const { account, friend, animate } = this.props;
const baseSrc = account.get(animate ? 'avatar' : 'avatar_static');
const overlaySrc = friend.get(animate ? 'avatar' : 'avatar_static');
return (
<div className='avatar-overlay'>
<img className='avatar-overlay__base' src={baseSrc} />
<img className='avatar-overlay__overlay' src={overlaySrc} />
</div>
);
}
}

View File

@ -0,0 +1,14 @@
.avatar-overlay {
@include circle(48px);
&__base {
@include circle(36px);
}
&__overlay {
@include circle(36px);
@include abs-position(auto, 0, 0);
z-index: 1;
}
}

View File

@ -0,0 +1,25 @@
import './index.scss';
export default class Badge extends PureComponent {
static propTypes = {
type: PropTypes.oneOf([
'pro',
'donor',
'investor',
]).isRequired,
};
render() {
const { type } = this.props;
if (!type) return null;
return (
<span className={`badge badge--${type}`}>
{type.toUpperCase()}
</span>
);
}
};

View File

@ -0,0 +1,23 @@
.badge {
text-transform: uppercase;
padding: 2px 6px;
border-radius: 2px;
margin: 0 5px 5px 0;
@include text-sizing(12px, 600, 1, center);
&--pro {
background-color: blueviolet;
color: #fff;
}
&--investor {
background-color: gold;
color: #000;
}
&--donor {
background-color: lightgreen;
color: #000;
}
}

View File

@ -8,18 +8,12 @@ export default class Button extends PureComponent {
disabled: PropTypes.bool,
block: PropTypes.bool,
secondary: PropTypes.bool,
size: PropTypes.number,
className: PropTypes.string,
style: PropTypes.object,
children: PropTypes.node,
};
static defaultProps = {
size: 36,
};
handleClick = (e) => {
if (!this.props.disabled) {
if (!this.props.disabled && this.props.onClick) {
this.props.onClick(e);
}
}
@ -33,15 +27,8 @@ export default class Button extends PureComponent {
}
render () {
const style = {
padding: `0 ${this.props.size / 2.25}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
};
const className = classNames('button', this.props.className, {
'button-secondary': this.props.secondary,
'button--secondary': this.props.secondary,
'button--block': this.props.block,
});
@ -51,7 +38,6 @@ export default class Button extends PureComponent {
disabled={this.props.disabled}
onClick={this.handleClick}
ref={this.setRef}
style={style}
>
{this.props.text || this.props.children}
</button>

View File

@ -0,0 +1,93 @@
.button {
display: inline-block;
font-family: inherit;
padding: 0 16px;
position: relative;
text-decoration: none;
background-color: $ui-highlight-color;
box-sizing: border-box;
color: $primary-text-color;
cursor: pointer;
@include border-design(transparent, 10px, 4px);
@include text-sizing(14px, 500, 36px, center);
@include size(auto, 36px);
@include text-overflow(nowrap);
&:active,
&:focus,
&:hover {
background-color: lighten($ui-highlight-color, 10%);
}
&:disabled,
&.disabled {
background-color: $ui-primary-color;
cursor: default;
}
&::-moz-focus-inner {
border: 0;
}
&::-moz-focus-inner,
&:focus,
&:active {
outline: 0 !important;
}
&--block {
display: block;
width: 100%;
}
&--destructive {
transition: none;
&:active,
&:focus,
&:hover {
background-color: $error-red;
transition: none;
}
}
&--alternative {
color: $inverted-text-color;
background: $ui-primary-color;
&:active,
&:focus,
&:hover {
background-color: lighten($ui-primary-color, 4%);
}
}
&--alternative-2 {
background: $ui-base-lighter-color;
&:active,
&:focus,
&:hover {
background-color: lighten($ui-base-lighter-color, 4%);
}
}
&--secondary {
color: $darker-text-color;
background: transparent;
padding: 3px 15px;
border: 1px solid $ui-primary-color;
&:active,
&:focus,
&:hover {
border-color: lighten($ui-primary-color, 4%);
color: lighten($darker-text-color, 4%);
}
&:disabled {
opacity: 0.5;
}
}
}

View File

@ -1,3 +1,4 @@
import './index.scss';
export default class Column extends PureComponent {
@ -16,4 +17,4 @@ export default class Column extends PureComponent {
);
}
}
}

View File

@ -0,0 +1,7 @@
.column {
display: flex;
position: relative;
box-sizing: border-box;
flex-direction: column;
width: 350px;
}

View File

@ -1,5 +1,8 @@
import { FormattedMessage } from 'react-intl';
import Icon from 'gabsocial/components/icon';
import classNames from 'classnames';
import Icon from '../icon';
import './index.scss';
export default class ColumnBackButton extends PureComponent {
@ -7,6 +10,10 @@ export default class ColumnBackButton extends PureComponent {
router: PropTypes.object,
};
static propTypes = {
slim: PropTypes.bool,
};
handleClick = () => {
if (window.history && window.history.length === 1) {
this.context.router.history.push('/home'); // homehack
@ -16,12 +23,18 @@ export default class ColumnBackButton extends PureComponent {
}
render () {
const { slim } = this.props;
const btnClasses = classNames('column-back-button', {
'column-back-button--slim': slim,
});
return (
<button onClick={this.handleClick} className='column-back-button'>
<button className={btnClasses} onClick={this.handleClick}>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>
);
}
}
}

View File

@ -0,0 +1,32 @@
.column-back-button {
background: lighten($ui-base-color, 4%);
color: $highlight-text-color;
cursor: pointer;
flex: 0 0 auto;
border: 0;
text-align: unset;
padding: 15px;
margin: 0;
z-index: 3;
outline: 0;
@include text-sizing(16px, 400, 1);
&:hover {
text-decoration: underline;
}
&--slim {
cursor: pointer;
flex: 0 0 auto;
font-size: 16px;
padding: 15px;
@include abs-position(0, 0);
}
&__icon {
display: inline-block;
margin-right: 5px;
}
}

View File

@ -1,18 +0,0 @@
import { FormattedMessage } from 'react-intl';
import ColumnBackButton from './column_back_button';
import Icon from 'gabsocial/components/icon';
export default class ColumnBackButtonSlim extends ColumnBackButton {
render () {
return (
<div className='column-back-button--slim'>
<div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</div>
</div>
);
}
}

View File

@ -1,120 +0,0 @@
'use strict';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import Icon from 'gabsocial/components/icon';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
});
export default @injectIntl
class ColumnHeader extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
intl: PropTypes.object.isRequired,
title: PropTypes.node,
icon: PropTypes.string,
active: PropTypes.bool,
extraButton: PropTypes.node,
children: PropTypes.node,
};
state = {
collapsed: true,
animating: false,
};
historyBack = () => {
if (window.history && window.history.length === 1) {
this.context.router.history.push('/home'); // homehack
} else {
this.context.router.history.goBack();
}
}
handleToggleClick = (e) => {
e.stopPropagation();
this.setState({ collapsed: !this.state.collapsed, animating: true });
}
handleBackClick = () => {
this.historyBack();
}
handleTransitionEnd = () => {
this.setState({ animating: false });
}
render () {
const { title, icon, active, children, extraButton, intl: { formatMessage } } = this.props;
const { collapsed, animating } = this.state;
const wrapperClassName = classNames('column-header__wrapper', {
'active': active,
});
const buttonClassName = classNames('column-header', {
'active': active,
});
const collapsibleClassName = classNames('column-header__collapsible', {
'collapsed': collapsed,
'animating': animating,
});
const collapsibleButtonClassName = classNames('column-header__button', {
'active': !collapsed,
});
let extraContent, collapseButton;
if (children) {
extraContent = (
<div key='extra-content' className='column-header__collapsible__extra'>
{children}
</div>
);
}
const collapsedContent = [
extraContent,
];
if (children) {
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
}
const hasTitle = icon && title;
return (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
{hasTitle && (
<button>
<Icon id={icon} fixedWidth className='column-header__icon' />
{title}
</button>
)}
<div className='column-header__buttons'>
{extraButton}
{collapseButton}
</div>
</h1>
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}
</div>
</div>
</div>
);
}
}

View File

@ -1,13 +1,13 @@
'use strict';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { injectIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import Icon from 'gabsocial/components/icon';
import { me } from 'gabsocial/initial_state';
import { fetchLists } from 'gabsocial/actions/lists';
import { createSelector } from 'reselect';
import Icon from '../icon';
import { fetchLists } from '../../actions/lists';
import './index.scss';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
@ -31,7 +31,7 @@ const mapStateToProps = state => {
};
};
class ColumnHeader extends PureComponent {
class ColumnHeader extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
@ -49,8 +49,7 @@ class ColumnHeader extends PureComponent {
state = {
collapsed: true,
animating: false,
expandedFor: null, //lists, groups, etc.
listsExpanded: false,
};
componentDidMount() {
@ -59,123 +58,113 @@ class ColumnHeader extends PureComponent {
handleToggleClick = (e) => {
e.stopPropagation();
this.setState({ collapsed: !this.state.collapsed, animating: true });
}
handleTransitionEnd = () => {
this.setState({ animating: false });
this.setState({ collapsed: !this.state.collapsed });
}
expandLists = () => {
this.setState({
expandedFor: 'lists',
});
this.setState({ listsExpanded: !this.state.listsExpanded });
}
render () {
const { active, children, intl: { formatMessage }, activeItem, activeSubItem, lists } = this.props;
const { collapsed, animating, expandedFor } = this.state;
const { collapsed, listsExpanded } = this.state;
const wrapperClassName = classNames('column-header__wrapper', {
'active': active,
'column-header__wrapper--active': active,
});
const buttonClassName = classNames('column-header', {
'active': active,
'column-header--active': active,
});
const collapsibleClassName = classNames('column-header__collapsible', {
'collapsed': collapsed,
'animating': animating,
'column-header__collapsible--collapsed': collapsed,
});
const collapsibleButtonClassName = classNames('column-header__button', {
'active': !collapsed,
'column-header__button--active': !collapsed,
});
const expansionClassName = classNames('column-header column-header__expansion', {
'open': expandedFor,
'column-header__expansion--open': listsExpanded,
});
let extraContent, collapseButton;
if (children) {
extraContent = (
<div key='extra-content' className='column-header__collapsible__extra'>
{children}
</div>
);
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
}
const collapsedContent = [
extraContent,
];
const btnTitle = formatMessage(collapsed ? messages.show : messages.hide);
let expandedContent = null;
if ((expandedFor === 'lists' || activeItem === 'lists') && lists) {
expandedContent = lists.map(list =>
<Link
key={list.get('id')}
to={`/list/${list.get('id')}`}
className={
classNames('btn btn--sub grouped', {
'active': list.get('id') === activeSubItem
})
}
>
{list.get('title')}
</Link>
)
if ((listsExpanded || activeItem === 'lists') && lists) {
expandedContent = lists.map((list) => {
const listId = list.get('id');
const linkUrl = `/list/${listId}`;
const classes = classNames('column-header-btn column-header-btn--sub column-header-btn--grouped', {
'column-header-btn--active': listId === activeSubItem,
});
return (
<Link key={listId} to={linkUrl} className={classes}>
{list.get('title')}
</Link>
);
});
}
return (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
<Link to='/home' className={classNames('btn grouped', {'active': 'home' === activeItem})}>
<Link to='/home' className={classNames('column-header-btn column-header-btn--grouped', { 'column-header-btn--active': 'home' === activeItem })}>
<Icon id='home' fixedWidth className='column-header__icon' />
{formatMessage(messages.homeTitle)}
</Link>
<Link to='/timeline/all' className={classNames('btn grouped', {'active': 'all' === activeItem})}>
<Link to='/timeline/all' className={classNames('column-header-btn column-header-btn--grouped', { 'column-header-btn--active': 'all' === activeItem })}>
<Icon id='globe' fixedWidth className='column-header__icon' />
{formatMessage(messages.allTitle)}
</Link>
{ lists.size > 0 &&
<a onClick={this.expandLists} className={classNames('btn grouped', {'active': 'lists' === activeItem})}>
<a onClick={this.expandLists} className={classNames('column-header-btn column-header-btn--grouped', { 'column-header-btn--active': 'lists' === activeItem })}>
<Icon id='list' fixedWidth className='column-header__icon' />
{formatMessage(messages.listTitle)}
</a>
}
{ lists.size == 0 &&
<Link to='/lists' className='btn grouped'>
<Link to='/lists' className='column-header-btn column-header-btn--grouped'>
<Icon id='list' fixedWidth className='column-header__icon' />
{formatMessage(messages.listTitle)}
</Link>
}
<div className='column-header__buttons'>
{collapseButton}
<button
className={collapsibleButtonClassName}
title={btnTitle}
aria-label={btnTitle}
aria-pressed={collapsed ? 'false' : 'true'}
onClick={this.handleToggleClick}
>
<Icon id='sliders' />
</button>
</div>
</h1>
{
expandedContent &&
<h1 className={expansionClassName}>
{expandedContent}
</h1>
}
<h1 className={expansionClassName}>
{expandedContent}
</h1>
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null}>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}
{
!collapsed &&
<div key='extra-content' className='column-header__collapsible__extra'>
{children}
</div>
}
</div>
</div>
</div>
);
}
}
export default injectIntl(connect(mapStateToProps)(ColumnHeader));

View File

@ -0,0 +1,125 @@
import { Fragment } from 'react';
import classNames from 'classnames';
import { injectIntl, defineMessages } from 'react-intl';
import Icon from '../icon';
import './index.scss';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
});
export default @injectIntl
class ColumnHeader extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
intl: PropTypes.object.isRequired,
title: PropTypes.node,
icon: PropTypes.string,
active: PropTypes.bool,
children: PropTypes.node,
};
state = {
collapsed: true,
};
historyBack = () => {
if (window.history && window.history.length === 1) {
this.context.router.history.push('/home'); // homehack
} else {
this.context.router.history.goBack();
}
}
handleToggleClick = (e) => {
e.stopPropagation();
this.setState({
collapsed: !this.state.collapsed,
});
}
handleBackClick = () => {
this.historyBack();
}
render () {
const { title, icon, active, children, intl: { formatMessage } } = this.props;
const { collapsed } = this.state;
const wrapperClassName = classNames('column-header__wrapper', {
'column-header__wrapper--active': active,
});
const buttonClassName = classNames('column-header', {
'column-header--active': active,
});
const btnTitle = formatMessage(collapsed ? messages.show : messages.hide);
const hasTitle = icon && title;
const hasChildren = !!children;
if (!hasChildren && !hasTitle) {
return null;
} else if (!hasChildren && hasTitle) {
return (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
<Icon id={icon} fixedWidth className='column-header__icon' />
{title}
</h1>
</div>
);
}
const collapsibleClassName = classNames('column-header__collapsible', {
'column-header__collapsible--collapsed': collapsed,
});
const collapsibleButtonClassName = classNames('column-header__button', {
'column-header__button--active': !collapsed,
});
return (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
{
hasTitle && (
<Fragment>
<Icon id={icon} fixedWidth className='column-header__icon' />
{title}
</Fragment>
)
}
<button
className={collapsibleButtonClassName}
title={btnTitle}
aria-label={btnTitle}
aria-pressed={!collapsed}
onClick={this.handleToggleClick}
>
<Icon id='sliders' />
</button>
</h1>
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null}>
<div className='column-header__collapsible-inner'>
{
!collapsed &&
<div key='extra-content' className='column-header__collapsible__extra'>
{children}
</div>
}
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,172 @@
.column-header {
display: flex;
font-size: 16px;
flex: 0 0 auto;
cursor: pointer;
position: relative;
z-index: 2;
outline: 0;
overflow: hidden;
background: $gab-background-container;
body.theme-gabsocial-light & {
background: $gab-background-container-light;
color: $gab-default-text-light;
}
&--active {
box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);
.column-header__icon {
color: $highlight-text-color;
text-shadow: 0 0 10px rgba($highlight-text-color, 0.4);
}
}
&:focus,
&:active {
outline: 0;
}
&__wrapper {
position: relative;
flex: 0 0 auto;
overflow: hidden;
&--active {
&::before {
margin: 0 auto;
pointer-events: none;
z-index: 1;
background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);
@include pseudo;
@include size(60%, 28px);
@include abs-position(35px, 0, auto, 0, false);
}
}
}
&__icon {
display: inline-block;
margin-right: 5px;
}
&__expansion {
overflow-x: scroll;
overflow-y: hidden;
white-space: nowrap;
max-height: 0px;
&--open {
max-height: 55px;
}
}
&__links {
.text-btn {
margin-right: 10px;
}
}
&__setting-btn {
padding: 0 10px;
&:last-child {
padding-right: 0;
}
&:hover {
color: $darker-text-color;
text-decoration: underline;
}
&--link {
text-decoration: none;
.fa {
margin-left: 10px;
}
}
}
&__button {
margin-left: auto;
cursor: pointer;
border: 0;
padding: 0 15px;
font-size: 16px;
color: #fff;
background: $gab-background-container;
body.theme-gabsocial-light & {
color: $gab-default-text-light;
background: $gab-background-container-light;
}
&:hover {
color: lighten($darker-text-color, 7%);
}
&--active {
color: $primary-text-color;
background: lighten($ui-base-color, 8%);
&:hover {
color: $primary-text-color;
background: lighten($ui-base-color, 8%);
}
}
}
&__collapsible-inner {
background: #3f3f3f;
padding: 15px;
body.theme-gabsocial-light & {
background: #e6e6e6;
}
}
&__collapsible {
max-height: 70vh;
overflow: hidden;
overflow-y: auto;
color: $darker-text-color;
transition: max-height 150ms linear;
&--collapsed {
max-height: 0;
}
hr {
height: 0;
background: transparent;
border: 0;
border-top: 1px solid lighten($ui-base-color, 12%);
margin: 10px 0;
}
}
}
.column-header-btn {
padding: 15px;
color: inherit;
text-decoration: none;
white-space: nowrap;
&--sub {
font-size: 14px;
padding: 6px s10px;
}
&--grouped {
margin: 6px;
}
&--active {
color: $primary-text-color;
border-radius: 10px;
background-color: rgba($highlight-text-color, .1);
}
}

View File

@ -0,0 +1,41 @@
import { defineMessages, injectIntl } from 'react-intl';
import Column from '../column';
import './index.scss';
const messages = defineMessages({
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading...' },
missing: { id: 'missing_indicator.sublabel', defaultMessage: 'This resource could not be found.' },
});
export default @injectIntl
class ColumnIndicator extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
type: PropTypes.oneOf([
'loading',
'missing',
'error',
]),
message: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
]),
};
render() {
const { type, message, intl } = this.props;
const title = type !== 'error' ? intl.formatMessage(messages[type]) : message;
return (
<Column>
<div className={`column-indicator column-indicator--${type}`}>
<div className='column-indicator__figure' />
<span className='column-indicator__title'>{title}</span>
</div>
</Column>
);
}
};

View File

@ -0,0 +1,51 @@
.column-indicator {
overflow: visible;
transform: translate(-50%, -50%);
@include abs-position(50%, auto, auto, 50%);
&--loading & {
&__figure {
border: 6px solid lighten($ui-base-color, 26%);
}
}
&--missing & {
&__figure {
&:before {
@include pseudo('!');
@include text-sizing(40px, 600, 1, center);
@include abs-position(0, 0, 0, 0, false);
}
}
}
&__figure {
transform: translate(-50%, -50%);
box-sizing: border-box;
background-color: transparent;
@include circle(42px);
@include abs-position(50%, auto, auto, 50%);
}
span {
display: block;
float: left;
margin-left: 50%;
transform: translateX(-50%);
margin: 82px 0 0 50%;
white-space: nowrap;
color: $dark-text-color;
@include text-sizing(14px, 400);
}
}
.no-reduce-motion .column-indicator--loading span {
animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
}
.no-reduce-motion .column-indicator--loading .column-indicator__figure {
animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);
}

View File

@ -1,43 +0,0 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import VerificationBadge from './verification_badge';
export default class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
others: ImmutablePropTypes.list,
localDomain: PropTypes.string,
};
render () {
const { others, localDomain } = this.props;
let displayName, suffix, account;
if (others && others.size > 1) {
displayName = others.take(2).map(a => <bdi key={a.get('id')}><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi>).reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else {
if (others && others.size > 0) {
account = others.first();
} else {
account = this.props.account;
}
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
suffix = <span className='display-name__account'>@{account.get('acct')}</span>;
}
return (
<span className='display-name'>
{displayName}
{account.get('is_verified') && <VerificationBadge />}
{suffix}
</span>
);
}
}

View File

@ -0,0 +1,27 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import VerifiedIcon from '../verified_icon';
import './index.scss';
export default class DisplayName extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<span className='display-name'>
<bdi>
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
</bdi>
{account.get('is_verified') && <VerifiedIcon />}
<span className='display-name__account'>@{account.get('acct')}</span>
</span>
);
}
}

View File

@ -0,0 +1,15 @@
.display-name {
display: block;
max-width: 100%;
@include text-overflow(break-word);
&__html {
font-weight: 600;
padding-right: 4px;
}
&__account {
font-size: 14px;
}
}

View File

@ -1,13 +1,14 @@
import IconButton from './icon_button';
import IconButton from '../icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import './index.scss';
const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
});
export default @injectIntl
class Account extends ImmutablePureComponent {
class Domain extends PureComponent {
static propTypes = {
domain: PropTypes.string,
@ -25,12 +26,19 @@ class Account extends ImmutablePureComponent {
return (
<div className='domain'>
<div className='domain__wrapper'>
<span className='domain__domain-name'>
<span className='domain__name'>
<strong>{domain}</strong>
</span>
<div className='domain__buttons'>
<IconButton active icon='unlock' title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} />
<IconButton
active
icon='unlock'
title={intl.formatMessage(messages.unblockDomain, {
domain,
})}
onClick={this.handleDomainUnblock}
/>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
.domain {
padding: 10px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
&__wrapper {
display: flex;
}
.domain__domain-name {
flex: 1 1 auto;
display: block;
color: $primary-text-color;
text-decoration: none;
@include text-sizing(14px, 400);
}
.domain_buttons {
height: 18px;
padding: 10px;
white-space: nowrap;
}
}

View File

@ -1,6 +0,0 @@
const DonorBadge = () => (
<span className='badge badge--donor'>Donor</span>
);
export default DonorBadge;

View File

@ -1,9 +1,11 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events';
import Overlay from 'react-overlays/lib/Overlay';
import spring from 'react-motion/lib/spring';
import IconButton from '../icon_button';
import Motion from '../../features/ui/util/optional_motion';
import './index.scss';
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
let id = 0;
@ -43,7 +45,9 @@ class DropdownMenu extends PureComponent {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus();
this.setState({ mounted: true });
}
@ -69,27 +73,19 @@ class DropdownMenu extends PureComponent {
switch(e.key) {
case 'ArrowDown':
element = items[index+1];
if (element) {
element.focus();
}
if (element) element.focus();
break;
case 'ArrowUp':
element = items[index-1];
if (element) {
element.focus();
}
if (element) element.focus();
break;
case 'Home':
element = items[0];
if (element) {
element.focus();
}
if (element) element.focus();
break;
case 'End':
element = items[items.length-1];
if (element) {
element.focus();
}
if (element) element.focus();
break;
}
}
@ -133,7 +129,8 @@ class DropdownMenu extends PureComponent {
onKeyDown={this.handleItemKeyDown}
data-index={i}
target={newTab ? '_blank' : null}
data-method={isLogout ? 'delete' : null}>
data-method={isLogout ? 'delete' : null}
>
{text}
</a>
</li>

View File

@ -0,0 +1,93 @@
.dropdown-menu {
z-index: 9999;
position: absolute;
background: $gab-background-container;
border-radius: 4px;
border: 1px solid $gab-placeholder-accent;
padding: 4px 0;
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.5);
&.left {
transform-origin: 100% 50%;
}
&.top {
transform-origin: 50% 100%;
}
&.bottom {
transform-origin: 50% 0;
}
&.right {
transform-origin: 0 50%;
}
&__arrow {
position: absolute;
border: 0 solid transparent;
@include size(0);
&.left {
right: -5px;
margin-top: -5px;
border-width: 5px 0 5px 5px;
border-left-color: $gab-placeholder-accent;
}
&.top {
bottom: -5px;
margin-left: -5px;
border-width: 5px 5px 0;
border-top-color: $gab-placeholder-accent;
}
&.bottom {
top: -5px;
margin-left: -5px;
border-width: 0 5px 5px;
border-bottom-color: $gab-placeholder-accent;
}
&.right {
left: -5px;
margin-top: -5px;
border-width: 5px 5px 5px 0;
border-right-color: $gab-placeholder-accent;
}
}
ul {
overflow: hidden;
padding: 6px 0;
}
&__item a {
display: block;
box-sizing: border-box;
padding: 3px 10px 1px;
text-decoration: none;
text-transform: capitalize;
color: $gab-secondary-text;
@include text-overflow(nowrap);
@include text-sizing(13px, 400, 26px);
&:focus,
&:hover,
&:active {
outline: 0;
color: $gab-text-highlight;
background: $gab-background-base !important;
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.5);
}
}
&__separator {
display: block;
margin: 10px !important;
height: 1px;
background: $gab-background-base;
}
}

View File

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl';
import './index.scss';
export default class ErrorBoundary extends PureComponent {
static propTypes = {
@ -23,15 +25,13 @@ export default class ErrorBoundary extends PureComponent {
render() {
const { hasError } = this.state;
if (!hasError) {
return this.props.children;
}
if (!hasError) return this.props.children;
return (
<div className='error-boundary'>
<div>
<div className='error-boundary__container'>
<FormattedMessage id='alert.unexpected.message' defaultMessage='Error' />
<a href='/home'>Return Home</a>
<a className='error-boundary__link' href='/home'>Return Home</a>
</div>
</div>
);

View File

@ -0,0 +1,18 @@
.error-boundary {
&__container {
margin: auto;
}
span {
display: block;
text-align: center;
color: $secondary-text-color;
}
&__link {
display: block;
margin: 15px auto;
text-align: center;
color: $gab-brand-default;
}
}

View File

@ -1,3 +1,4 @@
import './index.scss';
export default class ExtendedVideoPlayer extends PureComponent {

View File

@ -0,0 +1,8 @@
.extended-video-player {
@include size(100%);
@include flex(center, center);
video {
@include max-size($media-modal-media-max-width, $media-modal-media-max-height);
}
}

View File

@ -1,33 +0,0 @@
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from './permalink';
import { shortNumberFormat } from '../utils/numbers';
const Hashtag = ({ hashtag }) => (
<div className='trends__item'>
<div className='trends__item__name'>
<Permalink href={hashtag.get('url')} to={`/tags/${hashtag.get('name')}`}>
#<span>{hashtag.get('name')}</span>
</Permalink>
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
</div>
<div className='trends__item__current'>
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
</div>
<div className='trends__item__sparkline'>
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</div>
</div>
);
Hashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
export default Hashtag;

View File

@ -11,8 +11,12 @@ export default class Icon extends PureComponent {
render () {
const { id, className, fixedWidth, ...other } = this.props;
const classes = classNames('fa', `fa-${id}`, className, {
'fa-fw': fixedWidth,
});
return (
<i role='img' alt={id} className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} />
<i role='img' alt={id} className={classes} {...other} />
);
}

View File

@ -1,7 +1,9 @@
import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import classNames from 'classnames';
import Icon from 'gabsocial/components/icon';
import spring from 'react-motion/lib/spring';
import Motion from '../../features/ui/util/optional_motion';
import Icon from '../icon';
import './index.scss';
export default class IconButton extends PureComponent {
@ -15,7 +17,6 @@ export default class IconButton extends PureComponent {
pressed: PropTypes.bool,
expanded: PropTypes.bool,
style: PropTypes.object,
activeStyle: PropTypes.object,
disabled: PropTypes.bool,
inverted: PropTypes.bool,
animate: PropTypes.bool,
@ -47,7 +48,6 @@ export default class IconButton extends PureComponent {
height: `${this.props.size * 1.28571429}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
...(this.props.active ? this.props.activeStyle : {}),
};
const {
@ -71,9 +71,8 @@ export default class IconButton extends PureComponent {
overlayed: overlay,
});
// Perf optimization: avoid unnecessary <Motion> components unless we actually need to animate.
if (!animate) {
// Perf optimization: avoid unnecessary <Motion> components unless
// we actually need to animate.
return (
<button
aria-label={title}

View File

@ -0,0 +1,69 @@
.icon-button {
display: inline-block;
padding: 0;
color: $gab-secondary-text;
border: none;
background: transparent;
cursor: pointer;
transition: color 100ms ease-in;
&:hover,
&:active,
&:focus {
color: lighten($action-button-color, 7%);
transition: color 200ms ease-out;
}
&.disabled {
color: darken($action-button-color, 13%);
cursor: default;
}
&.active {
color: $highlight-text-color;
}
&::-moz-focus-inner {
border: 0;
}
&::-moz-focus-inner,
&:focus,
&:active {
outline: 0 !important;
}
&.inverted {
color: $gab-secondary-text;
&:hover,
&:active,
&:focus {
color: darken($lighter-text-color, 7%);
}
&.disabled {
color: lighten($lighter-text-color, 7%);
}
&.active {
color: $highlight-text-color;
&.disabled {
color: lighten($highlight-text-color, 13%);
}
}
}
&.overlayed {
box-sizing: content-box;
background: rgba($base-overlay-background, 0.6);
color: rgba($primary-text-color, 0.7);
border-radius: 4px;
padding: 2px;
&:hover {
background: rgba($base-overlay-background, 0.9);
}
}
}

View File

@ -1,20 +0,0 @@
import Icon from 'gabsocial/components/icon';
import { shortNumberFormat } from 'gabsocial/utils/numbers';
const IconWithBadge = ({ id, count, className }) => {
if (count < 1) return null;
return (
<i className='icon-with-badge'>
{count > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(count)}</i>}
</i>
)
};
IconWithBadge.propTypes = {
id: PropTypes.string.isRequired,
count: PropTypes.number.isRequired,
className: PropTypes.string,
};
export default IconWithBadge;

View File

@ -1,6 +1,8 @@
import scheduleIdleTask from '../utils/schedule_idle_task';
import getRectFromEntry from '../utils/get_rect_from_entry';
import { is } from 'immutable';
import scheduleIdleTask from '../../utils/schedule_idle_task';
import getRectFromEntry from '../../utils/get_rect_from_entry';
import './index.scss';
// Diff these props in the "rendered" state
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
@ -27,10 +29,12 @@ export default class IntersectionObserverArticle extends Component {
shouldComponentUpdate (nextProps, nextState) {
const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
// If we're going from rendered to unrendered (or vice versa) then update
if (!!isUnrendered !== !!willBeUnrendered) {
// If we're going from rendered to unrendered (or vice versa) then update
return true;
}
// Otherwise, diff based on props
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
@ -66,6 +70,7 @@ export default class IntersectionObserverArticle extends Component {
if (prevState.isIntersecting !== false && !this.entry.isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting);
}
return {
isIntersecting: this.entry.isIntersecting,
isHidden: false,
@ -74,7 +79,7 @@ export default class IntersectionObserverArticle extends Component {
calculateHeight = () => {
const { onHeightChange, saveHeightKey, id } = this.props;
// save the height of the fully-rendered element (this is expensive
// Save the height of the fully-rendered element (this is expensive
// on Chrome, where we need to fall back to getBoundingClientRect)
this.height = getRectFromEntry(this.entry).height;
@ -84,9 +89,7 @@ export default class IntersectionObserverArticle extends Component {
}
hideIfNotIntersecting = () => {
if (!this.componentMounted) {
return;
}
if (!this.componentMounted) return;
// When the browser gets a chance, test if we're still not intersecting,
// and if so, set our isHidden to true to trigger an unrender. The point of
@ -118,7 +121,13 @@ export default class IntersectionObserverArticle extends Component {
}
return (
<article ref={this.handleRef} aria-posinset={index + 1} aria-setsize={listLength} data-id={id} tabIndex='0'>
<article
ref={this.handleRef}
aria-posinset={index + 1}
aria-setsize={listLength}
data-id={id}
tabIndex='0'
>
{children && React.cloneElement(children, { hidden: false })}
</article>
);

View File

@ -0,0 +1,4 @@
article {
// TEMPORARY - content of columns may be significantly altered
background: $gab-background-container;
}

View File

@ -1,6 +0,0 @@
const InvestorBadge = () => (
<span className='badge badge--investor'>Investor</span>
);
export default InvestorBadge;

View File

@ -1,32 +0,0 @@
import { injectIntl, defineMessages } from 'react-intl';
import Icon from 'gabsocial/components/icon';
const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
});
export default @injectIntl
class LoadGap extends PureComponent {
static propTypes = {
disabled: PropTypes.bool,
maxId: PropTypes.string,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleClick = () => {
this.props.onClick(this.props.maxId);
}
render () {
const { disabled, intl } = this.props;
return (
<button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}>
<Icon id='ellipsis-h' />
</button>
);
}
}

View File

@ -1,25 +0,0 @@
import { FormattedMessage } from 'react-intl';
export default class LoadMore extends PureComponent {
static propTypes = {
onClick: PropTypes.func,
disabled: PropTypes.bool,
visible: PropTypes.bool,
}
static defaultProps = {
visible: true,
}
render() {
const { disabled, visible } = this.props;
return (
<button className='load-more' disabled={disabled || !visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
</button>
);
}
}

View File

@ -0,0 +1,53 @@
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import Icon from '../icon';
import './index.scss';
const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
});
export default @injectIntl
class LoadMore extends PureComponent {
static propTypes = {
onClick: PropTypes.func,
disabled: PropTypes.bool,
visible: PropTypes.bool,
maxId: PropTypes.string,
gap: PropTypes.bool,
intl: PropTypes.object.isRequired,
}
static defaultProps = {
visible: true,
}
handleClick = () => {
const { gap, maxId } = this.props;
this.props.onClick(gap ? maxId : undefined);
}
render() {
const { disabled, visible, gap, intl } = this.props;
const btnClasses = classNames('load-more', {
'load-more--gap': gap,
});
return (
<button
className={btnClasses}
disabled={disabled || !visible}
style={{ visibility: visible ? 'visible' : 'hidden' }}
onClick={this.handleClick}
aria-label={intl.formatMessage(messages.load_more)}
>
{!gap && <FormattedMessage id='status.load_more' defaultMessage='Load more' />}
{gap && <Icon id='ellipsis-h' />}
</button>
);
}
}

View File

@ -0,0 +1,22 @@
.load-more {
display: block;
color: $dark-text-color;
background-color: transparent;
border: 0;
margin: 0;
padding: 15px;
box-sizing: border-box;
width: 100%;
clear: both;
text-decoration: none;
@include text-sizing(14px, 400, 1.2, center);
&:hover {
background: lighten($ui-base-color, 2%);
}
&--gap {
border-bottom: 1px solid lighten($ui-base-color, 8%);
}
}

View File

@ -1,10 +0,0 @@
import { FormattedMessage } from 'react-intl';
const LoadingIndicator = () => (
<div className='loading-indicator'>
<div className='loading-indicator__figure' />
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
</div>
);
export default LoadingIndicator;

View File

@ -1,11 +1,13 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import { is } from 'immutable';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../utils/is_mobile';
import classNames from 'classnames';
import { autoPlayGif, displayMedia } from '../initial_state';
import { decode } from 'blurhash';
import IconButton from '../icon_button';
import { isIOS } from '../../utils/is_mobile';
import { autoPlayGif, displayMedia } from '../../initial_state';
import './index.scss';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@ -59,6 +61,7 @@ class Item extends PureComponent {
e.target.pause();
e.target.currentTime = 0;
}
e.preventDefault();
onClick(index);
}
@ -79,11 +82,11 @@ class Item extends PureComponent {
}
_decode () {
const hash = this.props.attachment.get('blurhash');
const hash = this.props.attachment.get('blurhash');
const pixels = decode(hash, 32, 32);
if (pixels) {
const ctx = this.canvas.getContext('2d');
const ctx = this.canvas.getContext('2d');
const imageData = new ImageData(pixels, 32, 32);
ctx.putImageData(imageData, 0, 0);
@ -101,85 +104,65 @@ class Item extends PureComponent {
render () {
const { attachment, index, size, standalone, displayWidth, visible } = this.props;
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
const width = (size === 1) ? 100 : 50;
const height = (size === 4 || (size === 3 && index > 0)) ? 50 : 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
switch(size) {
case 2:
if (index === 0) right = '2px';
else left = '2px';
break;
case 3:
if (index === 0) right = '2px';
else if (index > 0) left = '2px';
if (size === 4 || (size === 3 && index > 0)) {
height = 50;
}
if (index === 1) bottom = '2px';
else if (index > 1) top = '2px';
break;
case 4:
if (index === 0 || index === 2) right = '2px';
if (index === 1 || index === 3) left = '2px';
if (size === 2) {
if (index === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
} else if (index > 0) {
left = '2px';
}
if (index === 1) {
bottom = '2px';
} else if (index > 1) {
top = '2px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
}
if (index === 1 || index === 3) {
left = '2px';
}
if (index < 2) {
bottom = '2px';
} else {
top = '2px';
}
if (index < 2) bottom = '2px';
else top = '2px';
break;
}
let thumbnail = '';
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<div className={classNames('media-item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<a className='media-item__thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
</a>
</div>
);
} else if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url');
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
const sizes = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null;
const sizes = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null;
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
thumbnail = (
<a
className='media-gallery__item-thumbnail'
className='media-item__thumbnail'
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
@ -201,7 +184,7 @@ class Item extends PureComponent {
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video
className='media-gallery__item-gifv-thumbnail'
className='media-item__gifv'
aria-label={attachment.get('description')}
title={attachment.get('description')}
role='application'
@ -220,7 +203,7 @@ class Item extends PureComponent {
}
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<div className={classNames('media-item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
{visible && thumbnail}
</div>
@ -324,7 +307,13 @@ class MediaGallery extends PureComponent {
} else {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
<span className='spoiler-button__overlay__label'>
{
sensitive
? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />
: <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />
}
</span>
</button>
);
}

View File

@ -0,0 +1,146 @@
.media-item {
display: block;
float: left;
border: none;
box-sizing: border-box;
position: relative;
border-radius: 4px;
overflow: hidden;
&.standalone {
media-item-gifv-thumbnail {
transform: none;
top: 0;
}
}
&__thumbnail {
display: block;
cursor: zoom-in;
text-decoration: none;
color: $secondary-text-color;
line-height: 0;
position: relative;
z-index: 1;
&,
img {
@include size(100%);
}
img {
object-fit: cover;
}
}
&__gifv {
cursor: zoom-in;
object-fit: cover;
position: relative;
top: 50%;
transform: translateY(-50%);
z-index: 1;
@include size(100%);
}
}
.media-gallery {
box-sizing: border-box;
margin-top: 8px;
overflow: hidden;
border-radius: 4px;
position: relative;
width: 100%;
&__preview {
object-fit: cover;
z-index: 0;
background: $base-overlay-background;
@include size(100%);
@include abs-position(0, auto, auto, 0);
&--hidden {
display: none;
}
}
&__gifv {
overflow: hidden;
position: relative;
@include size(100%);
&.autoplay {
.media-gallery__gifv__label {
display: none;
}
}
&:hover {
.media-gallery__gifv__label {
opacity: 1;
}
}
&__label {
display: block;
color: $primary-text-color;
background: rgba($base-overlay-background, 0.5);
padding: 2px 6px;
border-radius: 2px;
z-index: 1;
pointer-events: none;
opacity: 0.9;
transition: opacity 0.1s ease;
@include text-sizing(11px, 600, 18px);
@include abs-position(auto, auto, 6px, 6px);
}
}
}
.spoiler-button {
z-index: 100;
@include size(100%);
@include abs-position(0, auto, auto, 0);
&--minified {
display: block;
@include size(auto);
@include abs-position(4px, auto, auto, 4px, false);
}
&--hidden {
display: none;
}
&__overlay {
display: block;
background: transparent;
border: 0;
@include size(100%);
&__label {
display: inline-block;
background: rgba($base-overlay-background, 0.5);
border-radius: 8px;
padding: 8px 12px;
color: $primary-text-color;
@include text-sizing(14px, 500);
}
&:hover,
&:focus,
&:active {
.spoiler-button__overlay__label {
background: rgba($base-overlay-background, 0.8);
}
}
}
}

View File

@ -1,14 +0,0 @@
import { FormattedMessage } from 'react-intl';
const MissingIndicator = () => (
<div className='regeneration-indicator missing-indicator'>
<div>
<div className='regeneration-indicator__label'>
<FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' />
<FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' />
</div>
</div>
</div>
);
export default MissingIndicator;

View File

@ -1,6 +1,9 @@
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import { openModal } from '../actions/modal';
import { cancelReplyCompose } from '../actions/compose';
import classNames from 'classnames';
import { openModal } from '../../actions/modal';
import { cancelReplyCompose } from '../../actions/compose';
import './index.scss';
const messages = defineMessages({
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -16,7 +19,7 @@ const mapDispatchToProps = (dispatch) => ({
},
onCancelReplyCompose() {
dispatch(cancelReplyCompose());
}
},
});
class ModalRoot extends PureComponent {
@ -47,16 +50,15 @@ class ModalRoot extends PureComponent {
handleOnClose = () => {
const { onOpenModal, composeText, onClose, intl, type, onCancelReplyCompose } = this.props;
if (composeText && type == 'COMPOSE') {
if (composeText && type === 'COMPOSE') {
onOpenModal('CONFIRM', {
message: <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this status?' />,
confirm: intl.formatMessage(messages.confirm),
onConfirm: () => onCancelReplyCompose(),
onCancel: () => onOpenModal('COMPOSE'),
});
}
else {
this.props.onClose();
} else {
onClose();
}
};
@ -72,6 +74,7 @@ class ModalRoot extends PureComponent {
} else if (!nextProps.children) {
this.setState({ revealed: false });
}
if (!nextProps.children && !!this.props.children) {
this.activeElement.focus();
this.activeElement = null;
@ -82,6 +85,7 @@ class ModalRoot extends PureComponent {
if (!this.props.children && !!prevProps.children) {
this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
}
if (this.props.children) {
requestAnimationFrame(() => {
this.setState({ revealed: true });
@ -108,19 +112,26 @@ class ModalRoot extends PureComponent {
if (!visible) {
return (
<div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
<div className='modal-root modal-root--hidden' ref={this.setRef} />
);
}
const classes = classNames('modal-root', {
'modal-root--hidden': !revealed,
});
return (
<div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
<div className={classes} ref={this.setRef}>
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
<div role='presentation' className='modal-root__overlay' onClick={() => this.handleOnClose()} />
<div role='dialog' className='modal-root__container'>{children}</div>
<div role='dialog' className='modal-root__container'>
{children}
</div>
</div>
</div>
);
}
}
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ModalRoot));

View File

@ -0,0 +1,34 @@
.modal-root {
position: relative;
z-index: 9999;
&--hidden {
display: none;
}
&__overlay {
position: fixed;
background: rgba($base-overlay-background, 0.7);
@include abs-position(0, 0, 0, 0, false);
}
&__container {
position: fixed;
align-content: space-around;
z-index: 9999;
@include size(100%);
@include flex(center, center, column);
@include abs-position(0, auto, auto, 0, false);
@include unselectable;
}
}
// .modal-root__modal {
// pointer-events: auto;
// display: flex;
// z-index: 9999;
// max-height: 100%;
// overflow-y: hidden;
// }

View File

@ -0,0 +1,27 @@
import { shortNumberFormat } from '../../utils/numbers';
import './index.scss';
const mapStateToProps = state => ({
count: state.getIn(['notifications', 'unread']),
});
class NotificationCounter extends PureComponent {
static propTypes = {
count: PropTypes.number.isRequired,
};
render() {
const { count } = this.props;
if (count < 1) return null;
return (
<span className='notification-counter'>{shortNumberFormat(count)}</span>
);
}
}
export default connect(mapStateToProps)(NotificationCounter);

View File

@ -0,0 +1,21 @@
.notification-counter {
box-sizing: border-box;
min-width: 16px;
height: 16px;
padding: 1px 3px 0;
border-radius: 8px;
// @include font-montserrat();
color: #fff;
background: $gab-alert-red;
@include text-sizing(14px, 400, 14px, center);
@include abs-position(-14px, auto, auto, -16px);
@media screen and (max-width: $nav-breakpoint-1) {
@include abs-position(0, auto, auto, 27px, false);
}
}
.column-link--transparent .notification-counter {
border-color: darken($ui-base-color, 8%);
}

View File

@ -1,3 +1,4 @@
import classNames from 'classnames';
export default class Permalink extends PureComponent {
@ -28,8 +29,10 @@ export default class Permalink extends PureComponent {
render () {
const { href, children, className, onInterceptClick, ...other } = this.props;
const classes = classNames('permalink', className);
return (
<a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>
<a target='_blank' href={href} onClick={this.handleClick} className={classes} {...other}>
{children}
</a>
);

View File

@ -1,138 +0,0 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { vote, fetchPoll } from 'gabsocial/actions/polls';
import Motion from 'gabsocial/features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import escapeTextContentForBrowser from 'escape-html';
import emojify from 'gabsocial/features/emoji/emoji';
import RelativeTimestamp from './relative_timestamp';
const messages = defineMessages({
closed: { id: 'poll.closed', defaultMessage: 'Closed' },
});
const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
return obj;
}, {});
export default @injectIntl
class Poll extends ImmutablePureComponent {
static propTypes = {
poll: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func,
disabled: PropTypes.bool,
};
state = {
selected: {},
};
handleOptionChange = e => {
const { target: { value } } = e;
if (this.props.poll.get('multiple')) {
const tmp = { ...this.state.selected };
if (tmp[value]) {
delete tmp[value];
} else {
tmp[value] = true;
}
this.setState({ selected: tmp });
} else {
const tmp = {};
tmp[value] = true;
this.setState({ selected: tmp });
}
};
handleVote = () => {
if (this.props.disabled) {
return;
}
this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected)));
};
handleRefresh = () => {
if (this.props.disabled) {
return;
}
this.props.dispatch(fetchPoll(this.props.poll.get('id')));
};
renderOption (option, optionIndex) {
const { poll, disabled } = this.props;
const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
const active = !!this.state.selected[`${optionIndex}`];
const showResults = poll.get('voted') || poll.get('expired');
let titleEmojified = option.get('title_emojified');
if (!titleEmojified) {
const emojiMap = makeEmojiMap(poll);
titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
}
return (
<li key={option.get('title')}>
{showResults && (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
{({ width }) =>
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
}
</Motion>
)}
<label className={classNames('poll__text', { selectable: !showResults })}>
<input
name='vote-options'
type={poll.get('multiple') ? 'checkbox' : 'radio'}
value={optionIndex}
checked={active}
onChange={this.handleOptionChange}
disabled={disabled}
/>
{!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />}
{showResults && <span className='poll__number'>{Math.round(percent)}%</span>}
<span dangerouslySetInnerHTML={{ __html: titleEmojified }} />
</label>
</li>
);
}
render () {
const { poll, intl } = this.props;
if (!poll) {
return null;
}
const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
const showResults = poll.get('voted') || poll.get('expired');
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
return (
<div className='poll'>
<ul>
{poll.get('options').map((option, i) => this.renderOption(option, i))}
</ul>
<div className='poll__footer'>
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
{showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
<FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />
{poll.get('expires_at') && <span> · {timeRemaining}</span>}
</div>
</div>
);
}
}

View File

@ -0,0 +1,175 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import escapeTextContentForBrowser from 'escape-html';
import spring from 'react-motion/lib/spring';
import Motion from '../../features/ui/util/optional_motion';
import { vote, fetchPoll } from '../../actions/polls';
import emojify from '../../features/emoji/emoji';
import RelativeTimestamp from '../relative_timestamp';
import Button from '../button';
import './index.scss';
const messages = defineMessages({
closed: { id: 'poll.closed', defaultMessage: 'Closed' },
});
const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
return obj;
}, {});
export default @injectIntl
class Poll extends ImmutablePureComponent {
static propTypes = {
poll: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func,
disabled: PropTypes.bool,
};
state = {
selected: {},
};
handleOptionChange = e => {
const { target: { value } } = e;
if (this.props.poll.get('multiple')) {
const tmp = { ...this.state.selected };
if (tmp[value]) {
delete tmp[value];
} else {
tmp[value] = true;
}
this.setState({ selected: tmp });
} else {
const tmp = {};
tmp[value] = true;
this.setState({ selected: tmp });
}
};
handleVote = () => {
if (this.props.disabled) return;
this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected)));
};
handleRefresh = () => {
if (this.props.disabled) return;
this.props.dispatch(fetchPoll(this.props.poll.get('id')));
};
renderOption (option, optionIndex) {
const { poll, disabled } = this.props;
const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
const active = !!this.state.selected[`${optionIndex}`];
const showResults = poll.get('voted') || poll.get('expired');
const multiple = poll.get('multiple');
let titleEmojified = option.get('title_emojified');
if (!titleEmojified) {
const emojiMap = makeEmojiMap(poll);
titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
}
const chartClasses = classNames('poll__chart', {
'poll__chart--leading': leading,
});
const textClasses = classNames('poll__text', {
selectable: !showResults,
});
const inputClasses = classNames('poll__input', {
'poll__input--checkbox': multiple,
'poll__input--active': active,
});
return (
<li className='poll-item' key={option.get('title')}>
{
showResults && (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
{({ width }) =>
<span className={chartClasses} style={{ width: `${width}%` }} />
}
</Motion>
)
}
<label className={textClasses}>
<input
name='vote-options'
type={multiple ? 'checkbox' : 'radio'}
value={optionIndex}
checked={active}
onChange={this.handleOptionChange}
disabled={disabled}
/>
{!showResults && <span className={inputClasses} />}
{showResults && <span className='poll-item__number'>{Math.round(percent)}%</span>}
<span className='poll-item__text' dangerouslySetInnerHTML={{ __html: titleEmojified }} />
</label>
</li>
);
}
render () {
const { poll, intl } = this.props;
if (!poll) return null;
const timeRemaining = poll.get('expired') ?
intl.formatMessage(messages.closed)
: <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
const showResults = poll.get('voted') || poll.get('expired');
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
return (
<div className='poll'>
<ul className='poll__list'>
{poll.get('options').map((option, i) => this.renderOption(option, i))}
</ul>
<div className='poll__footer'>
{
!showResults &&
<Button className='poll__button' disabled={disabled} onClick={this.handleVote} secondary>
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
</Button>
}
{
showResults && !this.props.disabled &&
<span>
<button className='poll__link' onClick={this.handleRefresh}>
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
</button>
&nbsp;·&nbsp;
</span>
}
<FormattedMessage
id='poll.total_votes'
defaultMessage='{count, plural, one {# vote} other {# votes}}'
values={{
count: poll.get('votes_count'),
}}
/>
{
poll.get('expires_at') &&
<span> · {timeRemaining}</span>
}
</div>
</div>
);
}
}

View File

@ -0,0 +1,148 @@
.poll {
margin-top: 16px;
&__list {
display: block;
}
&__chart {
height: 100%;
display: inline-block;
border-radius: 4px;
background: rgba($gab-placeholder-accent, .3);
@include abs-position(0, auto, auto, 0);
&--leading {
background: rgba($gab-placeholder-accent, .6);
}
}
&__text {
position: relative;
display: inline-block;
padding: 6px 0;
line-height: 18px;
cursor: default;
color: #fff;
@include text-overflow(nowrap);
body.theme-gabsocial-light & {
color: $gab-default-text-light;
}
input[type=radio],
input[type=checkbox] {
display: none;
}
.autossugest-input {
flex: 1 1 auto;
}
input[type=text] {
display: block;
box-sizing: border-box;
width: 100%;
font-size: 14px;
color: $inverted-text-color;
display: block;
outline: 0;
font-family: inherit;
background: $simple-background-color;
border: 1px solid darken($simple-background-color, 14%);
border-radius: 4px;
padding: 6px 10px;
&:focus {
border-color: $highlight-text-color;
}
}
&.selectable {
cursor: pointer;
}
&.editable {
display: flex;
align-items: center;
overflow: visible;
}
}
&__input {
display: inline-block;
position: relative;
border: 1px solid $ui-primary-color;
box-sizing: border-box;
flex: 0 0 auto;
margin-right: 10px;
top: -1px;
border-radius: 50%;
vertical-align: middle;
@include size(18px);
&--checkbox {
border-radius: 4px;
}
&--active {
border-color: $valid-value-color;
background: $valid-value-color;
}
}
&__footer {
padding-top: 6px;
padding-bottom: 5px;
color: $dark-text-color;
span {
font-size: 14px;
}
}
&__link {
display: inline;
background: transparent;
padding: 0;
margin: 0;
border: 0;
color: $dark-text-color;
text-decoration: underline;
font-size: inherit;
&:hover {
text-decoration: none;
}
&:active,
&:focus {
background-color: rgba($dark-text-color, .1);
}
}
&__button {
margin-right: 10px;
}
}
.poll-item {
position: relative;
margin-bottom: 10px;
height: 30px;
&__number {
display: inline-block;
width: 36px;
padding: 0 10px;
@include text-sizing(14px, 700, 1, right);
}
&__text {
@include text-sizing(14px, 400);
}
}

View File

@ -1,6 +0,0 @@
const ProBadge = () => (
<span className='badge badge--pro'>Pro</span>
);
export default ProBadge;

View File

@ -29,8 +29,8 @@ const shortDateFormatOptions = {
const SECOND = 1000;
const MINUTE = 1000 * 60;
const HOUR = 1000 * 60 * 60;
const DAY = 1000 * 60 * 60 * 24;
const HOUR = 1000 * 60 * 60;
const DAY = 1000 * 60 * 60 * 24;
const MAX_DELAY = 2147483647;
@ -156,12 +156,12 @@ class RelativeTimestamp extends Component {
_scheduleNextUpdate (props, state) {
clearTimeout(this._timer);
const { timestamp } = props;
const delta = (new Date(timestamp)).getTime() - state.now;
const unitDelay = getUnitDelay(selectUnits(delta));
const unitRemainder = Math.abs(delta % unitDelay);
const { timestamp } = props;
const delta = (new Date(timestamp)).getTime() - state.now;
const unitDelay = getUnitDelay(selectUnits(delta));
const unitRemainder = Math.abs(delta % unitDelay);
const updateInterval = 1000 * 10;
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
this._timer = setTimeout(() => {
this.setState({ now: this.props.intl.now() });
@ -171,7 +171,7 @@ class RelativeTimestamp extends Component {
render () {
const { timestamp, intl, year, futureDate } = this.props;
const date = new Date(timestamp);
const date = new Date(timestamp);
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
return (

View File

@ -1,11 +1,12 @@
import React, { PureComponent } from 'react';
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
import LoadMore from './load_more';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
import { throttle } from 'lodash';
import { List as ImmutableList } from 'immutable';
import classNames from 'classnames';
import LoadingIndicator from './loading_indicator';
import ColumnIndicator from '../column_indicator';
import IntersectionObserverArticleContainer from '../../containers/intersection_observer_article_container';
import LoadMore from '../load_more';
import IntersectionObserverWrapper from '../../features/ui/util/intersection_observer_wrapper';
import './index.scss';
const MOUSE_IDLE_DELAY = 300;
@ -21,8 +22,6 @@ export default class ScrollableList extends PureComponent {
isLoading: PropTypes.bool,
showLoading: PropTypes.bool,
hasMore: PropTypes.bool,
prepend: PropTypes.node,
alwaysPrepend: PropTypes.bool,
emptyMessage: PropTypes.node,
children: PropTypes.node,
onScrollToTop: PropTypes.func,
@ -48,9 +47,7 @@ export default class ScrollableList extends PureComponent {
};
clearMouseIdleTimer = () => {
if (this.mouseIdleTimer === null) {
return;
}
if (this.mouseIdleTimer === null) return;
clearTimeout(this.mouseIdleTimer);
this.mouseIdleTimer = null;
@ -61,8 +58,8 @@ export default class ScrollableList extends PureComponent {
this.clearMouseIdleTimer();
this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
// Only set if we just started moving and are scrolled to the top.
if (!this.mouseMovedRecently && this.documentElement.scrollTop === 0) {
// Only set if we just started moving and are scrolled to the top.
this.scrollToTopOnMouseIdle = true;
}
@ -92,9 +89,9 @@ export default class ScrollableList extends PureComponent {
getScrollPosition = () => {
if (this.documentElement && (this.documentElement.scrollTop > 0 || this.mouseMovedRecently)) {
return { height: this.documentElement.scrollHeight, top: this.documentElement.scrollTop };
} else {
return null;
}
return null;
}
updateScrollBottom = (snapshot) => {
@ -161,9 +158,9 @@ export default class ScrollableList extends PureComponent {
if (someItemInserted && (this.documentElement.scrollTop > 0 || this.mouseMovedRecently)) {
return this.documentElement.scrollHeight - this.documentElement.scrollTop;
} else {
return null;
}
return null;
}
cacheMediaWidth = (width) => {
@ -188,7 +185,7 @@ export default class ScrollableList extends PureComponent {
getFirstChildKey (props) {
const { children } = props;
let firstChild = children;
let firstChild = children;
if (children instanceof ImmutableList) {
firstChild = children.get(0);
@ -205,32 +202,19 @@ export default class ScrollableList extends PureComponent {
}
render () {
const { children, scrollKey, showLoading, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
const { children, scrollKey, showLoading, isLoading, hasMore, emptyMessage, onLoadMore } = this.props;
const childrenCount = React.Children.count(children);
const trackScroll = true; //placeholder
const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
let scrollableArea = null;
const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
if (showLoading) {
scrollableArea = (
<div className='slist slist--flex'>
<div role='feed' className='item-list'>
{prepend}
</div>
<div className='slist__append'>
<LoadingIndicator />
</div>
</div>
);
return ( <ColumnIndicator type='loading' /> );
} else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) {
scrollableArea = (
<div className='slist' ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div role='feed' className='item-list'>
{prepend}
return (
<div className='scrollable-list' onMouseMove={this.handleMouseMove}>
<div role='feed'>
{React.Children.map(this.props.children, (child, index) => (
<IntersectionObserverArticleContainer
key={child.key}
@ -253,19 +237,9 @@ export default class ScrollableList extends PureComponent {
</div>
</div>
);
} else {
scrollableArea = (
<div className='slist slist--flex' ref={this.setRef}>
{alwaysPrepend && prepend}
<div className='empty-column-indicator'>
{emptyMessage}
</div>
</div>
);
}
return scrollableArea;
return ( <ColumnIndicator type='error' message={emptyMessage} /> );
}
}

View File

@ -0,0 +1,6 @@
.scrollable-list {
&--flex {
display: flex;
flex-direction: column;
}
}

View File

@ -1,32 +0,0 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
export default class SettingText extends PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
settingKey: PropTypes.array.isRequired,
label: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
handleChange = (e) => {
this.props.onChange(this.props.settingKey, e.target.value);
}
render () {
const { settings, settingKey, label } = this.props;
return (
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={settings.getIn(settingKey)}
onChange={this.handleChange}
placeholder={label}
/>
</label>
);
}
}

View File

@ -1,33 +1,35 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from './avatar';
import AvatarOverlay from './avatar_overlay';
import AvatarComposite from './avatar_composite';
import RelativeTimestamp from './relative_timestamp';
import DisplayName from './display_name';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { NavLink } from 'react-router-dom';
import { injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames';
import Icon from 'gabsocial/components/icon';
import PollContainer from 'gabsocial/containers/poll_container';
import { displayMedia } from '../initial_state';
import { NavLink } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../avatar';
import AvatarOverlay from '../avatar_overlay';
import RelativeTimestamp from '../relative_timestamp';
import DisplayName from '../display_name';
import StatusContent from '../status_content';
import StatusActionBar from '../status_action_bar';
import Card from '../../features/status/components/card';
import { MediaGallery, Video } from '../../features/ui/util/async-components';
import Icon from '../icon';
import PollContainer from '../../containers/poll_container';
import { displayMedia } from '../../initial_state';
import './index.scss';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
import Bundle from '../../features/ui/components/bundle';
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
const displayName = status.getIn(['account', 'display_name']);
const values = [
displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length),
status.get('spoiler_text') && status.get('hidden')
? status.get('spoiler_text')
: status.get('search_index').slice(status.get('spoiler_text').length),
intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
status.getIn(['account', 'acct']),
];
@ -39,19 +41,18 @@ export const textForScreenReader = (intl, status, rebloggedByText = false) => {
return values.join(', ');
};
export const defaultMediaVisibility = (status) => {
if (!status) {
return undefined;
}
export const defaultMediaVisibility = status => {
if (!status) return undefined;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
status = status.get('reblog');
}
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
return (displayMedia !== 'hide_all' && !status.get('sensitive')) || displayMedia === 'show_all';
};
export default @injectIntl
export default
@injectIntl
class Status extends ImmutablePureComponent {
static contextTypes = {
@ -61,7 +62,6 @@ class Status extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
otherAccounts: ImmutablePropTypes.list,
onClick: PropTypes.func,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
@ -91,12 +91,7 @@ class Status extends ImmutablePureComponent {
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'account',
'muted',
'hidden',
];
updateOnProps = ['status', 'account', 'muted', 'hidden'];
state = {
showMedia: defaultMediaVisibility(this.props.status),
@ -104,16 +99,16 @@ class Status extends ImmutablePureComponent {
};
// Track height changes we know about to compensate scrolling
componentDidMount () {
componentDidMount() {
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
}
getSnapshotBeforeUpdate () {
getSnapshotBeforeUpdate() {
if (this.props.getScrollPosition) {
return this.props.getScrollPosition();
} else {
return null;
}
return null;
}
static getDerivedStateFromProps(nextProps, prevState) {
@ -122,14 +117,14 @@ class Status extends ImmutablePureComponent {
showMedia: defaultMediaVisibility(nextProps.status),
statusId: nextProps.status.get('id'),
};
} else {
return null;
}
return null;
}
// Compensate height changes
componentDidUpdate (prevProps, prevState, snapshot) {
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
componentDidUpdate(prevProps, prevState, snapshot) {
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
if (doShowCard && !this.didShowCard) {
this.didShowCard = true;
@ -155,7 +150,7 @@ class Status extends ImmutablePureComponent {
handleToggleMediaVisibility = () => {
this.setState({ showMedia: !this.state.showMedia });
}
};
handleClick = () => {
if (this.props.onClick) {
@ -163,106 +158,106 @@ class Status extends ImmutablePureComponent {
return;
}
if (!this.context.router) {
return;
}
if (!this.context.router) return;
this.context.router.history.push(`/${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`);
}
this.context.router.history.push(
`/${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`
);
};
handleExpandClick = (e) => {
handleExpandClick = e => {
if (e.button === 0) {
if (!this.context.router) {
return;
}
if (!this.context.router) return;
this.context.router.history.push(`/${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`);
this.context.router.history.push(
`/${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`
);
}
}
};
handleExpandedToggle = () => {
this.props.onToggleHidden(this._properStatus());
};
renderLoadingMediaGallery () {
renderLoadingMediaGallery() {
return <div className='media_gallery' style={{ height: '110px' }} />;
}
renderLoadingVideoPlayer () {
renderLoadingVideoPlayer() {
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
}
handleOpenVideo = (media, startTime) => {
this.props.onOpenVideo(media, startTime);
}
};
handleHotkeyReply = e => {
e.preventDefault();
this.props.onReply(this._properStatus(), this.context.router.history);
}
};
handleHotkeyFavourite = () => {
this.props.onFavourite(this._properStatus());
}
};
handleHotkeyBoost = e => {
this.props.onReblog(this._properStatus(), e);
}
};
handleHotkeyMention = e => {
e.preventDefault();
this.props.onMention(this._properStatus().get('account'), this.context.router.history);
}
};
handleHotkeyOpen = () => {
this.context.router.history.push(`/${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`);
}
this.context.router.history.push(
`/${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`
);
};
handleHotkeyOpenProfile = () => {
this.context.router.history.push(`/${this._properStatus().getIn(['account', 'acct'])}`);
}
};
handleHotkeyMoveUp = e => {
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
}
};
handleHotkeyMoveDown = e => {
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
}
};
handleHotkeyToggleHidden = () => {
this.props.onToggleHidden(this._properStatus());
}
};
handleHotkeyToggleSensitive = () => {
this.handleToggleMediaVisibility();
}
};
_properStatus () {
_properStatus() {
const { status } = this.props;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
return status.get('reblog');
} else {
return status;
}
return status;
}
handleRef = c => {
this.node = c;
}
};
render () {
render() {
let media = null;
let statusAvatar, prepend, rebloggedByText, reblogContent;
const { intl, hidden, featured, otherAccounts, unread, showThread, group } = this.props;
const { intl, hidden, featured, unread, showThread, group } = this.props;
let { status, account, ...other } = this.props;
if (status === null) {
return null;
}
if (status === null) return null;
if (hidden) {
return (
@ -274,10 +269,12 @@ class Status extends ImmutablePureComponent {
}
if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
const minHandlers = this.props.muted ? {} : {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
const minHandlers = this.props.muted
? {}
: {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
return (
<HotKeys handlers={minHandlers}>
@ -291,7 +288,9 @@ class Status extends ImmutablePureComponent {
if (featured) {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='thumb-tack' className='status__prepend-icon' fixedWidth /></div>
<div className='status__prepend-icon-wrapper'>
<Icon id='thumb-tack' className='status__prepend-icon' fixedWidth />
</div>
<FormattedMessage id='status.pinned' defaultMessage='Pinned gab' />
</div>
);
@ -300,39 +299,43 @@ class Status extends ImmutablePureComponent {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} reposted' values={{
name: <NavLink to={`/${status.getIn(['account', 'acct'])}`} className='status__display-name muted'>
<bdi>
<strong dangerouslySetInnerHTML={display_name_html} />
</bdi>
</NavLink>
}} />
<div className='status__prepend-icon-wrapper'>
<Icon id='retweet' className='status__prepend-icon' fixedWidth />
</div>
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: (
<NavLink to={`/${status.getIn(['account', 'acct'])}`} className='status__display-name muted'>
<bdi>
<strong dangerouslySetInnerHTML={display_name_html} />
</bdi>
</NavLink>
),
}}
/>
</div>
);
rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} reposted' }, { name: status.getIn(['account', 'acct']) });
rebloggedByText = intl.formatMessage(
{ id: 'status.reblogged_by', defaultMessage: '{name} reposted' },
{ name: status.getIn(['account', 'acct']) }
);
account = status.get('account');
reblogContent = status.get('contentHtml')
status = status.get('reblog');
reblogContent = status.get('contentHtml');
status = status.get('reblog');
}
if (status.get('poll')) {
media = <PollContainer pollId={status.get('poll')} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted) {
media = (
<AttachmentList
compact
media={status.get('media_attachments')}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer}>
{Component => (
<Component
preview={video.get('preview_url')}
@ -381,53 +384,73 @@ class Status extends ImmutablePureComponent {
);
}
if (otherAccounts && otherAccounts.size > 0) {
statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} />;
} else if (account === undefined || account === null) {
if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={48} />;
} else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
const handlers = this.props.muted ? {} : {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
};
const handlers = this.props.muted
? {}
: {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
};
const statusUrl = `/${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`;
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
<div
className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, {
'status__wrapper-reply': !!status.get('in_reply_to_id'),
read: unread === false,
focusable: !this.props.muted,
})}
tabIndex={this.props.muted ? null : 0}
data-featured={featured ? 'true' : null}
aria-label={textForScreenReader(intl, status, rebloggedByText)}
ref={this.handleRef}
>
{prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>
<div
className={classNames('status', `status-${status.get('visibility')}`, {
'status-reply': !!status.get('in_reply_to_id'),
muted: this.props.muted,
read: unread === false,
})}
data-id={status.get('id')}
>
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'>
<NavLink to={statusUrl} className='status__relative-time'>
<RelativeTimestamp timestamp={status.get('created_at')} />
</NavLink>
<NavLink to={`/${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name'>
<div className='status__avatar'>
{statusAvatar}
</div>
<NavLink
to={`/${status.getIn(['account', 'acct'])}`}
title={status.getIn(['account', 'acct'])}
className='status__display-name'
>
<div className='status__avatar'>{statusAvatar}</div>
<DisplayName account={status.get('account')} others={otherAccounts} />
<DisplayName account={status.get('account')} />
</NavLink>
</div>
{!group && status.get('group') && (
<div className='status__meta'>
Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink>
Posted in{' '}
<NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink>
</div>
)}
@ -442,7 +465,9 @@ class Status extends ImmutablePureComponent {
{media}
{showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
{showThread &&
status.get('in_reply_to_id') &&
status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
<button className='status__content__read-more-button' onClick={this.handleClick}>
<FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
</button>

View File

@ -0,0 +1,118 @@
.status {
padding: 8px 10px;
padding-left: 68px;
position: relative;
min-height: 54px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
cursor: default;
@supports (-ms-overflow-style: -ms-autohiding-scrollbar) {
// Add margin to avoid Edge auto-hiding scrollbar appearing over content.
// On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.
padding-right: 26px; // 10px + 16px
}
@keyframes fade {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
opacity: 1;
animation: fade 150ms linear;
.video-player {
margin-top: 8px;
}
&.status-direct:not(.read) {
background: lighten($ui-base-color, 8%);
border-bottom-color: lighten($ui-base-color, 12%);
}
&.light {
.status__relative-time {
color: $light-text-color;
}
.status__display-name {
color: $inverted-text-color;
}
.display-name {
strong {
color: $inverted-text-color;
}
span {
color: $light-text-color;
}
}
.status__content {
color: $inverted-text-color;
a {
color: $highlight-text-color;
}
a.status__content__spoiler-link {
color: $primary-text-color;
background: $ui-primary-color;
&:hover {
background: lighten($ui-primary-color, 8%);
}
}
}
}
&__meta {
font-size: 14px;
color: $gab-secondary-text;
a {
color: $gab-brand-default;
font-weight: bold;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
@media screen and (min-width:630px) {
.status {
padding: 15px 15px 15px 78px;
min-height: 50px;
&__avatar {
left: 15px;
top: 17px;
}
&__content {
padding-top: 5px;
}
&__prepend {
margin-left: 78px;
padding-top: 15px;
}
&__prepend-icon-wrapper {
left: -32px;
}
.media-gallery,
.video-player {
margin-top: 10px;
}
}
}

View File

@ -1,17 +1,18 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, isStaff } from '../initial_state';
import { openModal } from '../actions/modal';
import { defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import DropdownMenuContainer from '../../containers/dropdown_menu_container';
import IconButton from '../icon_button';
import { me, isStaff } from '../../initial_state';
import { openModal } from '../../actions/modal';
import './index.scss';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
@ -51,7 +52,6 @@ class StatusActionBar extends ImmutablePureComponent {
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
onMute: PropTypes.func,
onBlock: PropTypes.func,
@ -120,10 +120,6 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onMention(this.props.status.get('account'), this.context.router.history);
}
handleDirectClick = () => {
this.props.onDirect(this.props.status.get('account'), this.context.router.history);
}
handleMuteClick = () => {
this.props.onMute(this.props.status.get('account'));
}
@ -149,10 +145,10 @@ class StatusActionBar extends ImmutablePureComponent {
}
handleCopy = () => {
const url = this.props.status.get('url');
const url = this.props.status.get('url');
const textarea = document.createElement('textarea');
textarea.textContent = url;
textarea.textContent = url;
textarea.style.position = 'fixed';
document.body.appendChild(textarea);
@ -161,12 +157,12 @@ class StatusActionBar extends ImmutablePureComponent {
textarea.select();
document.execCommand('copy');
} catch (e) {
//
} finally {
document.body.removeChild(textarea);
}
}
handleGroupRemoveAccount = () => {
const { status } = this.props;
@ -180,16 +176,16 @@ class StatusActionBar extends ImmutablePureComponent {
}
_makeMenu = (publicStatus) => {
const { status, intl, withDismiss, withGroupAdmin } = this.props;
const { status, intl: { formatMessage }, withDismiss, withGroupAdmin } = this.props;
const mutingConversation = status.get('muted');
let menu = [];
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
menu.push({ text: formatMessage(messages.open), action: this.handleOpen });
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
menu.push({ text: formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: formatMessage(messages.embed), action: this.handleEmbed });
}
if (!me) {
@ -199,39 +195,38 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push(null);
if (status.getIn(['account', 'id']) === me || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push({ text: formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
}
if (status.getIn(['account', 'id']) === me) {
if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push({ text: formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
} else {
if (status.get('visibility') === 'private') {
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
menu.push({ text: formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
}
}
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
menu.push({ text: formatMessage(messages.delete), action: this.handleDeleteClick });
menu.push({ text: formatMessage(messages.redraft), action: this.handleRedraftClick });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
//menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
menu.push({ text: formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
menu.push({ text: formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
if (isStaff) {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
menu.push({ text: formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
menu.push({ text: formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
}
if (withGroupAdmin) {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.group_remove_account), action: this.handleGroupRemoveAccount });
menu.push({ text: intl.formatMessage(messages.group_remove_post), action: this.handleGroupRemovePost });
menu.push({ text: formatMessage(messages.group_remove_account), action: this.handleGroupRemoveAccount });
menu.push({ text: formatMessage(messages.group_remove_post), action: this.handleGroupRemovePost });
}
}
@ -239,59 +234,82 @@ class StatusActionBar extends ImmutablePureComponent {
}
render () {
const { status, intl, withDismiss } = this.props;
const { status, intl: { formatMessage } } = this.props;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const replyCount = status.get('replies_count');
const replyIcon = (status.get('in_reply_to_id', null) === null) ? 'reply' : 'reply-all';
const replyTitle = (status.get('in_reply_to_id', null) === null) ? formatMessage(messages.reply) : formatMessage(messages.replyAll);
const reblogCount = status.get('reblogs_count');
const reblogTitle = !publicStatus ? formatMessage(messages.cannot_reblog) : formatMessage(messages.reblog);
const reblogIcon = (status.get('visibility') === 'private') ? 'lock' : 'retweet';
const favoriteCount = status.get('favourites_count');
let menu = this._makeMenu(publicStatus);
let reblogIcon = 'retweet';
let replyIcon;
let replyTitle;
if (status.get('visibility') === 'direct') {
reblogIcon = 'envelope';
} else if (status.get('visibility') === 'private') {
reblogIcon = 'lock';
}
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyTitle = intl.formatMessage(messages.replyAll);
}
const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
<IconButton className='status-action-bar-button' title={formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
);
const menu = this._makeMenu(publicStatus);
return (
<div className='status__action-bar'>
<div className='status__action-bar__counter'>
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} />
{replyCount !== 0 && <Link to={`/${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`} className='detailed-status__link'>{replyCount}</Link>}
<div className='status-action-bar'>
<div className='status-action-bar-item'>
<IconButton
className='status-action-bar-item__btn'
title={replyTitle}
icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon}
onClick={this.handleReplyClick}
/>
{
replyCount !== 0 &&
<Link to={`/${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`} className='status-action-bar-item__link'>{replyCount}</Link>
}
</div>
<div className='status__action-bar__counter'>
<IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
{reblogCount !== 0 && <Link to={`/${status.getIn(['account', 'acct'])}/posts/${status.get('id')}/reblogs`} className='detailed-status__link'>{reblogCount}</Link>}
<div className='status-action-bar-item'>
<IconButton
className='status-action-bar-item__btn'
disabled={!publicStatus}
active={status.get('reblogged')}
pressed={status.get('reblogged')}
title={reblogTitle}
icon={reblogIcon}
onClick={this.handleReblogClick}
/>
{reblogCount !== 0 && <Link to={`/${status.getIn(['account', 'acct'])}/posts/${status.get('id')}/reblogs`} className='status-action-bar-item__link'>{reblogCount}</Link>}
</div>
<div className='status__action-bar__counter'>
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{favoriteCount !== 0 && <span className='detailed-status__link'>{favoriteCount}</span>}
<div className='status-action-bar-item'>
<IconButton
className='status-action-bar-item__btn star-icon'
active={status.get('favourited')}
pressed={status.get('favourited')}
title={formatMessage(messages.favourite)}
icon='star'
onClick={this.handleFavouriteClick}
/>
{favoriteCount !== 0 && <span className='status-action-bar-item__link'>{favoriteCount}</span>}
</div>
{shareButton}
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer status={status} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} />
<div className='status-action-bar__dropdown'>
<DropdownMenuContainer
status={status}
items={menu}
icon='ellipsis-h'
size={18}
direction='right'
title={formatMessage(messages.more)}
/>
</div>
</div>
);
}
}
const mapDispatchToProps = (dispatch) => ({
@ -302,4 +320,4 @@ const mapDispatchToProps = (dispatch) => ({
export default injectIntl(
connect(null, mapDispatchToProps, null, { forwardRef: true }
)(StatusActionBar))
)(StatusActionBar));

View File

@ -0,0 +1,29 @@
.status-action-bar {
display: flex;
align-items: center;
margin-top: 25px;
z-index: 4;
&__dropdown {
@include size(23px);
}
}
.status-action-bar-item {
display: inline-flex;
margin-right: 22px;
align-items: center;
&__btn {
margin-right: 4px;
}
&__link {
display: inline-block;
color: $action-button-color;
cursor: pointer;
text-decoration: none;
@include text-sizing(14px, 500);
}
}

View File

@ -1,9 +1,11 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import { isRtl } from '../utils/rtl';
import { FormattedMessage } from 'react-intl';
import Permalink from './permalink';
import classnames from 'classnames';
import Icon from 'gabsocial/components/icon';
import { isRtl } from '../../utils/rtl';
import Permalink from '../permalink';
import Icon from '../icon';
import './index.scss';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
@ -30,9 +32,7 @@ export default class StatusContent extends PureComponent {
_updateStatusLinks () {
const node = this.node;
if (!node) {
return;
}
if (!node) return;
const links = node.querySelectorAll('a');
@ -95,14 +95,14 @@ export default class StatusContent extends PureComponent {
}
handleMouseUp = (e) => {
if (!this.startXY) {
return;
}
if (!this.startXY) return;
const [ startX, startY ] = this.startXY;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
if (e.target.localName === 'button' ||
e.target.localName === 'a' ||
(e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
return;
}
@ -137,18 +137,16 @@ export default class StatusContent extends PureComponent {
const { status, reblogContent } = this.props;
const properContent = status.get('contentHtml');
return reblogContent
? `${reblogContent} <div class='status__quote'>${properContent}</div>`
: properContent;
}
render () {
const { status, reblogContent } = this.props;
const { status } = this.props;
if (status.get('content').length === 0) {
return null;
}
if (status.get('content').length === 0) return null;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
@ -167,7 +165,8 @@ export default class StatusContent extends PureComponent {
const readMoreButton = (
<button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'>
<FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' fixedWidth />
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
<Icon id='angle-right' fixedWidth />
</button>
);
@ -219,18 +218,18 @@ export default class StatusContent extends PureComponent {
}
return output;
} else {
return (
<div
tabIndex='0'
ref={this.setRef}
className='status__content'
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.get('language')}
/>
);
}
return (
<div
tabIndex='0'
ref={this.setRef}
className='status__content'
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.get('language')}
/>
);
}
}

View File

@ -0,0 +1,129 @@
.status__content--with-action {
cursor: pointer;
}
.status__content {
position: relative;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
padding-top: 2px;
color: $primary-text-color;
@include text-sizing(15px, 400, 20px);
&:focus {
outline: 0;
}
&.status__content--with-spoiler {
white-space: normal;
.status__content__text {
white-space: pre-wrap;
}
}
.emojione {
margin: -3px 0 0;
@include size(20px);
}
p {
margin-bottom: 20px;
white-space: pre-wrap;
&:last-child {
margin-bottom: 2px;
}
}
a {
color: $gab-brand-default;
text-decoration: none;
&:hover {
text-decoration: underline;
.fa {
color: lighten($dark-text-color, 7%);
}
}
&.mention {
&:hover {
text-decoration: none;
span {
text-decoration: underline;
}
}
}
.fa {
color: $dark-text-color;
}
}
.status__content__spoiler-link {
background: $action-button-color;
&:hover {
background: lighten($action-button-color, 7%);
text-decoration: none;
}
&::-moz-focus-inner {
border: 0;
}
&::-moz-focus-inner,
&:focus,
&:active {
outline: 0 !important;
}
}
.status__content__text {
display: none;
&.status__content__text--visible {
display: block;
}
}
}
.status__content.status__content--collapsed {
max-height: 20px * 15; // 15 lines is roughly above 500 characters
}
.status__content__read-more-button {
display: block;
color: $gab-brand-default;
border: 0;
background: transparent;
padding: 0;
padding-top: 8px;
@include text-sizing(15px, 400, 20px);
&:hover,
&:active {
text-decoration: underline;
}
}
.status__content__spoiler-link {
display: inline-block;
border-radius: 2px;
background: transparent;
border: 0;
color: $inverted-text-color;
padding: 0 6px;
text-transform: uppercase;
cursor: pointer;
vertical-align: middle;
@include text-sizing(11px, 700, 20px);
}

View File

@ -1,11 +1,12 @@
import { Fragment } from 'react';
import { debounce } from 'lodash';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusContainer from '../containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import LoadGap from './load_gap';
import ScrollableList from './scrollable_list';
import TimelineQueueButtonHeader from './timeline_queue_button_header';
import LoadMore from '../load_more';
import ScrollableList from '../scrollable_list';
import TimelineQueueButtonHeader from '../timeline_queue_button_header';
import ColumnIndicator from '../column_indicator';
import StatusContainer from '../../containers/status_container';
export default class StatusList extends ImmutablePureComponent {
@ -17,9 +18,7 @@ export default class StatusList extends ImmutablePureComponent {
isLoading: PropTypes.bool,
isPartial: PropTypes.bool,
hasMore: PropTypes.bool,
prepend: PropTypes.node,
emptyMessage: PropTypes.node,
alwaysPrepend: PropTypes.bool,
timelineId: PropTypes.string,
queuedItemSize: PropTypes.number,
onDequeueTimeline: PropTypes.func,
@ -40,9 +39,9 @@ export default class StatusList extends ImmutablePureComponent {
getCurrentStatusIndex = (id, featured) => {
if (featured) {
return this.props.featuredStatusIds.indexOf(id);
} else {
return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
}
return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
}
handleMoveUp = (id, featured) => {
@ -76,6 +75,7 @@ export default class StatusList extends ImmutablePureComponent {
handleDequeueTimeline = () => {
const { onDequeueTimeline, timelineId } = this.props;
if (!onDequeueTimeline || !timelineId) return;
onDequeueTimeline(timelineId);
}
@ -87,39 +87,38 @@ export default class StatusList extends ImmutablePureComponent {
const { statusIds, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, ...other } = this.props;
if (isPartial) {
return (
<div className='regeneration-indicator'>
<div>
<div className='regeneration-indicator__label'>
<FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading&hellip;' />
<FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
</div>
</div>
</div>
);
return ( <ColumnIndicator type='loading' /> );
}
let scrollableContent = (isLoading || statusIds.size > 0) ? (
statusIds.map((statusId, index) => statusId === null ? (
<LoadGap
key={'gap:' + statusIds.get(index + 1)}
disabled={isLoading}
maxId={index > 0 ? statusIds.get(index - 1) : null}
onClick={onLoadMore}
/>
) : (
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
group={group}
withGroupAdmin={withGroupAdmin}
showThread
/>
))
) : null;
let scrollableContent = null;
if (isLoading || statusIds.size > 0) {
scrollableContent = statusIds.map((statusId, i) => {
if (statusId === null) {
return (
<LoadMore
gap
key={'gap:' + statusIds.get(i + 1)}
disabled={isLoading}
maxId={i > 0 ? statusIds.get(i - 1) : null}
onClick={onLoadMore}
/>
);
}
return (
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
group={group}
withGroupAdmin={withGroupAdmin}
showThread
/>
);
});
}
if (scrollableContent && featuredStatusIds) {
scrollableContent = featuredStatusIds.map(statusId => (
@ -135,12 +134,24 @@ export default class StatusList extends ImmutablePureComponent {
)).concat(scrollableContent);
}
return [
<TimelineQueueButtonHeader key='timeline-queue-button-header' onClick={this.handleDequeueTimeline} count={totalQueuedItemsCount} itemType='gab' />,
<ScrollableList key='scrollable-list' {...other} isLoading={isLoading} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
{scrollableContent}
</ScrollableList>
];
return (
<Fragment>
<TimelineQueueButtonHeader
onClick={this.handleDequeueTimeline}
count={totalQueuedItemsCount}
itemType='gab'
/>
<ScrollableList
ref={this.setRef}
isLoading={isLoading}
showLoading={isLoading && statusIds.size === 0}
onLoadMore={onLoadMore && this.handleLoadOlder}
{...other}
>
{scrollableContent}
</ScrollableList>
</Fragment>
);
}
}

View File

@ -1,8 +1,11 @@
import { FormattedMessage } from 'react-intl';
import { shortNumberFormat } from '../utils/numbers';
import classNames from 'classnames';
import { shortNumberFormat } from '../../utils/numbers';
import './index.scss';
export default class TimelineQueueButtonHeader extends PureComponent {
static propTypes = {
onClick: PropTypes.func.isRequired,
count: PropTypes.number,
@ -17,23 +20,29 @@ export default class TimelineQueueButtonHeader extends PureComponent {
render () {
const { count, itemType, onClick } = this.props;
const hasItems = (count > 0);
const classes = classNames('timeline-queue-header', {
'hidden': (count <= 0)
'timeline-queue-header--extended': hasItems,
});
return (
<div className={classes}>
<a className='timeline-queue-header__btn' onClick={onClick}>
{(count > 0) && <FormattedMessage
id='timeline_queue.label'
defaultMessage='Click to see {count} new {type}'
values={{
count: shortNumberFormat(count),
type: count == 1 ? itemType : `${itemType}s`,
}}
/>}
{
hasItems &&
<FormattedMessage
id='timeline_queue.label'
defaultMessage='{count} new {type}'
values={{
count: shortNumberFormat(count),
type: count === 1 ? itemType : `${itemType}s`,
}}
/>
}
</a>
</div>
);
}
}
}

View File

@ -0,0 +1,29 @@
.timeline-queue-header {
display: block;
width: 100%;
max-height: 0px;
position: relative;
background-color: darken($ui-base-color, 8%);
border-bottom: 1px solid;
border-top: 1px solid;
border-color: darken($ui-base-color, 4%);
transition: max-height 2.5s ease;
overflow: hidden;
&--extended {
max-height: 46px;
}
&__btn {
display: block;
cursor: pointer;
color: $secondary-text-color;
@include size(100%);
@include text-sizing(14px, 400, 46px, center);
span {
height: 46px;
}
}
}

View File

@ -0,0 +1,47 @@
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Permalink from '../permalink';
import { shortNumberFormat } from '../../utils/numbers';
import './index.scss';
export default class TrendingItem extends ImmutablePureComponent {
static propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
render() {
return (
<div className='trending-item'>
<div className='trending-item__text'>
<Permalink href={hashtag.get('url')} to={`/tags/${hashtag.get('name')}`}>
#<span>{hashtag.get('name')}</span>
</Permalink>
<FormattedMessage
id='trends.count_by_accounts'
defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking'
values={{
rawCount: hashtag.getIn(['history', 0, 'accounts']),
count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong>,
}}
/>
</div>
<div className='trending-item__uses'>
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
</div>
<div className='trending-item__sparkline'>
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</div>
</div>
);
}
}

View File

@ -0,0 +1,55 @@
.trending-item {
display: flex;
align-items: center;
padding: 15px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
&:last-child {
border-bottom: 0;
}
&__name {
flex: 1 1 auto;
color: $dark-text-color;
@include text-overflow(nowrap);
strong {
font-weight: 500;
}
a {
display: block;
color: $darker-text-color;
text-decoration: none;
@include text-sizing(14px, 500);
@include text-overflow(nowrap);
&:hover,
&:focus,
&:active {
span {
text-decoration: underline;
}
}
}
}
&__uses {
flex: 0 0 auto;
width: 100px;
color: $secondary-text-color;
@include text-sizing(24px, 500, 36px, center);
}
&__sparkline {
flex: 0 0 auto;
width: 50px;
path {
stroke: lighten($highlight-text-color, 6%) !important;
}
}
}

View File

@ -1,9 +0,0 @@
import Icon from './icon';
const VerificationBadge = () => (
<span className="verified-icon">
<span className="visuallyhidden">Verified Account</span>
</span>
);
export default VerificationBadge;

View File

@ -0,0 +1,13 @@
import './index.scss';
export default class VerifiedIcon extends PureComponent {
render() {
return (
<span className='verified-icon'>
<span className='visuallyhidden'>Verified Account</span>
</span>
);
}
};

View File

@ -0,0 +1,27 @@
.verified-icon {
display: inline-block;
margin: 0 4px 0 1px;
vertical-align: top;
position: relative;
@include size(15px);
&:before {
background-color: #00A3ED;
border-radius: 50%;
@include pseudo;
@include abs-position(0, 0, 0, 0, false);
}
&:after {
font: normal normal normal 14px/1 FontAwesome;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
color: #fff;
@include pseudo('\f00c');
@include size(15px);
@include text-sizing(0.6em, 400, 15px, center);
}
}