Forking glitch theme

This commit is contained in:
kibigo! 2017-11-17 19:11:18 -08:00
parent 5a9982b425
commit 45c44989c8
333 changed files with 1714 additions and 4235 deletions

View File

@ -1,93 +0,0 @@
/*
`actions/local_settings`
========================
> For more information on the contents of this file, please contact:
>
> - kibigo! [@kibi@glitch.social]
This file provides our Redux actions related to local settings. It
consists of the following:
- __`changesLocalSetting(key, value)` :__
Changes the local setting with the given `key` to the given
`value`. `key` **MUST** be an array of strings, as required by
`Immutable.Map.prototype.getIn()`.
- __`saveLocalSettings()` :__
Saves the local settings to `localStorage` as a JSON object. We
shouldn't ever need to call this ourselves.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Constants:
----------
We provide the following constants:
- __`LOCAL_SETTING_CHANGE` :__
This string constant is used to dispatch a setting change to our
reducer in `reducers/local_settings`, where the setting is
actually changed.
*/
export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
`changeLocalSetting(key, value)`:
---------------------------------
Changes the local setting with the given `key` to the given `value`.
`key` **MUST** be an array of strings, as required by
`Immutable.Map.prototype.getIn()`.
To accomplish this, we just dispatch a `LOCAL_SETTING_CHANGE` to our
reducer in `reducers/local_settings`.
*/
export function changeLocalSetting(key, value) {
return dispatch => {
dispatch({
type: LOCAL_SETTING_CHANGE,
key,
value,
});
dispatch(saveLocalSettings());
};
};
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
`saveLocalSettings()`:
----------------------
Saves the local settings to `localStorage` as a JSON object.
`changeLocalSetting()` calls this whenever it changes a setting. We
shouldn't ever need to call this ourselves.
> __TODO :__
> Right now `saveLocalSettings()` doesn't keep track of which user
> is currently signed in, but it might be better to give each user
> their *own* local settings.
*/
export function saveLocalSettings() {
return (_, getState) => {
const localSettings = getState().get('local_settings').toJS();
localStorage.setItem('mastodon-settings', JSON.stringify(localSettings));
};
};

View File

@ -1,227 +0,0 @@
/*
`<AccountHeader>`
=================
> For more information on the contents of this file, please contact:
>
> - kibigo! [@kibi@glitch.social]
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. We've expanded it in order to handle user bio
frontmatter.
The `<AccountHeader>` component provides the header for account
timelines. It is a fairly simple component which mostly just consists
of a `render()` method.
__Props:__
- __`account` (`ImmutablePropTypes.map`) :__
The account to render a header for.
- __`me` (`PropTypes.number.isRequired`) :__
The id of the currently-signed-in account.
- __`onFollow` (`PropTypes.func.isRequired`) :__
The function to call when the user clicks the "follow" button.
- __`intl` (`PropTypes.object.isRequired`) :__
Our internationalization object, inserted by `@injectIntl`.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import emojify from '../../../mastodon/features/emoji/emoji';
import IconButton from '../../../mastodon/components/icon_button';
import Avatar from '../../../mastodon/components/avatar';
import { me } from '../../../mastodon/initial_state';
// Our imports //
import { processBio } from '../../util/bio_metadata';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we need
from inside props. In our case, these are the `unfollow`, `follow`, and
`requested` messages used in the `title` of our buttons.
*/
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
});
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Implementation:
---------------
*/
@injectIntl
export default class AccountHeader extends ImmutablePureComponent {
static propTypes = {
account : ImmutablePropTypes.map,
onFollow : PropTypes.func.isRequired,
intl : PropTypes.object.isRequired,
};
/*
### `render()`
The `render()` function is used to render our component.
*/
render () {
const { account, intl } = this.props;
/*
If no `account` is provided, then we can't render a header. Otherwise,
we get the `displayName` for the account, if available. If it's blank,
then we set the `displayName` to just be the `username` of the account.
*/
if (!account) {
return null;
}
let displayName = account.get('display_name_html');
let info = '';
let actionBtn = '';
let following = false;
/*
Next, we handle the account relationships. If the account follows the
user, then we add an `info` message. If the user has requested a
follow, then we disable the `actionBtn` and display an hourglass.
Otherwise, if the account isn't blocked, we set the `actionBtn` to the
appropriate icon.
*/
if (me !== account.get('id')) {
if (account.getIn(['relationship', 'followed_by'])) {
info = (
<span className='account--follows-info'>
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
</span>
);
}
if (account.getIn(['relationship', 'requested'])) {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />
</div>
);
} else if (!account.getIn(['relationship', 'blocking'])) {
following = account.getIn(['relationship', 'following']);
actionBtn = (
<div className='account--action-button'>
<IconButton
size={26}
icon={following ? 'user-times' : 'user-plus'}
active={following ? true : false}
title={intl.formatMessage(following ? messages.unfollow : messages.follow)}
onClick={this.props.onFollow}
/>
</div>
);
}
}
/*
we extract the `text` and
`metadata` from our account's `note` using `processBio()`.
*/
const { text, metadata } = processBio(account.get('note'));
/*
Here, we render our component using all the things we've defined above.
*/
return (
<div className='account__header__wrapper'>
<div
className='account__header'
style={{ backgroundImage: `url(${account.get('header')})` }}
>
<div>
<a href={account.get('url')} target='_blank' rel='noopener'>
<span className='account__header__avatar'>
<Avatar account={account} size={90} />
</span>
<span
className='account__header__display-name'
dangerouslySetInnerHTML={{ __html: displayName }}
/>
</a>
<span className='account__header__username'>
@{account.get('acct')}
{account.get('locked') ? <i className='fa fa-lock' /> : null}
</span>
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
{info}
{actionBtn}
</div>
</div>
{metadata.length && (
<table className='account__metadata'>
<tbody>
{(() => {
let data = [];
for (let i = 0; i < metadata.length; i++) {
data.push(
<tr key={i}>
<th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
<td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
</tr>
);
}
return data;
})()}
</tbody>
</table>
) || null}
</div>
);
}
}

View File

@ -1,66 +0,0 @@
/*
`<ComposeAdvancedOptionsContainer>`
===================================
This container connects `<ComposeAdvancedOptions>` to the Redux store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { toggleComposeAdvancedOption } from '../../../../mastodon/actions/compose';
// Our imports //
import ComposeAdvancedOptions from '.';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
State mapping:
--------------
The `mapStateToProps()` function maps various state properties to the
props of our component. The only property we care about is
`compose.advanced_options`.
*/
const mapStateToProps = state => ({
values: state.getIn(['compose', 'advanced_options']),
});
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We just need to provide a dispatch for
when an advanced option toggle changes.
*/
const mapDispatchToProps = dispatch => ({
onChange (option) {
dispatch(toggleComposeAdvancedOption(option));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);

View File

@ -1,163 +0,0 @@
/*
`<ComposeAdvancedOptions>`
==========================
> For more information on the contents of this file, please contact:
>
> - surinna [@srn@dev.glitch.social]
This adds an advanced options dropdown to the toot compose box, for
toggles that don't necessarily fit elsewhere.
__Props:__
- __`values` (`ImmutablePropTypes.contains(…).isRequired`) :__
An Immutable map with the following values:
- __`do_not_federate` (`PropTypes.bool.isRequired`) :__
Specifies whether or not to federate the status.
- __`onChange` (`PropTypes.func.isRequired`) :__
The function to call when a toggle is changed. We pass this from
our container to the toggle.
- __`intl` (`PropTypes.object.isRequired`) :__
Our internationalization object, inserted by `@injectIntl`.
__State:__
- __`open` :__
This tells whether the dropdown is currently open or closed.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
// Our imports //
import ComposeAdvancedOptionsToggle from './toggle';
import ComposeDropdown from '../dropdown/index';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we need
from inside props. These are the various titles and labels on our
toggles.
`iconStyle` styles the icon used for the dropdown button.
*/
const messages = defineMessages({
local_only_short :
{ id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
local_only_long :
{ id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
advanced_options_icon_title :
{ id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
});
/*
Implementation:
---------------
*/
@injectIntl
export default class ComposeAdvancedOptions extends React.PureComponent {
static propTypes = {
values : ImmutablePropTypes.contains({
do_not_federate : PropTypes.bool.isRequired,
}).isRequired,
onChange : PropTypes.func.isRequired,
intl : PropTypes.object.isRequired,
};
/*
### `render()`
`render()` actually puts our component on the screen.
*/
render () {
const { intl, values } = this.props;
/*
The `options` array provides all of the available advanced options
alongside their icon, text, and name.
*/
const options = [
{ icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
];
/*
`anyEnabled` tells us if any of our advanced options have been enabled.
*/
const anyEnabled = values.some((enabled) => enabled);
/*
`optionElems` takes our `options` and creates
`<ComposeAdvancedOptionsToggle>`s out of them. We use the `name` of the
toggle as its `key` so that React can keep track of it.
*/
const optionElems = options.map((option) => {
return (
<ComposeAdvancedOptionsToggle
onChange={this.props.onChange}
active={values.get(option.name)}
key={option.name}
name={option.name}
shortText={intl.formatMessage(option.shortText)}
longText={intl.formatMessage(option.longText)}
/>
);
});
/*
Finally, we can render our component.
*/
return (
<ComposeDropdown
title={intl.formatMessage(messages.advanced_options_icon_title)}
icon='home'
highlight={anyEnabled}
>
{optionElems}
</ComposeDropdown>
);
}
}

View File

@ -1,103 +0,0 @@
/*
`<ComposeAdvancedOptionsToggle>`
================================
> For more information on the contents of this file, please contact:
>
> - surinna [@srn@dev.glitch.social]
This creates the toggle used by `<ComposeAdvancedOptions>`.
__Props:__
- __`onChange` (`PropTypes.func`) :__
This provides the function to call when the toggle is
(de-?)activated.
- __`active` (`PropTypes.bool`) :__
This prop controls whether the toggle is currently active or not.
- __`name` (`PropTypes.string`) :__
This identifies the toggle, and is sent to `onChange()` when it is
called.
- __`shortText` (`PropTypes.string`) :__
This is a short string used as the title of the toggle.
- __`longText` (`PropTypes.string`) :__
This is a longer string used as a subtitle for the toggle.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import Toggle from 'react-toggle';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Implementation:
---------------
*/
export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
static propTypes = {
onChange: PropTypes.func.isRequired,
active: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
shortText: PropTypes.string.isRequired,
longText: PropTypes.string.isRequired,
}
/*
### `onToggle()`
The `onToggle()` function simply calls the `onChange()` prop with the
toggle's `name`.
*/
onToggle = () => {
this.props.onChange(this.props.name);
}
/*
### `render()`
The `render()` function is used to render our component. We just render
a `<Toggle>` and place next to it our text.
*/
render() {
const { active, shortText, longText } = this.props;
return (
<div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}>
<div className='advanced-options-dropdown__option__toggle'>
<Toggle checked={active} onChange={this.onToggle} />
</div>
<div className='advanced-options-dropdown__option__content'>
<strong>{shortText}</strong>
{longText}
</div>
</div>
);
}
}

View File

@ -1,24 +0,0 @@
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { closeModal } from '../../../mastodon/actions/modal';
// Our imports //
import { changeLocalSetting } from '../../../glitch/actions/local_settings';
import LocalSettings from '.';
const mapStateToProps = state => ({
settings: state.get('local_settings'),
});
const mapDispatchToProps = dispatch => ({
onChange (setting, value) {
dispatch(changeLocalSetting(setting, value));
},
onClose () {
dispatch(closeModal());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings);

View File

@ -1,48 +0,0 @@
/*
`<NotificationContainer>`
=========================
This container connects `<Notification>`s to the Redux store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { connect } from 'react-redux';
// Our imports //
import Notification from '.';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
const mapStateToProps = (state, props) => {
// replace account id with object
let leNotif = props.notification.set('account', state.getIn(['accounts', props.notification.get('account')]));
// populate markedForDelete from state - is mysteriously lost somewhere
for (let n of state.getIn(['notifications', 'items'])) {
if (n.get('id') === props.notification.get('id')) {
leNotif = leNotif.set('markedForDelete', n.get('markedForDelete'));
break;
}
}
return ({
notification: leNotif,
settings: state.get('local_settings'),
notifCleaning: state.getIn(['notifications', 'cleaningMode']),
});
};
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
export default connect(mapStateToProps)(Notification);

View File

@ -1,72 +0,0 @@
// `<NotificationFollow>`
// ======================
// * * * * * * * //
// Imports
// -------
// Package imports.
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports.
import Permalink from '../../../mastodon/components/permalink';
import AccountContainer from '../../../mastodon/containers/account_container';
// Our imports.
import NotificationOverlayContainer from '../notification/overlay/container';
// * * * * * * * //
// Implementation
// --------------
export default class NotificationFollow extends ImmutablePureComponent {
static propTypes = {
id : PropTypes.string.isRequired,
account : ImmutablePropTypes.map.isRequired,
notification : ImmutablePropTypes.map.isRequired,
};
render () {
const { account, notification } = this.props;
// Links to the display name.
const displayName = account.get('display_name_html') || account.get('username');
const link = (
<Permalink
className='notification__display-name'
href={account.get('url')}
title={account.get('acct')}
to={`/accounts/${account.get('id')}`}
dangerouslySetInnerHTML={{ __html: displayName }}
/>
);
// Renders.
return (
<div className='notification notification-follow'>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-user-plus' />
</div>
<FormattedMessage
id='notification.follow'
defaultMessage='{name} followed you'
values={{ name: link }}
/>
</div>
<AccountContainer id={account.get('id')} withNote={false} />
<NotificationOverlayContainer notification={notification} />
</div>
);
}
}

View File

@ -1,49 +0,0 @@
/*
`<NotificationOverlayContainer>`
=========================
This container connects `<NotificationOverlay>`s to the Redux store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { connect } from 'react-redux';
// Our imports //
import NotificationOverlay from './notification_overlay';
import { markNotificationForDelete } from '../../../../mastodon/actions/notifications';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We only need to provide a dispatch for
deleting notifications.
*/
const mapDispatchToProps = dispatch => ({
onMarkForDelete(id, yes) {
dispatch(markNotificationForDelete(id, yes));
},
});
const mapStateToProps = state => ({
show: state.getIn(['notifications', 'cleaningMode']),
});
export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay);

View File

@ -1,187 +0,0 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import RelativeTimestamp from '../../../mastodon/components/relative_timestamp';
import IconButton from '../../../mastodon/components/icon_button';
import DropdownMenuContainer from '../../../mastodon/containers/dropdown_menu_container';
import { me } from '../../../mastodon/initial_state';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
share: { id: 'status.share', defaultMessage: 'Share' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Embed' },
});
@injectIntl
export default class StatusActionBar extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onMention: PropTypes.func,
onMute: PropTypes.func,
onBlock: PropTypes.func,
onReport: PropTypes.func,
onEmbed: PropTypes.func,
onMuteConversation: PropTypes.func,
onPin: PropTypes.func,
withDismiss: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'withDismiss',
]
handleReplyClick = () => {
this.props.onReply(this.props.status, this.context.router.history);
}
handleShareClick = () => {
navigator.share({
text: this.props.status.get('search_index'),
url: this.props.status.get('url'),
});
}
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
}
handleReblogClick = (e) => {
this.props.onReblog(this.props.status, e);
}
handleDeleteClick = () => {
this.props.onDelete(this.props.status);
}
handlePinClick = () => {
this.props.onPin(this.props.status);
}
handleMentionClick = () => {
this.props.onMention(this.props.status.get('account'), this.context.router.history);
}
handleMuteClick = () => {
this.props.onMute(this.props.status.get('account'));
}
handleBlockClick = () => {
this.props.onBlock(this.props.status.get('account'));
}
handleOpen = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
}
handleEmbed = () => {
this.props.onEmbed(this.props.status);
}
handleReport = () => {
this.props.onReport(this.props.status);
}
handleConversationMuteClick = () => {
this.props.onMuteConversation(this.props.status);
}
render () {
const { status, intl, withDismiss } = this.props;
const mutingConversation = status.get('muted');
const anonymousAccess = !me;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
let menu = [];
let reblogIcon = 'retweet';
let replyIcon;
let replyTitle;
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
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(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: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else {
menu.push({ text: intl.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 });
}
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} />
);
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
<IconButton className='status__action-bar-button' disabled={anonymousAccess || !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} />
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton}
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
</div>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div>
);
}
}

View File

@ -1,263 +0,0 @@
/*
`<StatusContainer>`
===================
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. Documentation by @kibi@glitch.social. The code
detecting reblogs has been moved here from <Status>.
*/
/* * * * */
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import { connect } from 'react-redux';
import {
defineMessages,
injectIntl,
FormattedMessage,
} from 'react-intl';
// Mastodon imports //
import { makeGetStatus } from '../../../mastodon/selectors';
import {
replyCompose,
mentionCompose,
} from '../../../mastodon/actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite,
pin,
unpin,
} from '../../../mastodon/actions/interactions';
import { blockAccount } from '../../../mastodon/actions/accounts';
import { initMuteModal } from '../../../mastodon/actions/mutes';
import {
muteStatus,
unmuteStatus,
deleteStatus,
} from '../../../mastodon/actions/statuses';
import { initReport } from '../../../mastodon/actions/reports';
import { openModal } from '../../../mastodon/actions/modal';
// Our imports //
import Status from '.';
/* * * * */
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we will
need in our component. In our case, these are the various confirmation
messages used with statuses.
*/
const messages = defineMessages({
deleteConfirm : {
id : 'confirmations.delete.confirm',
defaultMessage : 'Delete',
},
deleteMessage : {
id : 'confirmations.delete.message',
defaultMessage : 'Are you sure you want to delete this status?',
},
blockConfirm : {
id : 'confirmations.block.confirm',
defaultMessage : 'Block',
},
});
/* * * * */
/*
State mapping:
--------------
The `mapStateToProps()` function maps various state properties to the
props of our component. We wrap this in a `makeMapStateToProps()`
function to give us closure and preserve `getStatus()` across function
calls.
*/
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, ownProps) => {
let status = getStatus(state, ownProps.id);
if(status === null) {
console.error(`ERROR! NULL STATUS! ${ownProps.id}`);
// work-around: find first good status
for (let k of state.get('statuses').keys()) {
status = getStatus(state, k);
if (status !== null) break;
}
}
let reblogStatus = status.get('reblog', null);
let account = undefined;
let prepend = undefined;
/*
Here we process reblogs. If our status is a reblog, then we create a
`prependMessage` to pass along to our `<Status>` along with the
reblogger's `account`, and set `coreStatus` (the one we will actually
render) to the status which has been reblogged.
*/
if (reblogStatus !== null && typeof reblogStatus === 'object') {
account = status.get('account');
status = reblogStatus;
prepend = 'reblogged_by';
}
/*
Here are the props we pass to `<Status>`.
*/
return {
status : status,
account : account || ownProps.account,
settings : state.get('local_settings'),
prepend : prepend || ownProps.prepend,
reblogModal : state.getIn(['meta', 'boost_modal']),
deleteModal : state.getIn(['meta', 'delete_modal']),
};
};
return mapStateToProps;
};
/* * * * */
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We need to provide dispatches for all
of the things you can do with a status: reply, reblog, favourite, et
cetera.
For a few of these dispatches, we open up confirmation modals; the rest
just immediately execute their corresponding actions.
*/
const mapDispatchToProps = (dispatch, { intl }) => ({
onReply (status, router) {
dispatch(replyCompose(status, router));
},
onModalReblog (status) {
dispatch(reblog(status));
},
onReblog (status, e) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
if (e.shiftKey || !this.reblogModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
}
}
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
onPin (status) {
if (status.get('pinned')) {
dispatch(unpin(status));
} else {
dispatch(pin(status));
}
},
onEmbed (status) {
dispatch(openModal('EMBED', { url: status.get('url') }));
},
onDelete (status) {
if (!this.deleteModal) {
dispatch(deleteStatus(status.get('id')));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'))),
}));
}
},
onMention (account, router) {
dispatch(mentionCompose(account, router));
},
onOpenMedia (media, index) {
dispatch(openModal('MEDIA', { media, index }));
},
onOpenVideo (media, time) {
dispatch(openModal('VIDEO', { media, time }));
},
onBlock (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.get('id'))),
}));
},
onReport (status) {
dispatch(initReport(status.get('account'), status));
},
onMute (account) {
dispatch(initMuteModal(account));
},
onMuteConversation (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
});
export default injectIntl(
connect(makeMapStateToProps, mapDispatchToProps)(Status)
);

View File

@ -1,79 +0,0 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../../mastodon/components/icon_button';
// Our imports //
import StatusGalleryItem from './item';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
});
@injectIntl
export default class StatusGallery extends React.PureComponent {
static propTypes = {
sensitive: PropTypes.bool,
media: ImmutablePropTypes.list.isRequired,
letterbox: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
state = {
visible: !this.props.sensitive,
};
handleOpen = () => {
this.setState({ visible: !this.state.visible });
}
handleClick = (index) => {
this.props.onOpenMedia(this.props.media, index);
}
render () {
const { media, intl, sensitive, letterbox, fullwidth } = this.props;
let children;
if (!this.state.visible) {
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
children = (
<div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}>
<span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
const size = media.take(4).size;
children = media.take(4).map((attachment, i) => <StatusGalleryItem key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} letterbox={letterbox} />);
}
return (
<div className={`media-gallery ${fullwidth ? 'full-width' : ''}`} style={{ height: `${this.props.height}px` }}>
<div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div>
{children}
</div>
);
}
}

View File

@ -1,158 +0,0 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
// Mastodon imports //
import { isIOS } from '../../../../mastodon/is_mobile';
export default class StatusGalleryItem extends React.PureComponent {
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
handleMouseEnter = (e) => {
if (this.hoverToPlay()) {
e.target.play();
}
}
handleMouseLeave = (e) => {
if (this.hoverToPlay()) {
e.target.pause();
e.target.currentTime = 0;
}
}
hoverToPlay () {
const { attachment, autoPlayGif } = this.props;
return !autoPlayGif && attachment.get('type') === 'gifv';
}
handleClick = (e) => {
const { index, onClick } = this.props;
if (e.button === 0) {
e.preventDefault();
onClick(index);
}
e.stopPropagation();
}
render () {
const { attachment, index, size, letterbox } = 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';
}
}
let thumbnail = '';
if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
const sizes = `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`;
thumbnail = (
<a
className='media-gallery__item-thumbnail'
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
>
<img
className={letterbox ? 'letterbox' : ''}
src={previewUrl} srcSet={srcSet}
sizes={sizes}
alt={attachment.get('description')}
title={attachment.get('description')}
/>
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && this.props.autoPlayGif;
thumbnail = (
<div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}>
<video
className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`}
role='application'
src={attachment.get('url')}
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
autoPlay={autoPlay}
loop
muted
/>
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
}
return (
<div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail}
</div>
);
}
}

View File

@ -1,760 +0,0 @@
/*
`<Status>`
==========
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. *Heavily* rewritten (and documented!) by
@kibi@glitch.social as a part of glitch-soc/mastodon. The following
features have been added:
- Better separating the "guts" of statuses from their wrapper(s)
- Collapsing statuses
- Moving images inside of CWs
A number of aspects of this original file have been split off into
their own components for better maintainance; for these, see:
- <StatusHeader>
- <StatusPrepend>
And, of course, the other <Status>-related components as well.
*/
/* * * * */
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import scheduleIdleTask from '../../../mastodon/features/ui/util/schedule_idle_task';
import { autoPlayGif } from '../../../mastodon/initial_state';
// Our imports //
import StatusPrepend from './prepend';
import StatusHeader from './header';
import StatusContent from './content';
import StatusActionBar from './action_bar';
import StatusGallery from './gallery';
import StatusPlayer from './player';
import NotificationOverlayContainer from '../notification/overlay/container';
/* * * * */
/*
The `<Status>` component:
-------------------------
The `<Status>` component is a container for statuses. It consists of a
few parts:
- The `<StatusPrepend>`, which contains tangential information about
the status, such as who reblogged it.
- The `<StatusHeader>`, which contains the avatar and username of the
status author, as well as a media icon and the "collapse" toggle.
- The `<StatusContent>`, which contains the content of the status.
- The `<StatusActionBar>`, which provides actions to be performed
on statuses, like reblogging or sending a reply.
### Context
- __`router` (`PropTypes.object`) :__
We need to get our router from the surrounding React context.
### Props
- __`id` (`PropTypes.number`) :__
The id of the status.
- __`status` (`ImmutablePropTypes.map`) :__
The status object, straight from the store.
- __`account` (`ImmutablePropTypes.map`) :__
Don't be confused by this one! This is **not** the account which
posted the status, but the associated account with any further
action (eg, a reblog or a favourite).
- __`settings` (`ImmutablePropTypes.map`) :__
These are our local settings, fetched from our store. We need this
to determine how best to collapse our statuses, among other things.
- __`onFavourite`, `onReblog`, `onModalReblog`, `onDelete`,
`onMention`, `onMute`, `onMuteConversation`, onBlock`, `onReport`,
`onOpenMedia`, `onOpenVideo` (`PropTypes.func`) :__
These are all functions passed through from the
`<StatusContainer>`. We don't deal with them directly here.
- __`reblogModal`, `deleteModal` (`PropTypes.bool`) :__
These tell whether or not the user has modals activated for
reblogging and deleting statuses. They are used by the `onReblog`
and `onDelete` functions, but we don't deal with them here.
- __`muted` (`PropTypes.bool`) :__
This has nothing to do with a user or conversation mute! "Muted" is
what Mastodon internally calls the subdued look of statuses in the
notifications column. This should be `true` for notifications, and
`false` otherwise.
- __`collapse` (`PropTypes.bool`) :__
This prop signals a directive from a higher power to (un)collapse
a status. Most of the time it should be `undefined`, in which case
we do nothing.
- __`prepend` (`PropTypes.string`) :__
The type of prepend: `'reblogged_by'`, `'reblog'`, or
`'favourite'`.
- __`withDismiss` (`PropTypes.bool`) :__
Whether or not the status can be dismissed. Used for notifications.
- __`intersectionObserverWrapper` (`PropTypes.object`) :__
This holds our intersection observer. In Mastodon parlance,
an "intersection" is just when the status is viewable onscreen.
### State
- __`isExpanded` :__
Should be either `true`, `false`, or `null`. The meanings of
these values are as follows:
- __`true` :__ The status contains a CW and the CW is expanded.
- __`false` :__ The status is collapsed.
- __`null` :__ The status is not collapsed or expanded.
- __`isIntersecting` :__
This boolean tells us whether or not the status is currently
onscreen.
- __`isHidden` :__
This boolean tells us if the status has been unrendered to save
CPUs.
*/
export default class Status extends ImmutablePureComponent {
static contextTypes = {
router : PropTypes.object,
};
static propTypes = {
id : PropTypes.string,
status : ImmutablePropTypes.map,
account : ImmutablePropTypes.map,
settings : ImmutablePropTypes.map,
notification : ImmutablePropTypes.map,
onFavourite : PropTypes.func,
onReblog : PropTypes.func,
onModalReblog : PropTypes.func,
onDelete : PropTypes.func,
onPin : PropTypes.func,
onMention : PropTypes.func,
onMute : PropTypes.func,
onMuteConversation : PropTypes.func,
onBlock : PropTypes.func,
onEmbed : PropTypes.func,
onHeightChange : PropTypes.func,
onReport : PropTypes.func,
onOpenMedia : PropTypes.func,
onOpenVideo : PropTypes.func,
reblogModal : PropTypes.bool,
deleteModal : PropTypes.bool,
muted : PropTypes.bool,
collapse : PropTypes.bool,
prepend : PropTypes.string,
withDismiss : PropTypes.bool,
intersectionObserverWrapper : PropTypes.object,
};
state = {
isExpanded : null,
isIntersecting : true,
isHidden : false,
markedForDelete : false,
}
/*
### Implementation
#### `updateOnProps` and `updateOnStates`.
`updateOnProps` and `updateOnStates` tell the component when to update.
We specify them explicitly because some of our props are dynamically=
generated functions, which would otherwise always trigger an update.
Of course, this means that if we add an important prop, we will need
to remember to specify it here.
*/
updateOnProps = [
'status',
'account',
'settings',
'prepend',
'boostModal',
'muted',
'collapse',
'notification',
]
updateOnStates = [
'isExpanded',
'markedForDelete',
]
/*
#### `componentWillReceiveProps()`.
If our settings have changed to disable collapsed statuses, then we
need to make sure that we uncollapse every one. We do that by watching
for changes to `settings.collapsed.enabled` in
`componentWillReceiveProps()`.
We also need to watch for changes on the `collapse` prop---if this
changes to anything other than `undefined`, then we need to collapse or
uncollapse our status accordingly.
*/
componentWillReceiveProps (nextProps) {
if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
if (this.state.isExpanded === false) {
this.setExpansion(null);
}
} else if (
nextProps.collapse !== this.props.collapse &&
nextProps.collapse !== undefined
) this.setExpansion(nextProps.collapse ? false : null);
}
/*
#### `componentDidMount()`.
When mounting, we just check to see if our status should be collapsed,
and collapse it if so. We don't need to worry about whether collapsing
is enabled here, because `setExpansion()` already takes that into
account.
The cases where a status should be collapsed are:
- The `collapse` prop has been set to `true`
- The user has decided in local settings to collapse all statuses.
- The user has decided to collapse all notifications ('muted'
statuses).
- The user has decided to collapse long statuses and the status is
over 400px (without media, or 650px with).
- The status is a reply and the user has decided to collapse all
replies.
- The status contains media and the user has decided to collapse all
statuses with media.
We also start up our intersection observer to monitor our statuses.
`componentMounted` lets us know that everything has been set up
properly and our intersection observer is good to go.
*/
componentDidMount () {
const { node, handleIntersection } = this;
const {
status,
settings,
collapse,
muted,
id,
intersectionObserverWrapper,
prepend,
} = this.props;
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
if (
collapse ||
autoCollapseSettings.get('all') || (
autoCollapseSettings.get('notifications') && muted
) || (
autoCollapseSettings.get('lengthy') &&
node.clientHeight > (
status.get('media_attachments').size && !muted ? 650 : 400
)
) || (
autoCollapseSettings.get('reblogs') &&
prepend === 'reblogged_by'
) || (
autoCollapseSettings.get('replies') &&
status.get('in_reply_to_id', null) !== null
) || (
autoCollapseSettings.get('media') &&
!(status.get('spoiler_text').length) &&
status.get('media_attachments').size
)
) this.setExpansion(false);
if (!intersectionObserverWrapper) return;
else intersectionObserverWrapper.observe(
id,
node,
handleIntersection
);
this.componentMounted = true;
}
/*
#### `shouldComponentUpdate()`.
If the status is about to be both offscreen (not intersecting) and
hidden, then we only need to update it if it's not that way currently.
If the status is moving from offscreen to onscreen, then we *have* to
re-render, so that we can unhide the element if necessary.
If neither of these cases are true, we can leave it up to our
`updateOnProps` and `updateOnStates` arrays.
*/
shouldComponentUpdate (nextProps, nextState) {
switch (true) {
case !nextState.isIntersecting && nextState.isHidden:
return this.state.isIntersecting || !this.state.isHidden;
case nextState.isIntersecting && !this.state.isIntersecting:
return true;
default:
return super.shouldComponentUpdate(nextProps, nextState);
}
}
/*
#### `componentDidUpdate()`.
If our component is being rendered for any reason and an update has
triggered, this will save its height.
This is, frankly, a bit overkill, as the only instance when we
actually *need* to update the height right now should be when the
value of `isExpanded` has changed. But it makes for more readable
code and prevents bugs in the future where the height isn't set
properly after some change.
*/
componentDidUpdate () {
if (
this.state.isIntersecting || !this.state.isHidden
) this.saveHeight();
}
/*
#### `componentWillUnmount()`.
If our component is about to unmount, then we'd better unset
`this.componentMounted`.
*/
componentWillUnmount () {
this.componentMounted = false;
}
/*
#### `handleIntersection()`.
`handleIntersection()` either hides the status (if it is offscreen) or
unhides it (if it is onscreen). It's called by
`intersectionObserverWrapper.observe()`.
If our status isn't intersecting, we schedule an idle task (using the
aptly-named `scheduleIdleTask()`) to hide the status at the next
available opportunity.
tootsuite/mastodon left us with the following enlightening comment
regarding this function:
> Edge 15 doesn't support isIntersecting, but we can infer it
It then implements a polyfill (intersectionRect.height > 0) which isn't
actually sufficient. The short answer is, this behaviour isn't really
supported on Edge but we can get kinda close.
*/
handleIntersection = (entry) => {
const isIntersecting = (
typeof entry.isIntersecting === 'boolean' ?
entry.isIntersecting :
entry.intersectionRect.height > 0
);
this.setState(
(prevState) => {
if (prevState.isIntersecting && !isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting);
}
return {
isIntersecting : isIntersecting,
isHidden : false,
};
}
);
}
/*
#### `hideIfNotIntersecting()`.
This function will hide the status if we're still not intersecting.
Hiding the status means that it will just render an empty div instead
of actual content, which saves RAMS and CPUs or some such.
*/
hideIfNotIntersecting = () => {
if (!this.componentMounted) return;
this.setState(
(prevState) => ({ isHidden: !prevState.isIntersecting })
);
}
/*
#### `saveHeight()`.
`saveHeight()` saves the height of our status so that when whe hide it
we preserve its dimensions. We only want to store our height, though,
if our status has content (otherwise, it would imply that it is
already hidden).
*/
saveHeight = () => {
if (this.node && this.node.children.length) {
this.height = this.node.getBoundingClientRect().height;
}
}
/*
#### `setExpansion()`.
`setExpansion()` sets the value of `isExpanded` in our state. It takes
one argument, `value`, which gives the desired value for `isExpanded`.
The default for this argument is `null`.
`setExpansion()` automatically checks for us whether toot collapsing
is enabled, so we don't have to.
We use a `switch` statement to simplify our code.
*/
setExpansion = (value) => {
switch (true) {
case value === undefined || value === null:
this.setState({ isExpanded: null });
break;
case !value && this.props.settings.getIn(['collapsed', 'enabled']):
this.setState({ isExpanded: false });
break;
case !!value:
this.setState({ isExpanded: true });
break;
}
}
/*
#### `handleRef()`.
`handleRef()` just saves a reference to our status node to `this.node`.
It also saves our height, in case the height of our node has changed.
*/
handleRef = (node) => {
this.node = node;
this.saveHeight();
}
/*
#### `parseClick()`.
`parseClick()` takes a click event and responds appropriately.
If our status is collapsed, then clicking on it should uncollapse it.
If `Shift` is held, then clicking on it should collapse it.
Otherwise, we open the url handed to us in `destination`, if
applicable.
*/
parseClick = (e, destination) => {
const { router } = this.context;
const { status } = this.props;
const { isExpanded } = this.state;
if (!router) return;
if (destination === undefined) {
destination = `/statuses/${
status.getIn(['reblog', 'id'], status.get('id'))
}`;
}
if (e.button === 0) {
if (isExpanded === false) this.setExpansion(null);
else if (e.shiftKey) {
this.setExpansion(false);
document.getSelection().removeAllRanges();
} else router.history.push(destination);
e.preventDefault();
}
}
/*
#### `render()`.
`render()` actually puts our element on the screen. The particulars of
this operation are further explained in the code below.
*/
render () {
const {
parseClick,
setExpansion,
saveHeight,
handleRef,
} = this;
const { router } = this.context;
const {
status,
account,
settings,
collapsed,
muted,
prepend,
intersectionObserverWrapper,
onOpenVideo,
onOpenMedia,
notification,
...other
} = this.props;
const { isExpanded, isIntersecting, isHidden } = this.state;
let background = null;
let attachments = null;
let media = null;
let mediaIcon = null;
/*
If we don't have a status, then we don't render anything.
*/
if (status === null) {
return null;
}
/*
If our status is offscreen and hidden, then we render an empty <div> in
its place. We fill it with "content" but note that opacity is set to 0.
*/
if (!isIntersecting && isHidden) {
return (
<div
ref={this.handleRef}
data-id={status.get('id')}
style={{
height : `${this.height}px`,
opacity : 0,
overflow : 'hidden',
}}
>
{
status.getIn(['account', 'display_name']) ||
status.getIn(['account', 'username'])
}
{status.get('content')}
</div>
);
}
/*
If user backgrounds for collapsed statuses are enabled, then we
initialize our background accordingly. This will only be rendered if
the status is collapsed.
*/
if (
settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])
) background = status.getIn(['account', 'header']);
/*
This handles our media attachments. Note that we don't show media on
muted (notification) statuses. If the media type is unknown, then we
simply ignore it.
After we have generated our appropriate media element and stored it in
`media`, we snatch the thumbnail to use as our `background` if media
backgrounds for collapsed statuses are enabled.
*/
attachments = status.get('media_attachments');
if (attachments.size && !muted) {
if (attachments.some((item) => item.get('type') === 'unknown')) {
} else if (
attachments.getIn([0, 'type']) === 'video'
) {
media = ( // Media type is 'video'
<StatusPlayer
media={attachments.get(0)}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenVideo={onOpenVideo}
/>
);
mediaIcon = 'video-camera';
} else { // Media type is 'image' or 'gifv'
media = (
<StatusGallery
media={attachments}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenMedia={onOpenMedia}
autoPlayGif={autoPlayGif}
/>
);
mediaIcon = 'picture-o';
}
if (
!status.get('sensitive') &&
!(status.get('spoiler_text').length > 0) &&
settings.getIn(['collapsed', 'backgrounds', 'preview_images'])
) background = attachments.getIn([0, 'preview_url']);
}
/*
Here we prepare extra data-* attributes for CSS selectors.
Users can use those for theming, hiding avatars etc via UserStyle
*/
const selectorAttribs = {
'data-status-by': `@${status.getIn(['account', 'acct'])}`,
};
if (prepend && account) {
const notifKind = {
favourite: 'favourited',
reblog: 'boosted',
reblogged_by: 'boosted',
}[prepend];
selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
}
/*
Finally, we can render our status. We just put the pieces together
from above. We only render the action bar if the status isn't
collapsed.
*/
return (
<article
className={
`status${
muted ? ' muted' : ''
} status-${status.get('visibility')}${
isExpanded === false ? ' collapsed' : ''
}${
isExpanded === false && background ? ' has-background' : ''
}${
this.state.markedForDelete ? ' marked-for-delete' : ''
}`
}
style={{
backgroundImage: (
isExpanded === false && background ?
`url(${background})` :
'none'
),
}}
ref={handleRef}
{...selectorAttribs}
>
{prepend && account ? (
<StatusPrepend
type={prepend}
account={account}
parseClick={parseClick}
notificationId={this.props.notificationId}
/>
) : null}
<StatusHeader
status={status}
friend={account}
mediaIcon={mediaIcon}
collapsible={settings.getIn(['collapsed', 'enabled'])}
collapsed={isExpanded === false}
parseClick={parseClick}
setExpansion={setExpansion}
/>
<StatusContent
status={status}
media={media}
mediaIcon={mediaIcon}
expanded={isExpanded}
setExpansion={setExpansion}
onHeightUpdate={saveHeight}
parseClick={parseClick}
disabled={!router}
/>
{isExpanded !== false ? (
<StatusActionBar
{...other}
status={status}
account={status.get('account')}
/>
) : null}
{notification ? (
<NotificationOverlayContainer
notification={notification}
/>
) : null}
</article>
);
}
}

View File

@ -1,203 +0,0 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../mastodon/components/icon_button';
import { isIOS } from '../../../mastodon/is_mobile';
const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
});
@injectIntl
export default class StatusPlayer extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
letterbox: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number,
sensitive: PropTypes.bool,
intl: PropTypes.object.isRequired,
autoplay: PropTypes.bool,
onOpenVideo: PropTypes.func.isRequired,
};
static defaultProps = {
height: 110,
};
state = {
visible: !this.props.sensitive,
preview: true,
muted: true,
hasAudio: true,
videoError: false,
};
handleClick = () => {
this.setState({ muted: !this.state.muted });
}
handleVideoClick = (e) => {
e.stopPropagation();
const node = this.video;
if (node.paused) {
node.play();
} else {
node.pause();
}
}
handleOpen = () => {
this.setState({ preview: !this.state.preview });
}
handleVisibility = () => {
this.setState({
visible: !this.state.visible,
preview: true,
});
}
handleExpand = () => {
this.video.pause();
this.props.onOpenVideo(this.props.media, this.video.currentTime);
}
setRef = (c) => {
this.video = c;
}
handleLoadedData = () => {
if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError = () => {
this.setState({ videoError: true });
}
componentDidMount () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentDidUpdate () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentWillUnmount () {
if (!this.video) {
return;
}
this.video.removeEventListener('loadeddata', this.handleLoadedData);
this.video.removeEventListener('error', this.handleVideoError);
}
render () {
const { media, intl, letterbox, fullwidth, height, sensitive, autoplay } = this.props;
let spoilerButton = (
<div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}>
<IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
</div>
);
let expandButton = !this.context.router ? '' : (
<div className='status__video-player-expand'>
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
</div>
);
let muteButton = '';
if (this.state.hasAudio) {
muteButton = (
<div className='status__video-player-mute'>
<IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
</div>
);
}
if (!this.state.visible) {
if (sensitive) {
return (
<div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
return (
<div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
}
}
if (this.state.preview && !autoplay) {
return (
<div role='button' tabIndex='0' className={`media-spoiler-video ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
{spoilerButton}
<div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
</div>
);
}
if (this.state.videoError) {
return (
<div style={{ height: `${height}px` }} className='video-error-cover' >
<span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
</div>
);
}
return (
<div className={`status__video-player ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px` }}>
{spoilerButton}
{muteButton}
{expandButton}
<video
className={`status__video-player-video${letterbox ? ' letterbox' : ''}`}
role='button'
tabIndex='0'
ref={this.setRef}
src={media.get('url')}
autoPlay={!isIOS()}
loop
muted={this.state.muted}
onClick={this.handleVideoClick}
/>
</div>
);
}
}

View File

@ -1,126 +0,0 @@
/*
`reducers/local_settings`
========================
> For more information on the contents of this file, please contact:
>
> - kibigo! [@kibi@glitch.social]
This file provides our Redux reducers related to local settings. The
associated actions are:
- __`STORE_HYDRATE` :__
Used to hydrate the store with its initial values.
- __`LOCAL_SETTING_CHANGE` :__
Used to change the value of a local setting in the store.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import { Map as ImmutableMap } from 'immutable';
// Mastodon imports //
import { STORE_HYDRATE } from '../../mastodon/actions/store';
// Our imports //
import { LOCAL_SETTING_CHANGE } from '../actions/local_settings';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
initialState:
-------------
You can see the default values for all of our local settings here.
These are only used if no previously-saved values exist.
*/
const initialState = ImmutableMap({
layout : 'auto',
stretch : true,
navbar_under : false,
side_arm : 'none',
collapsed : ImmutableMap({
enabled : true,
auto : ImmutableMap({
all : false,
notifications : true,
lengthy : true,
reblogs : false,
replies : false,
media : false,
}),
backgrounds : ImmutableMap({
user_backgrounds : false,
preview_images : false,
}),
}),
media : ImmutableMap({
letterbox : true,
fullwidth : true,
}),
});
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Helper functions:
-----------------
### `hydrate(state, localSettings)`
`hydrate()` is used to hydrate the `local_settings` part of our store
with its initial values. The `state` will probably just be the
`initialState`, and the `localSettings` should be whatever we pulled
from `localStorage`.
*/
const hydrate = (state, localSettings) => state.mergeDeep(localSettings);
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
`localSettings(state = initialState, action)`:
----------------------------------------------
This function holds our actual reducer.
If our action is `STORE_HYDRATE`, then we call `hydrate()` with the
`local_settings` property of the provided `action.state`.
If our action is `LOCAL_SETTING_CHANGE`, then we set `action.key` in
our state to the provided `action.value`. Note that `action.key` MUST
be an array, since we use `setIn()`.
> __Note :__
> We call this function `localSettings`, but its associated object
> in the store is `local_settings`.
*/
export default function localSettings(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
return hydrate(state, action.state.get('local_settings'));
case LOCAL_SETTING_CHANGE:
return state.setIn(action.key, action.value);
default:
return state;
}
};

View File

@ -1,249 +0,0 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
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 { 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';
// 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';
export default class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onPin: PropTypes.func,
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
onBlock: PropTypes.func,
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};
state = {
isExpanded: false,
}
// 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',
]
updateOnStates = ['isExpanded']
handleClick = () => {
if (!this.context.router) {
return;
}
const { status } = this.props;
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
}
handleAccountClick = (e) => {
if (this.context.router && e.button === 0) {
const id = e.currentTarget.getAttribute('data-id');
e.preventDefault();
this.context.router.history.push(`/accounts/${id}`);
}
}
handleExpandedToggle = () => {
this.setState({ isExpanded: !this.state.isExpanded });
};
renderLoadingMediaGallery () {
return <div className='media_gallery' style={{ height: '110px' }} />;
}
renderLoadingVideoPlayer () {
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
}
handleOpenVideo = startTime => {
this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), 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(`/statuses/${this._properStatus().get('id')}`);
}
handleHotkeyOpenProfile = () => {
this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
}
handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.status.get('id'));
}
handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.status.get('id'));
}
_properStatus () {
const { status } = this.props;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
return status.get('reblog');
} else {
return status;
}
}
render () {
let media = null;
let statusAvatar, prepend;
const { hidden } = this.props;
const { isExpanded } = this.state;
let { status, account, ...other } = this.props;
if (status === null) {
return null;
}
if (hidden) {
return (
<div>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')}
</div>
);
}
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
</div>
);
account = status.get('account');
status = status.get('reblog');
}
if (status.get('media_attachments').size > 0 && !this.props.muted) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => <Component
preview={video.get('preview_url')}
src={video.get('url')}
width={239}
height={110}
sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo}
/>}
</Bundle>
);
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />}
</Bundle>
);
}
}
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,
};
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
{prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
<div className='status__avatar'>
{statusAvatar}
</div>
<DisplayName account={status.get('account')} />
</a>
</div>
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
{media}
<StatusActionBar status={status} account={account} {...other} />
</div>
</div>
</HotKeys>
);
}
}

View File

@ -1,188 +0,0 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/content
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { isRtl } from '../rtl';
import { FormattedMessage } from 'react-intl';
import Permalink from './permalink';
import classnames from 'classnames';
export default class StatusContent extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
expanded: PropTypes.bool,
onExpandedToggle: PropTypes.func,
onClick: PropTypes.func,
};
state = {
hidden: true,
};
_updateStatusLinks () {
const node = this.node;
const links = node.querySelectorAll('a');
for (var i = 0; i < links.length; ++i) {
let link = links[i];
if (link.classList.contains('status-link')) {
continue;
}
link.classList.add('status-link');
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', mention.get('acct'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else {
link.setAttribute('title', link.href);
}
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
}
}
componentDidMount () {
this._updateStatusLinks();
}
componentDidUpdate () {
this._updateStatusLinks();
}
onMentionClick = (mention, e) => {
if (this.context.router && e.button === 0) {
e.preventDefault();
this.context.router.history.push(`/accounts/${mention.get('id')}`);
}
}
onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (this.context.router && e.button === 0) {
e.preventDefault();
this.context.router.history.push(`/timelines/tag/${hashtag}`);
}
}
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
handleMouseUp = (e) => {
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'))) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
this.props.onClick();
}
this.startXY = null;
}
handleSpoilerClick = (e) => {
e.preventDefault();
if (this.props.onExpandedToggle) {
// The parent manages the state
this.props.onExpandedToggle();
} else {
this.setState({ hidden: !this.state.hidden });
}
}
setRef = (c) => {
this.node = c;
}
render () {
const { status } = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const content = { __html: status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') };
const directionStyle = { direction: 'ltr' };
const classNames = classnames('status__content', {
'status__content--with-action': this.props.onClick && this.context.router,
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
});
if (isRtl(status.get('search_index'))) {
directionStyle.direction = 'rtl';
}
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
const mentionLinks = status.get('mentions').map(item => (
<Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'>
@<span>{item.get('username')}</span>
</Permalink>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
if (hidden) {
mentionsPlaceholder = <div>{mentionLinks}</div>;
}
return (
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
<span dangerouslySetInnerHTML={spoilerContent} />
{' '}
<button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>{toggleText}</button>
</p>
{mentionsPlaceholder}
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
</div>
);
} else if (this.props.onClick) {
return (
<div
ref={this.setRef}
tabIndex='0'
className={classNames}
style={directionStyle}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
);
} else {
return (
<div
tabIndex='0'
ref={this.setRef}
className='status__content'
style={directionStyle}
dangerouslySetInnerHTML={content}
/>
);
}
}
}

View File

@ -1,131 +0,0 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/account/header
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { autoPlayGif, me } from '../../../initial_state';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
});
class Avatar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
state = {
isHovered: false,
};
handleMouseOver = () => {
if (this.state.isHovered) return;
this.setState({ isHovered: true });
}
handleMouseOut = () => {
if (!this.state.isHovered) return;
this.setState({ isHovered: false });
}
render () {
const { account } = this.props;
const { isHovered } = this.state;
return (
<Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
{({ radius }) =>
<a
href={account.get('url')}
className='account__header__avatar'
role='presentation'
target='_blank'
rel='noopener'
style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
onFocus={this.handleMouseOver}
onBlur={this.handleMouseOut}
>
<span style={{ display: 'none' }}>{account.get('acct')}</span>
</a>
}
</Motion>
);
}
}
@injectIntl
export default class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
onFollow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { account, intl } = this.props;
if (!account) {
return null;
}
let info = '';
let actionBtn = '';
let lockedIcon = '';
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
}
if (me !== account.get('id')) {
if (account.getIn(['relationship', 'requested'])) {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />
</div>
);
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
</div>
);
}
}
if (account.get('locked')) {
lockedIcon = <i className='fa fa-lock' />;
}
const content = { __html: account.get('note_emojified') };
const displayNameHtml = { __html: account.get('display_name_html') };
return (
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div>
<Avatar account={account} />
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
<div className='account__header__content' dangerouslySetInnerHTML={content} />
{info}
{actionBtn}
</div>
</div>
);
}
}

View File

@ -1,155 +0,0 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/notification
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusContainer from '../../../containers/status_container';
import AccountContainer from '../../../containers/account_container';
import { FormattedMessage } from 'react-intl';
import Permalink from '../../../components/permalink';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
export default class Notification extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
notification: ImmutablePropTypes.map.isRequired,
hidden: PropTypes.bool,
onMoveUp: PropTypes.func.isRequired,
onMoveDown: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
};
handleMoveUp = () => {
const { notification, onMoveUp } = this.props;
onMoveUp(notification.get('id'));
}
handleMoveDown = () => {
const { notification, onMoveDown } = this.props;
onMoveDown(notification.get('id'));
}
handleOpen = () => {
const { notification } = this.props;
if (notification.get('status')) {
this.context.router.history.push(`/statuses/${notification.get('status')}`);
} else {
this.handleOpenProfile();
}
}
handleOpenProfile = () => {
const { notification } = this.props;
this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
}
handleMention = e => {
e.preventDefault();
const { notification, onMention } = this.props;
onMention(notification.get('account'), this.context.router.history);
}
getHandlers () {
return {
moveUp: this.handleMoveUp,
moveDown: this.handleMoveDown,
open: this.handleOpen,
openProfile: this.handleOpenProfile,
mention: this.handleMention,
reply: this.handleMention,
};
}
renderFollow (account, link) {
return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-follow focusable' tabIndex='0'>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-user-plus' />
</div>
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
</div>
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
</div>
</HotKeys>
);
}
renderMention (notification) {
return (
<StatusContainer
id={notification.get('status')}
withDismiss
hidden={this.props.hidden}
onMoveDown={this.handleMoveDown}
onMoveUp={this.handleMoveUp}
/>
);
}
renderFavourite (notification, link) {
return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-favourite focusable' tabIndex='0'>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-star star-icon' />
</div>
<FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
</div>
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
</div>
</HotKeys>
);
}
renderReblog (notification, link) {
return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-reblog focusable' tabIndex='0'>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-retweet' />
</div>
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
</div>
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
</div>
</HotKeys>
);
}
render () {
const { notification } = this.props;
const account = notification.get('account');
const displayNameHtml = { __html: account.get('display_name_html') };
const link = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHtml} />;
switch(notification.get('type')) {
case 'follow':
return this.renderFollow(account, link);
case 'mention':
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification, link);
case 'reblog':
return this.renderReblog(notification, link);
}
return null;
}
}

View File

@ -1,20 +0,0 @@
import React from 'react';
import ComposeFormContainer from '../../compose/containers/compose_form_container';
import NotificationsContainer from '../../ui/containers/notifications_container';
import LoadingBarContainer from '../../ui/containers/loading_bar_container';
import ModalContainer from '../../ui/containers/modal_container';
export default class Compose extends React.PureComponent {
render () {
return (
<div>
<ComposeFormContainer />
<NotificationsContainer />
<ModalContainer />
<LoadingBarContainer className='loading-bar' />
</div>
);
}
}

View File

@ -1,118 +0,0 @@
export function EmojiPicker () {
return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker');
}
export function Compose () {
return import(/* webpackChunkName: "features/compose" */'../../compose');
}
export function Notifications () {
return import(/* webpackChunkName: "features/notifications" */'../../notifications');
}
export function HomeTimeline () {
return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline');
}
export function PublicTimeline () {
return import(/* webpackChunkName: "features/public_timeline" */'../../public_timeline');
}
export function CommunityTimeline () {
return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline');
}
export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
}
export function DirectTimeline() {
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
}
export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status');
}
export function GettingStarted () {
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
}
export function PinnedStatuses () {
return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses');
}
export function AccountTimeline () {
return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline');
}
export function AccountGallery () {
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
}
export function Followers () {
return import(/* webpackChunkName: "features/followers" */'../../followers');
}
export function Following () {
return import(/* webpackChunkName: "features/following" */'../../following');
}
export function Reblogs () {
return import(/* webpackChunkName: "features/reblogs" */'../../reblogs');
}
export function Favourites () {
return import(/* webpackChunkName: "features/favourites" */'../../favourites');
}
export function FollowRequests () {
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
}
export function GenericNotFound () {
return import(/* webpackChunkName: "features/generic_not_found" */'../../generic_not_found');
}
export function FavouritedStatuses () {
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
}
export function Blocks () {
return import(/* webpackChunkName: "features/blocks" */'../../blocks');
}
export function Mutes () {
return import(/* webpackChunkName: "features/mutes" */'../../mutes');
}
export function OnboardingModal () {
return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal');
}
export function MuteModal () {
return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal');
}
export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
}
export function SettingsModal () {
return import(/* webpackChunkName: "modals/settings_modal" */'glitch/components/local_settings/container');
}
// THESE AREN'T USED BY US; SEE `glitch/components/status` AND `mastodon/features/status`. //
// IF MASTODON EVER CHANGES DETAILED STATUSES TO REQUIRE THEM, WE'LL NEED TO UPDATE THE URLS OR SOMETHING LOL. //
export function MediaGallery () {
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
}
export function Video () {
return import(/* webpackChunkName: "features/video" */'../../video');
}
export function EmbedModal () {
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
}

View File

@ -1,5 +0,0 @@
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
const adapter = new Adapter();
configure({ adapter });

View File

@ -1,9 +1,9 @@
import loadPolyfills from '../mastodon/load_polyfills'; import loadPolyfills from 'themes/glitch/util/load_polyfills';
require.context('../images/', true); require.context('../images/', true);
function loaded() { function loaded() {
const TimelineContainer = require('../mastodon/containers/timeline_container').default; const TimelineContainer = require('themes/glitch/containers/timeline_container').default;
const React = require('react'); const React = require('react');
const ReactDOM = require('react-dom'); const ReactDOM = require('react-dom');
const mountNode = document.getElementById('mastodon-timeline'); const mountNode = document.getElementById('mastodon-timeline');
@ -15,7 +15,7 @@ function loaded() {
} }
function main() { function main() {
const ready = require('../mastodon/ready').default; const ready = require('themes/glitch/util/ready').default;
ready(loaded); ready(loaded);
} }

View File

@ -1,3 +1,7 @@
// THIS IS THE `vanilla` THEME PACK FILE!!
// IT'S HERE FOR UPSTREAM COMPATIBILITY!!
// THE `glitch` PACK FILE IS IN `themes/glitch/index.js`!!
import loadPolyfills from '../mastodon/load_polyfills'; import loadPolyfills from '../mastodon/load_polyfills';
// import default stylesheet with variables // import default stylesheet with variables

View File

@ -1,9 +1,6 @@
import { start } from 'rails-ujs'; import { start } from 'rails-ujs';
import 'font-awesome/css/font-awesome.css'; import 'font-awesome/css/font-awesome.css';
// import common styling
require('../styles/common.scss');
require.context('../images/', true); require.context('../images/', true);
start(); start();

View File

@ -1,6 +1,6 @@
import loadPolyfills from '../mastodon/load_polyfills'; import loadPolyfills from 'themes/glitch/util/load_polyfills';
import { processBio } from '../glitch/util/bio_metadata'; import { processBio } from 'themes/glitch/util/bio_metadata';
import ready from '../mastodon/ready'; import ready from 'themes/glitch/util/ready';
window.addEventListener('message', e => { window.addEventListener('message', e => {
const data = e.data || {}; const data = e.data || {};
@ -22,12 +22,12 @@ function main() {
const { length } = require('stringz'); const { length } = require('stringz');
const IntlRelativeFormat = require('intl-relativeformat').default; const IntlRelativeFormat = require('intl-relativeformat').default;
const { delegate } = require('rails-ujs'); const { delegate } = require('rails-ujs');
const emojify = require('../mastodon/features/emoji/emoji').default; const emojify = require('../themes/glitch/features/emoji/emoji').default;
const { getLocale } = require('../mastodon/locales'); const { getLocale } = require('mastodon/locales');
const { localeData } = getLocale(); const { localeData } = getLocale();
const VideoContainer = require('../mastodon/containers/video_container').default; const VideoContainer = require('../themes/glitch/containers/video_container').default;
const MediaGalleryContainer = require('../mastodon/containers/media_gallery_container').default; const MediaGalleryContainer = require('../themes/glitch/containers/media_gallery_container').default;
const CardContainer = require('../mastodon/containers/card_container').default; const CardContainer = require('../themes/glitch/containers/card_container').default;
const React = require('react'); const React = require('react');
const ReactDOM = require('react-dom'); const ReactDOM = require('react-dom');

View File

@ -1,9 +1,9 @@
import loadPolyfills from '../mastodon/load_polyfills'; import loadPolyfills from 'themes/glitch/util/load_polyfills';
require.context('../images/', true); require.context('../images/', true);
function loaded() { function loaded() {
const ComposeContainer = require('../mastodon/containers/compose_container').default; const ComposeContainer = require('themes/glitch/containers/compose_container').default;
const React = require('react'); const React = require('react');
const ReactDOM = require('react-dom'); const ReactDOM = require('react-dom');
const mountNode = document.getElementById('mastodon-compose'); const mountNode = document.getElementById('mastodon-compose');
@ -15,7 +15,7 @@ function loaded() {
} }
function main() { function main() {
const ready = require('../mastodon/ready').default; const ready = require('themes/glitch/util/ready').default;
ready(loaded); ready(loaded);
} }

View File

@ -1,23 +0,0 @@
@import 'mastodon/mixins';
@import 'mastodon/variables';
@import 'variables-glitch';
@import 'fonts/roboto';
@import 'fonts/roboto-mono';
@import 'fonts/montserrat';
@import 'mastodon/reset';
@import 'mastodon/basics';
@import 'mastodon/containers';
@import 'mastodon/lists';
@import 'mastodon/footer';
@import 'mastodon/compact_header';
@import 'mastodon/landing_strip';
@import 'mastodon/forms';
@import 'mastodon/accounts';
@import 'mastodon/stream_entries';
@import 'mastodon/components';
@import 'mastodon/emoji_picker';
@import 'mastodon/about';
@import 'mastodon/tables';
@import 'mastodon/admin';
@import 'mastodon/rtl';

View File

@ -1,3 +0,0 @@
// glitch-soc added variables
$dismiss-overlay-width: 4rem;

View File

@ -1,4 +1,4 @@
import api, { getLinks } from '../api'; import api, { getLinks } from 'themes/glitch/util/api';
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';

View File

@ -1,4 +1,4 @@
import api, { getLinks } from '../api'; import api, { getLinks } from 'themes/glitch/util/api';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';

View File

@ -1,4 +1,4 @@
import api from '../api'; import api from 'themes/glitch/util/api';
export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST'; export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS'; export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';

View File

@ -1,6 +1,6 @@
import api from '../api'; import api from 'themes/glitch/util/api';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; import { search as emojiSearch } from 'themes/glitch/util/emoji/emoji_mart_search_light';
import { useEmoji } from './emojis'; import { useEmoji } from './emojis';
import { import {

View File

@ -1,4 +1,4 @@
import api, { getLinks } from '../api'; import api, { getLinks } from 'themes/glitch/util/api';
export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST';
export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS'; export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS';

View File

@ -1,4 +1,4 @@
import api, { getLinks } from '../api'; import api, { getLinks } from 'themes/glitch/util/api';
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';

View File

@ -1,4 +1,4 @@
import api from '../api'; import api from 'themes/glitch/util/api';
export const REBLOG_REQUEST = 'REBLOG_REQUEST'; export const REBLOG_REQUEST = 'REBLOG_REQUEST';
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';

View File

@ -0,0 +1,24 @@
export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
export function changeLocalSetting(key, value) {
return dispatch => {
dispatch({
type: LOCAL_SETTING_CHANGE,
key,
value,
});
dispatch(saveLocalSettings());
};
};
// __TODO :__
// Right now `saveLocalSettings()` doesn't keep track of which user
// is currently signed in, but it might be better to give each user
// their *own* local settings.
export function saveLocalSettings() {
return (_, getState) => {
const localSettings = getState().get('local_settings').toJS();
localStorage.setItem('mastodon-settings', JSON.stringify(localSettings));
};
};

View File

@ -1,6 +1,6 @@
import api, { getLinks } from '../api'; import api, { getLinks } from 'themes/glitch/util/api';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
import { openModal } from '../../mastodon/actions/modal'; import { openModal } from 'themes/glitch/actions/modal';
export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
@ -100,4 +100,4 @@ export function toggleHideNotifications() {
return dispatch => { return dispatch => {
dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
}; };
} }

View File

@ -1,4 +1,4 @@
import api, { getLinks } from '../api'; import api, { getLinks } from 'themes/glitch/util/api';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import IntlMessageFormat from 'intl-messageformat'; import IntlMessageFormat from 'intl-messageformat';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';

View File

@ -1,10 +1,10 @@
import api from '../api'; import api from 'themes/glitch/util/api';
export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
import { me } from '../initial_state'; import { me } from 'themes/glitch/util/initial_state';
export function fetchPinnedStatuses() { export function fetchPinnedStatuses() {
return (dispatch, getState) => { return (dispatch, getState) => {

View File

@ -1,4 +1,4 @@
import api from '../api'; import api from 'themes/glitch/util/api';
import { openModal, closeModal } from './modal'; import { openModal, closeModal } from './modal';
export const REPORT_INIT = 'REPORT_INIT'; export const REPORT_INIT = 'REPORT_INIT';

View File

@ -1,4 +1,4 @@
import api from '../api'; import api from 'themes/glitch/util/api';
export const SEARCH_CHANGE = 'SEARCH_CHANGE'; export const SEARCH_CHANGE = 'SEARCH_CHANGE';
export const SEARCH_CLEAR = 'SEARCH_CLEAR'; export const SEARCH_CLEAR = 'SEARCH_CLEAR';

View File

@ -1,4 +1,4 @@
import api from '../api'; import api from 'themes/glitch/util/api';
import { deleteFromTimelines } from './timelines'; import { deleteFromTimelines } from './timelines';
import { fetchStatusCard } from './cards'; import { fetchStatusCard } from './cards';

View File

@ -1,4 +1,4 @@
import { connectStream } from '../stream'; import { connectStream } from 'themes/glitch/util/stream';
import { import {
updateTimeline, updateTimeline,
deleteFromTimelines, deleteFromTimelines,
@ -7,7 +7,7 @@ import {
disconnectTimeline, disconnectTimeline,
} from './timelines'; } from './timelines';
import { updateNotifications, refreshNotifications } from './notifications'; import { updateNotifications, refreshNotifications } from './notifications';
import { getLocale } from '../locales'; import { getLocale } from 'mastodon/locales';
const { messages } = getLocale(); const { messages } = getLocale();

View File

@ -1,4 +1,4 @@
import api, { getLinks } from '../api'; import api, { getLinks } from 'themes/glitch/util/api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';

View File

@ -7,7 +7,7 @@ import Permalink from './permalink';
import IconButton from './icon_button'; import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state'; import { me } from 'themes/glitch/util/initial_state';
const messages = defineMessages({ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; import unicodeMapping from 'themes/glitch/util/emoji/emoji_unicode_mapping_light';
const assetHost = process.env.CDN_HOST || ''; const assetHost = process.env.CDN_HOST || '';

View File

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from 'themes/glitch/features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestEmoji from './autosuggest_emoji';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { isRtl } from '../rtl'; import { isRtl } from 'themes/glitch/util/rtl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import classNames from 'classnames'; import classNames from 'classnames';

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import Motion from '../features/ui/util/optional_motion'; import Motion from 'themes/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
import { scrollTop } from '../scroll'; import { scrollTop } from 'themes/glitch/util/scroll';
export default class Column extends React.PureComponent { export default class Column extends React.PureComponent {

View File

@ -5,7 +5,7 @@ import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
// Glitch imports // Glitch imports
import NotificationPurgeButtonsContainer from '../../glitch/components/column/notif_cleaning_widget/container'; import NotificationPurgeButtonsContainer from 'themes/glitch/containers/notification_purge_buttons_container';
const messages = defineMessages({ const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button'; import IconButton from './icon_button';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../features/ui/util/optional_motion'; import Motion from 'themes/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import Motion from '../features/ui/util/optional_motion'; import Motion from 'themes/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; import scheduleIdleTask from 'themes/glitch/util/schedule_idle_task';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; import getRectFromEntry from 'themes/glitch/util/get_rect_from_entry';
import { is } from 'immutable'; import { is } from 'immutable';
// Diff these props in the "rendered" state // Diff these props in the "rendered" state

View File

@ -1,15 +1,12 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/gallery
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { is } from 'immutable'; import { is } from 'immutable';
import IconButton from './icon_button'; import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile'; import { isIOS } from 'themes/glitch/util/is_mobile';
import classNames from 'classnames'; import classNames from 'classnames';
import { autoPlayGif } from '../initial_state'; import { autoPlayGif } from 'themes/glitch/util/initial_state';
const messages = defineMessages({ const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@ -26,6 +23,7 @@ class Item extends React.PureComponent {
standalone: PropTypes.bool, standalone: PropTypes.bool,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired, size: PropTypes.number.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
}; };
@ -65,7 +63,7 @@ class Item extends React.PureComponent {
} }
render () { render () {
const { attachment, index, size, standalone } = this.props; const { attachment, index, size, standalone, letterbox } = this.props;
let width = 50; let width = 50;
let height = 100; let height = 100;
@ -137,7 +135,7 @@ class Item extends React.PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
target='_blank' target='_blank'
> >
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} /> <img className={letterbox ? 'letterbox' : null} src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
</a> </a>
); );
} else if (attachment.get('type') === 'gifv') { } else if (attachment.get('type') === 'gifv') {
@ -146,7 +144,7 @@ class Item extends React.PureComponent {
thumbnail = ( thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video <video
className='media-gallery__item-gifv-thumbnail' className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`}
aria-label={attachment.get('description')} aria-label={attachment.get('description')}
role='application' role='application'
src={attachment.get('url')} src={attachment.get('url')}
@ -178,9 +176,10 @@ export default class MediaGallery extends React.PureComponent {
static propTypes = { static propTypes = {
sensitive: PropTypes.bool, sensitive: PropTypes.bool,
standalone: PropTypes.bool, standalone: PropTypes.bool,
letterbox: PropTypes.bool,
fullwidth: PropTypes.bool,
media: ImmutablePropTypes.list.isRequired, media: ImmutablePropTypes.list.isRequired,
size: PropTypes.object, size: PropTypes.object,
height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -207,41 +206,17 @@ export default class MediaGallery extends React.PureComponent {
this.props.onOpenMedia(this.props.media, index); this.props.onOpenMedia(this.props.media, index);
} }
handleRef = (node) => {
if (node && this.isStandaloneEligible()) {
// offsetWidth triggers a layout, so only calculate when we need to
this.setState({
width: node.offsetWidth,
});
}
}
isStandaloneEligible() { isStandaloneEligible() {
const { media, standalone } = this.props; const { media, standalone } = this.props;
return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
} }
render () { render () {
const { media, intl, sensitive, height } = this.props; const { media, intl, sensitive, letterbox, fullwidth } = this.props;
const { width, visible } = this.state; const { visible } = this.state;
let children; let children;
const style = {};
if (this.isStandaloneEligible()) {
if (!visible && width) {
// only need to forcibly set the height in "sensitive" mode
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
} else {
// layout automatically, using image's natural aspect ratio
style.height = '';
}
} else {
// crop the image
style.height = height;
}
if (!visible) { if (!visible) {
let warning; let warning;
@ -252,7 +227,7 @@ export default class MediaGallery extends React.PureComponent {
} }
children = ( children = (
<button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}> <button className='media-spoiler' onClick={this.handleOpen}>
<span className='media-spoiler__warning'>{warning}</span> <span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</button> </button>
@ -263,12 +238,12 @@ export default class MediaGallery extends React.PureComponent {
if (this.isStandaloneEligible()) { if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />; children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
} else { } else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />); children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} />);
} }
} }
return ( return (
<div className='media-gallery' style={style}> <div className={`media-gallery ${fullwidth ? 'full-width' : ''}`}>
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}> <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div> </div>

View File

@ -11,10 +11,6 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
const messages = defineMessages({ const messages = defineMessages({
btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' }, btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' },
btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' }, btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' },

View File

@ -1,13 +1,13 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { ScrollContainer } from 'react-router-scroll-4'; import { ScrollContainer } from 'react-router-scroll-4';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; import IntersectionObserverArticleContainer from 'themes/glitch/containers/intersection_observer_article_container';
import LoadMore from './load_more'; import LoadMore from './load_more';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import IntersectionObserverWrapper from 'themes/glitch/util/intersection_observer_wrapper';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import classNames from 'classnames'; import classNames from 'classnames';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'themes/glitch/util/fullscreen';
export default class ScrollableList extends PureComponent { export default class ScrollableList extends PureComponent {

View File

@ -0,0 +1,436 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import StatusPrepend from './status_prepend';
import StatusHeader from './status_header';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from 'themes/glitch/util/async-components';
import { HotKeys } from 'react-hotkeys';
import NotificationOverlayContainer from 'themes/glitch/features/notifications/containers/overlay_container';
// 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';
export default class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
id: PropTypes.string,
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onPin: PropTypes.func,
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
onBlock: PropTypes.func,
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
muted: PropTypes.bool,
collapse: PropTypes.bool,
hidden: PropTypes.bool,
prepend: PropTypes.string,
withDismiss: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};
state = {
isExpanded: null,
markedForDelete: false,
}
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'account',
'settings',
'prepend',
'boostModal',
'muted',
'collapse',
'notification',
]
updateOnStates = [
'isExpanded',
'markedForDelete',
]
// If our settings have changed to disable collapsed statuses, then we
// need to make sure that we uncollapse every one. We do that by watching
// for changes to `settings.collapsed.enabled` in
// `componentWillReceiveProps()`.
// We also need to watch for changes on the `collapse` prop---if this
// changes to anything other than `undefined`, then we need to collapse or
// uncollapse our status accordingly.
componentWillReceiveProps (nextProps) {
if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
if (this.state.isExpanded === false) {
this.setExpansion(null);
}
} else if (
nextProps.collapse !== this.props.collapse &&
nextProps.collapse !== undefined
) this.setExpansion(nextProps.collapse ? false : null);
}
// When mounting, we just check to see if our status should be collapsed,
// and collapse it if so. We don't need to worry about whether collapsing
// is enabled here, because `setExpansion()` already takes that into
// account.
// The cases where a status should be collapsed are:
//
// - The `collapse` prop has been set to `true`
// - The user has decided in local settings to collapse all statuses.
// - The user has decided to collapse all notifications ('muted'
// statuses).
// - The user has decided to collapse long statuses and the status is
// over 400px (without media, or 650px with).
// - The status is a reply and the user has decided to collapse all
// replies.
// - The status contains media and the user has decided to collapse all
// statuses with media.
// - The status is a reblog the user has decided to collapse all
// statuses which are reblogs.
componentDidMount () {
const { node } = this;
const {
status,
settings,
collapse,
muted,
prepend,
} = this.props;
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
if (function () {
switch (true) {
case collapse:
case autoCollapseSettings.get('all'):
case autoCollapseSettings.get('notifications') && muted:
case autoCollapseSettings.get('lengthy') && node.clientHeight > (
status.get('media_attachments').size && !muted ? 650 : 400
):
case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by':
case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null:
case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size:
return true;
default:
return false;
}
}()) this.setExpansion(false);
}
// `setExpansion()` sets the value of `isExpanded` in our state. It takes
// one argument, `value`, which gives the desired value for `isExpanded`.
// The default for this argument is `null`.
// `setExpansion()` automatically checks for us whether toot collapsing
// is enabled, so we don't have to.
setExpansion = (value) => {
switch (true) {
case value === undefined || value === null:
this.setState({ isExpanded: null });
break;
case !value && this.props.settings.getIn(['collapsed', 'enabled']):
this.setState({ isExpanded: false });
break;
case !!value:
this.setState({ isExpanded: true });
break;
}
}
// `parseClick()` takes a click event and responds appropriately.
// If our status is collapsed, then clicking on it should uncollapse it.
// If `Shift` is held, then clicking on it should collapse it.
// Otherwise, we open the url handed to us in `destination`, if
// applicable.
parseClick = (e, destination) => {
const { router } = this.context;
const { status } = this.props;
const { isExpanded } = this.state;
if (!router) return;
if (destination === undefined) {
destination = `/statuses/${
status.getIn(['reblog', 'id'], status.get('id'))
}`;
}
if (e.button === 0) {
if (isExpanded === false) this.setExpansion(null);
else if (e.shiftKey) {
this.setExpansion(false);
document.getSelection().removeAllRanges();
} else router.history.push(destination);
e.preventDefault();
}
}
handleAccountClick = (e) => {
if (this.context.router && e.button === 0) {
const id = e.currentTarget.getAttribute('data-id');
e.preventDefault();
this.context.router.history.push(`/accounts/${id}`);
}
}
handleExpandedToggle = () => {
this.setExpansion(this.state.isExpanded || !this.props.status.get('spoiler') ? null : true);
};
handleOpenVideo = startTime => {
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
}
handleHotkeyReply = e => {
e.preventDefault();
this.props.onReply(this.props.status, this.context.router.history);
}
handleHotkeyFavourite = () => {
this.props.onFavourite(this.props.status);
}
handleHotkeyBoost = e => {
this.props.onReblog(this.props.status, e);
}
handleHotkeyMention = e => {
e.preventDefault();
this.props.onMention(this.props.status.get('account'), this.context.router.history);
}
handleHotkeyOpen = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
}
handleHotkeyOpenProfile = () => {
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.status.get('id'));
}
handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.status.get('id'));
}
renderLoadingMediaGallery () {
return <div className='media_gallery' style={{ height: '110px' }} />;
}
renderLoadingVideoPlayer () {
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
}
render () {
const {
parseClick,
setExpansion,
} = this;
const { router } = this.context;
const {
status,
account,
settings,
collapsed,
muted,
prepend,
intersectionObserverWrapper,
onOpenVideo,
onOpenMedia,
notification,
hidden,
...other
} = this.props;
const { isExpanded } = this.state;
let background = null;
let attachments = null;
let media = null;
let mediaIcon = null;
if (status === null) {
return null;
}
if (hidden) {
return (
<div
ref={this.handleRef}
data-id={status.get('id')}
style={{
height: `${this.height}px`,
opacity: 0,
overflow: 'hidden',
}}
>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{' '}
{status.get('content')}
</div>
);
}
// If user backgrounds for collapsed statuses are enabled, then we
// initialize our background accordingly. This will only be rendered if
// the status is collapsed.
if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) {
background = status.getIn(['account', 'header']);
}
// This handles our media attachments. Note that we don't show media on
// muted (notification) statuses. If the media type is unknown, then we
// simply ignore it.
// After we have generated our appropriate media element and stored it in
// `media`, we snatch the thumbnail to use as our `background` if media
// backgrounds for collapsed statuses are enabled.
attachments = status.get('media_attachments');
if (attachments.size > 0 && !muted) {
if (attachments.some(item => item.get('type') === 'unknown')) { // Media type is 'unknown'
/* Do nothing */
} else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video'
const video = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => <Component
preview={video.get('preview_url')}
src={video.get('url')}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
onOpenVideo={this.handleOpenVideo}
/>}
</Bundle>
);
mediaIcon = 'video-camera';
} else { // Media type is 'image' or 'gifv'
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
{Component => (
<Component
media={attachments}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
onOpenMedia={this.props.onOpenMedia}
/>
)}
</Bundle>
);
mediaIcon = 'picture-o';
}
if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
background = attachments.getIn([0, 'preview_url']);
}
}
// Here we prepare extra data-* attributes for CSS selectors.
// Users can use those for theming, hiding avatars etc via UserStyle
const selectorAttribs = {
'data-status-by': `@${status.getIn(['account', 'acct'])}`,
};
if (prepend && account) {
const notifKind = {
favourite: 'favourited',
reblog: 'boosted',
reblogged_by: 'boosted',
}[prepend];
selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
}
const handlers = {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
return (
<HotKeys handlers={handlers}>
<div
className={
`status${
muted ? ' muted' : ''
} status-${status.get('visibility')}${
isExpanded === false ? ' collapsed' : ''
}${
isExpanded === false && background ? ' has-background' : ''
}${
this.state.markedForDelete ? ' marked-for-delete' : ''
}`
}
style={{
backgroundImage: (
isExpanded === false && background ?
`url(${background})` :
'none'
),
}}
{...selectorAttribs}
>
{prepend && account ? (
<StatusPrepend
type={prepend}
account={account}
parseClick={parseClick}
notificationId={this.props.notificationId}
/>
) : null}
<StatusHeader
status={status}
friend={account}
mediaIcon={mediaIcon}
collapsible={settings.getIn(['collapsed', 'enabled'])}
collapsed={isExpanded === false}
parseClick={parseClick}
setExpansion={setExpansion}
/>
<StatusContent
status={status}
media={media}
mediaIcon={mediaIcon}
expanded={isExpanded}
setExpansion={setExpansion}
parseClick={parseClick}
disabled={!router}
/>
{isExpanded !== false ? (
<StatusActionBar
{...other}
status={status}
account={status.get('account')}
/>
) : null}
{notification ? (
<NotificationOverlayContainer
notification={notification}
/>
) : null}
</div>
</HotKeys>
);
}
}

View File

@ -5,10 +5,11 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import IconButton from './icon_button'; import IconButton from './icon_button';
import DropdownMenuContainer from '../containers/dropdown_menu_container'; import DropdownMenuContainer from 'themes/glitch/containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state'; import { me } from 'themes/glitch/util/initial_state';
import RelativeTimestamp from './relative_timestamp';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -156,12 +157,6 @@ export default class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
} }
if (status.get('visibility') === 'direct') {
reblogIcon = 'envelope';
} else if (status.get('visibility') === 'private') {
reblogIcon = 'lock';
}
if (status.get('in_reply_to_id', null) === null) { if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply'; replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply); replyTitle = intl.formatMessage(messages.reply);
@ -184,6 +179,8 @@ export default class StatusActionBar extends ImmutablePureComponent {
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} /> <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
</div> </div>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div> </div>
); );
} }

View File

@ -1,21 +1,17 @@
// Package imports //
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { isRtl } from 'themes/glitch/util/rtl';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Permalink from './permalink';
import classnames from 'classnames'; import classnames from 'classnames';
// Mastodon imports //
import { isRtl } from '../../../mastodon/rtl';
import Permalink from '../../../mastodon/components/permalink';
export default class StatusContent extends React.PureComponent { export default class StatusContent extends React.PureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
expanded: PropTypes.oneOf([true, false, null]), expanded: PropTypes.bool,
setExpansion: PropTypes.func, setExpansion: PropTypes.func,
onHeightUpdate: PropTypes.func,
media: PropTypes.element, media: PropTypes.element,
mediaIcon: PropTypes.string, mediaIcon: PropTypes.string,
parseClick: PropTypes.func, parseClick: PropTypes.func,
@ -26,12 +22,17 @@ export default class StatusContent extends React.PureComponent {
hidden: true, hidden: true,
}; };
componentDidMount () { _updateStatusLinks () {
const node = this.node; const node = this.node;
const links = node.querySelectorAll('a'); const links = node.querySelectorAll('a');
for (let i = 0; i < links.length; ++i) { for (var i = 0; i < links.length; ++i) {
let link = links[i]; let link = links[i];
if (link.classList.contains('status-link')) {
continue;
}
link.classList.add('status-link');
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) { if (mention) {
@ -49,10 +50,12 @@ export default class StatusContent extends React.PureComponent {
} }
} }
componentDidMount () {
this._updateStatusLinks();
}
componentDidUpdate () { componentDidUpdate () {
if (this.props.onHeightUpdate) { this._updateStatusLinks();
this.props.onHeightUpdate();
}
} }
onLinkClick = (e) => { onLinkClick = (e) => {
@ -123,17 +126,14 @@ export default class StatusContent extends React.PureComponent {
disabled, disabled,
} = this.props; } = this.props;
const hidden = ( const hidden = this.props.setExpansion ? !this.props.expanded : this.state.hidden;
this.props.setExpansion ?
!this.props.expanded :
this.state.hidden
);
const content = { __html: status.get('contentHtml') }; const content = { __html: status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') }; const spoilerContent = { __html: status.get('spoilerHtml') };
const directionStyle = { direction: 'ltr' }; const directionStyle = { direction: 'ltr' };
const classNames = classnames('status__content', { const classNames = classnames('status__content', {
'status__content--with-action': parseClick && !disabled, 'status__content--with-action': parseClick && !disabled,
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
}); });
if (isRtl(status.get('search_index'))) { if (isRtl(status.get('search_index'))) {
@ -182,7 +182,7 @@ export default class StatusContent extends React.PureComponent {
} }
return ( return (
<div className={classNames}> <div className={classNames} tabIndex='0'>
<p <p
style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
@ -201,6 +201,7 @@ export default class StatusContent extends React.PureComponent {
<div <div
ref={this.setRef} ref={this.setRef}
style={directionStyle} style={directionStyle}
tabIndex={!hidden ? 0 : null}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp} onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
@ -215,12 +216,14 @@ export default class StatusContent extends React.PureComponent {
<div <div
className={classNames} className={classNames}
style={directionStyle} style={directionStyle}
tabIndex='0'
> >
<div <div
ref={this.setRef} ref={this.setRef}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp} onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
tabIndex='0'
/> />
{media} {media}
</div> </div>
@ -230,8 +233,9 @@ export default class StatusContent extends React.PureComponent {
<div <div
className='status__content' className='status__content'
style={directionStyle} style={directionStyle}
tabIndex='0'
> >
<div ref={this.setRef} dangerouslySetInnerHTML={content} /> <div ref={this.setRef} dangerouslySetInnerHTML={content} tabIndex='0' />
{media} {media}
</div> </div>
); );

View File

@ -1,19 +1,3 @@
/*
`<StatusHeader>`
================
Originally a part of `<Status>`, but extracted into a separate
component for better documentation and maintainance by
@kibi@glitch.social as a part of glitch-soc/mastodon.
*/
// * * * * * * * //
// Imports
// -------
// Package imports. // Package imports.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@ -21,16 +5,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
// Mastodon imports. // Mastodon imports.
import Avatar from '../../../mastodon/components/avatar'; import Avatar from './avatar';
import AvatarOverlay from '../../../mastodon/components/avatar_overlay'; import AvatarOverlay from './avatar_overlay';
import DisplayName from '../../../mastodon/components/display_name'; import DisplayName from './display_name';
import IconButton from '../../../mastodon/components/icon_button'; import IconButton from './icon_button';
import VisibilityIcon from './visibility_icon'; import VisibilityIcon from './status_visibility_icon';
// * * * * * * * //
// Initial setup
// -------------
// Messages for use with internationalization stuff. // Messages for use with internationalization stuff.
const messages = defineMessages({ const messages = defineMessages({
@ -42,11 +21,6 @@ const messages = defineMessages({
direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
}); });
// * * * * * * * //
// The component
// -------------
@injectIntl @injectIntl
export default class StatusHeader extends React.PureComponent { export default class StatusHeader extends React.PureComponent {

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusContainer from '../../glitch/components/status/container'; import StatusContainer from 'themes/glitch/containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import ScrollableList from './scrollable_list'; import ScrollableList from './scrollable_list';

View File

@ -1,54 +1,9 @@
/*
`<StatusPrepend>`
=================
Originally a part of `<Status>`, but extracted into a separate
component for better documentation and maintainance by
@kibi@glitch.social as a part of glitch-soc/mastodon.
*/
/* * * * */
/*
Imports:
--------
*/
// Package imports // // Package imports //
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
/* * * * */
/*
The `<StatusPrepend>` component:
--------------------------------
The `<StatusPrepend>` component holds a status's prepend, ie the text
that says X reblogged this, etc. It is represented by an `<aside>`
element.
### Props
- __`type` (`PropTypes.string`) :__
The type of prepend. One of `'reblogged_by'`, `'reblog'`,
`'favourite'`.
- __`account` (`ImmutablePropTypes.map`) :__
The account associated with the prepend.
- __`parseClick` (`PropTypes.func.isRequired`) :__
Our click parsing function.
*/
export default class StatusPrepend extends React.PureComponent { export default class StatusPrepend extends React.PureComponent {
static propTypes = { static propTypes = {
@ -58,33 +13,11 @@ export default class StatusPrepend extends React.PureComponent {
notificationId: PropTypes.number, notificationId: PropTypes.number,
}; };
/*
### Implementation
#### `handleClick()`.
This is just a small wrapper for `parseClick()` that gets fired when
an account link is clicked.
*/
handleClick = (e) => { handleClick = (e) => {
const { account, parseClick } = this.props; const { account, parseClick } = this.props;
parseClick(e, `/accounts/${+account.get('id')}`); parseClick(e, `/accounts/${+account.get('id')}`);
} }
/*
#### `<Message>`.
`<Message>` is a quick functional React component which renders the
actual prepend message based on our provided `type`. First we create a
`link` for the account's name, and then use `<FormattedMessage>` to
generate the message.
*/
Message = () => { Message = () => {
const { type, account } = this.props; const { type, account } = this.props;
let link = ( let link = (
@ -129,15 +62,6 @@ generate the message.
return null; return null;
} }
/*
#### `render()`.
Our `render()` is incredibly simple; we just render the icon and then
the `<Message>` inside of an <aside>.
*/
render () { render () {
const { Message } = this; const { Message } = this;
const { type } = this.props; const { type } = this.props;

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { makeGetAccount } from '../selectors'; import { makeGetAccount } from 'themes/glitch/selectors';
import Account from '../components/account'; import Account from 'themes/glitch/components/account';
import { import {
followAccount, followAccount,
unfollowAccount, unfollowAccount,
@ -10,10 +10,10 @@ import {
unblockAccount, unblockAccount,
muteAccount, muteAccount,
unmuteAccount, unmuteAccount,
} from '../actions/accounts'; } from 'themes/glitch/actions/accounts';
import { openModal } from '../actions/modal'; import { openModal } from 'themes/glitch/actions/modal';
import { initMuteModal } from '../actions/mutes'; import { initMuteModal } from 'themes/glitch/actions/mutes';
import { unfollowModal } from '../initial_state'; import { unfollowModal } from 'themes/glitch/util/initial_state';
const messages = defineMessages({ const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Card from '../features/status/components/card'; import Card from 'themes/glitch/features/status/components/card';
import { fromJS } from 'immutable'; import { fromJS } from 'immutable';
export default class CardContainer extends React.PureComponent { export default class CardContainer extends React.PureComponent {

View File

@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import configureStore from '../store/configureStore'; import configureStore from 'themes/glitch/store/configureStore';
import { hydrateStore } from '../actions/store'; import { hydrateStore } from 'themes/glitch/actions/store';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from 'mastodon/locales';
import Compose from '../features/standalone/compose'; import Compose from 'themes/glitch/features/standalone/compose';
import initialState from '../initial_state'; import initialState from 'themes/glitch/util/initial_state';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);

Some files were not shown because too many files have changed in this diff Show More