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:
parent
16a9bc6e93
commit
42917806e9
@ -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}
|
42
app/javascript/gabsocial/components/account/index.scss
Normal file
42
app/javascript/gabsocial/components/account/index.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
@ -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}
|
@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
25
app/javascript/gabsocial/components/avatar/index.scss
Normal file
25
app/javascript/gabsocial/components/avatar/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
33
app/javascript/gabsocial/components/avatar_overlay/index.js
Normal file
33
app/javascript/gabsocial/components/avatar_overlay/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
25
app/javascript/gabsocial/components/badge/index.js
Normal file
25
app/javascript/gabsocial/components/badge/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
23
app/javascript/gabsocial/components/badge/index.scss
Normal file
23
app/javascript/gabsocial/components/badge/index.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
93
app/javascript/gabsocial/components/button/index.scss
Normal file
93
app/javascript/gabsocial/components/button/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import './index.scss';
|
||||
|
||||
export default class Column extends PureComponent {
|
||||
|
||||
@ -16,4 +17,4 @@ export default class Column extends PureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
7
app/javascript/gabsocial/components/column/index.scss
Normal file
7
app/javascript/gabsocial/components/column/index.scss
Normal file
@ -0,0 +1,7 @@
|
||||
.column {
|
||||
display: flex;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
width: 350px;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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));
|
125
app/javascript/gabsocial/components/column_header/index.js
Normal file
125
app/javascript/gabsocial/components/column_header/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
172
app/javascript/gabsocial/components/column_header/index.scss
Normal file
172
app/javascript/gabsocial/components/column_header/index.scss
Normal 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);
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
@ -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);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
27
app/javascript/gabsocial/components/display_name/index.js
Normal file
27
app/javascript/gabsocial/components/display_name/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
15
app/javascript/gabsocial/components/display_name/index.scss
Normal file
15
app/javascript/gabsocial/components/display_name/index.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
23
app/javascript/gabsocial/components/domain/index.scss
Normal file
23
app/javascript/gabsocial/components/domain/index.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
|
||||
const DonorBadge = () => (
|
||||
<span className='badge badge--donor'>Donor</span>
|
||||
);
|
||||
|
||||
export default DonorBadge;
|
@ -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>
|
93
app/javascript/gabsocial/components/dropdown_menu/index.scss
Normal file
93
app/javascript/gabsocial/components/dropdown_menu/index.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import './index.scss';
|
||||
|
||||
export default class ExtendedVideoPlayer extends PureComponent {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
69
app/javascript/gabsocial/components/icon_button/index.scss
Normal file
69
app/javascript/gabsocial/components/icon_button/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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>
|
||||
);
|
@ -0,0 +1,4 @@
|
||||
article {
|
||||
// TEMPORARY - content of columns may be significantly altered
|
||||
background: $gab-background-container;
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
|
||||
const InvestorBadge = () => (
|
||||
<span className='badge badge--investor'>Investor</span>
|
||||
);
|
||||
|
||||
export default InvestorBadge;
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
53
app/javascript/gabsocial/components/load_more/index.js
Normal file
53
app/javascript/gabsocial/components/load_more/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
22
app/javascript/gabsocial/components/load_more/index.scss
Normal file
22
app/javascript/gabsocial/components/load_more/index.scss
Normal 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%);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
146
app/javascript/gabsocial/components/media_gallery/index.scss
Normal file
146
app/javascript/gabsocial/components/media_gallery/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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));
|
34
app/javascript/gabsocial/components/modal_root/index.scss
Normal file
34
app/javascript/gabsocial/components/modal_root/index.scss
Normal 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;
|
||||
// }
|
@ -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);
|
@ -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%);
|
||||
}
|
@ -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>
|
||||
);
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
175
app/javascript/gabsocial/components/poll/index.js
Normal file
175
app/javascript/gabsocial/components/poll/index.js
Normal 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>
|
||||
·
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
148
app/javascript/gabsocial/components/poll/index.scss
Normal file
148
app/javascript/gabsocial/components/poll/index.scss
Normal 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);
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
|
||||
const ProBadge = () => (
|
||||
<span className='badge badge--pro'>Pro</span>
|
||||
);
|
||||
|
||||
export default ProBadge;
|
@ -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 (
|
@ -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} /> );
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
.scrollable-list {
|
||||
&--flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
118
app/javascript/gabsocial/components/status/index.scss
Normal file
118
app/javascript/gabsocial/components/status/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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));
|
@ -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);
|
||||
}
|
||||
}
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
129
app/javascript/gabsocial/components/status_content/index.scss
Normal file
129
app/javascript/gabsocial/components/status_content/index.scss
Normal 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);
|
||||
}
|
@ -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…' />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
47
app/javascript/gabsocial/components/trending_item/index.js
Normal file
47
app/javascript/gabsocial/components/trending_item/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
55
app/javascript/gabsocial/components/trending_item/index.scss
Normal file
55
app/javascript/gabsocial/components/trending_item/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import Icon from './icon';
|
||||
|
||||
const VerificationBadge = () => (
|
||||
<span className="verified-icon">
|
||||
<span className="visuallyhidden">Verified Account</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export default VerificationBadge;
|
13
app/javascript/gabsocial/components/verified_icon/index.js
Normal file
13
app/javascript/gabsocial/components/verified_icon/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
27
app/javascript/gabsocial/components/verified_icon/index.scss
Normal file
27
app/javascript/gabsocial/components/verified_icon/index.scss
Normal 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);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user